227 lines
7.7 KiB
Elixir
227 lines
7.7 KiB
Elixir
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
|