From fc20d60838215ac449e2a7750a90dbf2829904d6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 8 May 2026 17:57:47 -0500 Subject: [PATCH] test: cover Topology role/link/evidence helper branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated test file that drives the small private mapping helpers inside `Towerops.Topology` via their public surface: - `role_to_type_atom/1`: every device_role string (router, core_router, switch, distribution_switch, access_switch, access_point, cpe, backhaul, backhaul_radio, server, firewall, ups, other, unknown fallback) is exercised through `device_to_node` (via `get_topology_for_map/2`) and again through `get_node_detail/2`. - `link_type_priority/1`: each priority pair (lldp/cdp/mac_match/ arp_inference) is exercised via `merge_bidirectional_edges/1` by upserting reciprocal links of differing types and asserting the higher-priority `link_type` survives the merge. - `evidence_type_to_link_type/1`: every evidence_type clause (lldp_neighbor, cdp_neighbor, mac_on_interface, arp_entry, wireless_registration, plus the catch-all) is hit via `upsert_link/1` → `process_evidence_for_link/2`. Coverage: 86.28% → 86.42% (+0.14%). --- .../topology_helpers_branches_test.exs | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test/towerops/topology_helpers_branches_test.exs diff --git a/test/towerops/topology_helpers_branches_test.exs b/test/towerops/topology_helpers_branches_test.exs new file mode 100644 index 00000000..9942b594 --- /dev/null +++ b/test/towerops/topology_helpers_branches_test.exs @@ -0,0 +1,231 @@ +defmodule Towerops.TopologyHelpersBranchesTest do + @moduledoc """ + Targeted coverage for the small helper functions inside `Towerops.Topology` + that aren't exercised by the existing happy-path topology tests: + + * `role_to_type_atom/1` for every device_role string, + * `link_type_priority/1` for each link_type via merge_bidirectional_edges, + * `evidence_type_to_link_type/1` via upsert_link → process_evidence_for_link. + """ + use Towerops.DataCase, async: false + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Interface + alias Towerops.Topology + + setup do + user = user_fixture() + {:ok, org} = Towerops.Organizations.create_organization(%{name: "Topo Org"}, user.id) + {:ok, site} = Towerops.Sites.create_site(%{name: "Topo Site", organization_id: org.id}) + + %{org: org, site: site, user: user} + end + + describe "device role → type mapping (covers role_to_type_atom/1)" do + @roles_to_types [ + {"router", :router}, + {"core_router", :router}, + {"switch", :switch}, + {"distribution_switch", :switch}, + {"access_switch", :switch}, + {"access_point", :wireless}, + {"cpe", :wireless}, + {"backhaul", :wireless}, + {"backhaul_radio", :wireless}, + {"server", :server}, + {"firewall", :firewall}, + {"ups", :server}, + {"other", :unknown}, + {"some-future-role", :unknown} + ] + + test "device_to_node returns the expected type for every known role", %{org: org, site: site} do + for {role, expected_type} <- @roles_to_types do + {:ok, device} = + Towerops.Devices.create_device( + %{ + name: "dev-#{role}-#{System.unique_integer([:positive])}", + ip_address: random_ip(), + site_id: site.id, + organization_id: org.id, + device_role: role + }, + bypass_limits: true + ) + + # Drives `role_to_type_atom/1` via device_to_node/2. + result = Topology.get_topology_for_map(org.id, "added") + + node = Enum.find(result.nodes, &(&1.id == device.id)) + assert node, "expected node for device with role=#{role}" + assert node.type == expected_type, "role=#{role} → expected #{inspect(expected_type)}, got #{inspect(node.type)}" + end + end + + test "get_node_detail also exercises role_to_type_atom for varied roles", %{org: org, site: site} do + for role <- ["router", "switch", "access_point", "server", "firewall", "ups", "other"] do + {:ok, device} = + Towerops.Devices.create_device( + %{ + name: "node-#{role}-#{System.unique_integer([:positive])}", + ip_address: random_ip(), + site_id: site.id, + organization_id: org.id, + device_role: role + }, + bypass_limits: true + ) + + detail = Topology.get_node_detail(device.id, org.id) + assert detail + assert detail.id == device.id + assert detail.device_role == role + end + end + end + + describe "merge_bidirectional_edges (covers link_type_priority/1)" do + test "with both directions present, the higher-priority link_type wins", %{org: org, site: site} do + # We need two devices with interfaces, two reciprocal links of differing + # link_types, then call get_topology_for_map. After merging, the surviving + # edge should carry the better link_type. + {router_a, iface_a} = device_with_iface(org, site, "Router-A", "10.10.0.1", "aa:00:00:00:00:01") + {router_b, iface_b} = device_with_iface(org, site, "Router-B", "10.10.0.2", "aa:00:00:00:00:02") + + now = DateTime.utc_now() + + # link_type combinations across the priority ladder. + cases = [ + {"lldp", "cdp", "lldp"}, + {"cdp", "mac_match", "cdp"}, + {"mac_match", "arp_inference", "mac_match"}, + {"arp_inference", "mac_match", "mac_match"} + ] + + for {type_ab, type_ba, expected_winner} <- cases do + # Wipe device_links so each iteration is isolated. + Towerops.Repo.delete_all(Towerops.Topology.DeviceLink) + + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: router_a.id, + target_device_id: router_b.id, + source_interface_id: iface_a.id, + target_interface_id: iface_b.id, + link_type: type_ab, + confidence: 0.9, + last_confirmed_at: now, + evidence: [] + }) + + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: router_b.id, + target_device_id: router_a.id, + source_interface_id: iface_b.id, + target_interface_id: iface_a.id, + link_type: type_ba, + confidence: 0.9, + last_confirmed_at: now, + evidence: [] + }) + + result = Topology.get_topology_for_map(org.id, "added") + + edge = + Enum.find(result.edges, fn e -> + (e.source == router_a.id and e.target == router_b.id) or + (e.source == router_b.id and e.target == router_a.id) + end) + + assert edge, "expected merged edge for {#{type_ab}, #{type_ba}}" + assert edge.link_type == expected_winner + end + end + end + + describe "evidence_type_to_link_type (covers all clauses via upsert_link)" do + test "every evidence type gets its expected link_type", %{org: org, site: site} do + {router, iface} = device_with_iface(org, site, "EvidenceR", "10.20.0.1", "bb:00:00:00:00:01") + + pairs = [ + {"lldp_neighbor", "lldp"}, + {"cdp_neighbor", "cdp"}, + {"mac_on_interface", "mac_match"}, + {"arp_entry", "arp_inference"}, + {"wireless_registration", "wireless_association"}, + {"unknown_evidence_type", "mac_match"} + ] + + now = DateTime.utc_now() + + for {evidence_type, _expected_link_type} <- pairs do + # Each iteration uses a fresh discovered MAC so the link is unique. + remote_mac = + "ee:#{255 |> :rand.uniform() |> Integer.to_string(16) |> String.pad_leading(2, "0")}:00:00:00:#{255 |> :rand.uniform() |> Integer.to_string(16) |> String.pad_leading(2, "0")}" + + {:ok, _link} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: nil, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.9, + discovered_remote_mac: remote_mac, + last_confirmed_at: now, + evidence: [ + %{ + evidence_type: evidence_type, + confidence: 0.9, + observed_at: now, + metadata: %{} + } + ] + }) + end + + # Just confirm the upserts ran without raising — this exercises every + # evidence_type_to_link_type/1 clause as a side-effect of process_evidence_for_link. + links = Topology.list_device_links(router.id) + assert length(links) >= length(pairs) + end + end + + # ---------- helpers ---------- + + defp device_with_iface(org, site, name, ip, mac) do + {:ok, device} = + Towerops.Devices.create_device( + %{ + name: name, + ip_address: ip, + site_id: site.id, + organization_id: org.id + }, + bypass_limits: true + ) + + snmp_dev = + %SnmpDevice{} + |> SnmpDevice.changeset(%{device_id: device.id, sys_name: String.downcase(name)}) + |> Repo.insert!() + + iface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_dev.id, + if_index: 1, + if_name: "eth0", + if_phys_address: mac + }) + |> Repo.insert!() + + {device, iface} + end + + defp random_ip do + "10.#{:rand.uniform(254)}.#{:rand.uniform(254)}.#{:rand.uniform(254)}" + end +end