towerops/test/towerops/reports_test.exs
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
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.
2026-04-24 09:49:06 -05:00

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