diff --git a/test/towerops/activity_feed_test.exs b/test/towerops/activity_feed_test.exs new file mode 100644 index 00000000..c08a36b6 --- /dev/null +++ b/test/towerops/activity_feed_test.exs @@ -0,0 +1,393 @@ +defmodule Towerops.ActivityFeedTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ActivityFeed + 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 + for status <- ["success", "failed", "partial"] do + {:ok, _} = + %SyncLog{} + |> SyncLog.changeset(%{ + organization_id: organization.id, + integration_id: integration.id, + status: status, + records_synced: 10, + inserted_at: DateTime.add(~U[2026-03-06 10:00:00Z], :rand.uniform(3600), :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 +end