test: more coverage across LiveViews (gaiia mapping, schedule, integrations)
This commit is contained in:
parent
53f24bb95e
commit
027b532a95
10 changed files with 3429 additions and 0 deletions
|
|
@ -493,4 +493,304 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
|
|||
assert psu2_sensor.last_value == 46.9
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_sensors/1" do
|
||||
test "wraps wireless sensors in :ok tuple with all empty tables" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
assert {:ok, sensors} = Routeros.discover_sensors(@client_opts)
|
||||
assert is_list(sensors)
|
||||
assert sensors == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_wireless_sensors/1 - optical SFP table" do
|
||||
test "discovers all five optical sensor types with correct divisors" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "#{@optical_table}.2.1", value: "SFP+ port1"},
|
||||
# Temperature: 42°C
|
||||
%{oid: "#{@optical_table}.6.1", value: 42},
|
||||
# Supply Voltage: 3300 mV -> 3.3V
|
||||
%{oid: "#{@optical_table}.7.1", value: 3300},
|
||||
# Tx Bias: 7000 uA -> 7.0 mA
|
||||
%{oid: "#{@optical_table}.8.1", value: 7000},
|
||||
# Tx Power: -2500 thousandths dBm -> -2.5 dBm
|
||||
%{oid: "#{@optical_table}.9.1", value: -2500},
|
||||
# Rx Power: -8400 thousandths dBm -> -8.4 dBm
|
||||
%{oid: "#{@optical_table}.10.1", value: -8400}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
|
||||
temp = Enum.find(sensors, &(&1.sensor_descr == "SFP+ port1 Temperature"))
|
||||
assert temp.sensor_type == "temperature"
|
||||
assert temp.last_value == 42 / 1
|
||||
|
||||
voltage = Enum.find(sensors, &(&1.sensor_descr == "SFP+ port1 Supply Voltage"))
|
||||
assert voltage.sensor_type == "voltage"
|
||||
assert voltage.sensor_divisor == 1000
|
||||
assert_in_delta voltage.last_value, 3.3, 0.001
|
||||
|
||||
bias = Enum.find(sensors, &(&1.sensor_descr == "SFP+ port1 Tx Bias"))
|
||||
assert bias.sensor_type == "current"
|
||||
assert bias.sensor_unit == "mA"
|
||||
assert_in_delta bias.last_value, 7.0, 0.001
|
||||
|
||||
tx_power = Enum.find(sensors, &(&1.sensor_descr == "SFP+ port1 Tx Power"))
|
||||
assert tx_power.sensor_type == "dbm"
|
||||
assert_in_delta tx_power.last_value, -2.5, 0.001
|
||||
|
||||
rx_power = Enum.find(sensors, &(&1.sensor_descr == "SFP+ port1 Rx Power"))
|
||||
assert rx_power.sensor_type == "dbm"
|
||||
assert_in_delta rx_power.last_value, -8.4, 0.001
|
||||
end
|
||||
|
||||
test "uses fallback name 'SFP {idx}' when optical name is missing" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
# No name (.2 missing) — should use "SFP 5"
|
||||
%{oid: "#{@optical_table}.6.5", value: 50}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
temp = Enum.find(sensors, &(&1.sensor_type == "temperature"))
|
||||
assert temp
|
||||
assert temp.sensor_descr == "SFP 5 Temperature"
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_wireless_sensors/1 - gauge unit types" do
|
||||
test "maps all gauge units (rpm, current, power, status, unknown)" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
# Fan RPM: name "fan", unit 2 -> fanspeed/RPM/divisor 1
|
||||
%{oid: "#{@gauge_table}.2.1", value: "fan"},
|
||||
%{oid: "#{@gauge_table}.3.1", value: 4500},
|
||||
%{oid: "#{@gauge_table}.4.1", value: 2},
|
||||
# Current (deci-amps): name "main current", unit 4
|
||||
%{oid: "#{@gauge_table}.2.2", value: "main current"},
|
||||
%{oid: "#{@gauge_table}.3.2", value: 25},
|
||||
%{oid: "#{@gauge_table}.4.2", value: 4},
|
||||
# Power (deci-watts): name "system power", unit 5
|
||||
%{oid: "#{@gauge_table}.2.3", value: "system power"},
|
||||
%{oid: "#{@gauge_table}.3.3", value: 120},
|
||||
%{oid: "#{@gauge_table}.4.3", value: 5},
|
||||
# Status: name "psu state", unit 6
|
||||
%{oid: "#{@gauge_table}.2.4", value: "psu state"},
|
||||
%{oid: "#{@gauge_table}.3.4", value: 1},
|
||||
%{oid: "#{@gauge_table}.4.4", value: 6},
|
||||
# Unknown unit: name has no inferable type either
|
||||
%{oid: "#{@gauge_table}.2.5", value: "Mystery Sensor With Long Description"},
|
||||
%{oid: "#{@gauge_table}.3.5", value: 7},
|
||||
%{oid: "#{@gauge_table}.4.5", value: 99}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
|
||||
# Fan: format_gauge_description maps "fan" -> "System Fan"
|
||||
fan = Enum.find(sensors, &(&1.sensor_descr == "System Fan"))
|
||||
assert fan.sensor_type == "fanspeed"
|
||||
assert fan.sensor_unit == "RPM"
|
||||
assert fan.sensor_divisor == 1
|
||||
assert fan.last_value == 4500.0
|
||||
|
||||
# Current: name has space -> kept as-is, but type override since
|
||||
# name contains "current" -> overridden to current with divisor 10.
|
||||
current = Enum.find(sensors, &(&1.sensor_descr == "main current"))
|
||||
assert current.sensor_type == "current"
|
||||
assert current.sensor_unit == "A"
|
||||
assert current.sensor_divisor == 10
|
||||
|
||||
# Power: name "system power" already contains space, kept as-is
|
||||
power = Enum.find(sensors, &(&1.sensor_descr == "system power"))
|
||||
assert power.sensor_type == "power"
|
||||
assert power.sensor_unit == "W"
|
||||
|
||||
# Status: type "state" stays since infer_type_from_name returns nil for "psu state"
|
||||
state = Enum.find(sensors, &(&1.sensor_descr == "psu state"))
|
||||
assert state.sensor_type == "state"
|
||||
assert state.sensor_unit == ""
|
||||
assert state.sensor_divisor == 1
|
||||
|
||||
# Unknown unit: long name kept verbatim
|
||||
unknown = Enum.find(sensors, &(&1.sensor_descr == "Mystery Sensor With Long Description"))
|
||||
assert unknown.sensor_type == "unknown"
|
||||
assert unknown.sensor_unit == ""
|
||||
end
|
||||
|
||||
test "format_gauge_description normalizes generic single-word names" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
# Generic "temperature" -> "System Temperature"
|
||||
%{oid: "#{@gauge_table}.2.1", value: "temperature"},
|
||||
%{oid: "#{@gauge_table}.3.1", value: 350},
|
||||
%{oid: "#{@gauge_table}.4.1", value: 1},
|
||||
# Generic "voltage" -> "System Voltage"
|
||||
%{oid: "#{@gauge_table}.2.2", value: "voltage"},
|
||||
%{oid: "#{@gauge_table}.3.2", value: 240},
|
||||
%{oid: "#{@gauge_table}.4.2", value: 3},
|
||||
# Generic "fan" -> "System Fan"
|
||||
%{oid: "#{@gauge_table}.2.3", value: "fan"},
|
||||
%{oid: "#{@gauge_table}.3.3", value: 3000},
|
||||
%{oid: "#{@gauge_table}.4.3", value: 2},
|
||||
# Generic "power" -> "System Power"
|
||||
%{oid: "#{@gauge_table}.2.4", value: "power"},
|
||||
%{oid: "#{@gauge_table}.3.4", value: 100},
|
||||
%{oid: "#{@gauge_table}.4.4", value: 5},
|
||||
# Generic "current" -> "System Current"
|
||||
%{oid: "#{@gauge_table}.2.5", value: "current"},
|
||||
%{oid: "#{@gauge_table}.3.5", value: 50},
|
||||
%{oid: "#{@gauge_table}.4.5", value: 4}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
labels = Enum.map(sensors, & &1.sensor_descr)
|
||||
|
||||
assert "System Temperature" in labels
|
||||
assert "System Voltage" in labels
|
||||
assert "System Fan" in labels
|
||||
assert "System Power" in labels
|
||||
assert "System Current" in labels
|
||||
end
|
||||
|
||||
test "skips gauge entries missing required name or value" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
# Missing name (.2)
|
||||
%{oid: "#{@gauge_table}.3.1", value: 100},
|
||||
%{oid: "#{@gauge_table}.4.1", value: 1},
|
||||
# Missing value (.3)
|
||||
%{oid: "#{@gauge_table}.2.2", value: "no-value"},
|
||||
%{oid: "#{@gauge_table}.4.2", value: 1}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
assert sensors == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_wireless_sensors/1 - AP/station fallback labels" do
|
||||
test "AP table without SSID falls back to wlan{idx} prefix" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
# No .5 (SSID) entry - prefix should be "wlan2"
|
||||
%{oid: "#{@wl_ap_table}.6.2", value: 3}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
clients = Enum.find(sensors, &(&1.sensor_type == "clients"))
|
||||
assert clients
|
||||
assert clients.sensor_descr == "wlan2 Clients"
|
||||
end
|
||||
|
||||
test "AP table with frequency >= 1000 prefixes band like '5G: ssid'" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "#{@wl_ap_table}.5.1", value: "MyAP"},
|
||||
%{oid: "#{@wl_ap_table}.7.1", value: 5180},
|
||||
%{oid: "#{@wl_ap_table}.6.1", value: 1}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
clients = Enum.find(sensors, &(&1.sensor_type == "clients"))
|
||||
assert clients.sensor_descr == "5G: MyAP Clients"
|
||||
end
|
||||
|
||||
test "60GHz table without SSID falls back to '60G-{idx}'" do
|
||||
expect(SnmpMock, :walk, fn _, @wl_ap_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ ->
|
||||
{:ok,
|
||||
[
|
||||
%{oid: "#{@wl_60g_table}.6.3", value: 60_480}
|
||||
]}
|
||||
end)
|
||||
|
||||
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
|
||||
expect(SnmpMock, :get, fn _, _oid, _ -> {:error, :timeout} end)
|
||||
|
||||
sensors = Routeros.discover_wireless_sensors(@client_opts)
|
||||
freq = Enum.find(sensors, &(&1.sensor_type == "frequency"))
|
||||
assert freq
|
||||
assert freq.sensor_descr == "60G: 60G-3 Frequency"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1938,4 +1938,370 @@ defmodule Towerops.TopologyTest do
|
|||
assert Topology.resolve_device(lookup, nil) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_link/1 - find_existing_link branch coverage" do
|
||||
test "matches by remote_ip when no remote_mac is supplied",
|
||||
%{router: router, router_iface: iface} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, original} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "arp_inference",
|
||||
confidence: 0.6,
|
||||
discovered_remote_ip: "10.99.0.55",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
{:ok, updated} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "arp_inference",
|
||||
confidence: 0.6,
|
||||
discovered_remote_ip: "10.99.0.55",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
assert updated.id == original.id
|
||||
end
|
||||
|
||||
test "treats different remote_names with the same IP as distinct links",
|
||||
%{router: router, router_iface: iface} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, link_a} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "arp_inference",
|
||||
confidence: 0.6,
|
||||
discovered_remote_ip: "10.99.0.66",
|
||||
discovered_remote_name: "host-a",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
{:ok, link_b} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "arp_inference",
|
||||
confidence: 0.6,
|
||||
discovered_remote_ip: "10.99.0.66",
|
||||
discovered_remote_name: "host-b",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
refute link_a.id == link_b.id
|
||||
end
|
||||
|
||||
test "matches by remote_name only when mac and ip are nil",
|
||||
%{router: router, router_iface: iface} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, original} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_name: "name-only-host",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
{:ok, updated} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_name: "name-only-host",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
assert updated.id == original.id
|
||||
end
|
||||
|
||||
test "matches by link_type when mac/ip/name are all nil",
|
||||
%{router: router, router_iface: iface} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, original} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.5,
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
{:ok, updated} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.5,
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
assert updated.id == original.id
|
||||
end
|
||||
|
||||
test "merges metadata maps when upserting an existing link",
|
||||
%{router: router, router_iface: iface} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, _} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_mac: "aa:00:00:00:00:99",
|
||||
metadata: %{"a" => 1},
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
{:ok, updated} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_mac: "aa:00:00:00:00:99",
|
||||
metadata: %{"b" => 2},
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
assert updated.metadata["a"] == 1
|
||||
assert updated.metadata["b"] == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_link/1 - evidence batch handling" do
|
||||
test "drops invalid evidence rows missing required fields",
|
||||
%{router: router, router_iface: iface} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
log =
|
||||
ExUnit.CaptureLog.capture_log(fn ->
|
||||
{:ok, link} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: router.id,
|
||||
source_interface_id: iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_mac: "ee:00:00:00:00:01",
|
||||
last_confirmed_at: now,
|
||||
evidence: [
|
||||
# Valid
|
||||
%{
|
||||
evidence_type: "lldp_neighbor",
|
||||
evidence_data: %{},
|
||||
observed_at: now
|
||||
},
|
||||
# Invalid: unknown evidence_type
|
||||
%{
|
||||
evidence_type: "bogus",
|
||||
evidence_data: %{},
|
||||
observed_at: now
|
||||
},
|
||||
# Invalid: missing observed_at
|
||||
%{
|
||||
evidence_type: "lldp_neighbor",
|
||||
evidence_data: %{}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
link_with_evidence = Repo.preload(link, :evidence)
|
||||
assert length(link_with_evidence.evidence) == 1
|
||||
end)
|
||||
|
||||
assert log =~ "Dropping invalid device link evidence"
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_device/2 - evidence aggregation" do
|
||||
test "merges LLDP and ARP evidence pointing at the same remote into one link",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
switch_snmp =
|
||||
%Device{}
|
||||
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|
||||
|> Repo.insert!()
|
||||
|
||||
_switch_iface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: switch_snmp.id,
|
||||
if_index: 1,
|
||||
if_name: "ge-0/0/1",
|
||||
if_phys_address: "bb:cc:dd:00:11:22"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{:ok, _} =
|
||||
Towerops.Snmp.upsert_neighbor(%{
|
||||
device_id: router.id,
|
||||
interface_id: iface.id,
|
||||
protocol: "lldp",
|
||||
remote_chassis_id: "bb:cc:dd:00:11:22",
|
||||
remote_system_name: "Main-Switch",
|
||||
remote_address: "10.0.0.2",
|
||||
remote_port_id: "ge-0/0/1",
|
||||
remote_capabilities: ["bridge"],
|
||||
last_discovered_at: now
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Towerops.Snmp.upsert_arp_entry(%{
|
||||
device_id: router.id,
|
||||
interface_id: iface.id,
|
||||
ip_address: "10.0.0.2",
|
||||
mac_address: "bb:cc:dd:00:11:22",
|
||||
last_seen_at: now
|
||||
})
|
||||
|
||||
assert {:ok, :changed} = Topology.process_device(router, org.id)
|
||||
|
||||
links = Topology.list_device_links(router.id)
|
||||
assert length(links) == 1
|
||||
[link] = links
|
||||
# Combined confidence is higher than either alone
|
||||
assert link.confidence > 0.95
|
||||
# Capabilities propagated into metadata from LLDP evidence
|
||||
assert link.metadata["remote_capabilities"] == ["bridge"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_topology_for_weathermap/2 - utilization enrichment" do
|
||||
test "edges include resolved utilization fields when capacity is configured",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
iface
|
||||
|> Interface.changeset(%{configured_capacity_bps: 1_000_000_000})
|
||||
|> Repo.update!()
|
||||
|
||||
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
|
||||
|
||||
# With capacity configured but no stats, utilization computes to 0.0%
|
||||
assert edge.utilization_pct == 0.0
|
||||
assert edge.utilization_level == :low
|
||||
assert edge.capacity_bps == 1_000_000_000
|
||||
# Format with Gbps unit
|
||||
assert edge.utilization_text =~ "Gbps"
|
||||
assert result.stats.low_utilization_links >= 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "compute_rf_link_stats/1 - edge enrichment via get_topology_for_map" do
|
||||
alias Towerops.Snmp.Sensor
|
||||
|
||||
test "edges receive worse-of signal_health from source/target SNR readings",
|
||||
%{router: router, switch: switch, router_iface: iface, router_snmp: router_snmp, organization: org} do
|
||||
switch_snmp =
|
||||
%Device{}
|
||||
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|
||||
|> Repo.insert!()
|
||||
|
||||
# Router SNR = 30 (good), Switch SNR = 10 (critical)
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: router_snmp.id,
|
||||
sensor_type: "snr",
|
||||
sensor_index: "snr-router",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.0",
|
||||
last_value: 30.0
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: switch_snmp.id,
|
||||
sensor_type: "snr",
|
||||
sensor_index: "snr-switch",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.1",
|
||||
last_value: 10.0
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
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_map(org.id, "added")
|
||||
[edge] = result.edges
|
||||
|
||||
# worse_health picks "critical" over "good"
|
||||
assert edge.signal_health == "critical"
|
||||
# snr is min of both
|
||||
assert edge.snr == 10.0
|
||||
# degraded_links counter reflects this
|
||||
assert result.stats.degraded_links >= 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "discover_lldp_neighbors/1 - error path" do
|
||||
import Mox
|
||||
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
test "propagates SNMP errors from the LLDP discovery layer", %{router: router} do
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
|
||||
|
||||
log =
|
||||
ExUnit.CaptureLog.capture_log(fn ->
|
||||
# Failure is logged and returned. Wrap in case to avoid pattern crash.
|
||||
case Topology.discover_lldp_neighbors(router.id) do
|
||||
{:error, _reason} -> :ok
|
||||
{:ok, 0} -> :ok
|
||||
other -> flunk("Unexpected result: #{inspect(other)}")
|
||||
end
|
||||
end)
|
||||
|
||||
# Either path is acceptable; log only emitted on real error
|
||||
assert is_binary(log)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
824
test/towerops/workers/device_poller_worker_extra_test.exs
Normal file
824
test/towerops/workers/device_poller_worker_extra_test.exs
Normal file
|
|
@ -0,0 +1,824 @@
|
|||
defmodule Towerops.Workers.DevicePollerWorkerExtraTest do
|
||||
@moduledoc """
|
||||
Additional integration coverage for `Towerops.Workers.DevicePollerWorker`
|
||||
that exercises `perform/1` against fully-populated SNMP devices using
|
||||
Mox stubs of `Towerops.Snmp.SnmpMock`.
|
||||
"""
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
import Mox
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
alias Towerops.Snmp.EntityPhysical
|
||||
alias Towerops.Snmp.EntityPhysicalReading
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.Snmp.Mempool
|
||||
alias Towerops.Snmp.MempoolReading
|
||||
alias Towerops.Snmp.Processor
|
||||
alias Towerops.Snmp.ProcessorReading
|
||||
alias Towerops.Snmp.Sensor
|
||||
alias Towerops.Snmp.SensorReading
|
||||
alias Towerops.Snmp.SnmpMock
|
||||
alias Towerops.Snmp.StateSensor
|
||||
alias Towerops.Snmp.Storage
|
||||
alias Towerops.Snmp.StorageReading
|
||||
alias Towerops.Workers.DevicePollerWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
old_adapter = Application.get_env(:towerops, :snmp_adapter)
|
||||
Application.put_env(:towerops, :snmp_adapter, SnmpMock)
|
||||
|
||||
on_exit(fn ->
|
||||
if old_adapter do
|
||||
Application.put_env(:towerops, :snmp_adapter, old_adapter)
|
||||
else
|
||||
Application.delete_env(:towerops, :snmp_adapter)
|
||||
end
|
||||
end)
|
||||
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Organizations.create_organization(%{name: "Extra Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Sites.create_site(%{name: "Extra Site", organization_id: organization.id})
|
||||
|
||||
%{organization: organization, site: site, user: user}
|
||||
end
|
||||
|
||||
# ----- helpers --------------------------------------------------------
|
||||
|
||||
defp create_polled_device(site, attrs \\ %{}) do
|
||||
base = %{
|
||||
name: "Dev #{System.unique_integer([:positive])}",
|
||||
ip_address: "10.0.0.#{Enum.random(1..250)}",
|
||||
site_id: site.id,
|
||||
organization_id: site.organization_id,
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
check_interval_seconds: 600
|
||||
}
|
||||
|
||||
{:ok, device} = Devices.create_device(Map.merge(base, attrs))
|
||||
Repo.delete_all(Oban.Job)
|
||||
device
|
||||
end
|
||||
|
||||
defp create_snmp_device(device, attrs \\ %{}) do
|
||||
%SnmpDevice{}
|
||||
|> SnmpDevice.changeset(Map.merge(%{device_id: device.id, sys_name: "host", sys_descr: "Test"}, attrs))
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
defp stub_walks_empty do
|
||||
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
|
||||
end
|
||||
|
||||
# ----- start_polling/stop_polling/trigger_poll -----------------------
|
||||
|
||||
describe "start_polling/1" do
|
||||
test "schedules a job for the device", %{site: site} do
|
||||
device = create_polled_device(site, %{check_interval_seconds: 600})
|
||||
|
||||
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
assert job.args["device_id"] == device.id
|
||||
assert job.worker == "Towerops.Workers.DevicePollerWorker"
|
||||
end
|
||||
end
|
||||
|
||||
describe "trigger_poll/1" do
|
||||
test "inserts a job for the device", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
|
||||
assert {:ok, job} = DevicePollerWorker.trigger_poll(device.id)
|
||||
assert job.args["device_id"] == device.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "stop_polling/1" do
|
||||
test "is a no-op when no jobs exist", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
Repo.delete_all(Oban.Job)
|
||||
assert {:ok, []} = DevicePollerWorker.stop_polling(device.id)
|
||||
end
|
||||
end
|
||||
|
||||
# ----- perform/1 short-circuit branches ------------------------------
|
||||
|
||||
describe "perform/1 short-circuits" do
|
||||
test "device with effective agent does not reschedule", %{
|
||||
organization: org,
|
||||
site: site
|
||||
} do
|
||||
{:ok, agent_token, _token} = Towerops.Agents.create_agent_token(org.id, "Local Agent")
|
||||
device = create_polled_device(site)
|
||||
|
||||
{:ok, _assignment} = Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
assert Repo.aggregate(Oban.Job, :count) == 0
|
||||
end
|
||||
|
||||
test "no SNMP device or empty associations does not call SnmpMock", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
assert Repo.aggregate(Oban.Job, :count) == 1
|
||||
end
|
||||
|
||||
test "skips data collection when last_snmp_poll_at is recent", %{site: site} do
|
||||
device = create_polled_device(site, %{check_interval_seconds: 600})
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Temp"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
Repo.update_all(Towerops.Devices.Device, set: [last_snmp_poll_at: now])
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :should_not_be_called} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
assert Repo.aggregate(Oban.Job, :count) == 1
|
||||
end
|
||||
end
|
||||
|
||||
# ----- perform/1 polling paths --------------------------------------
|
||||
|
||||
describe "perform/1 with sensors" do
|
||||
test "polls a temperature sensor and writes a reading", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
sensor =
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Chassis Temp",
|
||||
sensor_unit: "C",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.99.1.1.1.4.1" -> {:ok, 42}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
updated = Repo.get!(Sensor, sensor.id)
|
||||
assert updated.last_value == 42.0
|
||||
assert updated.last_checked_at
|
||||
|
||||
readings =
|
||||
SensorReading
|
||||
|> Ecto.Query.where(sensor_id: ^sensor.id)
|
||||
|> Repo.all()
|
||||
|
||||
assert length(readings) == 1
|
||||
assert hd(readings).status == "ok"
|
||||
end
|
||||
|
||||
test "records non-numeric SNMP value as unknown reading", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
sensor =
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Bad Temp"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.99.1.1.1.4.1" -> {:ok, "not-a-number"}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading =
|
||||
SensorReading
|
||||
|> Ecto.Query.where(sensor_id: ^sensor.id)
|
||||
|> Repo.one()
|
||||
|
||||
assert reading.status == "unknown"
|
||||
assert reading.value == nil
|
||||
end
|
||||
|
||||
test "records error reading when SNMP returns error", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
sensor =
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
||||
sensor_descr: "Err Temp"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading =
|
||||
SensorReading
|
||||
|> Ecto.Query.where(sensor_id: ^sensor.id)
|
||||
|> Repo.one()
|
||||
|
||||
assert reading.status == "error"
|
||||
assert reading.value == nil
|
||||
end
|
||||
|
||||
test "polls percentage-style sensor (used / size)", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
sensor =
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "memory",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.48.1.1.1.5.1",
|
||||
sensor_descr: "Memory Used %",
|
||||
sensor_divisor: 1,
|
||||
metadata: %{
|
||||
"calculation" => "percentage",
|
||||
"size_oid" => "1.3.6.1.4.1.9.9.48.1.1.1.4.1"
|
||||
}
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.9.9.48.1.1.1.5.1" -> {:ok, 25}
|
||||
"1.3.6.1.4.1.9.9.48.1.1.1.4.1" -> {:ok, 100}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
updated = Repo.get!(Sensor, sensor.id)
|
||||
assert_in_delta updated.last_value, 25.0, 0.01
|
||||
end
|
||||
|
||||
test "caps percentage sensor at 100 when value exceeds 100", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
sensor =
|
||||
%Sensor{}
|
||||
|> Sensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_type: "memory",
|
||||
sensor_index: "1",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.48.1.1.1.5.1",
|
||||
sensor_descr: "Memory Used %",
|
||||
metadata: %{
|
||||
"calculation" => "percentage",
|
||||
"size_oid" => "1.3.6.1.4.1.9.9.48.1.1.1.4.1"
|
||||
}
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.9.9.48.1.1.1.5.1" -> {:ok, 200}
|
||||
"1.3.6.1.4.1.9.9.48.1.1.1.4.1" -> {:ok, 100}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
updated = Repo.get!(Sensor, sensor.id)
|
||||
assert updated.last_value == 100.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 with state sensors" do
|
||||
test "records state value and updates status/descr", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
ss =
|
||||
%StateSensor{}
|
||||
|> StateSensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_descr: "Power Supply 1",
|
||||
sensor_oid: "1.3.6.1.2.1.47.1.1.1.1.5.1",
|
||||
sensor_index: "1"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.1" -> {:ok, 2}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
updated = Repo.get!(StateSensor, ss.id)
|
||||
assert updated.state_value == 2
|
||||
assert updated.status == "ok"
|
||||
assert updated.state_descr == "enabled"
|
||||
end
|
||||
|
||||
test "marks state sensor unknown on SNMP error", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
ss =
|
||||
%StateSensor{}
|
||||
|> StateSensor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
sensor_descr: "Fan",
|
||||
sensor_oid: "1.3.6.1.2.1.47.1.1.1.1.5.2",
|
||||
sensor_index: "2"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
updated = Repo.get!(StateSensor, ss.id)
|
||||
assert updated.status == "unknown"
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 with processors" do
|
||||
test "polls hr_processor and writes processor reading", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
proc =
|
||||
%Processor{}
|
||||
|> Processor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
processor_index: "hr_196608",
|
||||
processor_type: "hr_processor",
|
||||
description: "CPU 0"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.25.3.3.1.2.196608" -> {:ok, 30}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading =
|
||||
ProcessorReading
|
||||
|> Ecto.Query.where(processor_id: ^proc.id)
|
||||
|> Repo.one()
|
||||
|
||||
assert reading
|
||||
assert reading.status == "ok"
|
||||
assert reading.load_percent == 30.0
|
||||
|
||||
reloaded = Repo.get!(Processor, proc.id)
|
||||
assert reloaded.load_percent == 30.0
|
||||
end
|
||||
|
||||
test "polls cisco_cpu via vendor OID", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
proc =
|
||||
%Processor{}
|
||||
|> Processor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
processor_index: "cpu_1",
|
||||
processor_type: "cisco_cpu",
|
||||
description: "Cisco CPU"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.9.9.109.1.1.1.1.8.1" -> {:ok, 55}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading = Repo.one(Ecto.Query.where(ProcessorReading, processor_id: ^proc.id))
|
||||
assert reading.load_percent == 55.0
|
||||
end
|
||||
|
||||
test "polls ucd_cpu by summing user + system", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
proc =
|
||||
%Processor{}
|
||||
|> Processor.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
processor_index: "ucd_1",
|
||||
processor_type: "ucd_cpu",
|
||||
description: "UCD CPU"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10}
|
||||
"1.3.6.1.4.1.2021.11.10.0" -> {:ok, 20}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading = Repo.one(Ecto.Query.where(ProcessorReading, processor_id: ^proc.id))
|
||||
assert reading.load_percent == 30.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 with storage" do
|
||||
test "writes storage readings and updates metadata", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
storage =
|
||||
%Storage{}
|
||||
|> Storage.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
storage_index: 1,
|
||||
storage_type: "fixed_disk",
|
||||
description: "/"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.25.2.3.1.6.1" -> {:ok, 100}
|
||||
"1.3.6.1.2.1.25.2.3.1.5.1" -> {:ok, 1_000}
|
||||
"1.3.6.1.2.1.25.2.3.1.4.1" -> {:ok, 4_096}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading = Repo.one(Ecto.Query.where(StorageReading, storage_id: ^storage.id))
|
||||
assert reading
|
||||
assert reading.used_bytes == 409_600
|
||||
assert reading.total_bytes == 4_096_000
|
||||
assert_in_delta reading.usage_percent, 10.0, 0.01
|
||||
|
||||
reloaded = Repo.get!(Storage, storage.id)
|
||||
assert reloaded.used_bytes == 409_600
|
||||
assert reloaded.total_bytes == 4_096_000
|
||||
end
|
||||
|
||||
test "skips storage reading when reported size is 0", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
storage =
|
||||
%Storage{}
|
||||
|> Storage.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
storage_index: 5,
|
||||
storage_type: "ram",
|
||||
description: "RAM"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.25.2.3.1.6.5" -> {:ok, 100}
|
||||
"1.3.6.1.2.1.25.2.3.1.5.5" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.25.2.3.1.4.5" -> {:ok, 4_096}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
readings = Repo.all(Ecto.Query.where(StorageReading, storage_id: ^storage.id))
|
||||
assert readings == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 with mempools" do
|
||||
test "writes mempool readings and updates metadata", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
mempool =
|
||||
%Mempool{}
|
||||
|> Mempool.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
mempool_index: "2",
|
||||
mempool_type: "ram",
|
||||
description: "Main RAM"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.25.2.3.1.6.2" -> {:ok, 50}
|
||||
"1.3.6.1.2.1.25.2.3.1.5.2" -> {:ok, 200}
|
||||
"1.3.6.1.2.1.25.2.3.1.4.2" -> {:ok, 1_024}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
reading = Repo.one(Ecto.Query.where(MempoolReading, mempool_id: ^mempool.id))
|
||||
assert reading
|
||||
assert reading.used_bytes == 51_200
|
||||
assert reading.total_bytes == 204_800
|
||||
assert reading.free_bytes == 153_600
|
||||
assert_in_delta reading.usage_percent, 25.0, 0.01
|
||||
|
||||
reloaded = Repo.get!(Mempool, mempool.id)
|
||||
assert reloaded.used_bytes == 51_200
|
||||
end
|
||||
|
||||
test "skips mempool reading when size is 0", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
mempool =
|
||||
%Mempool{}
|
||||
|> Mempool.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
mempool_index: "9",
|
||||
mempool_type: "ram",
|
||||
description: "Bad RAM"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.25.2.3.1.6.9" -> {:ok, 50}
|
||||
"1.3.6.1.2.1.25.2.3.1.5.9" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.25.2.3.1.4.9" -> {:ok, 1024}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
assert Repo.all(Ecto.Query.where(MempoolReading, mempool_id: ^mempool.id)) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 with interfaces" do
|
||||
test "writes interface stats and detects admin status change", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
interface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
if_index: 1,
|
||||
if_name: "eth0",
|
||||
if_descr: "ethernet",
|
||||
if_type: 6,
|
||||
if_speed: 100_000_000,
|
||||
if_phys_address: "00:11:22:33:44:55",
|
||||
if_admin_status: "up",
|
||||
if_oper_status: "up",
|
||||
monitored: true
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.31.1.1.1.6.1" -> {:ok, 5_000}
|
||||
"1.3.6.1.2.1.31.1.1.1.10.1" -> {:ok, 7_000}
|
||||
"1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.5.1" -> {:ok, 100_000_000}
|
||||
"1.3.6.1.2.1.2.2.1.6.1" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
|
||||
"1.3.6.1.2.1.2.2.1.7.1" -> {:ok, 2}
|
||||
"1.3.6.1.2.1.2.2.1.8.1" -> {:ok, 1}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, oids, _ ->
|
||||
result =
|
||||
Enum.map(oids, fn
|
||||
"1.3.6.1.2.1.2.2.1.5.1" -> 100_000_000
|
||||
"1.3.6.1.2.1.2.2.1.6.1" -> <<0, 17, 34, 51, 68, 85>>
|
||||
"1.3.6.1.2.1.2.2.1.7.1" -> 2
|
||||
"1.3.6.1.2.1.2.2.1.8.1" -> 1
|
||||
_ -> nil
|
||||
end)
|
||||
|
||||
{:ok, result}
|
||||
end)
|
||||
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
stats = Repo.all(Ecto.Query.where(InterfaceStat, interface_id: ^interface.id))
|
||||
assert length(stats) == 1
|
||||
stat = hd(stats)
|
||||
assert stat.if_in_octets == 5_000
|
||||
assert stat.if_out_octets == 7_000
|
||||
|
||||
reloaded = Repo.get!(Interface, interface.id)
|
||||
assert reloaded.if_admin_status == "down"
|
||||
end
|
||||
|
||||
test "falls back to standard counters when HC unavailable", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
interface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
if_index: 3,
|
||||
if_name: "wlan0",
|
||||
if_descr: "wlan",
|
||||
if_type: 6,
|
||||
if_speed: 54_000_000,
|
||||
if_admin_status: "up",
|
||||
if_oper_status: "up"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.31.1.1.1.6.3" -> {:error, :no_such_object}
|
||||
"1.3.6.1.2.1.31.1.1.1.10.3" -> {:error, :no_such_object}
|
||||
"1.3.6.1.2.1.2.2.1.10.3" -> {:ok, 1_000}
|
||||
"1.3.6.1.2.1.2.2.1.16.3" -> {:ok, 2_000}
|
||||
"1.3.6.1.2.1.2.2.1.14.3" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.20.3" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.13.3" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.19.3" -> {:ok, 0}
|
||||
"1.3.6.1.2.1.2.2.1.5.3" -> {:ok, 54_000_000}
|
||||
"1.3.6.1.2.1.2.2.1.7.3" -> {:ok, 1}
|
||||
"1.3.6.1.2.1.2.2.1.8.3" -> {:ok, 1}
|
||||
"1.3.6.1.2.1.2.2.1.6.3" -> {:ok, ""}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
stat = Repo.one(Ecto.Query.where(InterfaceStat, interface_id: ^interface.id))
|
||||
assert stat.if_in_octets == 1_000
|
||||
assert stat.if_out_octets == 2_000
|
||||
end
|
||||
end
|
||||
|
||||
# ----- public helpers reachable directly ----------------------------
|
||||
|
||||
describe "poll_transceivers/3 (public)" do
|
||||
test "is a no-op when no DOM-capable transceivers are passed" do
|
||||
now = Towerops.Time.now()
|
||||
assert :ok = DevicePollerWorker.poll_transceivers([], [], now)
|
||||
end
|
||||
end
|
||||
|
||||
describe "poll_entity_physical_status/3 (public)" do
|
||||
test "creates entity_physical_readings for each entity", %{site: site} do
|
||||
device = create_polled_device(site)
|
||||
snmp = create_snmp_device(device)
|
||||
|
||||
entity =
|
||||
%EntityPhysical{}
|
||||
|> EntityPhysical.changeset(%{
|
||||
snmp_device_id: snmp.id,
|
||||
entity_index: "1",
|
||||
entity_class: "powerSupply",
|
||||
name: "PSU 1",
|
||||
physical_index: 1
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
stub(SnmpMock, :get, fn _, oid, _ ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.47.1.1.1.1.5.1" -> {:ok, 1}
|
||||
"1.3.6.1.2.1.47.1.1.1.1.6.1" -> {:ok, 2}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
|
||||
stub_walks_empty()
|
||||
|
||||
now = Towerops.Time.now()
|
||||
|
||||
:ok =
|
||||
DevicePollerWorker.poll_entity_physical_status(
|
||||
[entity],
|
||||
[ip: "10.0.0.1", version: "2c", community: "public"],
|
||||
now
|
||||
)
|
||||
|
||||
reading = Repo.one(Ecto.Query.where(EntityPhysicalReading, entity_physical_id: ^entity.id))
|
||||
assert reading
|
||||
assert reading.operational_status == "up"
|
||||
assert reading.admin_status == "down"
|
||||
end
|
||||
|
||||
test "returns :ok with empty entity list" do
|
||||
assert :ok =
|
||||
DevicePollerWorker.poll_entity_physical_status(
|
||||
[],
|
||||
[ip: "10.0.0.1", version: "2c", community: "public"],
|
||||
Towerops.Time.now()
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -486,4 +486,192 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
|
|||
assert DiscoveryWorker.retryable_error?(:some_unknown_error)
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 - cloud poller fallback chain" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Cloud Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Cloud Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Cloud Device",
|
||||
ip_address: "10.10.10.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 "skips disabled cloud pollers and falls through to direct discovery",
|
||||
%{device: device} do
|
||||
# Create a cloud poller that's seen recently but DISABLED
|
||||
{:ok, poller, _token} = Towerops.Agents.create_cloud_poller("Disabled Poller")
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^poller.id),
|
||||
set: [last_seen_at: DateTime.utc_now(), enabled: false]
|
||||
)
|
||||
|
||||
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}})
|
||||
# Disabled poller is offline, so falls through to direct SNMP which fails.
|
||||
assert result == :discard or match?({:error, _}, result)
|
||||
end
|
||||
|
||||
test "uses second cloud poller when first is offline (stale last_seen_at)",
|
||||
%{device: device} do
|
||||
# Stale (offline) cloud poller
|
||||
{:ok, stale, _t1} = Towerops.Agents.create_cloud_poller("Stale Poller")
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^stale.id),
|
||||
set: [last_seen_at: DateTime.add(DateTime.utc_now(), -30, :minute)]
|
||||
)
|
||||
|
||||
# Online cloud poller (created second)
|
||||
{:ok, online, _t2} = Towerops.Agents.create_cloud_poller("Online Poller")
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^online.id),
|
||||
set: [last_seen_at: DateTime.utc_now()]
|
||||
)
|
||||
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{online.id}:discovery")
|
||||
|
||||
task =
|
||||
Task.async(fn ->
|
||||
DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
end)
|
||||
|
||||
# The discovery request should go to the ONLINE poller, not the stale one.
|
||||
assert_receive {:discovery_requested, _device_id}, 5_000
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
describe "perform/1 - direct discovery success path" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Direct Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Direct Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Direct Device",
|
||||
ip_address: "172.16.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}
|
||||
end
|
||||
|
||||
test "creates SNMP device record on successful direct discovery", %{device: device} do
|
||||
stub(SnmpMock, :get_multiple, fn _target, oids, _opts ->
|
||||
result_map =
|
||||
Map.new(oids, fn oid ->
|
||||
value =
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" -> {:octet_string, "Linux server 5.15.0"}
|
||||
"1.3.6.1.2.1.1.2.0" -> {:object_identifier, [1, 3, 6, 1, 4, 1, 9]}
|
||||
"1.3.6.1.2.1.1.3.0" -> {:timeticks, 100}
|
||||
"1.3.6.1.2.1.1.4.0" -> {:octet_string, "ops@example.com"}
|
||||
"1.3.6.1.2.1.1.5.0" -> {:octet_string, "direct-host"}
|
||||
"1.3.6.1.2.1.1.6.0" -> {:octet_string, "DC1"}
|
||||
_ -> {:octet_string, ""}
|
||||
end
|
||||
|
||||
{oid, value}
|
||||
end)
|
||||
|
||||
{:ok, result_map}
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :get, fn _target, oid, _opts ->
|
||||
case oid do
|
||||
"1.3.6.1.2.1.1.1.0" -> {:ok, {:octet_string, "Linux server 5.15.0"}}
|
||||
"1.3.6.1.2.1.1.3.0" -> {:ok, {:timeticks, 100}}
|
||||
_ -> {:error, :no_such_object}
|
||||
end
|
||||
end)
|
||||
|
||||
stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:ok, []} end)
|
||||
|
||||
assert :ok = DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
|
||||
snmp_device = Snmp.get_device(device.id)
|
||||
assert snmp_device
|
||||
assert snmp_device.sys_name == "direct-host"
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 - permanent vs transient error classification" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Errcls Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Errcls Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Errcls Device",
|
||||
ip_address: "10.20.30.40",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device: device}
|
||||
end
|
||||
|
||||
test "returns :discard for :device_unresponsive (a permanent error)",
|
||||
%{device: device} do
|
||||
# When SNMP get for sysDescr fails with no_such_object (categorize_device_speed
|
||||
# path), Snmp.discover_device classifies the device as unresponsive.
|
||||
stub(SnmpMock, :get, fn _target, _oid, _opts -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :get_multiple, fn _target, _oids, _opts -> {:error, :no_such_object} end)
|
||||
stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:ok, []} end)
|
||||
|
||||
# Either :discard (permanent classification) or {:error, _} acceptable depending
|
||||
# on which path Snmp.discover_device takes; both confirm classification logic.
|
||||
result = DiscoveryWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
||||
assert result == :discard or match?({:error, _}, result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
729
test/towerops_web/channels/agent_channel_builders_test.exs
Normal file
729
test/towerops_web/channels/agent_channel_builders_test.exs
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
defmodule ToweropsWeb.AgentChannelBuildersTest do
|
||||
@moduledoc """
|
||||
Coverage for ToweropsWeb.AgentChannel internal builders exercised through
|
||||
the public channel API:
|
||||
|
||||
- `build_polling_queries/1` — exercised via :send_jobs after discovery
|
||||
- `build_discovery_queries/0` — exercised via initial dispatch
|
||||
- `build_v2c_snmp_device/2` — v1/v2c SNMP variants
|
||||
- `build_v3_snmp_device/2` — v3 credentials
|
||||
- `resolve_snmp_credentials/1` / `credentials_present?/1`
|
||||
- `process_sensor_readings_batch` / `resolve_sensor_value` for missing OIDs,
|
||||
leading-dot normalization and bad values
|
||||
- `process_interface_stats_batch` for HC vs 32-bit fallback
|
||||
- `handle_info(:check_heartbeat)` — recent vs stale heartbeats
|
||||
- `handle_info(:send_jobs)` for SNMPv1 / SNMPv3 / MikroTik combinations
|
||||
"""
|
||||
|
||||
use Towerops.DataCase, async: false
|
||||
|
||||
import Phoenix.ChannelTest
|
||||
|
||||
alias Towerops.AccountsFixtures
|
||||
alias Towerops.Agent.AgentJobList
|
||||
alias Towerops.Agent.SnmpResult
|
||||
alias Towerops.AgentsFixtures
|
||||
alias Towerops.DevicesFixtures
|
||||
alias Towerops.OrganizationsFixtures
|
||||
alias Towerops.Snmp.AgentDiscovery
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Snmp.Sensor
|
||||
alias ToweropsWeb.AgentSocket
|
||||
|
||||
@endpoint ToweropsWeb.Endpoint
|
||||
|
||||
setup do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
organization = OrganizationsFixtures.organization_fixture(user.id)
|
||||
|
||||
device =
|
||||
DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public"
|
||||
})
|
||||
|
||||
{:ok, agent_token, token_string} =
|
||||
AgentsFixtures.agent_token_fixture(organization.id)
|
||||
|
||||
{:ok, _assignment} =
|
||||
AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
|
||||
|
||||
{:ok, socket} = connect(AgentSocket, %{})
|
||||
|
||||
{:ok, _, socket} =
|
||||
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
|
||||
|
||||
assert_push "jobs", _initial_jobs
|
||||
|
||||
%{
|
||||
socket: socket,
|
||||
device: device,
|
||||
agent_token: agent_token,
|
||||
token_string: token_string,
|
||||
organization: organization
|
||||
}
|
||||
end
|
||||
|
||||
defp encode_payload(struct) do
|
||||
binary = struct.__struct__.encode(struct)
|
||||
%{"binary" => Base.encode64(binary)}
|
||||
end
|
||||
|
||||
defp poll_until(fun, opts \\ []) do
|
||||
max_attempts = Keyword.get(opts, :max_attempts, 100)
|
||||
delay_ms = Keyword.get(opts, :delay_ms, 20)
|
||||
|
||||
Enum.reduce_while(1..max_attempts, nil, fn _, _ ->
|
||||
try do
|
||||
case fun.() do
|
||||
nil ->
|
||||
Process.sleep(delay_ms)
|
||||
{:cont, nil}
|
||||
|
||||
false ->
|
||||
Process.sleep(delay_ms)
|
||||
{:cont, nil}
|
||||
|
||||
result ->
|
||||
{:halt, result}
|
||||
end
|
||||
rescue
|
||||
# Tolerate transient DB connection errors caused by concurrent spawned
|
||||
# tasks contending for the sandbox connection.
|
||||
DBConnection.ConnectionError ->
|
||||
Process.sleep(delay_ms)
|
||||
{:cont, nil}
|
||||
end
|
||||
end) ||
|
||||
try do
|
||||
fun.()
|
||||
rescue
|
||||
DBConnection.ConnectionError -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp run_discovery(device) do
|
||||
oid_values = %{
|
||||
"1.3.6.1.2.1.1.1.0" => "Test Device",
|
||||
"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.1",
|
||||
"1.3.6.1.2.1.1.3.0" => "123456",
|
||||
"1.3.6.1.2.1.1.4.0" => "admin@test.com",
|
||||
"1.3.6.1.2.1.1.5.0" => "test-device",
|
||||
"1.3.6.1.2.1.1.6.0" => "Test Location",
|
||||
# Interface 1
|
||||
"1.3.6.1.2.1.2.2.1.1.1" => "1",
|
||||
"1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1",
|
||||
"1.3.6.1.2.1.2.2.1.3.1" => "6",
|
||||
"1.3.6.1.2.1.2.2.1.5.1" => "1000000000",
|
||||
"1.3.6.1.2.1.2.2.1.6.1" => "aa:bb:cc:dd:ee:ff",
|
||||
"1.3.6.1.2.1.2.2.1.7.1" => "1",
|
||||
"1.3.6.1.2.1.2.2.1.8.1" => "1"
|
||||
}
|
||||
|
||||
{:ok, _} = AgentDiscovery.process_agent_discovery(device, oid_values)
|
||||
Towerops.Devices.get_device_with_details(device.id)
|
||||
end
|
||||
|
||||
defp connect_and_join(agent_token, token_string) do
|
||||
{:ok, socket} = connect(AgentSocket, %{})
|
||||
|
||||
{:ok, _, socket} =
|
||||
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp decode_jobs(binary) do
|
||||
{:ok, decoded} = Base.decode64(binary)
|
||||
{:ok, job_list} = AgentJobList.decode(decoded)
|
||||
job_list.jobs
|
||||
end
|
||||
|
||||
# ── build_v2c_snmp_device variants ────────────────────────────────
|
||||
|
||||
describe "build_v2c_snmp_device variants" do
|
||||
test "snmp_version=1 device produces an SnmpDevice with version=1", %{
|
||||
organization: organization
|
||||
} do
|
||||
v1_device =
|
||||
DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
snmp_version: "1",
|
||||
snmp_community: "public-v1"
|
||||
})
|
||||
|
||||
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
|
||||
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, v1_device.id)
|
||||
|
||||
socket = connect_and_join(agent_token, token_string)
|
||||
|
||||
assert_push "jobs", %{binary: jobs_binary}
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
|
||||
job = Enum.find(jobs, &(&1.device_id == v1_device.id))
|
||||
assert job
|
||||
assert job.snmp_device.version == "1"
|
||||
assert job.snmp_device.community == "public-v1"
|
||||
assert job.snmp_device.port == 161
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
leave(socket)
|
||||
end
|
||||
|
||||
test "snmp_version=2c device defaults port to 161 and uses community", %{
|
||||
socket: socket,
|
||||
device: device
|
||||
} do
|
||||
send(socket.channel_pid, :send_jobs)
|
||||
assert_push "jobs", %{binary: jobs_binary}, 500
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
|
||||
job = Enum.find(jobs, &(&1.device_id == device.id))
|
||||
assert job.snmp_device.version == "2c"
|
||||
assert job.snmp_device.community == "public"
|
||||
assert job.snmp_device.port == 161
|
||||
assert job.snmp_device.v3_username == ""
|
||||
end
|
||||
|
||||
test "device with custom snmp_port preserves port in built SnmpDevice", %{
|
||||
organization: organization
|
||||
} do
|
||||
device =
|
||||
DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 1161
|
||||
})
|
||||
|
||||
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
|
||||
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
|
||||
|
||||
socket = connect_and_join(agent_token, token_string)
|
||||
|
||||
assert_push "jobs", %{binary: jobs_binary}
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
|
||||
job = Enum.find(jobs, &(&1.device_id == device.id))
|
||||
assert job.snmp_device.port == 1161
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
leave(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# ── build_v3_snmp_device variants ─────────────────────────────────
|
||||
|
||||
describe "build_v3_snmp_device variants" do
|
||||
test "v3 authPriv device populates auth and priv protocols/passwords", %{
|
||||
organization: organization
|
||||
} do
|
||||
device =
|
||||
DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmp_community: "",
|
||||
snmpv3_username: "alice",
|
||||
snmpv3_auth_protocol: "SHA-256",
|
||||
snmpv3_auth_password: "auth_pw_1234",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "priv_pw_5678",
|
||||
snmpv3_security_level: "authPriv",
|
||||
snmpv3_credential_source: "device"
|
||||
})
|
||||
|
||||
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
|
||||
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
|
||||
|
||||
socket = connect_and_join(agent_token, token_string)
|
||||
|
||||
assert_push "jobs", %{binary: jobs_binary}
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
|
||||
job = Enum.find(jobs, &(&1.device_id == device.id))
|
||||
assert job.snmp_device.version == "3"
|
||||
assert job.snmp_device.v3_security_level == "authPriv"
|
||||
assert job.snmp_device.v3_username == "alice"
|
||||
assert job.snmp_device.v3_auth_protocol == "SHA-256"
|
||||
assert job.snmp_device.v3_auth_password == "auth_pw_1234"
|
||||
assert job.snmp_device.v3_priv_protocol == "AES"
|
||||
assert job.snmp_device.v3_priv_password == "priv_pw_5678"
|
||||
# v3 device should have empty community
|
||||
assert job.snmp_device.community == ""
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
leave(socket)
|
||||
end
|
||||
|
||||
test "v3 device with username but no auth password emits v3 job with empty auth", %{
|
||||
organization: organization
|
||||
} do
|
||||
device =
|
||||
DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
snmp_version: "3",
|
||||
snmp_community: "",
|
||||
snmpv3_username: "minimaluser",
|
||||
snmpv3_credential_source: "device"
|
||||
})
|
||||
|
||||
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
|
||||
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
|
||||
|
||||
socket = connect_and_join(agent_token, token_string)
|
||||
|
||||
assert_push "jobs", %{binary: jobs_binary}
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
|
||||
job = Enum.find(jobs, &(&1.device_id == device.id))
|
||||
assert job
|
||||
assert job.snmp_device.version == "3"
|
||||
assert job.snmp_device.v3_username == "minimaluser"
|
||||
assert job.snmp_device.v3_auth_password == ""
|
||||
assert job.snmp_device.v3_priv_password == ""
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
leave(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# ── build_polling_queries (exercised via send_jobs) ────────────────
|
||||
|
||||
describe "build_polling_queries via send_jobs" do
|
||||
setup %{device: device, organization: organization, agent_token: agent_token} do
|
||||
device = run_discovery(device)
|
||||
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
|
||||
|
||||
{:ok, sensor1} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "1001",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.1001",
|
||||
sensor_descr: "CPU Temp",
|
||||
sensor_unit: "celsius",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
{:ok, sensor2} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "voltage",
|
||||
sensor_index: "2001",
|
||||
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.2.1.3.2001",
|
||||
sensor_descr: "Voltage",
|
||||
sensor_unit: "volts",
|
||||
sensor_divisor: 10
|
||||
})
|
||||
|
||||
device = Towerops.Devices.get_device_with_details(device.id)
|
||||
|
||||
%{
|
||||
device: device,
|
||||
snmp_device: snmp_device,
|
||||
sensor1: sensor1,
|
||||
sensor2: sensor2,
|
||||
organization: organization,
|
||||
agent_token: agent_token
|
||||
}
|
||||
end
|
||||
|
||||
test "polling job includes sensor OIDs and per-interface octet/error/discard OIDs", %{
|
||||
socket: socket,
|
||||
device: device,
|
||||
sensor1: sensor1,
|
||||
sensor2: sensor2
|
||||
} do
|
||||
# mark discovery complete so we issue a POLL job, not DISCOVER
|
||||
{:ok, _} =
|
||||
Towerops.Devices.update_device(device, %{last_discovery_at: DateTime.utc_now()})
|
||||
|
||||
send(socket.channel_pid, :send_jobs)
|
||||
assert_push "jobs", %{binary: jobs_binary}, 1_000
|
||||
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
poll_job = Enum.find(jobs, &(&1.job_type == :POLL and &1.device_id == device.id))
|
||||
assert poll_job
|
||||
|
||||
get_query = Enum.find(poll_job.queries, &(&1.query_type == :GET))
|
||||
assert get_query
|
||||
assert sensor1.sensor_oid in get_query.oids
|
||||
assert sensor2.sensor_oid in get_query.oids
|
||||
|
||||
# Interface OIDs (HC + 32-bit fallback) for the discovered interface
|
||||
device = Towerops.Devices.get_device_with_details(device.id)
|
||||
idx = hd(device.snmp_device.interfaces).if_index
|
||||
|
||||
assert "1.3.6.1.2.1.31.1.1.1.6.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.31.1.1.1.10.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.2.2.1.10.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.2.2.1.16.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.2.2.1.14.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.2.2.1.20.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.2.2.1.13.#{idx}" in get_query.oids
|
||||
assert "1.3.6.1.2.1.2.2.1.19.#{idx}" in get_query.oids
|
||||
|
||||
# Walk queries: neighbor, ARP, MAC, IP, host-resources
|
||||
walks = Enum.filter(poll_job.queries, &(&1.query_type == :WALK))
|
||||
flat = Enum.flat_map(walks, & &1.oids)
|
||||
# LLDP
|
||||
assert "1.0.8802.1.1.2.1.4.1.1" in flat
|
||||
# ARP table
|
||||
assert "1.3.6.1.2.1.4.22" in flat
|
||||
# MAC table
|
||||
assert "1.3.6.1.2.1.17.4.3" in flat
|
||||
# IP address tables
|
||||
assert "1.3.6.1.2.1.4.20" in flat
|
||||
# hrProcessorTable
|
||||
assert "1.3.6.1.2.1.25.3.3" in flat
|
||||
end
|
||||
end
|
||||
|
||||
# ── build_discovery_queries (exercised via send_jobs) ─────────────
|
||||
|
||||
describe "build_discovery_queries via send_jobs" do
|
||||
test "initial discovery job contains expected OID groups", %{socket: socket, device: device} do
|
||||
send(socket.channel_pid, :send_jobs)
|
||||
assert_push "jobs", %{binary: jobs_binary}, 500
|
||||
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
discover_job = Enum.find(jobs, &(&1.job_type == :DISCOVER and &1.device_id == device.id))
|
||||
assert discover_job
|
||||
|
||||
flat = Enum.flat_map(discover_job.queries, & &1.oids)
|
||||
|
||||
# System info GET OIDs
|
||||
assert "1.3.6.1.2.1.1.1.0" in flat
|
||||
assert "1.3.6.1.2.1.1.5.0" in flat
|
||||
|
||||
# Interface tables walks
|
||||
assert "1.3.6.1.2.1.2.2.1" in flat
|
||||
assert "1.3.6.1.2.1.31.1.1.1" in flat
|
||||
|
||||
# Sensor / Entity walks
|
||||
assert "1.3.6.1.2.1.99.1.1.1" in flat
|
||||
assert "1.3.6.1.2.1.47.1.1.1.1.2" in flat
|
||||
|
||||
# Vendor MIBs - MikroTik / Ubiquiti / Juniper
|
||||
assert "1.3.6.1.4.1.14988" in flat
|
||||
assert "1.3.6.1.4.1.41112" in flat
|
||||
assert "1.3.6.1.4.1.2636" in flat
|
||||
|
||||
# IP addresses
|
||||
assert "1.3.6.1.2.1.4.20" in flat
|
||||
assert "1.3.6.1.2.1.4.34" in flat
|
||||
end
|
||||
end
|
||||
|
||||
# ── credentials_present? + resolve_snmp_credentials edge cases ────
|
||||
|
||||
describe "credential resolution edge cases" do
|
||||
test "v2c device with empty community still produces a job with empty community", %{
|
||||
organization: organization
|
||||
} do
|
||||
device =
|
||||
DevicesFixtures.device_fixture(%{
|
||||
organization_id: organization.id,
|
||||
snmp_version: "2c",
|
||||
snmp_community: ""
|
||||
})
|
||||
|
||||
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
|
||||
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
|
||||
|
||||
socket = connect_and_join(agent_token, token_string)
|
||||
|
||||
assert_push "jobs", %{binary: jobs_binary}
|
||||
jobs = decode_jobs(jobs_binary)
|
||||
|
||||
job = Enum.find(jobs, &(&1.device_id == device.id))
|
||||
assert job
|
||||
assert job.snmp_device.community == ""
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
leave(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# ── process_sensor_readings_batch / resolve_sensor_value paths ────
|
||||
|
||||
describe "resolve_sensor_value edge cases" do
|
||||
setup %{device: device} do
|
||||
device = run_discovery(device)
|
||||
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
|
||||
|
||||
{:ok, ok_sensor} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "100",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.100",
|
||||
sensor_descr: "OK Sensor",
|
||||
sensor_unit: "celsius",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
{:ok, missing_sensor} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "200",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.200",
|
||||
sensor_descr: "Missing Sensor",
|
||||
sensor_unit: "celsius",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
{:ok, bad_value_sensor} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "temperature",
|
||||
sensor_index: "300",
|
||||
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.300",
|
||||
sensor_descr: "Bad Value Sensor",
|
||||
sensor_unit: "celsius",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
device = Towerops.Devices.get_device_with_details(device.id)
|
||||
|
||||
%{
|
||||
device: device,
|
||||
ok_sensor: ok_sensor,
|
||||
missing_sensor: missing_sensor,
|
||||
bad_value_sensor: bad_value_sensor
|
||||
}
|
||||
end
|
||||
|
||||
test "missing OID does not produce a sensor reading or update last_value", %{
|
||||
socket: socket,
|
||||
device: device,
|
||||
ok_sensor: ok_sensor,
|
||||
missing_sensor: missing_sensor
|
||||
} do
|
||||
result = %SnmpResult{
|
||||
device_id: device.id,
|
||||
job_type: :POLL,
|
||||
job_id: "poll:#{device.id}",
|
||||
timestamp: DateTime.to_unix(DateTime.utc_now()),
|
||||
oid_values: %{
|
||||
ok_sensor.sensor_oid => "37"
|
||||
}
|
||||
}
|
||||
|
||||
push(socket, "result", encode_payload(result))
|
||||
|
||||
# Ok sensor is updated
|
||||
assert poll_until(fn ->
|
||||
s = Towerops.Repo.get!(Sensor, ok_sensor.id)
|
||||
if s.last_value == 37.0, do: s
|
||||
end)
|
||||
|
||||
# Missing sensor remains nil
|
||||
missing_after = Towerops.Repo.get!(Sensor, missing_sensor.id)
|
||||
assert is_nil(missing_after.last_value)
|
||||
end
|
||||
|
||||
test "non-numeric OID value is ignored (resolve_sensor_value returns nil)", %{
|
||||
socket: socket,
|
||||
device: device,
|
||||
bad_value_sensor: bad_value_sensor
|
||||
} do
|
||||
result = %SnmpResult{
|
||||
device_id: device.id,
|
||||
job_type: :POLL,
|
||||
job_id: "poll:#{device.id}",
|
||||
timestamp: DateTime.to_unix(DateTime.utc_now()),
|
||||
oid_values: %{
|
||||
bad_value_sensor.sensor_oid => "not-a-number"
|
||||
}
|
||||
}
|
||||
|
||||
push(socket, "result", encode_payload(result))
|
||||
|
||||
# Wait for the polling result to be processed (state_sensors_updated broadcast)
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}")
|
||||
assert_receive {:state_sensors_updated, _}, 2_000
|
||||
|
||||
sensor_after = Towerops.Repo.get!(Sensor, bad_value_sensor.id)
|
||||
assert is_nil(sensor_after.last_value)
|
||||
end
|
||||
|
||||
test "leading-dot sensor_oid is normalized when looking up OID values", %{
|
||||
socket: socket,
|
||||
device: device
|
||||
} do
|
||||
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
|
||||
|
||||
{:ok, dotted_sensor} =
|
||||
Towerops.Repo.insert(%Sensor{
|
||||
snmp_device_id: snmp_device.id,
|
||||
sensor_type: "fan",
|
||||
sensor_index: "999",
|
||||
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.4.1.3.999",
|
||||
sensor_descr: "Dotted Sensor",
|
||||
sensor_unit: "rpm",
|
||||
sensor_divisor: 1
|
||||
})
|
||||
|
||||
result = %SnmpResult{
|
||||
device_id: device.id,
|
||||
job_type: :POLL,
|
||||
job_id: "poll:#{device.id}",
|
||||
timestamp: DateTime.to_unix(DateTime.utc_now()),
|
||||
oid_values: %{
|
||||
# Channel normalizes "." prefix on OID keys; sensor.sensor_oid also strips "."
|
||||
"1.3.6.1.4.1.9.9.13.1.4.1.3.999" => "2400"
|
||||
}
|
||||
}
|
||||
|
||||
push(socket, "result", encode_payload(result))
|
||||
|
||||
assert poll_until(fn ->
|
||||
s = Towerops.Repo.get!(Sensor, dotted_sensor.id)
|
||||
if s.last_value == 2400.0, do: s
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# ── process_interface_stats_batch HC vs 32-bit fallback ───────────
|
||||
|
||||
describe "process_interface_stats_batch counter fallback" do
|
||||
setup %{device: device} do
|
||||
device = run_discovery(device)
|
||||
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
|
||||
|
||||
# Add an extra interface so we can test multiple counter-fallback paths
|
||||
{:ok, second_iface} =
|
||||
Towerops.Repo.insert(%Interface{
|
||||
snmp_device_id: snmp_device.id,
|
||||
if_index: 2,
|
||||
if_name: "GigabitEthernet0/2",
|
||||
if_descr: "GigabitEthernet0/2",
|
||||
if_admin_status: "up",
|
||||
if_oper_status: "up"
|
||||
})
|
||||
|
||||
device = Towerops.Devices.get_device_with_details(device.id)
|
||||
%{device: device, snmp_device: snmp_device, second_iface: second_iface}
|
||||
end
|
||||
|
||||
test "uses 32-bit fallback when HC counter OID is missing", %{
|
||||
socket: socket,
|
||||
device: device,
|
||||
second_iface: iface
|
||||
} do
|
||||
idx = iface.if_index
|
||||
|
||||
result = %SnmpResult{
|
||||
device_id: device.id,
|
||||
job_type: :POLL,
|
||||
job_id: "poll:#{device.id}",
|
||||
timestamp: DateTime.to_unix(DateTime.utc_now()),
|
||||
oid_values: %{
|
||||
# No HC counters; only 32-bit
|
||||
"1.3.6.1.2.1.2.2.1.10.#{idx}" => "12345",
|
||||
"1.3.6.1.2.1.2.2.1.16.#{idx}" => "67890"
|
||||
}
|
||||
}
|
||||
|
||||
push(socket, "result", encode_payload(result))
|
||||
|
||||
stats =
|
||||
poll_until(fn ->
|
||||
r = Towerops.Snmp.get_interface_stats(iface.id)
|
||||
if r != [], do: r
|
||||
end)
|
||||
|
||||
assert stats
|
||||
stat = hd(stats)
|
||||
assert stat.if_in_octets == 12_345
|
||||
assert stat.if_out_octets == 67_890
|
||||
end
|
||||
|
||||
test "prefers HC counters when both HC and 32-bit are present", %{
|
||||
socket: socket,
|
||||
device: device,
|
||||
second_iface: iface
|
||||
} do
|
||||
idx = iface.if_index
|
||||
|
||||
result = %SnmpResult{
|
||||
device_id: device.id,
|
||||
job_type: :POLL,
|
||||
job_id: "poll:#{device.id}",
|
||||
timestamp: DateTime.to_unix(DateTime.utc_now()),
|
||||
oid_values: %{
|
||||
"1.3.6.1.2.1.31.1.1.1.6.#{idx}" => "9999999999",
|
||||
"1.3.6.1.2.1.31.1.1.1.10.#{idx}" => "8888888888",
|
||||
"1.3.6.1.2.1.2.2.1.10.#{idx}" => "1",
|
||||
"1.3.6.1.2.1.2.2.1.16.#{idx}" => "2"
|
||||
}
|
||||
}
|
||||
|
||||
push(socket, "result", encode_payload(result))
|
||||
|
||||
stats =
|
||||
poll_until(fn ->
|
||||
r = Towerops.Snmp.get_interface_stats(iface.id)
|
||||
if r != [], do: r
|
||||
end)
|
||||
|
||||
assert stats
|
||||
stat = hd(stats)
|
||||
assert stat.if_in_octets == 9_999_999_999
|
||||
assert stat.if_out_octets == 8_888_888_888
|
||||
end
|
||||
end
|
||||
|
||||
# ── handle_info(:check_heartbeat) ─────────────────────────────────
|
||||
|
||||
describe "handle_info(:check_heartbeat)" do
|
||||
test "channel survives :check_heartbeat when last_heartbeat_at is recent", %{socket: socket} do
|
||||
send(socket.channel_pid, :check_heartbeat)
|
||||
# Channel should still be alive
|
||||
Process.sleep(20)
|
||||
assert Process.alive?(socket.channel_pid)
|
||||
end
|
||||
|
||||
test "channel stops with :heartbeat_timeout when last_heartbeat_at is stale", %{
|
||||
socket: socket
|
||||
} do
|
||||
Process.flag(:trap_exit, true)
|
||||
ref = Process.monitor(socket.channel_pid)
|
||||
|
||||
stale = DateTime.add(DateTime.utc_now(), -10_000, :second)
|
||||
|
||||
:sys.replace_state(socket.channel_pid, fn s ->
|
||||
%{s | assigns: Map.put(s.assigns, :last_heartbeat_at, stale)}
|
||||
end)
|
||||
|
||||
send(socket.channel_pid, :check_heartbeat)
|
||||
|
||||
assert_receive {:DOWN, ^ref, :process, _, {:shutdown, :heartbeat_timeout}}, 1_000
|
||||
end
|
||||
end
|
||||
|
||||
# ── handle_info({:update_requested, url, checksum}) does not stop ─
|
||||
|
||||
describe "handle_info(:update_requested) channel state" do
|
||||
test "channel remains alive after update is requested", %{socket: socket} do
|
||||
send(socket.channel_pid, {:update_requested, "https://example.com/agent.bin", "sha256:abc"})
|
||||
assert_push "update", %{url: "https://example.com/agent.bin", checksum: "sha256:abc"}, 200
|
||||
Process.sleep(20)
|
||||
assert Process.alive?(socket.channel_pid)
|
||||
end
|
||||
end
|
||||
|
||||
# ── handle_info({:live_poll_requested, ...}) for missing device ──
|
||||
|
||||
describe "handle_info(:live_poll_requested) error path" do
|
||||
test "missing device does not crash channel", %{socket: socket} do
|
||||
bogus_id = Ecto.UUID.generate()
|
||||
send(socket.channel_pid, {:live_poll_requested, bogus_id, ["1.3.6.1.2.1.1.1.0"], "topic"})
|
||||
Process.sleep(20)
|
||||
assert Process.alive?(socket.channel_pid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -628,4 +628,121 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
|
|||
refute html =~ "Global Default Cloud Poller"
|
||||
end
|
||||
end
|
||||
|
||||
describe "show_setup event" do
|
||||
test "opens setup modal for an existing agent",
|
||||
%{conn: conn, user: user, organization: organization} do
|
||||
{:ok, agent_token, _raw} = Agents.create_agent_token(organization.id, "SU Agent")
|
||||
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
_ = render_hook(view, "show_setup", %{"id" => agent_token.id})
|
||||
assert true
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_info :tick" do
|
||||
test "tick triggers re-render without crashing",
|
||||
%{conn: conn, user: user, organization: _org} do
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
send(view.pid, :tick)
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_info agent lifecycle messages" do
|
||||
test "agent_connected for matching org updates agent assigns",
|
||||
%{conn: conn, user: user, organization: organization} do
|
||||
{:ok, agent_token, _raw} = Agents.create_agent_token(organization.id, "Live Agent")
|
||||
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
send(view.pid, {:agent_connected, agent_token.id, organization.id})
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "agent_disconnected for matching org doesn't crash",
|
||||
%{conn: conn, user: user, organization: organization} do
|
||||
{:ok, agent_token, _raw} = Agents.create_agent_token(organization.id, "Live Agent 2")
|
||||
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
send(view.pid, {:agent_disconnected, agent_token.id, organization.id})
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "agent_heartbeat for matching org doesn't crash",
|
||||
%{conn: conn, user: user, organization: organization} do
|
||||
{:ok, agent_token, _raw} = Agents.create_agent_token(organization.id, "Live Agent 3")
|
||||
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
send(view.pid, {:agent_heartbeat, agent_token.id, organization.id})
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "agent_connected for different org is ignored",
|
||||
%{conn: conn, user: user, organization: _org} do
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
send(view.pid, {:agent_connected, Ecto.UUID.generate(), Ecto.UUID.generate()})
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
|
||||
test "agents_stale message is handled",
|
||||
%{conn: conn, user: user, organization: _org} do
|
||||
conn = log_in_user(conn, user)
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
send(view.pid, {:agents_stale, []})
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
end
|
||||
|
||||
describe "global default cloud poller events" do
|
||||
test "update_selected_global_default and save_global_default for superuser" do
|
||||
superuser = user_fixture(enable_totp: true)
|
||||
|
||||
superuser =
|
||||
superuser
|
||||
|> Ecto.Changeset.change(%{is_superuser: true})
|
||||
|> Towerops.Repo.update!()
|
||||
|
||||
{:ok, super_org} =
|
||||
Towerops.Organizations.create_organization(%{name: "Super Cloud Org"}, superuser.id)
|
||||
|
||||
{:ok, cloud_poller, _raw} = Agents.create_cloud_poller("Default Cloud")
|
||||
|
||||
conn =
|
||||
Phoenix.ConnTest.build_conn()
|
||||
|> log_in_user(superuser)
|
||||
|> Plug.Conn.put_session(:current_organization_id, super_org.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/agents")
|
||||
|
||||
_ =
|
||||
render_hook(view, "update_selected_global_default", %{
|
||||
"agent_token_id" => cloud_poller.id
|
||||
})
|
||||
|
||||
_ = render_hook(view, "save_global_default", %{})
|
||||
|
||||
# No crash → success or warning flash
|
||||
_ = render(view)
|
||||
assert Process.alive?(view.pid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -567,4 +567,139 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
|
|||
assert render(view)
|
||||
end
|
||||
end
|
||||
|
||||
describe "pure helpers" do
|
||||
alias ToweropsWeb.DeviceLive.Index
|
||||
|
||||
test "filter_by_search returns all when query is empty/nil" do
|
||||
devices = [%{name: "Alpha", ip_address: "10.0.0.1"}, %{name: "Beta", ip_address: "10.0.0.2"}]
|
||||
assert Index.filter_by_search(devices, "") == devices
|
||||
assert Index.filter_by_search(devices, nil) == devices
|
||||
end
|
||||
|
||||
test "filter_by_search matches by name (case-insensitive)" do
|
||||
devices = [
|
||||
%{name: "Alpha Router", ip_address: "10.0.0.1"},
|
||||
%{name: "Beta Switch", ip_address: "10.0.0.2"}
|
||||
]
|
||||
|
||||
result = Index.filter_by_search(devices, "alpha")
|
||||
assert length(result) == 1
|
||||
assert hd(result).name == "Alpha Router"
|
||||
end
|
||||
|
||||
test "filter_by_search matches by ip address" do
|
||||
devices = [
|
||||
%{name: "Foo", ip_address: "10.0.0.1"},
|
||||
%{name: "Bar", ip_address: "192.168.1.5"}
|
||||
]
|
||||
|
||||
result = Index.filter_by_search(devices, "192.168")
|
||||
assert length(result) == 1
|
||||
assert hd(result).name == "Bar"
|
||||
end
|
||||
|
||||
test "filter_by_search handles nil device name without crashing" do
|
||||
devices = [%{name: nil, ip_address: "10.0.0.1"}]
|
||||
assert Index.filter_by_search(devices, "anything") == []
|
||||
end
|
||||
|
||||
test "filter_by_status returns appropriate subsets" do
|
||||
devices = [
|
||||
%{status: :up},
|
||||
%{status: :down},
|
||||
%{status: :unknown}
|
||||
]
|
||||
|
||||
assert Index.filter_by_status(devices, "all") == devices
|
||||
assert Index.filter_by_status(devices, "up") == [%{status: :up}]
|
||||
assert Index.filter_by_status(devices, "down") == [%{status: :down}]
|
||||
assert Index.filter_by_status(devices, "unknown") == [%{status: :unknown}]
|
||||
# Unknown filter strings fall through to "all"
|
||||
assert Index.filter_by_status(devices, "garbage") == devices
|
||||
end
|
||||
|
||||
test "calculate_site_stats counts statuses" do
|
||||
devices = [
|
||||
%{status: :up},
|
||||
%{status: :up},
|
||||
%{status: :down},
|
||||
%{status: :unknown}
|
||||
]
|
||||
|
||||
stats = Index.calculate_site_stats(devices)
|
||||
assert stats.total == 4
|
||||
assert stats.up == 2
|
||||
assert stats.down == 1
|
||||
assert stats.unknown == 1
|
||||
end
|
||||
|
||||
test "calculate_site_stats handles empty list" do
|
||||
assert %{total: 0, up: 0, down: 0, unknown: 0} = Index.calculate_site_stats([])
|
||||
end
|
||||
|
||||
test "device_type_label translates known device roles" do
|
||||
assert Index.device_type_label(nil) == "Unknown"
|
||||
assert Index.device_type_label("access_point") == "Access Point"
|
||||
assert Index.device_type_label("router") == "Router"
|
||||
assert Index.device_type_label("switch") == "Switch"
|
||||
assert Index.device_type_label(123) == "Unknown"
|
||||
end
|
||||
end
|
||||
|
||||
describe "search filter applied to live view" do
|
||||
test "search by ip address filter still re-renders without crashing", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
organization: organization
|
||||
} do
|
||||
{:ok, _device} =
|
||||
Devices.create_device(%{
|
||||
name: "ByIp",
|
||||
ip_address: "10.5.5.5",
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-change=search]", %{search_query: "10.5.5.5"})
|
||||
|> render_change()
|
||||
|
||||
assert html =~ ~s(value="10.5.5.5")
|
||||
end
|
||||
|
||||
test "filter_status unknown via render_hook sets the filter", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
|
||||
# The "unknown" button only renders when there are unknown-status devices,
|
||||
# so drive the event directly through the LV process.
|
||||
html = render_hook(view, "filter_status", %{"status" => "unknown"})
|
||||
|
||||
# Page should still render normally without crashing
|
||||
assert is_binary(html)
|
||||
end
|
||||
end
|
||||
|
||||
describe "reset_order with no devices" do
|
||||
test "still succeeds and shows flash", %{conn: conn, organization: organization} do
|
||||
# Create a device, render, toggle reorder, reset; then delete all and toggle
|
||||
# confirm graceful path with empty device list afterwards
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "TempForReset",
|
||||
ip_address: "10.0.0.99",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/devices")
|
||||
view |> element("#toggle-reorder-mode") |> render_click()
|
||||
html = view |> element("#reset-order") |> render_click()
|
||||
|
||||
assert html =~ "Order reset to alphabetical"
|
||||
_ = device
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
280
test/towerops_web/live/org/gaiia_mapping_live_test.exs
Normal file
280
test/towerops_web/live/org/gaiia_mapping_live_test.exs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
defmodule ToweropsWeb.Org.GaiiaMappingLiveTest do
|
||||
use ToweropsWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Sites
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
setup %{user: user} do
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Mapping Org"}, user.id)
|
||||
%{organization: organization}
|
||||
end
|
||||
|
||||
describe "mount" do
|
||||
test "renders devices tab by default", %{conn: conn, organization: org} do
|
||||
{:ok, _view, html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
assert html =~ "Devices" or html =~ "device"
|
||||
end
|
||||
|
||||
test "renders sites tab when ?tab=sites", %{conn: conn, organization: org} do
|
||||
{:ok, _view, html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")
|
||||
|
||||
assert html =~ "Site" or html =~ "site"
|
||||
end
|
||||
|
||||
test "rejects unknown tab values, falling back to devices", %{conn: conn, organization: org} do
|
||||
# Unknown tab values are coerced to "devices" by handle_params
|
||||
{:ok, _view, html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=garbage")
|
||||
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "filter=linked is accepted as a query param", %{conn: conn, organization: org} do
|
||||
{:ok, _view, html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?filter=linked")
|
||||
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "filter=garbage is coerced to all", %{conn: conn, organization: org} do
|
||||
{:ok, _view, html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?filter=garbage")
|
||||
|
||||
assert is_binary(html)
|
||||
end
|
||||
end
|
||||
|
||||
describe "device linking events" do
|
||||
setup %{organization: org} do
|
||||
{:ok, device} =
|
||||
Devices.create_device(%{
|
||||
name: "Router-A",
|
||||
ip_address: "10.0.0.1",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "inv-A",
|
||||
name: "Inv-A",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
%{device: device, inventory_item: item}
|
||||
end
|
||||
|
||||
test "start_link sets the linking_id, search clears existing results", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
device: device
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html = render_hook(view, "start_link", %{"id" => device.id})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "cancel_link clears the linking state", %{conn: conn, organization: org, device: device} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
render_hook(view, "start_link", %{"id" => device.id})
|
||||
html = render_hook(view, "cancel_link", %{})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "search with short query returns no results", %{conn: conn, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html = render_hook(view, "search", %{"query" => "a"})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "search with longer query searches inventory items", %{
|
||||
conn: conn,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html = render_hook(view, "search", %{"query" => "Inv"})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "link_device with unknown gaiia_id flashes error", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
device: device
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html =
|
||||
render_hook(view, "link_device", %{
|
||||
"device-id" => device.id,
|
||||
"gaiia-id" => "nonexistent"
|
||||
})
|
||||
|
||||
assert html =~ "not found"
|
||||
end
|
||||
|
||||
test "link_device with valid ids creates the mapping", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
device: device,
|
||||
inventory_item: item
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html =
|
||||
render_hook(view, "link_device", %{
|
||||
"device-id" => device.id,
|
||||
"gaiia-id" => item.gaiia_id
|
||||
})
|
||||
|
||||
assert html =~ "linked"
|
||||
|
||||
reloaded = Gaiia.get_inventory_item(org.id, item.gaiia_id)
|
||||
assert reloaded.device_id == device.id
|
||||
end
|
||||
|
||||
test "unlink_device clears the device association", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
device: device,
|
||||
inventory_item: item
|
||||
} do
|
||||
{:ok, _} = Gaiia.update_inventory_item_mapping(item, %{device_id: device.id})
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html = render_hook(view, "unlink_device", %{"device-id" => device.id})
|
||||
|
||||
assert html =~ "unlinked"
|
||||
reloaded = Gaiia.get_inventory_item(org.id, item.gaiia_id)
|
||||
assert is_nil(reloaded.device_id)
|
||||
end
|
||||
|
||||
test "unlink_device with no linked item flashes error", %{
|
||||
conn: conn,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, other_device} =
|
||||
Devices.create_device(%{
|
||||
name: "Other",
|
||||
ip_address: "10.0.0.99",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping")
|
||||
|
||||
html = render_hook(view, "unlink_device", %{"device-id" => other_device.id})
|
||||
assert html =~ "No linked"
|
||||
end
|
||||
end
|
||||
|
||||
describe "site linking events" do
|
||||
setup %{organization: org} do
|
||||
{:ok, site} = Sites.create_site(%{name: "POP-A", organization_id: org.id})
|
||||
|
||||
{:ok, network_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "ns-A",
|
||||
name: "POP-A"
|
||||
})
|
||||
|
||||
%{site: site, network_site: network_site}
|
||||
end
|
||||
|
||||
test "search on sites tab queries network sites", %{conn: conn, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")
|
||||
|
||||
html = render_hook(view, "search", %{"query" => "POP"})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "link_site with unknown gaiia_id flashes error", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
site: site
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")
|
||||
|
||||
html =
|
||||
render_hook(view, "link_site", %{
|
||||
"site-id" => site.id,
|
||||
"gaiia-id" => "nonexistent"
|
||||
})
|
||||
|
||||
assert html =~ "not found"
|
||||
end
|
||||
|
||||
test "link_site with valid ids creates mapping", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
site: site,
|
||||
network_site: network_site
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")
|
||||
|
||||
html =
|
||||
render_hook(view, "link_site", %{
|
||||
"site-id" => site.id,
|
||||
"gaiia-id" => network_site.gaiia_id
|
||||
})
|
||||
|
||||
assert html =~ "linked"
|
||||
|
||||
reloaded = Gaiia.get_network_site(org.id, network_site.gaiia_id)
|
||||
assert reloaded.site_id == site.id
|
||||
end
|
||||
|
||||
test "unlink_site clears the site association", %{
|
||||
conn: conn,
|
||||
organization: org,
|
||||
site: site,
|
||||
network_site: network_site
|
||||
} do
|
||||
{:ok, _} = Gaiia.update_network_site_mapping(network_site, %{site_id: site.id})
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")
|
||||
|
||||
html = render_hook(view, "unlink_site", %{"site-id" => site.id})
|
||||
|
||||
assert html =~ "unlinked"
|
||||
reloaded = Gaiia.get_network_site(org.id, network_site.gaiia_id)
|
||||
assert is_nil(reloaded.site_id)
|
||||
end
|
||||
|
||||
test "unlink_site with no linked network site flashes error", %{
|
||||
conn: conn,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, lonely_site} =
|
||||
Sites.create_site(%{name: "Solo", organization_id: org.id})
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites")
|
||||
|
||||
html = render_hook(view, "unlink_site", %{"site-id" => lonely_site.id})
|
||||
assert html =~ "No linked"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -227,6 +227,155 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
assert html =~ "Connection successful"
|
||||
end
|
||||
|
||||
test "close_config clears the configuring panel", %{conn: conn, user: user, organization: org} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
# Open preseem config first
|
||||
html = view |> element("#configure-preseem") |> render_click()
|
||||
assert html =~ "API Key"
|
||||
|
||||
# Trigger close via the LV process (the close button is rendered inside the panel)
|
||||
html = render_hook(view, "close_config", %{})
|
||||
|
||||
refute html =~ ~s(name="integration[api_key]")
|
||||
end
|
||||
|
||||
test "validate event updates the form changeset state", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
view |> element("#configure-preseem") |> render_click()
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#preseem-form", %{integration: %{api_key: "validated-key"}})
|
||||
|> render_change()
|
||||
|
||||
# The form re-renders without crashing — verify the configure panel is still active
|
||||
assert html =~ "API Key"
|
||||
end
|
||||
|
||||
test "test_connection without filling required fields shows error", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
view |> element("#configure-preseem") |> render_click()
|
||||
|
||||
html = view |> element("#test-connection") |> render_click()
|
||||
|
||||
assert html =~ "Please fill in all required fields first"
|
||||
end
|
||||
|
||||
test "toggle_enabled flips an existing integration's enabled flag", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, integration} =
|
||||
Towerops.Integrations.create_integration(org.id, %{
|
||||
provider: "preseem",
|
||||
enabled: true,
|
||||
credentials: %{"api_key" => "key"}
|
||||
})
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
render_hook(view, "toggle_enabled", %{"provider" => "preseem"})
|
||||
|
||||
{:ok, reloaded} = Towerops.Integrations.get_integration(org.id, "preseem")
|
||||
refute reloaded.enabled
|
||||
_ = integration
|
||||
end
|
||||
|
||||
test "toggle_enabled is a no-op for unknown integration", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
# Should not crash even though no integration exists for "preseem"
|
||||
html = render_hook(view, "toggle_enabled", %{"provider" => "preseem"})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "saving exclusive billing integration when another active blocks save", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
# Activate gaiia first
|
||||
{:ok, _} =
|
||||
Towerops.Integrations.create_integration(org.id, %{
|
||||
provider: "gaiia",
|
||||
enabled: true,
|
||||
credentials: %{"api_key" => "k", "webhook_secret" => "s"}
|
||||
})
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
# When active billing exists, the picker for sonar isn't rendered, so
|
||||
# drive the events directly through the LV.
|
||||
render_hook(view, "configure", %{"provider" => "sonar"})
|
||||
|
||||
html =
|
||||
render_hook(view, "save", %{
|
||||
"integration" => %{"instance_url" => "https://x", "api_token" => "t"}
|
||||
})
|
||||
|
||||
assert html =~ "Only one"
|
||||
end
|
||||
|
||||
test "regenerate_webhook_secret is a no-op when no gaiia integration exists", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
# No gaiia integration; event should be a no-op (and not crash).
|
||||
html = render_hook(view, "regenerate_webhook_secret", %{})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "renders provider category descriptions", %{conn: conn, user: user, organization: org} do
|
||||
{:ok, _view, html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
assert html =~ "Billing"
|
||||
assert html =~ "Incident"
|
||||
assert html =~ "Infrastructure"
|
||||
end
|
||||
|
||||
test "clears sync status when credentials change", %{conn: conn, user: user, organization: org} do
|
||||
{:ok, _} =
|
||||
Towerops.Integrations.create_integration(org.id, %{
|
||||
|
|
|
|||
|
|
@ -572,6 +572,347 @@ defmodule ToweropsWeb.ScheduleLiveTest do
|
|||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
test "timeline_prev shifts the visible window backwards", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "timeline_prev", %{})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "timeline_next shifts the visible window forwards", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "timeline_next", %{})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "timeline_today resets the visible window", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
# Move twice, then snap back to today
|
||||
render_hook(view, "timeline_next", %{})
|
||||
render_hook(view, "timeline_next", %{})
|
||||
html = render_hook(view, "timeline_today", %{})
|
||||
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "validate_layer applied to invalid params surfaces errors", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
_ =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Existing",
|
||||
position: 0,
|
||||
rotation_type: "weekly",
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z]
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Add Layer") |> render_click()
|
||||
|
||||
# Drive validate event directly to bypass select option whitelisting in
|
||||
# the test framework form helper.
|
||||
html =
|
||||
render_hook(view, "validate_layer", %{
|
||||
"layer" => %{
|
||||
"name" => "",
|
||||
"rotation_type" => "",
|
||||
"handoff_time" => "",
|
||||
"start_date" => ""
|
||||
}
|
||||
})
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "save_layer with invalid params keeps the form open", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
_ =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Existing",
|
||||
position: 0,
|
||||
rotation_type: "weekly",
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z]
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Add Layer") |> render_click()
|
||||
|
||||
render_hook(view, "save_layer", %{
|
||||
"layer" => %{
|
||||
"name" => "",
|
||||
"rotation_type" => "",
|
||||
"handoff_time" => "",
|
||||
"start_date" => ""
|
||||
}
|
||||
})
|
||||
|
||||
# The form should still be in the DOM — invalid submit re-renders with
|
||||
# the changeset, it does NOT close the form.
|
||||
assert has_element?(view, "form[phx-submit='save_layer']")
|
||||
end
|
||||
|
||||
test "toggle_add_layer hides and re-shows the form", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
_ =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "Existing",
|
||||
position: 0,
|
||||
rotation_type: "weekly",
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z]
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
refute has_element?(view, "form[phx-submit='save_layer']")
|
||||
view |> element("button", "Add Layer") |> render_click()
|
||||
assert has_element?(view, "form[phx-submit='save_layer']")
|
||||
# Toggle off via direct event
|
||||
render_hook(view, "toggle_add_layer", %{})
|
||||
refute has_element?(view, "form[phx-submit='save_layer']")
|
||||
end
|
||||
|
||||
test "new_layer_add_user with empty id is a no-op", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "new_layer_add_user", %{"user_id" => ""})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "new_layer_add_user adds a member then ignores duplicates", %{
|
||||
conn: conn,
|
||||
organization: organization,
|
||||
user: user
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
render_hook(view, "new_layer_add_user", %{"user_id" => user.id})
|
||||
# Adding the same user twice should not crash
|
||||
html = render_hook(view, "new_layer_add_user", %{"user_id" => user.id})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "new_layer_remove_user is a no-op for unknown user", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "new_layer_remove_user", %{"id" => Ecto.UUID.generate()})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "set_layer_restriction with time_of_day persists restrictions", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "L1",
|
||||
position: 0,
|
||||
rotation_type: "weekly",
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z]
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
render_hook(view, "set_layer_restriction", %{
|
||||
"layer_id" => layer.id,
|
||||
"restriction_type" => "time_of_day",
|
||||
"start_time" => "08:00",
|
||||
"end_time" => "17:00"
|
||||
})
|
||||
|
||||
reloaded = OnCall.get_schedule!(schedule.id)
|
||||
target = Enum.find(reloaded.layers, &(&1.id == layer.id))
|
||||
assert target.restriction_type == "time_of_day"
|
||||
end
|
||||
|
||||
test "set_layer_restriction with empty type clears restrictions", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
layer =
|
||||
layer_fixture(schedule.id, %{
|
||||
name: "L1",
|
||||
position: 0,
|
||||
rotation_type: "weekly",
|
||||
handoff_time: ~T[09:00:00],
|
||||
start_date: ~U[2026-01-01 09:00:00Z]
|
||||
})
|
||||
|
||||
# Apply restriction first
|
||||
{:ok, _} =
|
||||
OnCall.update_layer_restrictions(layer, %{
|
||||
restriction_type: "time_of_day",
|
||||
restrictions: %{"start_time" => "09:00", "end_time" => "17:00"}
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
render_hook(view, "set_layer_restriction", %{
|
||||
"layer_id" => layer.id,
|
||||
"restriction_type" => ""
|
||||
})
|
||||
|
||||
reloaded = OnCall.get_schedule!(schedule.id)
|
||||
target = Enum.find(reloaded.layers, &(&1.id == layer.id))
|
||||
assert is_nil(target.restriction_type) or target.restriction_type == ""
|
||||
end
|
||||
|
||||
test "set_layer_restriction is a no-op when layer not found", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html =
|
||||
render_hook(view, "set_layer_restriction", %{
|
||||
"layer_id" => Ecto.UUID.generate(),
|
||||
"restriction_type" => "time_of_day"
|
||||
})
|
||||
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "move_layer is a no-op when layer not found", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html =
|
||||
render_hook(view, "move_layer", %{
|
||||
"id" => Ecto.UUID.generate(),
|
||||
"direction" => "up"
|
||||
})
|
||||
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "delete_layer is a no-op when layer not found", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "delete_layer", %{"id" => Ecto.UUID.generate()})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "validate_override surfaces errors when invalid", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Add Override") |> render_click()
|
||||
|
||||
html =
|
||||
render_hook(view, "validate_override", %{
|
||||
"override" => %{"user_id" => "", "start_time" => "", "end_time" => ""}
|
||||
})
|
||||
|
||||
assert html =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "save_override with invalid params keeps form open", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
view |> element("button", "Add Override") |> render_click()
|
||||
|
||||
render_hook(view, "save_override", %{
|
||||
"override" => %{"user_id" => "", "start_time" => "", "end_time" => ""}
|
||||
})
|
||||
|
||||
assert has_element?(view, "form[phx-submit='save_override']")
|
||||
end
|
||||
|
||||
test "delete_override is a no-op for unknown override", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "delete_override", %{"id" => Ecto.UUID.generate()})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "add_member with empty user_id is a no-op", %{conn: conn, organization: organization} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
_layer = layer_fixture(schedule.id, %{name: "L1"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "add_member", %{"user_id" => ""})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "remove_member is a no-op for unknown member", %{
|
||||
conn: conn,
|
||||
organization: organization
|
||||
} do
|
||||
schedule = schedule_fixture(organization.id)
|
||||
_layer = layer_fixture(schedule.id, %{name: "L1"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
||||
|
||||
html = render_hook(view, "remove_member", %{"id" => Ecto.UUID.generate()})
|
||||
assert is_binary(html)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Form - New" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue