towerops/test/towerops/activity_feed_test.exs
Graham McIntire 22f5c3ed63 test: lift coverage to 78.42% and link Ansible collection in docs
- 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
2026-05-07 16:48:53 -05:00

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