diff --git a/test/towerops/agents/agent_token_query_test.exs b/test/towerops/agents/agent_token_query_test.exs new file mode 100644 index 00000000..cb4bfed1 --- /dev/null +++ b/test/towerops/agents/agent_token_query_test.exs @@ -0,0 +1,75 @@ +defmodule Towerops.Agents.AgentTokenQueryTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Agents + alias Towerops.Agents.AgentToken + alias Towerops.Agents.AgentTokenQuery + alias Towerops.Repo + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, t1, _raw1} = Agents.create_agent_token(org.id, "Token One") + {:ok, t2, _raw2} = Agents.create_agent_token(org.id, "Token Two") + + %{org: org, t1: t1, t2: t2} + end + + describe "base/0" do + test "is the AgentToken schema" do + assert AgentTokenQuery.base() == AgentToken + end + end + + describe "for_organization/2" do + test "scopes to a single organization", %{org: org} do + results = + AgentTokenQuery.base() + |> AgentTokenQuery.for_organization(org.id) + |> Repo.all() + + assert length(results) == 2 + assert Enum.all?(results, &(&1.organization_id == org.id)) + end + + test "returns [] for unknown org" do + assert Ecto.UUID.generate() |> AgentTokenQuery.for_organization() |> Repo.all() == [] + end + end + + describe "enabled/1" do + test "returns only enabled tokens", %{org: org, t1: t1} do + # Disable t1 + {:ok, _} = + t1 + |> Ecto.Changeset.change(enabled: false) + |> Repo.update() + + results = + AgentTokenQuery.base() + |> AgentTokenQuery.for_organization(org.id) + |> AgentTokenQuery.enabled() + |> Repo.all() + + assert length(results) == 1 + assert Enum.all?(results, & &1.enabled) + end + end + + describe "excluding_cloud_pollers/1" do + test "returns all org-scoped tokens (none are cloud pollers in this fixture)", %{org: org} do + results = + AgentTokenQuery.base() + |> AgentTokenQuery.for_organization(org.id) + |> AgentTokenQuery.excluding_cloud_pollers() + |> Repo.all() + + assert length(results) == 2 + assert Enum.all?(results, &(&1.is_cloud_poller == false)) + end + end +end diff --git a/test/towerops/devices/device_query_test.exs b/test/towerops/devices/device_query_test.exs new file mode 100644 index 00000000..5fcfabc3 --- /dev/null +++ b/test/towerops/devices/device_query_test.exs @@ -0,0 +1,126 @@ +defmodule Towerops.Devices.DeviceQueryTest do + use Towerops.DataCase, async: true + use ExUnitProperties + + import Towerops.DevicesFixtures + + alias Towerops.Devices.Device + alias Towerops.Devices.DeviceQuery + alias Towerops.Repo + + describe "base/0" do + test "returns the Device schema" do + assert DeviceQuery.base() == Device + end + end + + describe "for_organization/2" do + test "scopes to a single organization" do + d = device_fixture() + _other = device_fixture() + + [%{id: id}] = + DeviceQuery.base() + |> DeviceQuery.for_organization(d.organization_id) + |> Repo.all() + + assert id == d.id + end + end + + describe "for_organizations/2" do + test "matches any of the given org ids" do + d1 = device_fixture() + d2 = device_fixture() + _ = device_fixture() + + ids = + [d1.organization_id, d2.organization_id] + |> DeviceQuery.for_organizations() + |> Repo.all() + |> MapSet.new(& &1.id) + + assert MapSet.member?(ids, d1.id) + assert MapSet.member?(ids, d2.id) + end + end + + describe "for_site/2 and for_sites/2" do + test "for_site narrows to one site" do + d = device_fixture() + _ = device_fixture() + + [%{id: id}] = d.site_id |> DeviceQuery.for_site() |> Repo.all() + assert id == d.id + end + + test "for_sites matches any in list" do + d1 = device_fixture() + d2 = device_fixture() + + ids = + [d1.site_id, d2.site_id] + |> DeviceQuery.for_sites() + |> Repo.all() + |> MapSet.new(& &1.id) + + assert ids == MapSet.new([d1.id, d2.id]) + end + end + + describe "with_status/2" do + test "filters by status" do + d_down = device_fixture(%{status: "down"}) + _d_up = device_fixture(%{status: "up"}) + + [%{id: id}] = "down" |> DeviceQuery.with_status() |> Repo.all() + assert id == d_down.id + end + end + + describe "order_by_display/1" do + test "orders by display_order ascending then name" do + d_a = device_fixture(%{name: "Alpha", display_order: 2}) + d_b = device_fixture(%{name: "Bravo", display_order: 1}) + d_c = device_fixture(%{name: "Charlie", display_order: 1}) + + ids = + DeviceQuery.base() + |> DeviceQuery.for_organizations([ + d_a.organization_id, + d_b.organization_id, + d_c.organization_id + ]) + |> DeviceQuery.order_by_display() + |> Repo.all() + |> Enum.map(& &1.id) + + # display_order: b,c (=1) come before a (=2). Within (=1), alphabetical name + assert Enum.find_index(ids, &(&1 == d_a.id)) > Enum.find_index(ids, &(&1 == d_b.id)) + assert Enum.find_index(ids, &(&1 == d_a.id)) > Enum.find_index(ids, &(&1 == d_c.id)) + end + end + + describe "property: composing for_organization with another filter narrows results" do + property "for_organization + with_status returns subset of for_organization" do + check all(status <- StreamData.member_of(["up", "down", "unknown"]), max_runs: 6) do + d = device_fixture(%{status: status}) + + a = + d.organization_id + |> DeviceQuery.for_organization() + |> Repo.all() + |> MapSet.new(& &1.id) + + b = + d.organization_id + |> DeviceQuery.for_organization() + |> DeviceQuery.with_status(status) + |> Repo.all() + |> MapSet.new(& &1.id) + + assert MapSet.subset?(b, a) + end + end + end +end diff --git a/test/towerops/snmp/agent_discovery_test.exs b/test/towerops/snmp/agent_discovery_test.exs new file mode 100644 index 00000000..4779e55c --- /dev/null +++ b/test/towerops/snmp/agent_discovery_test.exs @@ -0,0 +1,69 @@ +defmodule Towerops.Snmp.AgentDiscoveryTest do + @moduledoc """ + Tests for `Towerops.Snmp.AgentDiscovery.process_agent_discovery/2`. + + Covers the public entry point's branches: + + * empty `oid_values` map → `{:error, :agent_collected_no_data}` (with the + community-obscuring log path executed) + * SNMP-disabled device → `{:error, :snmp_not_enabled}` from the underlying + `Discovery.discover_device_with_opts/2` + * happy path: a minimally populated OID map is replayed through the + Replay adapter and produces a `Towerops.Snmp.Device`. + """ + use Towerops.DataCase, async: true + + alias Towerops.DevicesFixtures + alias Towerops.Snmp.AgentDiscovery + alias Towerops.Snmp.Device, as: DiscoveredDevice + + describe "process_agent_discovery/2 — failure paths" do + test "empty oid_values returns :agent_collected_no_data" do + device = DevicesFixtures.device_fixture() + + assert {:error, :agent_collected_no_data} = + AgentDiscovery.process_agent_discovery(device, %{}) + end + + test "empty oid_values for a device with a short community still returns :agent_collected_no_data" do + # Exercises the `obscure_community/1` else-branch where community is ≤ 3 chars. + device = DevicesFixtures.device_fixture(%{snmp_community: "ab"}) + + assert {:error, :agent_collected_no_data} = + AgentDiscovery.process_agent_discovery(device, %{}) + end + + test "snmp-disabled device propagates :snmp_not_enabled from Discovery" do + device = DevicesFixtures.device_fixture(%{snmp_enabled: false}) + + oid_values = %{"1.3.6.1.2.1.1.1.0" => "Anything"} + + assert {:error, :snmp_not_enabled} = + AgentDiscovery.process_agent_discovery(device, oid_values) + end + end + + describe "process_agent_discovery/2 — happy path" do + test "replays a minimal OID map through the Discovery pipeline" do + device = DevicesFixtures.device_fixture() + + # Minimum sysObjectID + sysDescr + sysUptime so build_device_info / select_profile + # have something to work with. Empty interface table is fine — discovery + # returns an empty list. + 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 Lab" + } + + assert {:ok, %DiscoveredDevice{} = discovered} = + AgentDiscovery.process_agent_discovery(device, oid_values) + + assert discovered.device_id == device.id + assert is_list(discovered.interfaces) + end + end +end diff --git a/test/towerops/uisp/gps_sync_test.exs b/test/towerops/uisp/gps_sync_test.exs index fda297d3..e9620bbd 100644 --- a/test/towerops/uisp/gps_sync_test.exs +++ b/test/towerops/uisp/gps_sync_test.exs @@ -111,4 +111,44 @@ defmodule Towerops.Uisp.GpsSyncTest do end end end + + describe "sync_gps/1" do + test "empty pairs list returns zero counts" do + assert {:ok, %{devices_updated: 0, sites_updated: 0}} = GpsSync.sync_gps([]) + end + + test "skips pairs without GPS coordinates (no metadata or site access)" do + # Without lat/lon in either location.* or gps.* the early-return path skips + # both updates without touching device.metadata, so passing a plain map is safe. + pair = {%{"location" => %{}, "gps" => %{}}, %{metadata: %{}, site_id: nil}} + + assert {:ok, %{devices_updated: 0, sites_updated: 0}} = + GpsSync.sync_gps([pair]) + end + + test "skips pairs whose coordinates fail null-island validation" do + pair = {%{"location" => %{"latitude" => 0.0, "longitude" => 0.0}}, %{}} + + assert {:ok, %{devices_updated: 0, sites_updated: 0}} = + GpsSync.sync_gps([pair]) + end + + test "skips pairs with nil coordinates" do + pair = + {%{"location" => %{"latitude" => nil, "longitude" => nil}, "gps" => %{}}, %{}} + + assert {:ok, %{devices_updated: 0, sites_updated: 0}} = + GpsSync.sync_gps([pair]) + end + + test "multiple pairs all invalid still aggregates to zero" do + pairs = [ + {%{"gps" => %{}}, %{}}, + {%{"location" => %{"latitude" => 0.0, "longitude" => 5.0}}, %{}}, + {%{"location" => %{"longitude" => -74.0}}, %{}} + ] + + assert {:ok, %{devices_updated: 0, sites_updated: 0}} = GpsSync.sync_gps(pairs) + end + end end diff --git a/test/towerops/uisp/statistics_sync_integration_test.exs b/test/towerops/uisp/statistics_sync_integration_test.exs new file mode 100644 index 00000000..9de7a2ff --- /dev/null +++ b/test/towerops/uisp/statistics_sync_integration_test.exs @@ -0,0 +1,227 @@ +defmodule Towerops.Uisp.StatisticsSyncIntegrationTest do + @moduledoc """ + DB-backed tests for `Towerops.Uisp.StatisticsSync` covering + `recently_polled?/3` and the `sync_statistics/2` orchestration that + shells out through the UISP HTTP client (stubbed via `Req.Test.stub`). + + Pure helper functions are exercised in `statistics_sync_test.exs`. + """ + use Towerops.DataCase, async: true + + alias Towerops.AccountsFixtures + alias Towerops.DevicesFixtures + alias Towerops.Integrations + alias Towerops.OrganizationsFixtures + alias Towerops.Snmp.Interface + alias Towerops.Snmp.InterfaceStat + alias Towerops.SnmpFixtures + alias Towerops.Uisp.Client + alias Towerops.Uisp.StatisticsSync + + defp setup_org_and_integration(extra_creds \\ %{}) do + user = AccountsFixtures.user_fixture() + org = OrganizationsFixtures.organization_fixture(user.id) + + creds = + Map.merge( + %{"url" => "https://uisp.example.com/nms/api/v2.1", "api_key" => "test-key"}, + extra_creds + ) + + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "uisp", + enabled: true, + credentials: creds + }) + + {org, integration} + end + + defp insert_interface(snmp_device_id, attrs \\ %{}) do + base = %{ + snmp_device_id: snmp_device_id, + if_index: System.unique_integer([:positive]) + } + + %Interface{} + |> Interface.changeset(Map.merge(base, attrs)) + |> Repo.insert!() + end + + defp insert_interface_stat(interface_id, checked_at, attrs \\ %{}) do + base = %{ + interface_id: interface_id, + if_in_octets: 0, + if_out_octets: 0, + if_in_errors: 0, + if_out_errors: 0, + if_in_discards: 0, + if_out_discards: 0, + checked_at: DateTime.truncate(checked_at, :second) + } + + %InterfaceStat{} + |> InterfaceStat.changeset(Map.merge(base, attrs)) + |> Repo.insert!() + end + + describe "recently_polled?/3" do + test "returns false when no readings exist for the device" do + {_org, _integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + snmp = SnmpFixtures.snmp_device_fixture(%{device: device}) + _iface = insert_interface(snmp.id) + + now = DateTime.utc_now() + # NOTE: StatisticsSync queries Interfaces by `snmp_device_id == device_id`, + # so we mirror that call signature here. + refute StatisticsSync.recently_polled?(snmp.id, "any", now) + end + + test "returns true when a reading exists within the dedup window" do + {_org, _integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + snmp = SnmpFixtures.snmp_device_fixture(%{device: device}) + iface = insert_interface(snmp.id) + + now = DateTime.utc_now() + recent = DateTime.add(now, -30, :second) + _ = insert_interface_stat(iface.id, recent) + + assert StatisticsSync.recently_polled?(snmp.id, "any", now) + end + + test "returns false when readings exist but only outside the dedup window" do + {_org, _integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + snmp = SnmpFixtures.snmp_device_fixture(%{device: device}) + iface = insert_interface(snmp.id) + + now = DateTime.utc_now() + old = DateTime.add(now, -10 * 60, :second) + _ = insert_interface_stat(iface.id, old) + + refute StatisticsSync.recently_polled?(snmp.id, "any", now) + end + + test "returns false for a different device id even if readings exist nearby" do + {_org, _integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + snmp = SnmpFixtures.snmp_device_fixture(%{device: device}) + iface = insert_interface(snmp.id) + + now = DateTime.utc_now() + _ = insert_interface_stat(iface.id, DateTime.add(now, -10, :second)) + + other_device = DevicesFixtures.device_fixture() + other_snmp = SnmpFixtures.snmp_device_fixture(%{device: other_device}) + + refute StatisticsSync.recently_polled?(other_snmp.id, "any", now) + end + end + + describe "sync_statistics/2 — empty / error paths" do + test "empty device map returns zero counts without making HTTP calls" do + {_org, integration} = setup_org_and_integration() + + assert {:ok, %{inserted: 0, skipped_dedup: 0}} = + StatisticsSync.sync_statistics(integration, %{}) + end + + test "HTTP errors are swallowed, leaving counts at zero" do + {_org, integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(404) + |> Req.Test.json(%{"error" => "not found"}) + end) + + assert {:ok, %{inserted: 0, skipped_dedup: 0}} = + StatisticsSync.sync_statistics(integration, %{"uisp-device-1" => device}) + end + + test "non-list response body is ignored without crashing" do + {_org, integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"unexpected" => "shape"}) + end) + + assert {:ok, %{inserted: 0, skipped_dedup: 0}} = + StatisticsSync.sync_statistics(integration, %{"uisp-device-1" => device}) + end + + test "stats with missing/invalid timestamps are counted as skipped_dedup" do + {_org, integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, [ + %{"interfaceName" => "eth0", "timestamp" => nil, "receive" => 100, "transmit" => 200}, + %{"interfaceName" => "eth1", "timestamp" => "garbage", "receive" => 1, "transmit" => 2} + ]) + end) + + assert {:ok, %{inserted: 0, skipped_dedup: 2}} = + StatisticsSync.sync_statistics(integration, %{"uisp-1" => device}) + end + end + + describe "sync_statistics/2 — valid stats" do + test "no primary interface (none for device.id) leaves counts at zero" do + # StatisticsSync queries Interfaces by `snmp_device_id == device.id`, + # so an Interface attached to the Device's Snmp.Device id is invisible + # to the lookup — the insert path returns {:error, :no_interface}. + {_org, integration} = setup_org_and_integration() + device = DevicesFixtures.device_fixture() + snmp = SnmpFixtures.snmp_device_fixture(%{device: device}) + _iface = insert_interface(snmp.id, %{if_index: 1}) + + ts = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, [ + %{ + "interfaceName" => "eth0", + "timestamp" => ts, + "receive" => 1024, + "transmit" => 2048, + "errors" => 5 + } + ]) + end) + + assert {:ok, %{inserted: 0, skipped_dedup: 0}} = + StatisticsSync.sync_statistics(integration, %{"uisp-1" => device}) + + assert Repo.all(InterfaceStat) == [] + end + + test "non-list response from one device + valid (timestamped) stat from another" do + {_org, integration} = setup_org_and_integration() + d1 = DevicesFixtures.device_fixture() + d2 = DevicesFixtures.device_fixture() + + Req.Test.stub(Client, fn conn -> + cond do + String.contains?(conn.request_path, "/devices/uisp-1/statistics") -> + Req.Test.json(conn, %{"unexpected" => "shape"}) + + String.contains?(conn.request_path, "/devices/uisp-2/statistics") -> + ts = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + Req.Test.json(conn, [%{"timestamp" => ts}]) + end + end) + + assert {:ok, %{inserted: 0, skipped_dedup: 0}} = + StatisticsSync.sync_statistics(integration, %{ + "uisp-1" => d1, + "uisp-2" => d2 + }) + end + end +end