towerops/test/towerops/dashboard_test.exs
Graham McIntire 2b50fc0732 fix agent check cascade and at-risk subscriber double-counting (#79)
## Summary

- **`list_checks_for_agent` cascade**: the agent query only matched checks with `agent_token_id` set directly on the check record — checks on devices inheriting their agent via site/org/global default were never sent to the Go agent. Now uses left joins with the full cascade (device assignment → site → org default → global default cloud poller), matching the pattern from `list_agent_polling_targets`.
- **At-risk summary double-counting**: `get_impact_summary` called `analyze_device_impact` per down AP, and each returned the full site subscriber count. With 3 APs down at a 147-sub site it showed 441 subs / $31k instead of 147 / $10k. Fixed by deduplicating impact per site. Same fix applied to `calculate_down_impact` in site impact summaries.

## Test plan

- [x] 3 new cascade tests for `list_checks_for_agent` (site, org default, global default)
- [x] 3 new tests for `get_impact_summary` (single site dedup, multi-site sum, zero case)
- [x] All 8,607 existing tests pass
- [ ] Deploy to staging, verify At Risk numbers on dashboard
- [ ] Verify Go agent receives DNS/HTTP checks via cascade

Reviewed-on: graham/towerops-web#79
2026-03-18 20:17:39 -05:00

276 lines
8.4 KiB
Elixir

defmodule Towerops.DashboardTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Dashboard
alias Towerops.Gaiia
alias Towerops.Integrations
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org, user: user}
end
describe "get_dashboard_summary/1" do
test "returns correct structure with empty org", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert is_map(summary)
assert Map.has_key?(summary, :health_score)
assert Map.has_key?(summary, :devices)
assert Map.has_key?(summary, :alerts)
assert Map.has_key?(summary, :subscribers)
assert Map.has_key?(summary, :insights)
end
test "returns device counts", %{org: org} do
device = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(device, :up)
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.devices.total == 1
assert summary.devices.up == 1
assert summary.devices.down == 0
end
test "returns alert count", %{org: org} do
device = device_fixture(%{organization_id: org.id})
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.alerts.active_count == 1
end
test "returns subscriber totals from billing integrations", %{org: org} do
{:ok, integration} =
Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true})
{:ok, _} = Integrations.update_billing_totals(integration, 100, Decimal.new("5000.00"))
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.subscribers.total == 100
assert Decimal.equal?(summary.subscribers.total_mrr, Decimal.new("5000.00"))
end
test "returns zero subscribers when no billing integrations", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.subscribers.total == 0
end
test "returns insight counts by urgency", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.insights.critical == 0
assert summary.insights.warning == 0
assert summary.insights.info == 0
end
end
describe "compute_health_score/1" do
test "returns 100 when all devices up, no alerts", %{org: org} do
device = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(device, :up)
score = Dashboard.compute_health_score(org.id)
assert score == 100
end
test "returns lower score when devices down", %{org: org} do
d1 = device_fixture(%{organization_id: org.id})
d2 = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(d1, :up)
Towerops.Devices.update_device_status(d2, :down)
score = Dashboard.compute_health_score(org.id)
assert score < 100
assert score > 0
end
test "returns 100 when no devices exist", %{org: org} do
score = Dashboard.compute_health_score(org.id)
assert score == 100
end
test "penalizes for critical alerts", %{org: org} do
device = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(device, :up)
{:ok, _} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device down"
})
score = Dashboard.compute_health_score(org.id)
assert score < 100
end
end
describe "get_org_subscriber_summary/1" do
test "returns totals from billing integrations", %{org: org} do
{:ok, i1} =
Integrations.create_integration(org.id, %{provider: "gaiia", enabled: true})
{:ok, _} = Integrations.update_billing_totals(i1, 50, Decimal.new("2500.00"))
{:ok, i2} =
Integrations.create_integration(org.id, %{provider: "splynx", enabled: true})
{:ok, _} = Integrations.update_billing_totals(i2, 30, Decimal.new("1500.00"))
result = Dashboard.get_org_subscriber_summary(org.id)
assert result.total == 80
assert Decimal.equal?(result.total_mrr, Decimal.new("4000.00"))
end
test "returns zeros when no billing integrations", %{org: org} do
result = Dashboard.get_org_subscriber_summary(org.id)
assert result.total == 0
assert Decimal.equal?(result.total_mrr, Decimal.new(0))
end
end
describe "get_impact_summary/1" do
test "does not multiply site subscribers by number of down APs", %{org: org} do
site = site_fixture(org.id)
{:ok, _network_site} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "gaiia-site-1",
name: "Tower 3",
account_count: 147,
total_mrr: Decimal.new("10479.00")
})
Gaiia.update_network_site_mapping(
Gaiia.get_network_site(org.id, "gaiia-site-1"),
%{site_id: site.id}
)
# Create 3 APs at this site, all down
for i <- 1..3 do
device =
device_fixture(%{
organization_id: org.id,
site_id: site.id,
name: "AP #{i}"
})
Towerops.Devices.update_device(device, %{status: :down})
end
summary = Dashboard.get_impact_summary(org.id)
# Should be 147 subscribers (site total), NOT 147 * 3 = 441
assert summary.subscribers_affected == 147
assert Decimal.equal?(summary.mrr_at_risk, Decimal.new("10479.00"))
assert summary.down_device_count == 3
assert summary.sites_with_outages == 1
end
test "sums subscribers across different sites correctly", %{org: org} do
# Site 1 with 100 subscribers, 2 APs down
site1 = site_fixture(org.id, %{name: "Tower A"})
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "site-a",
name: "Tower A",
account_count: 100,
total_mrr: Decimal.new("5000.00")
})
Gaiia.update_network_site_mapping(
Gaiia.get_network_site(org.id, "site-a"),
%{site_id: site1.id}
)
for i <- 1..2 do
device =
device_fixture(%{
organization_id: org.id,
site_id: site1.id,
name: "Site1 AP #{i}"
})
Towerops.Devices.update_device(device, %{status: :down})
end
# Site 2 with 50 subscribers, 1 AP down
site2 = site_fixture(org.id, %{name: "Tower B"})
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "site-b",
name: "Tower B",
account_count: 50,
total_mrr: Decimal.new("2500.00")
})
Gaiia.update_network_site_mapping(
Gaiia.get_network_site(org.id, "site-b"),
%{site_id: site2.id}
)
device =
device_fixture(%{
organization_id: org.id,
site_id: site2.id,
name: "Site2 AP 1"
})
Towerops.Devices.update_device(device, %{status: :down})
summary = Dashboard.get_impact_summary(org.id)
# 100 + 50 = 150 total subscribers across 2 sites
assert summary.subscribers_affected == 150
assert Decimal.equal?(summary.mrr_at_risk, Decimal.new("7500.00"))
assert summary.down_device_count == 3
assert summary.sites_with_outages == 2
end
test "returns zero impact when no devices are down", %{org: org} do
summary = Dashboard.get_impact_summary(org.id)
assert summary.subscribers_affected == 0
assert Decimal.equal?(summary.mrr_at_risk, Decimal.new("0"))
assert summary.down_device_count == 0
assert summary.sites_with_outages == 0
end
end
describe "list_site_summaries/1" do
test "returns enriched site data", %{org: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Tower",
organization_id: org.id
})
device = device_fixture(%{organization_id: org.id, site_id: site.id})
Towerops.Devices.update_device_status(device, :up)
summaries = Dashboard.list_site_summaries(org.id)
assert summaries != []
summary = Enum.find(summaries, &(&1.site_id == site.id))
assert summary.name == "Test Tower"
assert summary.device_count >= 1
end
end
end