- Add Codeberg Ansible collection link to API/GraphQL docs and Terraform guide - Un-skip the previously-disabled DevicePollerWorker tests that still pass - Expand MibController, CnMaestro.Sync, CheckWorker, ActivityFeed, MobileController, JobCleanupTask tests - New UploadMibs Mix task test covering arg validation and upload paths - Set :mib_dir in test config so MibController upload/delete flows can exercise the real on-disk paths against a writable tmp directory
556 lines
17 KiB
Elixir
556 lines
17 KiB
Elixir
defmodule Towerops.ActivityFeedTest do
|
|
use Towerops.DataCase
|
|
|
|
import Towerops.AccountsFixtures
|
|
import Towerops.OrganizationsFixtures
|
|
|
|
alias Towerops.ActivityFeed
|
|
alias Towerops.ConfigChanges.ConfigChangeEvent
|
|
alias Towerops.Devices.Event
|
|
alias Towerops.Integrations
|
|
alias Towerops.Preseem.SyncLog
|
|
alias Towerops.Repo
|
|
|
|
describe "list_org_activity/2" do
|
|
setup do
|
|
user = user_fixture()
|
|
organization = organization_fixture(user.id)
|
|
|
|
# Create integration for sync logs
|
|
{:ok, integration} =
|
|
Integrations.create_integration(organization.id, %{
|
|
provider: "preseem",
|
|
enabled: true,
|
|
credentials: %{"api_key" => "test_key"}
|
|
})
|
|
|
|
%{user: user, organization: organization, integration: integration}
|
|
end
|
|
|
|
test "returns empty list when no activity exists", %{organization: organization} do
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
assert activities == []
|
|
end
|
|
|
|
test "includes preseem sync logs with correct timestamp format", %{
|
|
organization: organization,
|
|
integration: integration
|
|
} do
|
|
# Create sync logs
|
|
{:ok, _sync_log1} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 150,
|
|
duration_ms: 223,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
{:ok, _sync_log2} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "failed",
|
|
records_synced: 0,
|
|
duration_ms: nil,
|
|
inserted_at: ~U[2026-03-06 10:01:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
|
|
assert length(activities) == 2
|
|
|
|
# Check first activity (most recent)
|
|
[first | _] = activities
|
|
assert first.type == :sync
|
|
assert first.summary =~ "Preseem sync"
|
|
assert first.detail =~ "records synced"
|
|
|
|
# Verify timestamp is DateTime (not NaiveDateTime)
|
|
assert %DateTime{} = first.timestamp
|
|
assert first.timestamp.time_zone == "Etc/UTC"
|
|
end
|
|
|
|
test "formats duration with reasonable precision", %{
|
|
organization: organization,
|
|
integration: integration
|
|
} do
|
|
# Create sync log with duration that will be averaged
|
|
{:ok, _sync_log} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 100,
|
|
duration_ms: 223,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
|
|
assert length(activities) == 1
|
|
[activity] = activities
|
|
|
|
# Should show duration (will be 223 from avg, rounded to integer)
|
|
assert activity.detail == "100 records synced in 223ms"
|
|
end
|
|
|
|
test "handles sync logs without duration", %{organization: organization, integration: integration} do
|
|
{:ok, _sync_log} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 50,
|
|
duration_ms: nil,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
|
|
assert length(activities) == 1
|
|
[activity] = activities
|
|
|
|
# Should not include duration
|
|
assert activity.detail == "50 records synced"
|
|
end
|
|
|
|
test "groups sync logs by minute", %{organization: organization, integration: integration} do
|
|
# Create multiple sync logs within the same minute
|
|
base_time = ~U[2026-03-06 10:00:00Z]
|
|
|
|
for i <- 0..4 do
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 10 * i,
|
|
duration_ms: 100 + i,
|
|
inserted_at: DateTime.add(base_time, i * 10, :second)
|
|
})
|
|
|> Repo.insert()
|
|
end
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
|
|
# Should be grouped into 1 activity (same minute)
|
|
assert length(activities) == 1
|
|
[activity] = activities
|
|
|
|
# Should sum records_synced (0 + 10 + 20 + 30 + 40 = 100)
|
|
assert activity.detail =~ "100 records synced"
|
|
|
|
# Should average duration_ms ((100 + 101 + 102 + 103 + 104) / 5 = 102)
|
|
assert activity.detail =~ "102ms"
|
|
end
|
|
|
|
test "sorts activities by timestamp descending", %{
|
|
organization: organization,
|
|
integration: integration
|
|
} do
|
|
# Create sync logs at different times
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 1,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 2,
|
|
inserted_at: ~U[2026-03-06 11:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 3,
|
|
inserted_at: ~U[2026-03-06 09:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
|
|
assert length(activities) == 3
|
|
|
|
# Should be sorted newest first
|
|
[first, second, third] = activities
|
|
assert first.detail =~ "2 records"
|
|
assert second.detail =~ "1 records"
|
|
assert third.detail =~ "3 records"
|
|
end
|
|
|
|
test "respects limit parameter", %{organization: organization, integration: integration} do
|
|
# Create many sync logs
|
|
for i <- 1..10 do
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: i,
|
|
inserted_at: DateTime.add(~U[2026-03-06 10:00:00Z], i * 60, :second)
|
|
})
|
|
|> Repo.insert()
|
|
end
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id, limit: 5)
|
|
|
|
assert length(activities) == 5
|
|
end
|
|
|
|
test "filters by date range", %{organization: organization, integration: integration} do
|
|
# Create sync logs over several days
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 1,
|
|
inserted_at: ~U[2026-03-05 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 2,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 3,
|
|
inserted_at: ~U[2026-03-07 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
activities =
|
|
ActivityFeed.list_org_activity(organization.id,
|
|
after: ~U[2026-03-06 00:00:00Z],
|
|
before: ~U[2026-03-07 00:00:00Z]
|
|
)
|
|
|
|
assert length(activities) == 1
|
|
[activity] = activities
|
|
assert activity.detail =~ "2 records"
|
|
end
|
|
|
|
test "only returns activities for the specified organization", %{user: user} do
|
|
org1 = organization_fixture(user.id)
|
|
org2 = organization_fixture(user.id, %{name: "Other Org"})
|
|
|
|
# Create integrations for both orgs
|
|
{:ok, int1} =
|
|
Integrations.create_integration(org1.id, %{
|
|
provider: "preseem",
|
|
enabled: true,
|
|
credentials: %{"api_key" => "test_key1"}
|
|
})
|
|
|
|
{:ok, int2} =
|
|
Integrations.create_integration(org2.id, %{
|
|
provider: "preseem",
|
|
enabled: true,
|
|
credentials: %{"api_key" => "test_key2"}
|
|
})
|
|
|
|
# Create sync log for org1
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: org1.id,
|
|
integration_id: int1.id,
|
|
status: "success",
|
|
records_synced: 100,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
# Create sync log for org2
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: org2.id,
|
|
integration_id: int2.id,
|
|
status: "success",
|
|
records_synced: 200,
|
|
inserted_at: ~U[2026-03-06 10:00:00Z]
|
|
})
|
|
|> Repo.insert()
|
|
|
|
# Should only see org1's activities
|
|
activities = ActivityFeed.list_org_activity(org1.id)
|
|
assert length(activities) == 1
|
|
[activity] = activities
|
|
assert activity.detail =~ "100 records"
|
|
|
|
# Should only see org2's activities
|
|
activities = ActivityFeed.list_org_activity(org2.id)
|
|
assert length(activities) == 1
|
|
[activity] = activities
|
|
assert activity.detail =~ "200 records"
|
|
end
|
|
|
|
test "handles different sync statuses", %{organization: organization, integration: integration} do
|
|
# Create sync logs in different minutes (sync groups by minute)
|
|
["success", "failed", "partial"]
|
|
|> Enum.with_index()
|
|
|> Enum.each(fn {status, idx} ->
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: status,
|
|
records_synced: 10,
|
|
# Ensure each is in a different minute (60 seconds apart)
|
|
inserted_at: DateTime.add(~U[2026-03-06 10:00:00Z], idx * 60, :second)
|
|
})
|
|
|> Repo.insert()
|
|
end)
|
|
|
|
activities = ActivityFeed.list_org_activity(organization.id)
|
|
|
|
assert length(activities) == 3
|
|
|
|
# Check that statuses are reflected in summaries
|
|
summaries = Enum.map(activities, & &1.summary)
|
|
assert Enum.any?(summaries, &(&1 =~ "completed"))
|
|
assert Enum.any?(summaries, &(&1 =~ "failed"))
|
|
assert Enum.any?(summaries, &(&1 =~ "partial"))
|
|
end
|
|
end
|
|
|
|
describe "count_by_type/1" do
|
|
test "returns zero counts when no activity exists" do
|
|
user = user_fixture()
|
|
organization = organization_fixture(user.id)
|
|
|
|
counts = ActivityFeed.count_by_type(organization.id)
|
|
|
|
assert is_map(counts)
|
|
assert counts |> Map.values() |> Enum.all?(&(&1 == 0))
|
|
end
|
|
|
|
test "counts recent sync activities" do
|
|
user = user_fixture()
|
|
organization = organization_fixture(user.id)
|
|
|
|
# Create integration
|
|
{:ok, integration} =
|
|
Integrations.create_integration(organization.id, %{
|
|
provider: "preseem",
|
|
enabled: true,
|
|
credentials: %{"api_key" => "test_key"}
|
|
})
|
|
|
|
# Create recent sync log
|
|
{:ok, _} =
|
|
%SyncLog{}
|
|
|> SyncLog.changeset(%{
|
|
organization_id: organization.id,
|
|
integration_id: integration.id,
|
|
status: "success",
|
|
records_synced: 100,
|
|
inserted_at: DateTime.utc_now()
|
|
})
|
|
|> Repo.insert()
|
|
|
|
counts = ActivityFeed.count_by_type(organization.id)
|
|
|
|
assert counts[:sync] > 0
|
|
end
|
|
end
|
|
|
|
describe "list_org_activity/2 — non-sync sources" do
|
|
setup do
|
|
user = user_fixture()
|
|
organization = organization_fixture(user.id)
|
|
|
|
{:ok, site} =
|
|
Towerops.Sites.create_site(%{name: "Site Alpha", organization_id: organization.id})
|
|
|
|
{:ok, device} =
|
|
Towerops.Devices.create_device(%{
|
|
name: "Edge router",
|
|
ip_address: "10.10.10.10",
|
|
site_id: site.id,
|
|
organization_id: organization.id,
|
|
snmp_enabled: true
|
|
})
|
|
|
|
%{user: user, organization: organization, site: site, device: device}
|
|
end
|
|
|
|
test "surfaces config change events", %{organization: org, device: device} do
|
|
{:ok, _} =
|
|
%ConfigChangeEvent{}
|
|
|> ConfigChangeEvent.changeset(%{
|
|
device_id: device.id,
|
|
organization_id: org.id,
|
|
changed_at: ~U[2026-04-01 12:00:00Z],
|
|
diff_summary: "/ip changed",
|
|
sections_changed: ["/ip", "/firewall"],
|
|
change_size: 80
|
|
})
|
|
|> Repo.insert()
|
|
|
|
[item] =
|
|
ActivityFeed.list_org_activity(org.id, types: [:config_change])
|
|
|
|
assert item.type == :config_change
|
|
assert item.device_name == "Edge router"
|
|
assert item.summary =~ "Config change"
|
|
assert item.detail =~ "/ip"
|
|
assert item.severity == :warning
|
|
assert item.icon == "hero-wrench-screwdriver"
|
|
end
|
|
|
|
test "small config changes mark severity as :info", %{organization: org, device: device} do
|
|
{:ok, _} =
|
|
%ConfigChangeEvent{}
|
|
|> ConfigChangeEvent.changeset(%{
|
|
device_id: device.id,
|
|
organization_id: org.id,
|
|
changed_at: ~U[2026-04-02 12:00:00Z],
|
|
sections_changed: [],
|
|
change_size: 5
|
|
})
|
|
|> Repo.insert()
|
|
|
|
[item] = ActivityFeed.list_org_activity(org.id, types: [:config_change])
|
|
assert item.severity == :info
|
|
end
|
|
|
|
test "surfaces alert_fired with device-down alerts", %{organization: org, device: device} do
|
|
{:ok, _alert} =
|
|
Towerops.Alerts.create_alert(%{
|
|
device_id: device.id,
|
|
organization_id: org.id,
|
|
alert_type: "device_down",
|
|
severity: 2,
|
|
message: "ICMP failed",
|
|
triggered_at: ~U[2026-04-03 12:00:00Z]
|
|
})
|
|
|
|
[item] = ActivityFeed.list_org_activity(org.id, types: [:alert_fired])
|
|
assert item.type == :alert_fired
|
|
assert item.severity == :critical
|
|
assert item.summary =~ "unreachable"
|
|
assert item.detail in ["ICMP failed", item.detail]
|
|
end
|
|
|
|
test "surfaces alert_resolved with formatted duration",
|
|
%{organization: org, device: device} do
|
|
{:ok, alert} =
|
|
Towerops.Alerts.create_alert(%{
|
|
device_id: device.id,
|
|
organization_id: org.id,
|
|
alert_type: "device_down",
|
|
severity: 2,
|
|
message: "down",
|
|
triggered_at: ~U[2026-04-04 12:00:00Z]
|
|
})
|
|
|
|
{:ok, _resolved} =
|
|
alert
|
|
|> Ecto.Changeset.change(%{resolved_at: ~U[2026-04-04 12:30:00Z]})
|
|
|> Repo.update()
|
|
|
|
[item] = ActivityFeed.list_org_activity(org.id, types: [:alert_resolved])
|
|
assert item.type == :alert_resolved
|
|
assert item.summary =~ "back online"
|
|
assert item.detail =~ "Duration"
|
|
end
|
|
|
|
test "surfaces device events with severity parsing",
|
|
%{organization: org, device: device} do
|
|
{:ok, _e1} =
|
|
Repo.insert(
|
|
Event.changeset(%Event{}, %{
|
|
device_id: device.id,
|
|
event_type: "interface_down",
|
|
severity: "critical",
|
|
message: "eth0 went down",
|
|
occurred_at: ~U[2026-04-05 12:00:00Z]
|
|
})
|
|
)
|
|
|
|
{:ok, _e2} =
|
|
Repo.insert(
|
|
Event.changeset(%Event{}, %{
|
|
device_id: device.id,
|
|
event_type: "device_discovered",
|
|
severity: "info",
|
|
message: "first contact",
|
|
occurred_at: ~U[2026-04-05 13:00:00Z]
|
|
})
|
|
)
|
|
|
|
items = ActivityFeed.list_org_activity(org.id, types: [:device_event])
|
|
assert length(items) == 2
|
|
assert Enum.any?(items, &(&1.severity == :critical))
|
|
assert Enum.any?(items, &(&1.severity == :info))
|
|
end
|
|
|
|
test "filters by full-text search", %{organization: org, device: device} do
|
|
{:ok, _} =
|
|
Repo.insert(
|
|
Event.changeset(%Event{}, %{
|
|
device_id: device.id,
|
|
event_type: "interface_down",
|
|
severity: "warning",
|
|
message: "needle in the haystack",
|
|
occurred_at: ~U[2026-04-06 12:00:00Z]
|
|
})
|
|
)
|
|
|
|
assert [_] =
|
|
ActivityFeed.list_org_activity(org.id,
|
|
types: [:device_event],
|
|
search: "needle"
|
|
)
|
|
|
|
assert [] =
|
|
ActivityFeed.list_org_activity(org.id,
|
|
types: [:device_event],
|
|
search: "missing"
|
|
)
|
|
end
|
|
end
|
|
end
|