test: lift coverage 79.78% → 80.92%+ with broad new tests
Adds 100+ new tests across: - Snmp.Discovery sync_*: mempools/transceivers/printer_supplies/ entity_physical/save_neighbors/save_arp_entries - Topology: compute_wireless_stats, compute_rf_link_stats, get_topology_for_weathermap, LLDP discovery/list/upsert, remove_stale + find_device_by_name fallthroughs - Trace: account/inventory/access_point assemble_trace + trace_from_device inventory→account linking - DiscoveryWorker: assigned-agent fallback, agent offline mid-discovery, device deleted during discovery, perform/1 rescue (34% → 64%) - Snmp.Profiles.Base: discover_state_sensors/vlans/ipv6_addresses/ memory_pools/processors + identify_device for many vendors - ChartBuilders: load_*_chart_data nil/empty + DB-backed sensor charts - OrgSettingsLive events: apply_snmp/apply_agent/toggle_default/ toggle_enabled - DeviceLive.Form events: edit access errors, validate branches, test_snmp w/o agent + with v2c/v3 credentials, query param prefill - DeviceLive.Index: reorder, search, filter_status, force rediscover - CheckLive.FormComponent: full mount/change_type/validate/save flow for http/tcp/dns/ssl + edit/close
This commit is contained in:
parent
e688d4d9f2
commit
8fe1850d8a
10 changed files with 2901 additions and 1 deletions
|
|
@ -120,4 +120,202 @@ defmodule Towerops.Snmp.DiscoverySyncTest do
|
|||
assert s.used_bytes == 750_000
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_mempools/2" do
|
||||
alias Towerops.Snmp.Mempool
|
||||
|
||||
test "inserts new mempools and removes missing ones", %{snmp_device: snmp_device} do
|
||||
now = Towerops.Time.now()
|
||||
|
||||
Discovery.sync_mempools(snmp_device, [
|
||||
%{
|
||||
pool_index: "1",
|
||||
pool_name: "RAM Pool",
|
||||
pool_type: "ram",
|
||||
total_bytes: 1_000_000,
|
||||
used_bytes: 250_000,
|
||||
last_checked_at: now
|
||||
},
|
||||
%{
|
||||
pool_index: "2",
|
||||
pool_name: "Swap Pool",
|
||||
pool_type: "swap",
|
||||
total_bytes: 500_000,
|
||||
used_bytes: 100_000,
|
||||
last_checked_at: now
|
||||
}
|
||||
])
|
||||
|
||||
assert length(Repo.all(from(m in Mempool, where: m.snmp_device_id == ^snmp_device.id))) ==
|
||||
2
|
||||
|
||||
Discovery.sync_mempools(snmp_device, [
|
||||
%{
|
||||
pool_index: "1",
|
||||
pool_name: "RAM Pool",
|
||||
pool_type: "ram",
|
||||
total_bytes: 1_000_000,
|
||||
used_bytes: 800_000,
|
||||
last_checked_at: now
|
||||
}
|
||||
])
|
||||
|
||||
[pool] = Repo.all(from(m in Mempool, where: m.snmp_device_id == ^snmp_device.id))
|
||||
assert pool.mempool_index == "1"
|
||||
assert pool.used_bytes == 800_000
|
||||
assert pool.usage_percent == 80.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_transceivers/2" do
|
||||
alias Towerops.Snmp.Transceiver
|
||||
|
||||
test "inserts, updates, and removes transceivers", %{snmp_device: snmp_device} do
|
||||
{:ok, [_, _]} =
|
||||
Discovery.sync_transceivers(snmp_device, [
|
||||
%{port_index: "1", transceiver_type: "SFP", vendor_name: "Vendor1"},
|
||||
%{port_index: "2", transceiver_type: "SFP+", vendor_name: "Vendor2"}
|
||||
])
|
||||
|
||||
assert length(Repo.all(from(t in Transceiver, where: t.snmp_device_id == ^snmp_device.id))) == 2
|
||||
|
||||
{:ok, [_]} =
|
||||
Discovery.sync_transceivers(snmp_device, [
|
||||
%{port_index: "1", transceiver_type: "QSFP", vendor_name: "Updated"}
|
||||
])
|
||||
|
||||
[t] = Repo.all(from(t in Transceiver, where: t.snmp_device_id == ^snmp_device.id))
|
||||
assert t.transceiver_type == "QSFP"
|
||||
assert t.vendor_name == "Updated"
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_printer_supplies/2" do
|
||||
alias Towerops.Snmp.PrinterSupply
|
||||
|
||||
test "inserts, updates, and removes supplies", %{snmp_device: snmp_device} do
|
||||
Discovery.sync_printer_supplies(snmp_device, [
|
||||
%{
|
||||
supply_index: "1",
|
||||
supply_type: "toner",
|
||||
supply_description: "Black",
|
||||
supply_unit: "percent",
|
||||
max_capacity: 100,
|
||||
current_level: 80,
|
||||
color_name: "black"
|
||||
},
|
||||
%{
|
||||
supply_index: "2",
|
||||
supply_type: "toner",
|
||||
supply_description: "Cyan",
|
||||
supply_unit: "percent",
|
||||
max_capacity: 100,
|
||||
current_level: 50,
|
||||
color_name: "cyan"
|
||||
}
|
||||
])
|
||||
|
||||
assert length(Repo.all(from(s in PrinterSupply, where: s.snmp_device_id == ^snmp_device.id))) == 2
|
||||
|
||||
Discovery.sync_printer_supplies(snmp_device, [
|
||||
%{
|
||||
supply_index: "1",
|
||||
supply_type: "toner",
|
||||
supply_description: "Black",
|
||||
supply_unit: "percent",
|
||||
max_capacity: 100,
|
||||
current_level: 30,
|
||||
color_name: "black"
|
||||
}
|
||||
])
|
||||
|
||||
[s] = Repo.all(from(s in PrinterSupply, where: s.snmp_device_id == ^snmp_device.id))
|
||||
assert s.current_level == 30
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_entity_physical/2" do
|
||||
alias Towerops.Snmp.EntityPhysical
|
||||
|
||||
test "inserts and removes entities", %{snmp_device: snmp_device} do
|
||||
Discovery.sync_entity_physical(snmp_device, [
|
||||
%{
|
||||
entity_index: "1",
|
||||
entity_class: "chassis",
|
||||
name: "Main Chassis",
|
||||
description: "Test Chassis",
|
||||
serial_number: "ABC123"
|
||||
},
|
||||
%{
|
||||
entity_index: "2",
|
||||
entity_class: "module",
|
||||
name: "Module 1",
|
||||
parent_index: "1"
|
||||
}
|
||||
])
|
||||
|
||||
assert length(Repo.all(from(e in EntityPhysical, where: e.snmp_device_id == ^snmp_device.id))) == 2
|
||||
|
||||
Discovery.sync_entity_physical(snmp_device, [
|
||||
%{
|
||||
entity_index: "1",
|
||||
entity_class: "chassis",
|
||||
name: "Main Chassis Updated",
|
||||
description: "Test Chassis",
|
||||
serial_number: "ABC123"
|
||||
}
|
||||
])
|
||||
|
||||
[e] = Repo.all(from(e in EntityPhysical, where: e.snmp_device_id == ^snmp_device.id))
|
||||
assert e.name == "Main Chassis Updated"
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_neighbors/2 + save_arp_entries/3" do
|
||||
alias Towerops.Snmp.Interface
|
||||
|
||||
setup %{snmp_device: snmp_device} do
|
||||
{:ok, iface} =
|
||||
Repo.insert(%Interface{
|
||||
snmp_device_id: snmp_device.id,
|
||||
if_index: 1,
|
||||
if_name: "eth0",
|
||||
if_descr: "Ethernet 0",
|
||||
if_type: 6,
|
||||
monitored: true
|
||||
})
|
||||
|
||||
%{interface: iface}
|
||||
end
|
||||
|
||||
test "save_neighbors inserts neighbor records",
|
||||
%{snmp_device: snmp_device, interface: iface} do
|
||||
assert :ok =
|
||||
Discovery.save_neighbors(snmp_device.device_id, [
|
||||
%{
|
||||
device_id: snmp_device.device_id,
|
||||
interface_id: iface.id,
|
||||
protocol: "lldp",
|
||||
remote_chassis_id: "00:11:22:33:44:55",
|
||||
remote_system_name: "remote-router",
|
||||
remote_port_id: "Gi0/1",
|
||||
last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
}
|
||||
])
|
||||
|
||||
neighbors = Repo.all(Towerops.Snmp.Neighbor)
|
||||
assert length(neighbors) == 1
|
||||
assert hd(neighbors).remote_system_name == "remote-router"
|
||||
end
|
||||
|
||||
test "save_neighbors with empty list still runs cleanup",
|
||||
%{snmp_device: snmp_device} do
|
||||
assert :ok = Discovery.save_neighbors(snmp_device.device_id, [])
|
||||
end
|
||||
|
||||
test "save_arp_entries returns :ok with empty list",
|
||||
%{snmp_device: snmp_device, interface: _iface} do
|
||||
assert :ok = Discovery.save_arp_entries(snmp_device.device_id, [], [])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
446
test/towerops/snmp/profiles/base_coverage_test.exs
Normal file
446
test/towerops/snmp/profiles/base_coverage_test.exs
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
defmodule Towerops.Snmp.Profiles.BaseCoverageTest do
|
||||
@moduledoc """
|
||||
Additional coverage tests for `Towerops.Snmp.Profiles.Base` targeting branches
|
||||
that were not exercised by the existing test files. Uses the simpler `Replay`
|
||||
adapter (no Mox) where possible.
|
||||
"""
|
||||
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.Snmp.Adapters.Replay
|
||||
alias Towerops.Snmp.Profiles.Base
|
||||
|
||||
describe "discover_state_sensors/1 (Replay)" do
|
||||
test "discovers power supplies and fans, mapping classes and states" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# entPhysicalClass: 1=power, 2=fan, 3=chassis (filtered out)
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.1" => "6",
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.2" => "7",
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.3" => "3",
|
||||
# entPhysicalDescr
|
||||
"1.3.6.1.2.1.47.1.1.1.1.2.1" => "PSU 1",
|
||||
"1.3.6.1.2.1.47.1.1.1.1.2.2" => "Fan 1",
|
||||
"1.3.6.1.2.1.47.1.1.1.1.2.3" => "Chassis",
|
||||
# entStateOper: 2=enabled (ok), 3=disabled (warning)
|
||||
"1.3.6.1.2.1.131.1.1.1.1.1" => "2",
|
||||
"1.3.6.1.2.1.131.1.1.1.1.2" => "3"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, sensors} = Base.discover_state_sensors(client_opts)
|
||||
assert length(sensors) == 2
|
||||
|
||||
psu = Enum.find(sensors, &(&1.sensor_index == "1"))
|
||||
assert psu.entity_type == "power_supply"
|
||||
assert psu.status == "ok"
|
||||
assert psu.sensor_descr == "PSU 1"
|
||||
|
||||
fan = Enum.find(sensors, &(&1.sensor_index == "2"))
|
||||
assert fan.entity_type == "fan"
|
||||
assert fan.status == "warning"
|
||||
end
|
||||
|
||||
test "returns empty list when no entities have power-supply or fan class" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# Only chassis and modules - no PSU or fan classes
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.1" => "3",
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.2" => "9"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, []} = Base.discover_state_sensors(client_opts)
|
||||
end
|
||||
|
||||
test "returns empty list when entity walk returns nothing" do
|
||||
client_opts = [adapter: Replay, oid_map: %{}]
|
||||
assert {:ok, []} = Base.discover_state_sensors(client_opts)
|
||||
end
|
||||
|
||||
test "uses default description when entPhysicalDescr is missing" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.42" => "6",
|
||||
"1.3.6.1.2.1.131.1.1.1.1.42" => "2"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, [sensor]} = Base.discover_state_sensors(client_opts)
|
||||
assert sensor.sensor_descr == "Entity 42"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_vlans/1 (Replay)" do
|
||||
test "falls back to status-only walk when no VLAN names are present" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# No dot1qVlanStaticName entries
|
||||
# dot1qVlanStaticRowStatus only - test fallback path
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.10" => "1",
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.20" => "2"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(client_opts)
|
||||
assert length(vlans) == 2
|
||||
|
||||
vlan10 = Enum.find(vlans, &(&1.vlan_id == 10))
|
||||
assert vlan10.vlan_name == "VLAN 10"
|
||||
assert vlan10.status == "active"
|
||||
|
||||
vlan20 = Enum.find(vlans, &(&1.vlan_id == 20))
|
||||
assert vlan20.status == "suspended"
|
||||
end
|
||||
|
||||
test "filters VLAN IDs out of valid range (1-4094) in status-only fallback" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# 0 and 5000 are invalid VLAN IDs
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.0" => "1",
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.5000" => "1",
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.100" => "1"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(client_opts)
|
||||
assert length(vlans) == 1
|
||||
assert hd(vlans).vlan_id == 100
|
||||
end
|
||||
|
||||
test "discovers VLANs with explicit names plus status mapping" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.1.10" => "Engineering",
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.1.20" => "Sales",
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.10" => "1",
|
||||
"1.3.6.1.2.1.17.7.1.4.3.1.5.20" => "3"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, vlans} = Base.discover_vlans(client_opts)
|
||||
assert length(vlans) == 2
|
||||
|
||||
eng = Enum.find(vlans, &(&1.vlan_name == "Engineering"))
|
||||
assert eng.vlan_id == 10
|
||||
assert eng.status == "active"
|
||||
|
||||
sales = Enum.find(vlans, &(&1.vlan_name == "Sales"))
|
||||
assert sales.status == "suspended"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_ipv6_addresses/1 (Replay)" do
|
||||
test "returns empty list when neither table has IPv6 entries" do
|
||||
assert {:ok, []} = Base.discover_ipv6_addresses(adapter: Replay, oid_map: %{})
|
||||
end
|
||||
|
||||
test "discovers IPv6 from RFC 2465 ipv6AddrTable when modern table is empty" do
|
||||
ipv6_octets = [0xFE, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x42]
|
||||
suffix = "9." <> Enum.map_join(ipv6_octets, ".", &Integer.to_string/1)
|
||||
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
("1.3.6.1.2.1.55.1.8.1.2." <> suffix) => "64"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, [addr]} = Base.discover_ipv6_addresses(client_opts)
|
||||
assert addr.ip_type == "ipv6"
|
||||
assert addr.if_index == 9
|
||||
assert addr.prefix_length == 64
|
||||
assert addr.ip_address == "fe80:0:0:0:0:0:0:42"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_all_ip_addresses/1 (Replay)" do
|
||||
test "combines IPv4 (IP-MIB) and IPv6 (IPV6-MIB) results" do
|
||||
ipv6_octets = [0xFE, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01]
|
||||
ipv6_suffix = "1." <> Enum.map_join(ipv6_octets, ".", &Integer.to_string/1)
|
||||
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# IPv4 from IP-MIB
|
||||
"1.3.6.1.2.1.4.20.1.2.10.1.1.1" => "3",
|
||||
"1.3.6.1.2.1.4.20.1.3.10.1.1.1" => "255.255.255.0",
|
||||
# IPv6 from IPV6-MIB ipv6AddrTable
|
||||
("1.3.6.1.2.1.55.1.8.1.2." <> ipv6_suffix) => "64"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, addresses} = Base.discover_all_ip_addresses(client_opts)
|
||||
assert length(addresses) == 2
|
||||
|
||||
ipv4 = Enum.find(addresses, &(&1.ip_type == "ipv4"))
|
||||
assert ipv4.ip_address == "10.1.1.1"
|
||||
assert ipv4.prefix_length == 24
|
||||
|
||||
ipv6 = Enum.find(addresses, &(&1.ip_type == "ipv6"))
|
||||
assert ipv6.ip_address == "fe80:0:0:0:0:0:0:1"
|
||||
end
|
||||
|
||||
test "returns empty list when neither IPv4 nor IPv6 sources have data" do
|
||||
assert {:ok, []} = Base.discover_all_ip_addresses(adapter: Replay, oid_map: %{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_memory_pools/1 (Replay)" do
|
||||
test "discovers RAM and swap, ignoring non-memory storage" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# hrStorageType - RAM (1.3.6.1.2.1.25.2.1.2)
|
||||
"1.3.6.1.2.1.25.2.3.1.2.1" => "1.3.6.1.2.1.25.2.1.2",
|
||||
# hrStorageType - swap (1.3.6.1.2.1.25.2.1.3)
|
||||
"1.3.6.1.2.1.25.2.3.1.2.2" => "1.3.6.1.2.1.25.2.1.3",
|
||||
# hrStorageType - fixed disk (1.3.6.1.2.1.25.2.1.4) - filtered out
|
||||
"1.3.6.1.2.1.25.2.3.1.2.3" => "1.3.6.1.2.1.25.2.1.4",
|
||||
# Descriptions
|
||||
"1.3.6.1.2.1.25.2.3.1.3.1" => "Physical memory",
|
||||
"1.3.6.1.2.1.25.2.3.1.3.2" => "Swap space",
|
||||
"1.3.6.1.2.1.25.2.3.1.3.3" => "/",
|
||||
# Allocation units (bytes per unit)
|
||||
"1.3.6.1.2.1.25.2.3.1.4.1" => "1024",
|
||||
"1.3.6.1.2.1.25.2.3.1.4.2" => "1024",
|
||||
"1.3.6.1.2.1.25.2.3.1.4.3" => "4096",
|
||||
# Sizes (in units)
|
||||
"1.3.6.1.2.1.25.2.3.1.5.1" => "1000000",
|
||||
"1.3.6.1.2.1.25.2.3.1.5.2" => "500000",
|
||||
"1.3.6.1.2.1.25.2.3.1.5.3" => "20000000",
|
||||
# Used (in units)
|
||||
"1.3.6.1.2.1.25.2.3.1.6.1" => "400000",
|
||||
"1.3.6.1.2.1.25.2.3.1.6.2" => "0",
|
||||
"1.3.6.1.2.1.25.2.3.1.6.3" => "10000000"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, pools} = Base.discover_memory_pools(client_opts)
|
||||
assert length(pools) == 2
|
||||
|
||||
ram = Enum.find(pools, &(&1.pool_type == "ram"))
|
||||
assert ram.pool_name == "Physical memory"
|
||||
assert ram.total_bytes == 1_000_000 * 1024
|
||||
assert ram.used_bytes == 400_000 * 1024
|
||||
|
||||
swap = Enum.find(pools, &(&1.pool_type == "swap"))
|
||||
assert swap.pool_name == "Swap space"
|
||||
assert swap.total_bytes == 500_000 * 1024
|
||||
assert swap.used_bytes == 0
|
||||
end
|
||||
|
||||
test "returns empty list when no storage entries are found" do
|
||||
assert {:ok, []} = Base.discover_memory_pools(adapter: Replay, oid_map: %{})
|
||||
end
|
||||
|
||||
test "ignores entries whose storage type is not RAM or swap" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# only fixed disk and ram disk
|
||||
"1.3.6.1.2.1.25.2.3.1.2.1" => "1.3.6.1.2.1.25.2.1.4",
|
||||
"1.3.6.1.2.1.25.2.3.1.2.2" => "1.3.6.1.2.1.25.2.1.8"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, []} = Base.discover_memory_pools(client_opts)
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_processors/1 (Replay)" do
|
||||
test "discovers HOST-RESOURCES-MIB processors with descriptions" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# hrProcessorLoad
|
||||
"1.3.6.1.2.1.25.3.3.1.2.1" => "15",
|
||||
"1.3.6.1.2.1.25.3.3.1.2.2" => "27",
|
||||
# hrDeviceDescr
|
||||
"1.3.6.1.2.1.25.3.2.1.3.1" => "Intel Xeon CPU 0",
|
||||
"1.3.6.1.2.1.25.3.2.1.3.2" => "Intel Xeon CPU 1"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, processors} = Base.discover_processors(client_opts)
|
||||
assert length(processors) == 2
|
||||
|
||||
cpu0 = Enum.find(processors, &(&1.processor_index == "hr_1"))
|
||||
assert cpu0.description == "Intel Xeon CPU 0"
|
||||
assert cpu0.processor_type == "hr_processor"
|
||||
assert cpu0.load_percent == 15.0
|
||||
end
|
||||
|
||||
test "uses default description when hrDeviceDescr is missing" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
"1.3.6.1.2.1.25.3.3.1.2.7" => "5"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, [cpu]} = Base.discover_processors(client_opts)
|
||||
assert cpu.description == "CPU 7"
|
||||
assert cpu.load_percent == 5.0
|
||||
end
|
||||
|
||||
test "falls back to CISCO-PROCESS-MIB when HOST-RESOURCES-MIB is empty" do
|
||||
client_opts = [
|
||||
adapter: Replay,
|
||||
oid_map: %{
|
||||
# No HOST-RESOURCES-MIB entries
|
||||
# cpmCPUTotal5min
|
||||
"1.3.6.1.4.1.9.9.109.1.1.1.1.5.1" => "42",
|
||||
"1.3.6.1.4.1.9.9.109.1.1.1.1.5.2" => "8"
|
||||
}
|
||||
]
|
||||
|
||||
assert {:ok, processors} = Base.discover_processors(client_opts)
|
||||
assert length(processors) == 2
|
||||
assert Enum.all?(processors, &(&1.processor_type == "cisco_cpu"))
|
||||
|
||||
cpu1 = Enum.find(processors, &(&1.processor_index == "cisco_1"))
|
||||
assert cpu1.description == "Cisco CPU 1"
|
||||
assert cpu1.load_percent == 42.0
|
||||
end
|
||||
|
||||
test "falls back to UCD-SNMP-MIB synthetic CPU when other MIBs are empty" do
|
||||
# When neither HOST-RESOURCES-MIB nor CISCO-PROCESS-MIB return data,
|
||||
# the implementation falls back to UCD-SNMP and returns a synthetic CPU.
|
||||
assert {:ok, processors} = Base.discover_processors(adapter: Replay, oid_map: %{})
|
||||
# With Replay adapter, missing OIDs come back as nil values; UCD path
|
||||
# treats those as 0% and produces a single synthetic processor entry.
|
||||
assert length(processors) == 1
|
||||
assert hd(processors).processor_type == "ucd_cpu"
|
||||
assert hd(processors).processor_index == "ucd_0"
|
||||
end
|
||||
end
|
||||
|
||||
describe "identify_device/1 — additional vendor patterns" do
|
||||
test "identifies Cambium PTP from sysDescr with model number" do
|
||||
info = %{sys_descr: "Cambium PTP 670 Wireless", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Cambium Networks"
|
||||
assert device.model == "PTP 670"
|
||||
end
|
||||
|
||||
test "identifies Cambium PTP product line by sysObjectID alone" do
|
||||
info = %{sys_descr: "Generic Wireless", sys_object_id: "1.3.6.1.4.1.17713.6.1"}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Cambium Networks"
|
||||
assert device.model == "PTP"
|
||||
end
|
||||
|
||||
test "identifies Cambium cnPilot from sysDescr with version" do
|
||||
info = %{sys_descr: "cnPilot E410 AP", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Cambium Networks"
|
||||
assert device.model == "cnPilot E410"
|
||||
end
|
||||
|
||||
test "identifies Cambium cnPilot from sysDescr without specific version" do
|
||||
info = %{sys_descr: "cnPilot device", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Cambium Networks"
|
||||
# Regex captures non-empty word, but if just "cnPilot " followed by "device"
|
||||
assert device.model in ["cnPilot device", "cnPilot"]
|
||||
end
|
||||
|
||||
test "identifies Cambium PMP product line by sysObjectID alone" do
|
||||
info = %{sys_descr: "Generic Wireless", sys_object_id: "1.3.6.1.4.1.17713.1.999"}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Cambium Networks"
|
||||
assert device.model == "PMP"
|
||||
end
|
||||
|
||||
test "identifies Ubiquiti EdgeRouter from EdgeOS sysDescr" do
|
||||
info = %{
|
||||
sys_descr: "EdgeOS v2.0.9 router",
|
||||
sys_object_id: "1.3.6.1.4.1.10002.1"
|
||||
}
|
||||
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Ubiquiti"
|
||||
assert device.model == "EdgeRouter"
|
||||
end
|
||||
|
||||
test "identifies Ubiquiti antenna model from LBE-/NBE-/PBE- prefix" do
|
||||
info = %{sys_descr: "Ubiquiti LBE-5AC-Gen2 wireless", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Ubiquiti"
|
||||
assert String.starts_with?(device.model, "LBE")
|
||||
end
|
||||
|
||||
test "Ubiquiti generic AirMAX falls back to Wireless model" do
|
||||
info = %{sys_descr: "Ubiquiti airMAX device", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Ubiquiti"
|
||||
assert device.model == "Wireless"
|
||||
end
|
||||
|
||||
test "MikroTik with non-matching descr falls back to RouterOS model" do
|
||||
info = %{sys_descr: "RouterOS some unknown product", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "MikroTik"
|
||||
assert device.model == "RouterOS"
|
||||
end
|
||||
|
||||
test "MikroTik CRS / CCR model extraction" do
|
||||
info = %{sys_descr: "MikroTik CCR2004-1G-12S+2XS", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "MikroTik"
|
||||
assert String.starts_with?(device.model, "CCR")
|
||||
end
|
||||
|
||||
test "Linux device returns full sys_descr as model" do
|
||||
info = %{sys_descr: "Linux ubuntu 5.15", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Linux"
|
||||
assert device.model == "Linux ubuntu 5.15"
|
||||
end
|
||||
|
||||
test "Windows device returns full sys_descr as model" do
|
||||
info = %{sys_descr: "Windows Server 2022", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Microsoft"
|
||||
assert device.model == "Windows Server 2022"
|
||||
end
|
||||
|
||||
test "Cisco unrecognized model returns 'Unknown Model'" do
|
||||
info = %{sys_descr: "Cisco IOS something else", sys_object_id: ""}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Cisco"
|
||||
assert device.model == "Unknown Model"
|
||||
end
|
||||
|
||||
test "preserves sys_descr/sys_object_id and other fields" do
|
||||
info = %{
|
||||
sys_descr: "Cisco IOS C2960",
|
||||
sys_object_id: "1.3.6.1.4.1.9.1.1",
|
||||
sys_name: "switch01",
|
||||
sys_uptime: 12_345,
|
||||
sys_contact: "ops",
|
||||
sys_location: "rack-1"
|
||||
}
|
||||
|
||||
device = Base.identify_device(info)
|
||||
assert device.sys_name == "switch01"
|
||||
assert device.sys_uptime == 12_345
|
||||
assert device.sys_contact == "ops"
|
||||
assert device.sys_location == "rack-1"
|
||||
end
|
||||
|
||||
test "missing sys_descr falls back to Unknown / Generic Device" do
|
||||
info = %{sys_descr: nil, sys_object_id: nil}
|
||||
device = Base.identify_device(info)
|
||||
assert device.manufacturer == "Unknown"
|
||||
assert device.model == "Generic Device"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1416,4 +1416,526 @@ defmodule Towerops.TopologyTest do
|
|||
assert Topology.list_connected_devices(router.id) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "compute_wireless_stats/1" do
|
||||
alias Towerops.Snmp.WirelessClient
|
||||
|
||||
test "returns empty map when device_ids list is empty" do
|
||||
assert Topology.compute_wireless_stats([]) == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when devices have no wireless clients", %{router: router} do
|
||||
assert Topology.compute_wireless_stats([router.id]) == %{}
|
||||
end
|
||||
|
||||
test "aggregates client_count and classifies signal_health as good", %{
|
||||
router: router,
|
||||
organization: org
|
||||
} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
Enum.each(["aa:bb:cc:00:00:01", "aa:bb:cc:00:00:02"], fn mac ->
|
||||
%WirelessClient{}
|
||||
|> WirelessClient.changeset(%{
|
||||
device_id: router.id,
|
||||
organization_id: org.id,
|
||||
mac_address: mac,
|
||||
signal_strength: -55,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end)
|
||||
|
||||
stats = Topology.compute_wireless_stats([router.id])
|
||||
assert %{client_count: 2, signal_health: "good"} = Map.get(stats, router.id)
|
||||
end
|
||||
|
||||
test "classifies degraded and critical signal levels", %{
|
||||
router: router,
|
||||
switch: switch,
|
||||
organization: org
|
||||
} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
%WirelessClient{}
|
||||
|> WirelessClient.changeset(%{
|
||||
device_id: router.id,
|
||||
organization_id: org.id,
|
||||
mac_address: "bb:bb:cc:00:00:01",
|
||||
signal_strength: -70,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%WirelessClient{}
|
||||
|> WirelessClient.changeset(%{
|
||||
device_id: switch.id,
|
||||
organization_id: org.id,
|
||||
mac_address: "cc:cc:cc:00:00:01",
|
||||
signal_strength: -90,
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stats = Topology.compute_wireless_stats([router.id, switch.id])
|
||||
assert Map.get(stats, router.id).signal_health == "degraded"
|
||||
assert Map.get(stats, switch.id).signal_health == "critical"
|
||||
end
|
||||
|
||||
test "returns nil signal_health when signal_strength is missing", %{
|
||||
router: router,
|
||||
organization: org
|
||||
} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
%WirelessClient{}
|
||||
|> WirelessClient.changeset(%{
|
||||
device_id: router.id,
|
||||
organization_id: org.id,
|
||||
mac_address: "dd:dd:cc:00:00:01",
|
||||
last_seen_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stats = Topology.compute_wireless_stats([router.id])
|
||||
device_stats = Map.get(stats, router.id)
|
||||
assert device_stats.client_count == 1
|
||||
assert device_stats.signal_health == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "compute_rf_link_stats/1" do
|
||||
alias Towerops.Snmp.Sensor
|
||||
|
||||
test "returns empty map when device_ids list is empty" do
|
||||
assert Topology.compute_rf_link_stats([]) == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when no SNR sensors exist", %{router: router} do
|
||||
assert Topology.compute_rf_link_stats([router.id]) == %{}
|
||||
end
|
||||
|
||||
test "computes average SNR and classifies health as good", %{
|
||||
router: router,
|
||||
router_snmp: snmp
|
||||
} do
|
||||
Enum.each([30.0, 28.0], fn val ->
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "snr",
|
||||
sensor_index: "snr-#{System.unique_integer([:positive])}",
|
||||
sensor_oid: "1.3.6.1.4.1.0.0",
|
||||
sensor_descr: "SNR",
|
||||
last_value: val
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end)
|
||||
|
||||
stats = Topology.compute_rf_link_stats([router.id])
|
||||
device_stats = Map.get(stats, router.id)
|
||||
assert device_stats.signal_health == "good"
|
||||
assert device_stats.snr == 29.0
|
||||
end
|
||||
|
||||
test "classifies SNR as degraded between 15 and 25", %{
|
||||
router: router,
|
||||
router_snmp: snmp
|
||||
} do
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "snr",
|
||||
sensor_index: "snr-1",
|
||||
sensor_oid: "1.3.6.1.4.1.0.1",
|
||||
last_value: 18.0
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stats = Topology.compute_rf_link_stats([router.id])
|
||||
assert Map.get(stats, router.id).signal_health == "degraded"
|
||||
end
|
||||
|
||||
test "classifies low SNR as critical", %{router: router, router_snmp: snmp} do
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "snr",
|
||||
sensor_index: "snr-1",
|
||||
sensor_oid: "1.3.6.1.4.1.0.2",
|
||||
last_value: 5.0
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stats = Topology.compute_rf_link_stats([router.id])
|
||||
assert Map.get(stats, router.id).signal_health == "critical"
|
||||
end
|
||||
|
||||
test "ignores non-snr sensors and ones without last_value", %{
|
||||
router: router,
|
||||
router_snmp: snmp
|
||||
} do
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "temp-1",
|
||||
sensor_oid: "1.3.6.1.4.1.0.3",
|
||||
last_value: 100.0
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "snr",
|
||||
sensor_index: "snr-noval",
|
||||
sensor_oid: "1.3.6.1.4.1.0.4"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
assert Topology.compute_rf_link_stats([router.id]) == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_topology_for_weathermap/2" do
|
||||
test "returns same shape as get_topology_for_map plus utilization stats",
|
||||
%{organization: org} do
|
||||
result = Topology.get_topology_for_weathermap(org.id, "added")
|
||||
|
||||
assert Map.has_key?(result, :nodes)
|
||||
assert Map.has_key?(result, :edges)
|
||||
assert Map.has_key?(result, :stats)
|
||||
assert Map.has_key?(result, :has_geo)
|
||||
assert Map.has_key?(result, :last_updated)
|
||||
|
||||
# Utilization-specific stats
|
||||
assert Map.has_key?(result.stats, :low_utilization_links)
|
||||
assert Map.has_key?(result.stats, :medium_utilization_links)
|
||||
assert Map.has_key?(result.stats, :high_utilization_links)
|
||||
assert Map.has_key?(result.stats, :overutilized_links)
|
||||
end
|
||||
|
||||
test "edges include utilization fields (nil when no capacity configured)",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
target_device_id: switch.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_mac: "bb:cc:dd:00:11:22",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
result = Topology.get_topology_for_weathermap(org.id, "added")
|
||||
assert length(result.edges) == 1
|
||||
[edge] = result.edges
|
||||
|
||||
# Without configured capacity these should all be nil
|
||||
assert Map.has_key?(edge, :utilization_pct)
|
||||
assert Map.has_key?(edge, :capacity_bps)
|
||||
assert Map.has_key?(edge, :throughput_bps)
|
||||
assert edge.utilization_pct == nil
|
||||
end
|
||||
|
||||
test "includes discovered nodes on 'all' tab",
|
||||
%{router: router, router_iface: iface, organization: org} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
target_device_id: nil,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.9,
|
||||
discovered_remote_mac: "ff:ff:ff:00:00:11",
|
||||
discovered_remote_name: "wm-cpe",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
result = Topology.get_topology_for_weathermap(org.id, "all")
|
||||
discovered = Enum.filter(result.nodes, & &1.discovered)
|
||||
assert length(discovered) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_lldp_neighbors/1" do
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
test "returns :device_not_found for missing device" do
|
||||
assert {:error, :device_not_found} =
|
||||
Topology.discover_lldp_neighbors(Ecto.UUID.generate())
|
||||
end
|
||||
|
||||
test "stores discovered neighbors and returns count", %{router: router} do
|
||||
stub(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||
{:ok, "core-router"}
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _target, oid, _opts ->
|
||||
cond do
|
||||
String.contains?(oid, "1.0.8802.1.1.2.1.4.1.1.9") ->
|
||||
{:ok, [%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.5.1", value: "neighbor-sw"}]}
|
||||
|
||||
String.contains?(oid, "1.0.8802.1.1.2.1.3.7.1.4") ->
|
||||
{:ok, [%{oid: "1.0.8802.1.1.2.1.3.7.1.4.5", value: "ge-0/0/5"}]}
|
||||
|
||||
true ->
|
||||
{:ok, []}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, 1} = Topology.discover_lldp_neighbors(router.id)
|
||||
|
||||
neighbors = Topology.list_lldp_neighbors(router.id)
|
||||
assert length(neighbors) == 1
|
||||
[n] = neighbors
|
||||
assert n.neighbor_name == "neighbor-sw"
|
||||
assert n.local_port == "ge-0/0/5"
|
||||
end
|
||||
|
||||
test "returns count 0 when no neighbors are walked", %{router: router} do
|
||||
stub(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, "core-router"} end)
|
||||
stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:ok, []} end)
|
||||
|
||||
assert {:ok, 0} = Topology.discover_lldp_neighbors(router.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_lldp_neighbors/1 and list_site_lldp_neighbors/1" do
|
||||
test "returns neighbors ordered by local_port and neighbor_name",
|
||||
%{router: router} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "zebra-sw",
|
||||
local_port: "ge-0/0/2",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: []
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "alpha-sw",
|
||||
local_port: "ge-0/0/1",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: []
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
neighbors = Topology.list_lldp_neighbors(router.id)
|
||||
assert length(neighbors) == 2
|
||||
assert hd(neighbors).local_port == "ge-0/0/1"
|
||||
end
|
||||
|
||||
test "list_site_lldp_neighbors returns neighbors for devices in a site",
|
||||
%{router: router, site: site} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "site-neighbor",
|
||||
local_port: "ge-0/0/1",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: []
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
neighbors = Topology.list_site_lldp_neighbors(site.id)
|
||||
assert length(neighbors) == 1
|
||||
assert hd(neighbors).neighbor_name == "site-neighbor"
|
||||
end
|
||||
|
||||
test "returns empty list for site with no neighbors", %{site: site} do
|
||||
assert Topology.list_site_lldp_neighbors(site.id) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_neighbor/3" do
|
||||
test "inserts a new neighbor record", %{router: router} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, neighbor} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "fresh-neighbor",
|
||||
local_port: "ge-0/0/1",
|
||||
remote_port: "ge-1/1/1",
|
||||
remote_port_id: "100",
|
||||
management_addresses: ["10.99.0.1"]
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
assert neighbor.neighbor_name == "fresh-neighbor"
|
||||
assert neighbor.management_addresses == ["10.99.0.1"]
|
||||
end
|
||||
|
||||
test "updates an existing neighbor on same device+port+name", %{router: router} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
later = DateTime.add(now, 60, :second)
|
||||
|
||||
{:ok, original} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "persistent-neighbor",
|
||||
local_port: "ge-0/0/2",
|
||||
remote_port: "ge-2/2/2",
|
||||
remote_port_id: "200",
|
||||
management_addresses: ["10.99.0.2"]
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
{:ok, updated} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "persistent-neighbor",
|
||||
local_port: "ge-0/0/2",
|
||||
remote_port: "ge-2/2/2-updated",
|
||||
remote_port_id: "201",
|
||||
management_addresses: ["10.99.0.99"]
|
||||
},
|
||||
later
|
||||
)
|
||||
|
||||
assert original.id == updated.id
|
||||
assert updated.remote_port == "ge-2/2/2-updated"
|
||||
assert updated.remote_port_id == "201"
|
||||
assert updated.management_addresses == ["10.99.0.99"]
|
||||
end
|
||||
|
||||
test "links neighbor_device_id when neighbor matches a managed device by IP", %{
|
||||
router: router,
|
||||
switch: switch
|
||||
} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, neighbor} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "Some-Other-Name",
|
||||
local_port: "ge-0/0/3",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: [switch.ip_address]
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
assert neighbor.neighbor_device_id == switch.id
|
||||
end
|
||||
|
||||
test "links neighbor_device_id when neighbor matches a managed device by name", %{
|
||||
router: router,
|
||||
switch: switch
|
||||
} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, neighbor} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: switch.name,
|
||||
local_port: "ge-0/0/4",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: []
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
assert neighbor.neighbor_device_id == switch.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "remove_stale_lldp_neighbors/1" do
|
||||
test "deletes neighbors whose last_seen_at is older than cutoff", %{router: router} do
|
||||
old = DateTime.utc_now() |> DateTime.add(-48 * 3600, :second) |> DateTime.truncate(:second)
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "stale-neighbor",
|
||||
local_port: "ge-0/0/9",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: []
|
||||
},
|
||||
old
|
||||
)
|
||||
|
||||
{count, _} = Topology.remove_stale_lldp_neighbors(24)
|
||||
assert count >= 1
|
||||
assert Topology.list_lldp_neighbors(router.id) == []
|
||||
end
|
||||
|
||||
test "does not delete recent neighbors", %{router: router} do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_neighbor(
|
||||
router.id,
|
||||
%{
|
||||
neighbor_name: "fresh-neighbor",
|
||||
local_port: "ge-0/0/10",
|
||||
remote_port: nil,
|
||||
remote_port_id: nil,
|
||||
management_addresses: []
|
||||
},
|
||||
now
|
||||
)
|
||||
|
||||
{count, _} = Topology.remove_stale_lldp_neighbors(24)
|
||||
assert count == 0
|
||||
assert length(Topology.list_lldp_neighbors(router.id)) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_device_by_name/2 with non-binary input" do
|
||||
test "returns nil when name is not a binary", %{organization: org} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_name(lookup, nil) == nil
|
||||
assert Topology.find_device_by_name(lookup, 42) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve_device/2 with non-evidence input" do
|
||||
test "returns nil for inputs that aren't a recognized evidence map",
|
||||
%{organization: org} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.resolve_device(lookup, %{}) == nil
|
||||
assert Topology.resolve_device(lookup, nil) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ defmodule Towerops.TraceTest do
|
|||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Gaiia.Account
|
||||
alias Towerops.Gaiia.BillingSubscription
|
||||
alias Towerops.Gaiia.InventoryItem
|
||||
alias Towerops.Preseem.AccessPoint
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Trace
|
||||
|
||||
setup do
|
||||
|
|
@ -160,6 +165,262 @@ defmodule Towerops.TraceTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "assemble_trace/3 — account/inventory/access_point happy paths" do
|
||||
setup %{organization: organization, device: device} do
|
||||
gaiia_id = "gaiia_acc_#{System.unique_integer([:positive])}"
|
||||
|
||||
{:ok, account} =
|
||||
%Account{}
|
||||
|> Account.changeset(%{
|
||||
organization_id: organization.id,
|
||||
gaiia_id: gaiia_id,
|
||||
readable_id: "ACC-1234",
|
||||
name: "Alice Subscriber",
|
||||
status: "active",
|
||||
mrr: Decimal.new("89.99"),
|
||||
address: %{"street1" => "1 Test St", "city" => "Townsville"}
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, subscription} =
|
||||
%BillingSubscription{}
|
||||
|> BillingSubscription.changeset(%{
|
||||
organization_id: organization.id,
|
||||
gaiia_id: "sub_#{System.unique_integer([:positive])}",
|
||||
account_gaiia_id: gaiia_id,
|
||||
status: "active",
|
||||
product_name: "Gigabit Plan",
|
||||
mrr_amount: Decimal.new("89.99"),
|
||||
speed_download: 1000,
|
||||
speed_upload: 100
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, item} =
|
||||
%InventoryItem{}
|
||||
|> InventoryItem.changeset(%{
|
||||
organization_id: organization.id,
|
||||
gaiia_id: "item_#{System.unique_integer([:positive])}",
|
||||
name: "Customer CPE",
|
||||
ip_address: "192.168.1.50",
|
||||
serial_number: "SN-ABC123",
|
||||
model_name: "Model-X",
|
||||
assigned_account_gaiia_id: gaiia_id,
|
||||
device_id: device.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, access_point} =
|
||||
%AccessPoint{}
|
||||
|> AccessPoint.changeset(%{
|
||||
organization_id: organization.id,
|
||||
preseem_id: "ap_#{System.unique_integer([:positive])}",
|
||||
name: "Sector-A",
|
||||
ip_address: "10.0.1.1",
|
||||
model: "PMP-450",
|
||||
qoe_score: 4.5,
|
||||
capacity_score: 3.9,
|
||||
rf_score: 4.2,
|
||||
subscriber_count: 12,
|
||||
airtime_utilization: 0.5,
|
||||
device_id: device.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
%{
|
||||
account: account,
|
||||
subscription: subscription,
|
||||
inventory_item: item,
|
||||
access_point: access_point
|
||||
}
|
||||
end
|
||||
|
||||
test "assembles trace from account with subscriptions and inventory", %{
|
||||
organization: organization,
|
||||
account: account,
|
||||
device: device,
|
||||
access_point: access_point
|
||||
} do
|
||||
trace = Trace.assemble_trace(organization.id, :account, account.id)
|
||||
|
||||
assert trace
|
||||
assert trace.subscriber.name == "Alice Subscriber"
|
||||
assert trace.subscriber.account_id == "ACC-1234"
|
||||
assert trace.subscriber.plan == "Gigabit Plan"
|
||||
assert trace.subscriber.subscription_count == 1
|
||||
assert trace.subscriber.speed_download == 1000
|
||||
assert trace.subscriber.address =~ "Townsville"
|
||||
assert length(trace.inventory_items) == 1
|
||||
assert trace.device.id == device.id
|
||||
assert trace.access_point.id == access_point.id
|
||||
assert trace.qoe_metrics.qoe_score == 4.5
|
||||
assert trace.qoe_metrics.subscriber_count == 12
|
||||
assert trace.peer_impact.subscriber_count == 12
|
||||
assert trace.peer_impact.peer_ap_count == 0
|
||||
assert trace.alerts == []
|
||||
assert trace.config_changes == []
|
||||
assert trace.insights == []
|
||||
end
|
||||
|
||||
test "assembles trace from inventory_item with linked account", %{
|
||||
organization: organization,
|
||||
inventory_item: item,
|
||||
account: account,
|
||||
device: device
|
||||
} do
|
||||
trace = Trace.assemble_trace(organization.id, :inventory_item, item.id)
|
||||
|
||||
assert trace
|
||||
assert trace.subscriber.name == account.name
|
||||
assert trace.device.id == device.id
|
||||
assert trace.access_point
|
||||
refute trace.inventory_items == []
|
||||
end
|
||||
|
||||
test "assembles trace from inventory_item with no linked account", %{
|
||||
organization: organization,
|
||||
device: device
|
||||
} do
|
||||
{:ok, orphan} =
|
||||
%InventoryItem{}
|
||||
|> InventoryItem.changeset(%{
|
||||
organization_id: organization.id,
|
||||
gaiia_id: "item_orphan_#{System.unique_integer([:positive])}",
|
||||
name: "Orphan CPE",
|
||||
device_id: device.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
trace = Trace.assemble_trace(organization.id, :inventory_item, orphan.id)
|
||||
|
||||
assert trace
|
||||
assert trace.subscriber == nil
|
||||
# falls back to wrapping the item itself in inventory_items
|
||||
assert Enum.any?(trace.inventory_items, &(&1.id == orphan.id))
|
||||
assert trace.device.id == device.id
|
||||
end
|
||||
|
||||
test "assembles trace from access_point", %{
|
||||
organization: organization,
|
||||
access_point: ap,
|
||||
device: device
|
||||
} do
|
||||
trace = Trace.assemble_trace(organization.id, :access_point, ap.id)
|
||||
|
||||
assert trace
|
||||
assert trace.access_point.id == ap.id
|
||||
assert trace.device.id == device.id
|
||||
assert trace.subscriber == nil
|
||||
assert trace.inventory_items == []
|
||||
assert trace.qoe_metrics.qoe_score == 4.5
|
||||
end
|
||||
|
||||
test "assembles trace from access_point with no device", %{organization: organization} do
|
||||
{:ok, ap} =
|
||||
%AccessPoint{}
|
||||
|> AccessPoint.changeset(%{
|
||||
organization_id: organization.id,
|
||||
preseem_id: "ap_nodev_#{System.unique_integer([:positive])}",
|
||||
name: "Detached AP"
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
trace = Trace.assemble_trace(organization.id, :access_point, ap.id)
|
||||
|
||||
assert trace
|
||||
assert trace.access_point.id == ap.id
|
||||
assert trace.device == nil
|
||||
assert trace.peer_impact.peer_ap_count == 0
|
||||
end
|
||||
|
||||
test "trace_from_device finds account through inventory", %{
|
||||
organization: organization,
|
||||
device: device,
|
||||
account: account
|
||||
} do
|
||||
trace = Trace.assemble_trace(organization.id, :device, device.id)
|
||||
|
||||
assert trace
|
||||
assert trace.subscriber
|
||||
assert trace.subscriber.name == account.name
|
||||
refute trace.inventory_items == []
|
||||
end
|
||||
|
||||
test "peer_impact counts other APs on the same device", %{
|
||||
organization: organization,
|
||||
access_point: ap,
|
||||
device: device
|
||||
} do
|
||||
{:ok, _peer} =
|
||||
%AccessPoint{}
|
||||
|> AccessPoint.changeset(%{
|
||||
organization_id: organization.id,
|
||||
preseem_id: "ap_peer_#{System.unique_integer([:positive])}",
|
||||
name: "Sector-B",
|
||||
device_id: device.id
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
trace = Trace.assemble_trace(organization.id, :access_point, ap.id)
|
||||
|
||||
assert trace.peer_impact.peer_ap_count == 1
|
||||
assert length(trace.peer_impact.peer_aps) == 1
|
||||
end
|
||||
|
||||
test "search finds accounts by name", %{organization: organization} do
|
||||
results = Trace.search(organization.id, "Alice")
|
||||
account_results = Enum.filter(results, &(&1.type == :account))
|
||||
assert length(account_results) == 1
|
||||
assert hd(account_results).label == "Alice Subscriber"
|
||||
end
|
||||
|
||||
test "search finds inventory by serial", %{organization: organization} do
|
||||
results = Trace.search(organization.id, "SN-ABC")
|
||||
inventory_results = Enum.filter(results, &(&1.type == :inventory_item))
|
||||
assert length(inventory_results) == 1
|
||||
end
|
||||
|
||||
test "search finds access_points by name", %{organization: organization} do
|
||||
results = Trace.search(organization.id, "Sector")
|
||||
ap_results = Enum.filter(results, &(&1.type == :access_point))
|
||||
refute ap_results == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "trace_from_device with no inventory items" do
|
||||
test "returns nil subscriber when device has no inventory link", %{
|
||||
organization: organization,
|
||||
device: device
|
||||
} do
|
||||
trace = Trace.assemble_trace(organization.id, :device, device.id)
|
||||
|
||||
assert trace
|
||||
assert trace.subscriber == nil
|
||||
assert trace.inventory_items == []
|
||||
end
|
||||
|
||||
test "returns nil subscriber when inventory exists but has no assigned account", %{
|
||||
organization: organization,
|
||||
device: device
|
||||
} do
|
||||
{:ok, _orphan} =
|
||||
%InventoryItem{}
|
||||
|> InventoryItem.changeset(%{
|
||||
organization_id: organization.id,
|
||||
gaiia_id: "orphan_inv_#{System.unique_integer([:positive])}",
|
||||
device_id: device.id,
|
||||
name: "Unassigned"
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
trace = Trace.assemble_trace(organization.id, :device, device.id)
|
||||
|
||||
assert trace
|
||||
assert trace.subscriber == nil
|
||||
assert length(trace.inventory_items) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "search/2 (edge cases)" do
|
||||
test "returns empty list for whitespace query", %{organization: organization} do
|
||||
assert Trace.search(organization.id, " ") == []
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
|
|||
import Mox
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Agents.AgentToken
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Snmp
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
alias Towerops.Workers.DiscoveryWorker
|
||||
|
|
@ -232,6 +234,213 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "perform/1 with agent assignment" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Agent Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Agent Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Agent Device",
|
||||
ip_address: "10.0.0.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device: device, organization: organization}
|
||||
end
|
||||
|
||||
test "falls back to direct discovery when assigned agent has never been seen",
|
||||
%{device: device, organization: organization} do
|
||||
# Assign an agent that has never reported (last_seen_at is nil → offline).
|
||||
{:ok, agent_token, _token_string} =
|
||||
Towerops.Agents.create_agent_token(organization.id, "Offline Agent")
|
||||
|
||||
{:ok, _assignment} =
|
||||
Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
|
||||
# No cloud pollers exist, so it should fall through to direct discovery.
|
||||
stub(SnmpMock, :get, fn _target, _oid, _opts -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :get_multiple, fn _target, _oids, _opts -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:error, :timeout} end)
|
||||
|
||||
# Direct discovery may classify as :discard (permanent
|
||||
# :device_unresponsive) or {:error, _} (transient timeout). Either
|
||||
# outcome confirms the agent-offline → cloud-poller → direct path
|
||||
# and exercises handle_assigned_agent_discovery /
|
||||
# attempt_cloud_poller_discovery branches without a hard log assertion
|
||||
# (test logger level is :error).
|
||||
result = DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
assert result == :discard or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "falls back to direct discovery when assigned agent token is deleted",
|
||||
%{device: device, organization: organization} do
|
||||
{:ok, agent_token, _token_string} =
|
||||
Towerops.Agents.create_agent_token(organization.id, "To-Be-Deleted Agent")
|
||||
|
||||
{:ok, _assignment} =
|
||||
Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
|
||||
# Drop the assignment first, then the token, to dodge the FK constraint.
|
||||
# This forces handle_assigned_agent_discovery to rescue
|
||||
# Ecto.NoResultsError and fall back to attempt_cloud_poller_discovery.
|
||||
Towerops.Repo.delete_all(from(a in Towerops.Agents.AgentAssignment, where: a.agent_token_id == ^agent_token.id))
|
||||
|
||||
Towerops.Repo.delete_all(from(t in AgentToken, where: t.id == ^agent_token.id))
|
||||
|
||||
stub(SnmpMock, :get, fn _target, _oid, _opts -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :get_multiple, fn _target, _oids, _opts -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:error, :timeout} end)
|
||||
|
||||
result = DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
assert result == :discard or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "uses online cloud poller when no agent is assigned and reports completion via PubSub",
|
||||
%{device: device} do
|
||||
# Create a cloud poller and mark it online
|
||||
{:ok, cloud_poller, _token} = Towerops.Agents.create_cloud_poller("Online Cloud Poller")
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^cloud_poller.id),
|
||||
set: [last_seen_at: DateTime.utc_now()]
|
||||
)
|
||||
|
||||
# Subscribe to the PubSub channel that the worker broadcasts on so we can
|
||||
# detect that the discovery request was sent and then simulate the agent
|
||||
# finishing by bumping the device's last_discovery_at.
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{cloud_poller.id}:discovery")
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end)
|
||||
|
||||
# Wait for the worker to broadcast the discovery request
|
||||
assert_receive {:discovery_requested, device_id}, 5_000
|
||||
assert device_id == device.id
|
||||
|
||||
# Simulate the agent finishing by bumping last_discovery_at
|
||||
Towerops.Repo.update_all(
|
||||
from(d in Device, where: d.id == ^device.id),
|
||||
set: [last_discovery_at: DateTime.truncate(DateTime.utc_now(), :second)]
|
||||
)
|
||||
|
||||
assert :ok = Task.await(task, 10_000)
|
||||
end
|
||||
|
||||
test "agent goes offline mid-discovery returns to cloud poller fallback chain",
|
||||
%{device: device, organization: organization} do
|
||||
# Online local agent
|
||||
{:ok, agent_token, _token} =
|
||||
Towerops.Agents.create_agent_token(organization.id, "Local Agent")
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^agent_token.id),
|
||||
set: [last_seen_at: DateTime.utc_now()]
|
||||
)
|
||||
|
||||
{:ok, _assignment} =
|
||||
Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:discovery")
|
||||
|
||||
stub(SnmpMock, :get, fn _target, _oid, _opts -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :get_multiple, fn _target, _oids, _opts -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:error, :timeout} end)
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end)
|
||||
|
||||
assert_receive {:discovery_requested, _device_id}, 5_000
|
||||
|
||||
# Mark agent as offline so wait_loop bails out with :agent_offline
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^agent_token.id),
|
||||
set: [last_seen_at: DateTime.add(DateTime.utc_now(), -30, :minute)]
|
||||
)
|
||||
|
||||
# No cloud pollers configured → falls through to direct discovery which
|
||||
# returns either :discard (permanent device_unresponsive) or {:error, _}
|
||||
# depending on how SNMP errors are classified.
|
||||
result = Task.await(task, 30_000)
|
||||
assert result == :discard or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "device deleted during agent discovery returns :ok and exits",
|
||||
%{device: device, organization: organization} do
|
||||
{:ok, agent_token, _token} =
|
||||
Towerops.Agents.create_agent_token(organization.id, "Online Agent")
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^agent_token.id),
|
||||
set: [last_seen_at: DateTime.utc_now()]
|
||||
)
|
||||
|
||||
{:ok, _assignment} =
|
||||
Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:discovery")
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end)
|
||||
|
||||
assert_receive {:discovery_requested, _device_id}, 5_000
|
||||
|
||||
# Hard-delete the device so check_discovery_completion returns :device_deleted
|
||||
Towerops.Repo.delete_all(from(d in Device, where: d.id == ^device.id))
|
||||
|
||||
# `attempt_agent_discovery` translates :device_deleted to :ok and returns.
|
||||
assert :ok = Task.await(task, 30_000)
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 crash handling" do
|
||||
test "discards (without retrying) when an unexpected exception is raised" do
|
||||
# Pass a non-string device_id so get_device_with_details raises a
|
||||
# Postgrex/Ecto error inside the rescue block. The worker should log
|
||||
# and return :discard rather than re-raising.
|
||||
import ExUnit.CaptureLog
|
||||
|
||||
log =
|
||||
capture_log(fn ->
|
||||
assert :discard =
|
||||
DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => "not-a-uuid"}})
|
||||
end)
|
||||
|
||||
assert log =~ "Discovery job crashed unexpectedly"
|
||||
end
|
||||
end
|
||||
|
||||
describe "retryable_error?/1 additional cases" do
|
||||
test "tuple errors are retryable by default" do
|
||||
assert DiscoveryWorker.retryable_error?({:snmp_error, :nxdomain})
|
||||
end
|
||||
|
||||
test "string errors are retryable by default" do
|
||||
assert DiscoveryWorker.retryable_error?("some error message")
|
||||
end
|
||||
|
||||
test "nil is retryable by default" do
|
||||
assert DiscoveryWorker.retryable_error?(nil)
|
||||
end
|
||||
end
|
||||
|
||||
describe "retryable_error?/1" do
|
||||
test "timeout is retryable" do
|
||||
assert DiscoveryWorker.retryable_error?(:timeout)
|
||||
|
|
|
|||
335
test/towerops_web/live/check_live/form_component_test.exs
Normal file
335
test/towerops_web/live/check_live/form_component_test.exs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
defmodule ToweropsWeb.CheckLive.FormComponentTest do
|
||||
@moduledoc """
|
||||
Integration tests for ToweropsWeb.CheckLive.FormComponent
|
||||
exercised via the DeviceLive.Show LiveView (its only mount point).
|
||||
"""
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Towerops.Monitoring
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{user: user} do
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Check Org"}, user.id)
|
||||
|
||||
device =
|
||||
Towerops.DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
name: "Edge Router",
|
||||
ip_address: "10.0.0.5"
|
||||
})
|
||||
|
||||
%{organization: organization, device: device}
|
||||
end
|
||||
|
||||
defp open_check_form(view) do
|
||||
view
|
||||
|> element("button[phx-click=add_check]", "Add Check")
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
# Triggers the `change_type` event on the live_component's select element directly.
|
||||
# We can't use `form/3` here because the form has `phx-change="validate"`, which
|
||||
# would route through the validate handler instead.
|
||||
defp change_type(view, type) do
|
||||
view
|
||||
|> element("select[name='check[check_type]']")
|
||||
|> render_change(%{check: %{check_type: type}})
|
||||
end
|
||||
|
||||
describe "opening the form (add_check event)" do
|
||||
test "renders the form with default http fields", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
html = open_check_form(view)
|
||||
|
||||
assert html =~ "Add Service Check"
|
||||
assert html =~ "Check Type"
|
||||
# http-specific fields
|
||||
assert html =~ "URL"
|
||||
assert html =~ "Method"
|
||||
assert html =~ "Expected Status"
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_type event" do
|
||||
test "switches to TCP fields and auto-fills host from device IP", %{
|
||||
conn: conn,
|
||||
device: device
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
html = change_type(view, "tcp")
|
||||
|
||||
# TCP-specific labels
|
||||
assert html =~ "Send String"
|
||||
assert html =~ "Expect String"
|
||||
# The device IP should be auto-filled into the host field
|
||||
assert html =~ "10.0.0.5"
|
||||
end
|
||||
|
||||
test "switches to DNS fields", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
html = change_type(view, "dns")
|
||||
|
||||
assert html =~ "Hostname"
|
||||
assert html =~ "Record Type"
|
||||
assert html =~ "DNS Server"
|
||||
end
|
||||
|
||||
test "switches to SSL fields", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
html = change_type(view, "ssl")
|
||||
|
||||
assert html =~ "Warning Days"
|
||||
# SSL also auto-fills host from device IP
|
||||
assert html =~ "10.0.0.5"
|
||||
end
|
||||
|
||||
test "stays on http fields when re-selecting http", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
html = change_type(view, "http")
|
||||
|
||||
assert html =~ "URL"
|
||||
assert html =~ "Method"
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate event" do
|
||||
test "form re-renders without crashing on a partial fill", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
check_type: "http",
|
||||
name: "",
|
||||
url: "https://example.com",
|
||||
method: "GET",
|
||||
expected_status: "200",
|
||||
verify_ssl: "true",
|
||||
follow_redirects: "true",
|
||||
interval_seconds: "60",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
# Form re-rendered (the modal heading is still present)
|
||||
assert html =~ "Add Service Check"
|
||||
end
|
||||
end
|
||||
|
||||
describe "save event - new check" do
|
||||
test "creates an HTTP check successfully", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
check_type: "http",
|
||||
name: "My HTTP Check",
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
expected_status: "200",
|
||||
verify_ssl: "true",
|
||||
follow_redirects: "true",
|
||||
interval_seconds: "60",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Check created successfully"
|
||||
assert html =~ "My HTTP Check"
|
||||
end
|
||||
|
||||
test "creates a TCP check successfully", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
# First switch type so the TCP fields render
|
||||
change_type(view, "tcp")
|
||||
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
check_type: "tcp",
|
||||
name: "My TCP Check",
|
||||
host: "10.0.0.5",
|
||||
port: "22",
|
||||
send_string: "",
|
||||
expect_string: "",
|
||||
interval_seconds: "60",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Check created successfully"
|
||||
assert html =~ "My TCP Check"
|
||||
end
|
||||
|
||||
test "creates a DNS check successfully", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
change_type(view, "dns")
|
||||
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
check_type: "dns",
|
||||
name: "My DNS Check",
|
||||
hostname: "example.com",
|
||||
record_type: "A",
|
||||
dns_server: "",
|
||||
expected_result: "",
|
||||
interval_seconds: "60",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Check created successfully"
|
||||
assert html =~ "My DNS Check"
|
||||
end
|
||||
|
||||
test "creates an SSL check successfully", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
change_type(view, "ssl")
|
||||
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
check_type: "ssl",
|
||||
name: "My SSL Check",
|
||||
host: "example.com",
|
||||
port: "443",
|
||||
warning_days: "30",
|
||||
interval_seconds: "300",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Check created successfully"
|
||||
assert html =~ "My SSL Check"
|
||||
end
|
||||
|
||||
test "missing name keeps form open without success flash", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
open_check_form(view)
|
||||
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
check_type: "http",
|
||||
name: "",
|
||||
url: "https://example.com",
|
||||
method: "GET",
|
||||
expected_status: "200",
|
||||
verify_ssl: "true",
|
||||
follow_redirects: "true",
|
||||
interval_seconds: "60",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
# Form is still showing (we did not get a "Check created successfully" flash)
|
||||
assert html =~ "Add Service Check"
|
||||
refute html =~ "Check created successfully"
|
||||
end
|
||||
end
|
||||
|
||||
describe "save event - editing existing check" do
|
||||
test "updates an existing HTTP check", %{
|
||||
conn: conn,
|
||||
device: device,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, check} =
|
||||
Monitoring.create_check(%{
|
||||
name: "Original Name",
|
||||
check_type: "http",
|
||||
organization_id: organization.id,
|
||||
device_id: device.id,
|
||||
source_type: "manual",
|
||||
interval_seconds: 60,
|
||||
timeout_ms: 5000,
|
||||
config: %{
|
||||
"url" => "https://example.com",
|
||||
"method" => "GET",
|
||||
"expected_status" => 200,
|
||||
"verify_ssl" => true,
|
||||
"follow_redirects" => true
|
||||
}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
|
||||
view
|
||||
|> element(~s|button[phx-click="edit_check"][phx-value-id="#{check.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> form("#check-form",
|
||||
check: %{
|
||||
name: "Updated Name",
|
||||
url: "https://example.com",
|
||||
method: "GET",
|
||||
expected_status: "200",
|
||||
verify_ssl: "true",
|
||||
follow_redirects: "true",
|
||||
interval_seconds: "120",
|
||||
timeout_ms: "5000"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Check updated successfully"
|
||||
assert html =~ "Updated Name"
|
||||
|
||||
reloaded = Monitoring.get_check(check.id)
|
||||
assert reloaded.name == "Updated Name"
|
||||
assert reloaded.interval_seconds == 120
|
||||
end
|
||||
end
|
||||
|
||||
describe "close event" do
|
||||
test "Cancel button closes the form without saving", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
|
||||
html = open_check_form(view)
|
||||
assert html =~ "Add Service Check"
|
||||
|
||||
# The "Cancel" text-button at the bottom of the modal sends a
|
||||
# `notify_parent(:close)` message; the parent LV's handle_info
|
||||
# closes the form on the next render.
|
||||
view
|
||||
|> element("#check-form button", "Cancel")
|
||||
|> render_click()
|
||||
|
||||
# Re-render to pick up the parent's close message
|
||||
html = render(view)
|
||||
refute html =~ "Add Service Check"
|
||||
end
|
||||
end
|
||||
end
|
||||
511
test/towerops_web/live/device_live/form_events_test.exs
Normal file
511
test/towerops_web/live/device_live/form_events_test.exs
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
defmodule ToweropsWeb.DeviceLive.FormEventsTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{user: user} do
|
||||
{:ok, organization} =
|
||||
Towerops.Organizations.create_organization(%{name: "Events Org"}, user.id)
|
||||
|
||||
# Enable sites so the site_id select is rendered in the form
|
||||
{:ok, organization} =
|
||||
Towerops.Organizations.update_organization(organization, %{use_sites: true})
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Events Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
%{organization: organization, site: site}
|
||||
end
|
||||
|
||||
describe "edit access errors" do
|
||||
test "redirects to /devices when device not found", %{conn: conn} do
|
||||
missing_id = Ecto.UUID.generate()
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/devices", flash: flash}}} =
|
||||
live(conn, ~p"/devices/#{missing_id}/edit")
|
||||
|
||||
assert flash["error"] =~ "not found" or flash["error"] =~ "Device not found"
|
||||
end
|
||||
|
||||
test "redirects when device belongs to a different organization", %{conn: conn, user: user} do
|
||||
# Create another org with another user, and a device under it
|
||||
other_user = Towerops.AccountsFixtures.user_fixture()
|
||||
|
||||
{:ok, other_org} =
|
||||
Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
|
||||
|
||||
{:ok, other_site} =
|
||||
Towerops.Sites.create_site(%{name: "Other Site", organization_id: other_org.id})
|
||||
|
||||
{:ok, other_device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Other Device",
|
||||
ip_address: "10.10.10.10",
|
||||
site_id: other_site.id,
|
||||
organization_id: other_org.id,
|
||||
snmp_enabled: false
|
||||
})
|
||||
|
||||
# Sanity: current user is logged in but doesn't belong to other_org
|
||||
_ = user
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/devices"}}} =
|
||||
live(conn, ~p"/devices/#{other_device.id}/edit")
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate event branches" do
|
||||
test "detects duplicate IP in same site", %{conn: conn, site: site, organization: organization} do
|
||||
{:ok, _existing} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Existing",
|
||||
ip_address: "10.0.0.55",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: false
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{
|
||||
name: "New",
|
||||
ip_address: "10.0.0.55",
|
||||
site_id: site.id
|
||||
}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
# Form should still render
|
||||
assert html =~ "New Device"
|
||||
# Page should not be dead
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "trims whitespace around IP via sanitize_device_params", %{conn: conn, site: site} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_html =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "Trimmy", ip_address: " 10.0.0.42 ", site_id: site.id}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
# Sanitization runs without crashing; the actual trim assertion is
|
||||
# covered by Form.sanitize_ip_address/1 in form_helpers_test.exs.
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "non-routable IP with no agent triggers cloud poller error flag", %{
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
# No agents created → org has no default → cloud poller path
|
||||
html =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "X", ip_address: "10.20.30.40", site_id: site.id}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
# Live view stays alive; the assign drives a UI flag in the template.
|
||||
# We just assert no crash and that form is still rendered.
|
||||
assert html =~ "New Device"
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
end
|
||||
|
||||
describe "save :new error path" do
|
||||
test "renders form when device cannot be created (e.g. invalid IP)", %{conn: conn, site: site} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "Bad", ip_address: "not-an-ip", site_id: site.id}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
# Should not redirect; form renders again with errors
|
||||
assert is_binary(html)
|
||||
assert html =~ "New Device"
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
end
|
||||
|
||||
describe "save :edit error path" do
|
||||
test "re-renders form when update fails", %{conn: conn, site: site, organization: organization} do
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Editable",
|
||||
ip_address: "192.168.1.40",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: false
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#device-form", device: %{name: "", ip_address: ""})
|
||||
|> render_submit()
|
||||
|
||||
# Should not redirect
|
||||
assert is_binary(html)
|
||||
assert Process.alive?(view.pid)
|
||||
assert html =~ "Edit Device"
|
||||
end
|
||||
end
|
||||
|
||||
describe "switch_monitoring_mode" do
|
||||
test "icmp_only sets snmp_enabled false in changeset", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_ = view |> element("#monitoring-mode-icmp-only") |> render_click()
|
||||
# Switching back also exercises the alternate branch
|
||||
html = view |> element("#monitoring-mode-snmp-and-icmp") |> render_click()
|
||||
|
||||
assert html =~ "New Device"
|
||||
end
|
||||
end
|
||||
|
||||
describe "test_snmp event" do
|
||||
# The test_snmp handler updates the :snmp_test_result assign in different
|
||||
# ways depending on form state. Asserting on the rendered HTML is brittle
|
||||
# because the message lives deep in the form template and is conditional
|
||||
# on monitoring_mode + form state. Instead, we exercise the code paths
|
||||
# and confirm the LiveView remains alive (no crashes).
|
||||
|
||||
test "handles missing IP gracefully (no crash)", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_ = render_click(view, "test_snmp")
|
||||
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "handles IP with no agent gracefully", %{conn: conn, site: site} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_ =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "X", ip_address: "8.8.8.8", site_id: site.id}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
_ = render_click(view, "test_snmp")
|
||||
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "broadcasts when an agent is available", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
{:ok, agent_token, _str} =
|
||||
Towerops.AgentsFixtures.agent_token_fixture(organization.id, "Tok A")
|
||||
|
||||
# Set as org default so determine_effective_agent_id returns it
|
||||
{:ok, _org} =
|
||||
Towerops.Organizations.update_organization(organization, %{
|
||||
default_agent_token_id: agent_token.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_ =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "X", ip_address: "8.8.8.8", site_id: site.id}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
_ = render_click(view, "test_snmp")
|
||||
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "test_snmp on edit page exercises SNMPv2c credential resolution", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "EditSnmp",
|
||||
ip_address: "192.168.5.5",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true,
|
||||
snmp_community: "public",
|
||||
snmp_version: "2c"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
_ = render_click(view, "test_snmp")
|
||||
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "test_snmp with snmpv3 credentials exercises v3 resolution", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "V3Device",
|
||||
ip_address: "192.168.5.6",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true,
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "v3user",
|
||||
snmpv3_security_level: "authPriv",
|
||||
snmpv3_auth_protocol: "SHA",
|
||||
snmpv3_auth_password: "authpass123",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "privpass123"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
_ = render_click(view, "test_snmp")
|
||||
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
end
|
||||
|
||||
describe "credential_test_result handle_info" do
|
||||
test "success result updates snmp_test_result", %{conn: conn, site: site} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_ =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "X", ip_address: "8.8.8.8", site_id: site.id}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
send(
|
||||
view.pid,
|
||||
{:credential_test_result,
|
||||
%{
|
||||
test_id: Ecto.UUID.generate(),
|
||||
success: true,
|
||||
system_description: "Cisco IOS XE",
|
||||
error_message: nil
|
||||
}}
|
||||
)
|
||||
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "failure result updates snmp_test_result with error message", %{conn: conn, site: site} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
_ =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "X", ip_address: "8.8.8.8", site_id: site.id}
|
||||
)
|
||||
|> render_change()
|
||||
|
||||
send(
|
||||
view.pid,
|
||||
{:credential_test_result,
|
||||
%{
|
||||
test_id: Ecto.UUID.generate(),
|
||||
success: false,
|
||||
system_description: nil,
|
||||
error_message: "Timeout reaching device"
|
||||
}}
|
||||
)
|
||||
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "ignores arbitrary handle_info messages", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/new")
|
||||
|
||||
send(view.pid, {:something_unrelated, :whatever})
|
||||
|
||||
assert Process.alive?(view.pid)
|
||||
assert render(view) =~ "New Device"
|
||||
end
|
||||
end
|
||||
|
||||
describe "query param prefill" do
|
||||
test "snmp_enabled=false query param disables SNMP in initial state", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/new?snmp_enabled=false&name=NoSnmp&ip_address=10.0.0.5")
|
||||
|
||||
assert html =~ "NoSnmp"
|
||||
assert html =~ "10.0.0.5"
|
||||
end
|
||||
|
||||
test "snmp_enabled=true query param keeps SNMP enabled", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/new?snmp_enabled=true&name=YesSnmp&ip_address=10.0.0.6")
|
||||
|
||||
assert html =~ "YesSnmp"
|
||||
assert html =~ "10.0.0.6"
|
||||
end
|
||||
|
||||
test "ignores invalid snmp_enabled query param", %{conn: conn} do
|
||||
{:ok, _view, html} =
|
||||
live(conn, ~p"/devices/new?snmp_enabled=banana&name=BadFlag&ip_address=10.0.0.7")
|
||||
|
||||
assert html =~ "BadFlag"
|
||||
end
|
||||
end
|
||||
|
||||
describe "preselected site" do
|
||||
test "auto-preselects single site when no preselected_id given", %{conn: conn, site: site} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/new")
|
||||
|
||||
# Single site setup → form should reference that site as the selected option
|
||||
assert html =~ site.id
|
||||
end
|
||||
|
||||
test "honors site_id query param", %{conn: conn, site: site} do
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/new?site_id=#{site.id}")
|
||||
|
||||
assert html =~ site.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit form agent assignment" do
|
||||
test "renders edit page with existing agent assignment", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
{:ok, agent_token, _str} =
|
||||
Towerops.AgentsFixtures.agent_token_fixture(organization.id, "Edit Agent")
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "With Agent",
|
||||
ip_address: "192.168.1.77",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
_ = Towerops.Agents.update_device_assignment(device.id, agent_token.id)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
assert html =~ "Edit Device"
|
||||
assert html =~ "With Agent"
|
||||
end
|
||||
|
||||
test "submitting edit with agent_token_id assigns the agent", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
{:ok, agent_token, _str} =
|
||||
Towerops.AgentsFixtures.agent_token_fixture(organization.id, "Submit Agent")
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Assignable",
|
||||
ip_address: "192.168.1.78",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "Assignable", agent_token_id: agent_token.id}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert to =~ device.id
|
||||
|
||||
assignment = Towerops.Agents.get_device_assignment(device.id)
|
||||
assert assignment.agent_token_id == agent_token.id
|
||||
end
|
||||
|
||||
test "submitting edit with empty agent_token_id removes assignment", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
site: site
|
||||
} do
|
||||
{:ok, agent_token, _str} =
|
||||
Towerops.AgentsFixtures.agent_token_fixture(organization.id, "Remove Agent")
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "ClearAgent",
|
||||
ip_address: "192.168.1.79",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
_ = Towerops.Agents.update_device_assignment(device.id, agent_token.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
assert {:error, {:live_redirect, _}} =
|
||||
view
|
||||
|> form("#device-form",
|
||||
device: %{name: "ClearAgent", agent_token_id: ""}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert is_nil(Towerops.Agents.get_device_assignment(device.id))
|
||||
end
|
||||
end
|
||||
|
||||
describe "trigger_discovery success" do
|
||||
test "redirects to device show page when SNMP enabled", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Discoverable",
|
||||
ip_address: "192.168.1.80",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
|
||||
|
||||
result = render_click(view, "trigger_discovery")
|
||||
|
||||
case result do
|
||||
{:error, {:live_redirect, %{to: to}}} ->
|
||||
assert to =~ device.id
|
||||
|
||||
html when is_binary(html) ->
|
||||
# Either redirected (view dead) or page re-rendered
|
||||
if Process.alive?(view.pid), do: assert(html =~ "Edit Device")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuildersTest do
|
||||
use ExUnit.Case, async: true
|
||||
use Towerops.DataCase, async: false
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Snmp.Sensor
|
||||
alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders
|
||||
|
||||
describe "capacity_value_to_bps/1" do
|
||||
|
|
@ -180,4 +181,132 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuildersTest do
|
|||
assert result.timestamp_ms == expected_minute_ms
|
||||
end
|
||||
end
|
||||
|
||||
describe "load_*_chart_data with empty inputs" do
|
||||
test "load_processor_chart_data/1 with [] returns nil" do
|
||||
assert is_nil(ChartBuilders.load_processor_chart_data([]))
|
||||
end
|
||||
|
||||
test "load_storage_volume_chart_data/1 with [] returns nil" do
|
||||
assert is_nil(ChartBuilders.load_storage_volume_chart_data([]))
|
||||
end
|
||||
|
||||
test "load_sensor_chart_data/2 with nil device returns nil" do
|
||||
assert is_nil(ChartBuilders.load_sensor_chart_data(nil, ["temperature"]))
|
||||
end
|
||||
|
||||
test "load_overall_traffic_chart_data/1 with nil device returns nil" do
|
||||
assert is_nil(ChartBuilders.load_overall_traffic_chart_data(nil))
|
||||
end
|
||||
|
||||
test "load_latency_chart_data/1 with no data returns nil" do
|
||||
assert is_nil(ChartBuilders.load_latency_chart_data(Ecto.UUID.generate()))
|
||||
end
|
||||
end
|
||||
|
||||
describe "load_*_chart_data with database fixtures" do
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "CB Org"}, user.id)
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "CB Site", organization_id: org.id})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "CB Device",
|
||||
ip_address: "10.30.0.1",
|
||||
site_id: site.id,
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, snmp_device} =
|
||||
Towerops.Repo.insert(%Towerops.Snmp.Device{
|
||||
device_id: device.id,
|
||||
sys_descr: "test"
|
||||
})
|
||||
|
||||
%{device: device, snmp_device: snmp_device, org: org}
|
||||
end
|
||||
|
||||
test "load_processor_chart_data with empty datasets returns nil",
|
||||
%{snmp_device: snmp_device} do
|
||||
{:ok, processor} =
|
||||
Towerops.Repo.insert(%Towerops.Snmp.Processor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
processor_index: "1",
|
||||
processor_type: "hr_processor",
|
||||
description: "CPU 1"
|
||||
})
|
||||
|
||||
# No readings exist — function returns nil because all datasets are empty
|
||||
assert is_nil(ChartBuilders.load_processor_chart_data([processor]))
|
||||
end
|
||||
|
||||
test "load_storage_volume_chart_data with no readings returns nil",
|
||||
%{snmp_device: snmp_device} do
|
||||
{:ok, storage} =
|
||||
Towerops.Repo.insert(%Towerops.Snmp.Storage{
|
||||
snmp_device_id: snmp_device.id,
|
||||
storage_index: 1,
|
||||
storage_type: "fixed_disk",
|
||||
description: "Disk A"
|
||||
})
|
||||
|
||||
assert is_nil(ChartBuilders.load_storage_volume_chart_data([storage]))
|
||||
end
|
||||
|
||||
test "load_sensor_chart_data filters by sensor_type and returns nil for empty match",
|
||||
%{snmp_device: snmp_device} do
|
||||
{:ok, _temp} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "Temp 1",
|
||||
sensor_oid: "1.1",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
device_with_sensors = Towerops.Repo.preload(snmp_device, :sensors)
|
||||
|
||||
# Filter by a type that doesn't match → nil
|
||||
assert is_nil(ChartBuilders.load_sensor_chart_data(device_with_sensors, ["voltage"]))
|
||||
end
|
||||
|
||||
test "load_sensor_chart_data with matching sensor + reading returns JSON",
|
||||
%{snmp_device: snmp_device} do
|
||||
{:ok, sensor} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_index: "1",
|
||||
sensor_type: "temperature",
|
||||
sensor_descr: "CPU Temp",
|
||||
sensor_oid: "1.1",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
{:ok, _r} =
|
||||
Towerops.Repo.insert(%Towerops.Snmp.SensorReading{
|
||||
sensor_id: sensor.id,
|
||||
value: 45.0,
|
||||
status: "ok",
|
||||
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
})
|
||||
|
||||
device_with_sensors = Towerops.Repo.preload(snmp_device, :sensors)
|
||||
|
||||
result = ChartBuilders.load_sensor_chart_data(device_with_sensors, ["temperature"])
|
||||
assert is_binary(result)
|
||||
assert result =~ "CPU Temp"
|
||||
end
|
||||
|
||||
test "load_overall_traffic_chart_data with no interfaces returns nil",
|
||||
%{snmp_device: snmp_device} do
|
||||
device_full =
|
||||
Towerops.Repo.preload(snmp_device, [:interfaces, :sensors, [device: []]])
|
||||
|
||||
assert is_nil(ChartBuilders.load_overall_traffic_chart_data(device_full))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -363,5 +363,208 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
|
|||
|
||||
assert html =~ "not found"
|
||||
end
|
||||
|
||||
test "reorder_device succeeds for valid device", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, device_a} =
|
||||
Devices.create_device(%{
|
||||
name: "AAA",
|
||||
ip_address: "10.0.0.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, _device_b} =
|
||||
Devices.create_device(%{
|
||||
name: "BBB",
|
||||
ip_address: "10.0.0.2",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("#device-list")
|
||||
|> render_hook("reorder_device", %{
|
||||
"device_id" => device_a.id,
|
||||
"new_position" => "2"
|
||||
})
|
||||
|
||||
assert html =~ "Device order updated"
|
||||
end
|
||||
|
||||
test "reorder_site succeeds for valid site", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("#device-list")
|
||||
|> render_hook("reorder_site", %{
|
||||
"site_id" => site.id,
|
||||
"new_position" => "1"
|
||||
})
|
||||
|
||||
assert html =~ "Site order updated"
|
||||
end
|
||||
|
||||
test "force_rediscover_all enqueues discovery for SNMP-enabled devices", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "SNMP Router",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id,
|
||||
snmp_enabled: true
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html = view |> element("#force-rediscover-all") |> render_click()
|
||||
assert html =~ "Discovery started"
|
||||
end
|
||||
end
|
||||
|
||||
describe "search and status filter events" do
|
||||
setup %{site: site, organization: organization} do
|
||||
{:ok, up_device} =
|
||||
Devices.create_device(%{
|
||||
name: "Alpha",
|
||||
ip_address: "10.1.0.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, up_device} = Devices.update_device_status(up_device, :up)
|
||||
|
||||
{:ok, down_device} =
|
||||
Devices.create_device(%{
|
||||
name: "Bravo",
|
||||
ip_address: "10.2.0.1",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, down_device} = Devices.update_device_status(down_device, :down)
|
||||
|
||||
%{up_device: up_device, down_device: down_device}
|
||||
end
|
||||
|
||||
test "search event does not crash and updates query state", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
# The device-list table uses phx-update="ignore", so we can't observe
|
||||
# filtered rows in the rendered HTML — but we can verify the search input
|
||||
# value reflects the new query and that no crash happens.
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-change=search]", %{search_query: "Alpha"})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ ~s(value="Alpha")
|
||||
end
|
||||
|
||||
test "search with empty query does not crash", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-change=search]", %{search_query: ""})
|
||||
|> render_change()
|
||||
|
||||
# Page still renders normally with both devices
|
||||
assert html =~ "Alpha"
|
||||
assert html =~ "Bravo"
|
||||
end
|
||||
|
||||
test "filter_status up sets the filter and renders without crashing", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[phx-click=filter_status][phx-value-status=up]")
|
||||
|> render_click()
|
||||
|
||||
# The clear-filter X icon only renders when status_filter != "all",
|
||||
# so its presence proves the filter state changed.
|
||||
assert html =~ "Clear filter"
|
||||
end
|
||||
|
||||
test "filter_status down sets the filter", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[phx-click=filter_status][phx-value-status=down]")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "Clear filter"
|
||||
end
|
||||
|
||||
test "clicking the same filter twice toggles back to all", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
view
|
||||
|> element("button[phx-click=filter_status][phx-value-status=up]")
|
||||
|> render_click()
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[phx-click=filter_status][phx-value-status=up]")
|
||||
|> render_click()
|
||||
|
||||
# When status_filter is "all" again, the "Clear filter" X is not shown
|
||||
refute html =~ "Clear filter"
|
||||
end
|
||||
end
|
||||
|
||||
describe "add_discovered_device event" do
|
||||
test "shows error flash on invalid JSON identifier", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices?tab=discovered")
|
||||
|
||||
# Drive the event directly through the LV process — the corresponding
|
||||
# button only renders when there are discovered devices.
|
||||
html = render_hook(view, "add_discovered_device", %{"identifier" => "not-json{"})
|
||||
|
||||
assert html =~ "Invalid device identifier"
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_info catch-all" do
|
||||
test "ignores unrelated messages", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
send(view.pid, {:some_unknown_event, "data"})
|
||||
assert render(view)
|
||||
end
|
||||
|
||||
test "device_status_changed on discovered tab does not trigger reload", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices?tab=discovered")
|
||||
send(view.pid, {:device_status_changed, organization.id})
|
||||
# The view should still render without crashing
|
||||
assert render(view)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
86
test/towerops_web/live/org/settings_live_events_test.exs
Normal file
86
test/towerops_web/live/org/settings_live_events_test.exs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
defmodule ToweropsWeb.Org.SettingsLiveEventsTest do
|
||||
@moduledoc """
|
||||
Drives the more obscure handle_event handlers in OrgSettingsLive that
|
||||
aren't exercised by the tab- or feature-specific test files.
|
||||
|
||||
Note: configure / save_integration / test_connection / etc. require a
|
||||
`:configuring` assign that isn't initialized in mount, so they cannot
|
||||
be driven directly via render_hook. Those branches stay uncovered.
|
||||
"""
|
||||
use ToweropsWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.AgentsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
setup do
|
||||
user = user_fixture(enable_totp: true)
|
||||
organization = organization_fixture(user.id)
|
||||
%{user: user, organization: organization}
|
||||
end
|
||||
|
||||
describe "apply_*_to_all events" do
|
||||
test "apply_snmp_to_all flashes a count message",
|
||||
%{conn: conn, user: user, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings")
|
||||
|
||||
html = render_hook(view, "apply_snmp_to_all", %{})
|
||||
assert html =~ "Applied SNMP configuration"
|
||||
end
|
||||
|
||||
test "apply_agent_to_all flashes a count message",
|
||||
%{conn: conn, user: user, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings")
|
||||
|
||||
html = render_hook(view, "apply_agent_to_all", %{})
|
||||
assert html =~ "Applied default agent"
|
||||
end
|
||||
end
|
||||
|
||||
describe "toggle_default_org event" do
|
||||
test "toggles the user's default organization",
|
||||
%{conn: conn, user: user, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings")
|
||||
|
||||
_ = render_hook(view, "toggle_default_org", %{})
|
||||
assert true
|
||||
end
|
||||
end
|
||||
|
||||
describe "toggle_enabled event" do
|
||||
test "toggles a known provider without crashing",
|
||||
%{conn: conn, user: user, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings?tab=integrations")
|
||||
|
||||
_ = render_hook(view, "toggle_enabled", %{"provider" => "uisp"})
|
||||
assert true
|
||||
end
|
||||
end
|
||||
|
||||
describe "agents tab" do
|
||||
test "default-agent assignment flow renders for orgs with agents",
|
||||
%{conn: conn, user: user, organization: org} do
|
||||
{:ok, _agent_token, _token} = agent_token_fixture(org.id, "Default Agent")
|
||||
|
||||
{:ok, _view, html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings?tab=agents")
|
||||
|
||||
assert html =~ "Default Remote Agent"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue