CI: switch postgres service image to timescale/timescaledb-ha:pg17-all which bundles PostGIS, fixing the lidar migration failure in PR and production workflows. Tests: ~30 new test files / extensions, ~600+ new tests, taking total coverage from 70.42% to 73.4%. Targets included GraphQL resolver unauthenticated paths, SNMP context queries (mempools, processors, ip_addresses, neighbors, wireless_clients, arp_entries), worker routing/lifecycle (alert notification, alert digest, weather sync, job cleanup, cn_maestro, uisp, mikrotik backup, report worker), SNMP monitoring executors (storage, interface, processor) end-to-end via stub adapter, NetBox + Sonar sync integration paths, ConfigChanges listing and correlator, organizations membership query, and live-view smoke tests for org-scoped settings, integrations, maintenance, agent, device deep paths, schedules. Property tests used for query composition and pure helpers where natural.
501 lines
18 KiB
Elixir
501 lines
18 KiB
Elixir
defmodule ToweropsWeb.GraphQL.Resolvers.AuthenticatedTest do
|
|
@moduledoc """
|
|
Direct invocation tests for happy and not-found paths of GraphQL resolvers.
|
|
Skips the Absinthe pipeline so we can test resolver logic in isolation.
|
|
"""
|
|
use Towerops.DataCase, async: true
|
|
|
|
import Towerops.AccountsFixtures
|
|
import Towerops.AgentsFixtures
|
|
import Towerops.DevicesFixtures
|
|
import Towerops.OrganizationsFixtures
|
|
|
|
alias Towerops.Repo
|
|
alias ToweropsWeb.GraphQL.Resolvers
|
|
|
|
setup do
|
|
user = user_fixture()
|
|
org = organization_fixture(user.id)
|
|
%{user: user, organization: org, ctx: %{context: %{organization_id: org.id, user: user}}}
|
|
end
|
|
|
|
describe "Activity.list/3" do
|
|
test "returns ok-tuple for authenticated context", %{ctx: ctx} do
|
|
assert {:ok, items} = Resolvers.Activity.list(nil, %{}, ctx)
|
|
assert is_list(items)
|
|
end
|
|
|
|
test "ignores invalid type strings", %{ctx: ctx} do
|
|
assert {:ok, _items} = Resolvers.Activity.list(nil, %{type: "definitely_not_an_atom_xyz"}, ctx)
|
|
end
|
|
|
|
test "accepts known-existing atom type", %{ctx: ctx} do
|
|
_ = :alert_created
|
|
assert {:ok, _items} = Resolvers.Activity.list(nil, %{type: "alert_created"}, ctx)
|
|
end
|
|
end
|
|
|
|
describe "Agent.list/3 and create/3 / delete/3" do
|
|
test "lists agent tokens for the org", %{organization: org, ctx: ctx} do
|
|
_token = agent_token_fixture(org.id, "Foo")
|
|
assert {:ok, agents} = Resolvers.Agent.list(nil, %{}, ctx)
|
|
assert Enum.any?(agents, &(&1.name == "Foo"))
|
|
end
|
|
|
|
test "creates a new agent token and returns the raw token", %{ctx: ctx} do
|
|
assert {:ok, %{token: raw, name: name}} = Resolvers.Agent.create(nil, %{name: "Bar"}, ctx)
|
|
assert is_binary(raw) and raw != ""
|
|
assert name == "Bar"
|
|
end
|
|
|
|
test "returns 'Agent not found' for unknown id", %{ctx: ctx} do
|
|
assert {:error, "Agent not found"} =
|
|
Resolvers.Agent.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "delete on unknown id returns not-found error", %{ctx: ctx} do
|
|
assert {:error, "Agent not found"} =
|
|
Resolvers.Agent.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
end
|
|
|
|
describe "Organization.list_mine/3 and get/3 and update/3" do
|
|
test "list_mine includes the user's org", %{user: user, organization: org} do
|
|
ctx = %{context: %{user: user}}
|
|
assert {:ok, orgs} = Resolvers.Organization.list_mine(nil, %{}, ctx)
|
|
assert Enum.any?(orgs, &(&1.id == org.id))
|
|
end
|
|
|
|
test "get/3 returns the org from context", %{ctx: ctx, organization: org} do
|
|
assert {:ok, fetched} = Resolvers.Organization.get(nil, %{}, ctx)
|
|
assert fetched.id == org.id
|
|
end
|
|
|
|
test "update/3 with valid input changes the name", %{ctx: ctx} do
|
|
assert {:ok, updated} = Resolvers.Organization.update(nil, %{input: %{name: "Renamed Co"}}, ctx)
|
|
assert updated.name == "Renamed Co"
|
|
end
|
|
|
|
test "update/3 with invalid input returns an error string", %{ctx: ctx} do
|
|
assert {:error, msg} = Resolvers.Organization.update(nil, %{input: %{name: "x"}}, ctx)
|
|
assert is_binary(msg)
|
|
end
|
|
end
|
|
|
|
describe "Site resolvers" do
|
|
test "list/3 returns sites for org, get/3 returns one, create/3 succeeds, update/3 + delete/3 work", %{
|
|
organization: org,
|
|
ctx: ctx
|
|
} do
|
|
device = device_fixture(%{organization_id: org.id})
|
|
site_id = device.site_id
|
|
|
|
assert {:ok, sites} = Resolvers.Site.list(nil, %{}, ctx)
|
|
assert Enum.any?(sites, &(&1.id == site_id))
|
|
|
|
assert {:ok, fetched} = Resolvers.Site.get(nil, %{id: site_id}, ctx)
|
|
assert fetched.id == site_id
|
|
|
|
assert {:ok, created} =
|
|
Resolvers.Site.create(nil, %{input: %{name: "New Site"}}, ctx)
|
|
|
|
assert created.name == "New Site"
|
|
|
|
assert {:ok, updated} =
|
|
Resolvers.Site.update(nil, %{id: created.id, input: %{name: "Renamed"}}, ctx)
|
|
|
|
assert updated.name == "Renamed"
|
|
|
|
assert {:ok, %{success: true}} = Resolvers.Site.delete(nil, %{id: created.id}, ctx)
|
|
refute Repo.get(Towerops.Sites.Site, created.id)
|
|
end
|
|
|
|
test "get/3 with unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Site not found"} =
|
|
Resolvers.Site.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "update/3 with unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Site not found"} =
|
|
Resolvers.Site.update(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{name: "x"}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete/3 with unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Site not found"} =
|
|
Resolvers.Site.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
end
|
|
|
|
describe "Integration resolvers" do
|
|
test "list/3 returns []", %{ctx: ctx} do
|
|
assert {:ok, []} = Resolvers.Integration.list(nil, %{}, ctx)
|
|
end
|
|
|
|
test "update on unknown id returns 'Integration not found'", %{ctx: ctx} do
|
|
assert {:error, "Integration not found"} =
|
|
Resolvers.Integration.update(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{name: "x"}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete on unknown id returns 'Integration not found'", %{ctx: ctx} do
|
|
assert {:error, "Integration not found"} =
|
|
Resolvers.Integration.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "test_connection on unknown id returns 'Integration not found'", %{ctx: ctx} do
|
|
assert {:error, "Integration not found"} =
|
|
Resolvers.Integration.test_connection(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
end
|
|
|
|
describe "MaintenanceWindow resolvers" do
|
|
test "list/3 returns []", %{ctx: ctx} do
|
|
assert {:ok, []} = Resolvers.MaintenanceWindow.list(nil, %{}, ctx)
|
|
end
|
|
|
|
test "list/3 honors filter args", %{ctx: ctx} do
|
|
for filter <- ["active", "upcoming", "past", "everything"] do
|
|
assert {:ok, _} = Resolvers.MaintenanceWindow.list(nil, %{filter: filter}, ctx)
|
|
end
|
|
end
|
|
|
|
test "get/3 unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Maintenance window not found"} =
|
|
Resolvers.MaintenanceWindow.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "update on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Maintenance window not found"} =
|
|
Resolvers.MaintenanceWindow.update(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Maintenance window not found"} =
|
|
Resolvers.MaintenanceWindow.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "create + get + update + delete happy path", %{ctx: ctx} do
|
|
starts = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
|
|
ends = DateTime.add(starts, 7200, :second)
|
|
|
|
assert {:ok, window} =
|
|
Resolvers.MaintenanceWindow.create(
|
|
nil,
|
|
%{
|
|
input: %{
|
|
name: "Smoke Window",
|
|
starts_at: starts,
|
|
ends_at: ends,
|
|
suppress_alerts: true
|
|
}
|
|
},
|
|
ctx
|
|
)
|
|
|
|
assert {:ok, fetched} = Resolvers.MaintenanceWindow.get(nil, %{id: window.id}, ctx)
|
|
assert fetched.id == window.id
|
|
|
|
assert {:ok, updated} =
|
|
Resolvers.MaintenanceWindow.update(
|
|
nil,
|
|
%{id: window.id, input: %{name: "Renamed"}},
|
|
ctx
|
|
)
|
|
|
|
assert updated.name == "Renamed"
|
|
|
|
assert {:ok, %{success: true}} =
|
|
Resolvers.MaintenanceWindow.delete(nil, %{id: window.id}, ctx)
|
|
end
|
|
|
|
test "create with invalid input returns formatted error", %{ctx: ctx} do
|
|
assert {:error, msg} =
|
|
Resolvers.MaintenanceWindow.create(nil, %{input: %{}}, ctx)
|
|
|
|
assert is_binary(msg)
|
|
end
|
|
end
|
|
|
|
describe "Alert resolvers — happy paths" do
|
|
test "list returns inserted alerts; get/3 fetches by id", %{organization: org, ctx: ctx} 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.truncate(DateTime.utc_now(), :second),
|
|
message: "down"
|
|
})
|
|
|
|
assert {:ok, alerts} = Resolvers.Alert.list(nil, %{}, ctx)
|
|
assert Enum.any?(alerts, &(&1.id == alert.id))
|
|
|
|
assert {:ok, fetched} = Resolvers.Alert.get(nil, %{id: alert.id}, ctx)
|
|
assert fetched.id == alert.id
|
|
end
|
|
|
|
test "acknowledge transitions an alert and returns it", %{organization: org, ctx: ctx} 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.truncate(DateTime.utc_now(), :second)
|
|
})
|
|
|
|
assert {:ok, acked} = Resolvers.Alert.acknowledge(nil, %{id: alert.id}, ctx)
|
|
assert acked.id == alert.id
|
|
assert acked.acknowledged_at
|
|
end
|
|
|
|
test "resolve_alert transitions and returns it", %{organization: org, ctx: ctx} 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.truncate(DateTime.utc_now(), :second)
|
|
})
|
|
|
|
assert {:ok, resolved} = Resolvers.Alert.resolve_alert(nil, %{id: alert.id}, ctx)
|
|
assert resolved.resolved_at
|
|
end
|
|
end
|
|
|
|
describe "EscalationPolicy resolvers (not-found paths)" do
|
|
test "get on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Escalation policy not found"} =
|
|
Resolvers.EscalationPolicy.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "update on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Escalation policy not found"} =
|
|
Resolvers.EscalationPolicy.update(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Escalation policy not found"} =
|
|
Resolvers.EscalationPolicy.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "create_rule with unknown policy returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Escalation policy not found"} =
|
|
Resolvers.EscalationPolicy.create_rule(
|
|
nil,
|
|
%{escalation_policy_id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "update_rule on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Rule not found"} =
|
|
Resolvers.EscalationPolicy.update_rule(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete_rule on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Rule not found"} =
|
|
Resolvers.EscalationPolicy.delete_rule(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "delete_target on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Target not found"} =
|
|
Resolvers.EscalationPolicy.delete_target(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
end
|
|
|
|
describe "Schedule resolvers (not-found paths)" do
|
|
test "get on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Schedule not found"} =
|
|
Resolvers.Schedule.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "on_call with unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Schedule not found"} =
|
|
Resolvers.Schedule.on_call(nil, %{schedule_id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "update on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Schedule not found"} =
|
|
Resolvers.Schedule.update(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Schedule not found"} =
|
|
Resolvers.Schedule.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "create_layer with unknown schedule returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Schedule not found"} =
|
|
Resolvers.Schedule.create_layer(
|
|
nil,
|
|
%{schedule_id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete_layer on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Layer not found"} =
|
|
Resolvers.Schedule.delete_layer(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "remove_member on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Member not found"} =
|
|
Resolvers.Schedule.remove_member(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "create_override with unknown schedule returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Schedule not found"} =
|
|
Resolvers.Schedule.create_override(
|
|
nil,
|
|
%{schedule_id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "delete_override on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Override not found"} =
|
|
Resolvers.Schedule.delete_override(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
end
|
|
|
|
describe "Member resolvers (error paths)" do
|
|
test "remove on unknown user returns 'Member not found'", %{ctx: ctx} do
|
|
assert {:error, "Member not found"} =
|
|
Resolvers.Member.remove(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "update_role on unknown user returns 'Member not found'", %{ctx: ctx} do
|
|
assert {:error, "Member not found"} =
|
|
Resolvers.Member.update_role(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), role: "technician"},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "cancel_invitation on unknown id returns 'Invitation not found'", %{ctx: ctx} do
|
|
assert {:error, "Invitation not found"} =
|
|
Resolvers.Member.cancel_invitation(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "list/3 returns owner as a member", %{ctx: ctx, user: user} do
|
|
assert {:ok, members} = Resolvers.Member.list(nil, %{}, ctx)
|
|
assert Enum.any?(members, &(&1.user_id == user.id))
|
|
end
|
|
end
|
|
|
|
describe "Device resolvers" do
|
|
test "list/3 with no devices returns []", %{ctx: ctx} do
|
|
assert {:ok, []} = Resolvers.Device.list(nil, %{}, ctx)
|
|
end
|
|
|
|
test "get/3 returns the device", %{organization: org, ctx: ctx} do
|
|
d = device_fixture(%{organization_id: org.id})
|
|
assert {:ok, found} = Resolvers.Device.get(nil, %{id: d.id}, ctx)
|
|
assert found.id == d.id
|
|
end
|
|
|
|
test "get on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Device not found"} =
|
|
Resolvers.Device.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "delete on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Device not found"} =
|
|
Resolvers.Device.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "update on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Device not found"} =
|
|
Resolvers.Device.update(
|
|
nil,
|
|
%{id: Ecto.UUID.generate(), input: %{}},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "metrics on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Device not found"} =
|
|
Resolvers.Device.metrics(nil, %{device_id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "interfaces on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Device not found"} =
|
|
Resolvers.Device.interfaces(nil, %{device_id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "interfaces returns [] when device has no snmp_device", %{organization: org, ctx: ctx} do
|
|
d = device_fixture(%{organization_id: org.id})
|
|
assert {:ok, []} = Resolvers.Device.interfaces(nil, %{device_id: d.id}, ctx)
|
|
end
|
|
|
|
test "metrics returns [] when device has no checks", %{organization: org, ctx: ctx} do
|
|
d = device_fixture(%{organization_id: org.id})
|
|
|
|
assert {:ok, []} =
|
|
Resolvers.Device.metrics(
|
|
nil,
|
|
%{device_id: d.id, time_range: "12h", sensor_type: nil},
|
|
ctx
|
|
)
|
|
end
|
|
|
|
test "metrics with malformed time_range falls back to 24h", %{organization: org, ctx: ctx} do
|
|
d = device_fixture(%{organization_id: org.id})
|
|
|
|
assert {:ok, []} =
|
|
Resolvers.Device.metrics(
|
|
nil,
|
|
%{device_id: d.id, time_range: "garbage"},
|
|
ctx
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "Alert resolvers" do
|
|
test "list/3 returns []", %{ctx: ctx} do
|
|
assert {:ok, []} = Resolvers.Alert.list(nil, %{}, ctx)
|
|
end
|
|
|
|
test "get on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Alert not found"} =
|
|
Resolvers.Alert.get(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "acknowledge on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Alert not found"} =
|
|
Resolvers.Alert.acknowledge(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
|
|
test "resolve_alert on unknown id returns not-found", %{ctx: ctx} do
|
|
assert {:error, "Alert not found"} =
|
|
Resolvers.Alert.resolve_alert(nil, %{id: Ecto.UUID.generate()}, ctx)
|
|
end
|
|
end
|
|
end
|