Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
390 lines
11 KiB
Elixir
390 lines
11 KiB
Elixir
defmodule Towerops.ReportsTest do
|
|
use Towerops.DataCase, async: false
|
|
|
|
alias Towerops.Organizations.Organization
|
|
alias Towerops.Reports
|
|
alias Towerops.Reports.Report
|
|
|
|
describe "CRUD" do
|
|
test "create_report/1 with valid attrs" do
|
|
org = insert_org()
|
|
|
|
assert {:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Weekly Uptime",
|
|
report_type: "uptime_summary",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: ["admin@example.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert report.name == "Weekly Uptime"
|
|
assert report.report_type == "uptime_summary"
|
|
assert report.enabled == true
|
|
end
|
|
|
|
test "create_report/1 rejects invalid report_type" do
|
|
org = insert_org()
|
|
|
|
assert {:error, changeset} =
|
|
Reports.create_report(%{
|
|
name: "Bad Report",
|
|
report_type: "invalid_type",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: ["admin@example.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert errors_on(changeset).report_type
|
|
end
|
|
|
|
test "create_report/1 rejects empty recipients" do
|
|
org = insert_org()
|
|
|
|
assert {:error, changeset} =
|
|
Reports.create_report(%{
|
|
name: "No Recipients",
|
|
report_type: "uptime_summary",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: [],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert errors_on(changeset).recipients
|
|
end
|
|
|
|
test "list_reports/1 returns reports for org" do
|
|
org = insert_org()
|
|
|
|
{:ok, _} =
|
|
Reports.create_report(%{
|
|
name: "Report 1",
|
|
report_type: "uptime_summary",
|
|
schedule: %{"type" => "daily"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
reports = Reports.list_reports(org.id)
|
|
assert length(reports) == 1
|
|
end
|
|
|
|
test "toggle_report/1 flips enabled" do
|
|
org = insert_org()
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Toggle Me",
|
|
report_type: "alert_history",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert report.enabled == true
|
|
{:ok, toggled} = Reports.toggle_report(report)
|
|
assert toggled.enabled == false
|
|
end
|
|
|
|
test "delete_report/1 removes report" do
|
|
org = insert_org()
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Delete Me",
|
|
report_type: "capacity_trends",
|
|
schedule: %{"type" => "monthly"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert {:ok, _} = Reports.delete_report(report)
|
|
assert Reports.list_reports(org.id) == []
|
|
end
|
|
end
|
|
|
|
describe "due?/1" do
|
|
test "one_time report is due if never run" do
|
|
report = %Report{schedule: %{"type" => "one_time"}, last_run_at: nil}
|
|
assert Reports.due?(report)
|
|
end
|
|
|
|
test "one_time report is not due if already run" do
|
|
report = %Report{schedule: %{"type" => "one_time"}, last_run_at: DateTime.utc_now()}
|
|
refute Reports.due?(report)
|
|
end
|
|
|
|
test "daily report is due after 24h" do
|
|
old = DateTime.add(DateTime.utc_now(), -25, :hour)
|
|
report = %Report{schedule: %{"type" => "daily"}, last_run_at: old}
|
|
assert Reports.due?(report)
|
|
end
|
|
|
|
test "daily report is not due within 24h" do
|
|
recent = DateTime.add(DateTime.utc_now(), -1, :hour)
|
|
report = %Report{schedule: %{"type" => "daily"}, last_run_at: recent}
|
|
refute Reports.due?(report)
|
|
end
|
|
|
|
test "weekly report never run is due" do
|
|
report = %Report{schedule: %{"type" => "weekly"}, last_run_at: nil}
|
|
assert Reports.due?(report)
|
|
end
|
|
|
|
test "weekly report last run over 7 days ago is due" do
|
|
old = DateTime.add(DateTime.utc_now(), -604_801, :second)
|
|
report = %Report{schedule: %{"type" => "weekly"}, last_run_at: old}
|
|
assert Reports.due?(report)
|
|
end
|
|
|
|
test "weekly report last run under 7 days ago is not due" do
|
|
recent = DateTime.add(DateTime.utc_now(), -600_000, :second)
|
|
report = %Report{schedule: %{"type" => "weekly"}, last_run_at: recent}
|
|
refute Reports.due?(report)
|
|
end
|
|
|
|
test "monthly report never run is due" do
|
|
report = %Report{schedule: %{"type" => "monthly"}, last_run_at: nil}
|
|
assert Reports.due?(report)
|
|
end
|
|
|
|
test "monthly report last run over 30 days ago is due" do
|
|
old = DateTime.add(DateTime.utc_now(), -2_592_001, :second)
|
|
report = %Report{schedule: %{"type" => "monthly"}, last_run_at: old}
|
|
assert Reports.due?(report)
|
|
end
|
|
|
|
test "monthly report last run under 30 days ago is not due" do
|
|
recent = DateTime.add(DateTime.utc_now(), -2_000_000, :second)
|
|
report = %Report{schedule: %{"type" => "monthly"}, last_run_at: recent}
|
|
refute Reports.due?(report)
|
|
end
|
|
|
|
test "unknown schedule type is not due" do
|
|
report = %Report{schedule: %{"type" => "hourly"}}
|
|
refute Reports.due?(report)
|
|
end
|
|
|
|
test "empty schedule map is not due" do
|
|
report = %Report{schedule: %{}}
|
|
refute Reports.due?(report)
|
|
end
|
|
|
|
test "nil schedule is not due" do
|
|
report = %Report{schedule: nil}
|
|
refute Reports.due?(report)
|
|
end
|
|
end
|
|
|
|
describe "csv_escape/1" do
|
|
test "nil becomes empty string" do
|
|
assert "" == Reports.csv_escape(nil)
|
|
end
|
|
|
|
test "plain strings pass through unchanged" do
|
|
assert "hello" == Reports.csv_escape("hello")
|
|
assert "no escaping needed" == Reports.csv_escape("no escaping needed")
|
|
end
|
|
|
|
test "strings with commas get quoted" do
|
|
assert ~s("a, b, c") == Reports.csv_escape("a, b, c")
|
|
end
|
|
|
|
test "strings with quotes get quoted and quotes doubled" do
|
|
assert ~s("she said ""hi""") == Reports.csv_escape(~s(she said "hi"))
|
|
end
|
|
|
|
test "strings with newlines get quoted" do
|
|
assert ~s("line1\nline2") == Reports.csv_escape("line1\nline2")
|
|
end
|
|
|
|
test "integers and other types coerced to string" do
|
|
assert "42" == Reports.csv_escape(42)
|
|
assert "3.14" == Reports.csv_escape(3.14)
|
|
assert "true" == Reports.csv_escape(true)
|
|
assert "foo" == Reports.csv_escape(:foo)
|
|
end
|
|
end
|
|
|
|
describe "parse_time_range/1" do
|
|
test "defaults to 7 days when days is missing" do
|
|
now = DateTime.utc_now()
|
|
range = Reports.parse_time_range(%{})
|
|
|
|
# `to` should be within 1 second of now
|
|
assert DateTime.diff(range.to, now, :second) in -1..1
|
|
# `from` should be ~7 days before `to`
|
|
diff_days = DateTime.diff(range.to, range.from, :day)
|
|
assert diff_days == 7
|
|
end
|
|
|
|
test "honours explicit days" do
|
|
range = Reports.parse_time_range(%{"days" => 30})
|
|
assert DateTime.diff(range.to, range.from, :day) == 30
|
|
end
|
|
|
|
test "range.from is before range.to" do
|
|
range = Reports.parse_time_range(%{"days" => 1})
|
|
assert DateTime.before?(range.from, range.to)
|
|
end
|
|
|
|
test "zero days makes range.from == range.to (same timestamp)" do
|
|
range = Reports.parse_time_range(%{"days" => 0})
|
|
assert range.from == range.to
|
|
end
|
|
end
|
|
|
|
describe "mark_run/2" do
|
|
test "updates last_run_at and status" do
|
|
org = insert_org()
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Run Me",
|
|
report_type: "rf_link_health",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert is_nil(report.last_run_at)
|
|
{:ok, updated} = Reports.mark_run(report, "success")
|
|
assert updated.last_run_status == "success"
|
|
assert updated.last_run_at
|
|
end
|
|
end
|
|
|
|
describe "generate_csv/1" do
|
|
test "uptime_summary produces header + rows from devices" do
|
|
%{org: org, site: site} = setup_org_and_site()
|
|
|
|
{:ok, _d1} =
|
|
Towerops.Devices.create_device(
|
|
%{
|
|
name: "Router-1",
|
|
ip_address: "10.0.0.1",
|
|
organization_id: org.id,
|
|
site_id: site.id,
|
|
monitoring_enabled: true
|
|
},
|
|
bypass_limits: true
|
|
)
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Uptime",
|
|
report_type: "uptime_summary",
|
|
schedule: %{"type" => "daily"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert {:ok, csv} = Reports.generate_csv(report)
|
|
assert String.contains?(csv, "Device,IP Address,Status,Last Seen")
|
|
assert String.contains?(csv, "Router-1")
|
|
assert String.contains?(csv, "monitored")
|
|
end
|
|
|
|
test "uptime_summary with unmonitored device marks as unmonitored" do
|
|
%{org: org, site: site} = setup_org_and_site()
|
|
|
|
{:ok, _d} =
|
|
Towerops.Devices.create_device(
|
|
%{
|
|
name: "Off",
|
|
ip_address: "10.0.0.2",
|
|
organization_id: org.id,
|
|
site_id: site.id,
|
|
monitoring_enabled: false
|
|
},
|
|
bypass_limits: true
|
|
)
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Uptime",
|
|
report_type: "uptime_summary",
|
|
schedule: %{"type" => "daily"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
{:ok, csv} = Reports.generate_csv(report)
|
|
assert String.contains?(csv, "unmonitored")
|
|
end
|
|
|
|
test "alert_history produces CSV with no alerts" do
|
|
%{org: org} = setup_org_and_site()
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Alerts",
|
|
report_type: "alert_history",
|
|
schedule: %{"type" => "daily"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert {:ok, csv} = Reports.generate_csv(report)
|
|
assert String.contains?(csv, "Alert,Severity,Status,Device,Created At,Resolved At")
|
|
end
|
|
|
|
test "capacity_trends returns header even for empty data" do
|
|
%{org: org} = setup_org_and_site()
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "Cap",
|
|
report_type: "capacity_trends",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert {:ok, csv} = Reports.generate_csv(report)
|
|
assert String.contains?(csv, "Site,Total Capacity (bps),Throughput (bps),Utilization %")
|
|
end
|
|
|
|
test "rf_link_health returns header with empty rf links" do
|
|
%{org: org} = setup_org_and_site()
|
|
|
|
{:ok, report} =
|
|
Reports.create_report(%{
|
|
name: "RF",
|
|
report_type: "rf_link_health",
|
|
schedule: %{"type" => "weekly"},
|
|
recipients: ["a@b.com"],
|
|
organization_id: org.id
|
|
})
|
|
|
|
assert {:ok, csv} = Reports.generate_csv(report)
|
|
assert String.contains?(csv, "Client,MAC,Signal (dBm),SNR (dB),TX Rate,RX Rate,Health,Last Seen")
|
|
end
|
|
end
|
|
|
|
# -- Helpers --
|
|
|
|
defp setup_org_and_site do
|
|
org = insert_org()
|
|
|
|
{:ok, site} =
|
|
Towerops.Sites.create_site(%{
|
|
name: "Test Site #{System.unique_integer([:positive])}",
|
|
organization_id: org.id
|
|
})
|
|
|
|
%{org: org, site: site}
|
|
end
|
|
|
|
defp insert_org do
|
|
{:ok, org} =
|
|
%Organization{}
|
|
|> Organization.changeset(%{
|
|
name: "Test Org #{System.unique_integer([:positive])}",
|
|
slug: "test-org-#{System.unique_integer([:positive])}"
|
|
})
|
|
|> Towerops.Repo.insert()
|
|
|
|
org
|
|
end
|
|
end
|