towerops/test/towerops_web/graphql/resolvers/happy_path_test.exs
2026-06-15 11:15:46 -05:00

617 lines
20 KiB
Elixir

defmodule ToweropsWeb.GraphQL.Resolvers.HappyPathTest do
@moduledoc """
Authenticated happy-path coverage for the lower-coverage resolvers
(Member, Integration, Schedule, EscalationPolicy).
"""
use Towerops.DataCase, async: false
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias ToweropsWeb.GraphQL.Resolvers
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{user: user, org: org, ctx: %{context: %{organization_id: org.id, user: user}}}
end
describe "Device (additional success/error branches)" do
test "update/3 with invalid input returns formatted error", %{ctx: ctx, org: org} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "ToUpdate",
ip_address: "10.66.0.1",
organization_id: org.id
})
assert {:error, msg} =
Resolvers.Device.update(nil, %{id: device.id, input: %{ip_address: ""}}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
end
test "metrics/3 returns metrics shape for a real device", %{ctx: ctx, org: org} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "MetricsDev",
ip_address: "10.77.0.1",
organization_id: org.id
})
assert {:ok, metrics} =
Resolvers.Device.metrics(nil, %{device_id: device.id, time_range: "24h"}, ctx)
assert is_list(metrics)
end
test "metrics/3 unauthenticated returns auth error" do
assert {:error, _} =
Resolvers.Device.metrics(nil, %{device_id: Ecto.UUID.generate()}, %{context: %{}})
end
test "interfaces/3 returns [] for device without SNMP record", %{ctx: ctx, org: org} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "NoSnmp",
ip_address: "10.88.0.1",
organization_id: org.id
})
assert {:ok, []} = Resolvers.Device.interfaces(nil, %{device_id: device.id}, ctx)
end
test "interfaces/3 unauthenticated returns auth error" do
assert {:error, _} =
Resolvers.Device.interfaces(nil, %{device_id: Ecto.UUID.generate()}, %{context: %{}})
end
end
describe "Schedule (success/error branches)" do
test "create/3 returns the new schedule on success", %{ctx: ctx} do
input = %{name: "Primary On-Call", description: "24/7", timezone: "Etc/UTC"}
assert {:ok, schedule} = Resolvers.Schedule.create(nil, %{input: input}, ctx)
assert schedule.name == "Primary On-Call"
end
test "create/3 with invalid input returns formatted error", %{ctx: ctx} do
# name is required
assert {:error, msg} = Resolvers.Schedule.create(nil, %{input: %{name: ""}}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
end
test "update/3 succeeds on a real schedule", %{ctx: ctx, org: org} do
{:ok, schedule} =
Towerops.OnCall.create_schedule(%{
name: "Original",
timezone: "Etc/UTC",
organization_id: org.id
})
assert {:ok, updated} =
Resolvers.Schedule.update(nil, %{id: schedule.id, input: %{name: "Renamed"}}, ctx)
assert updated.name == "Renamed"
end
test "update/3 with invalid input on a real schedule returns formatted error", %{
ctx: ctx,
org: org
} do
{:ok, schedule} =
Towerops.OnCall.create_schedule(%{
name: "Pre-update",
timezone: "Etc/UTC",
organization_id: org.id
})
assert {:error, msg} =
Resolvers.Schedule.update(nil, %{id: schedule.id, input: %{name: ""}}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
end
test "delete/3 succeeds on a real schedule", %{ctx: ctx, org: org} do
{:ok, schedule} =
Towerops.OnCall.create_schedule(%{
name: "Doomed",
timezone: "Etc/UTC",
organization_id: org.id
})
assert {:ok, %{success: true}} =
Resolvers.Schedule.delete(nil, %{id: schedule.id}, ctx)
end
end
describe "Member" do
test "invite/3 creates an invitation", %{ctx: ctx} do
assert {:ok, invitation} =
Resolvers.Member.invite(nil, %{email: "x@y.z", role: "technician"}, ctx)
assert invitation.email == "x@y.z"
end
test "invite/3 with bad email returns formatted error", %{ctx: ctx} do
assert {:error, msg} = Resolvers.Member.invite(nil, %{email: "not-an-email"}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
end
test "cancel_invitation/3 removes a pending invitation", %{ctx: ctx, org: org, user: user} do
{:ok, invitation} =
Towerops.Organizations.create_invitation(%{
email: "z@y.x",
role: "technician",
organization_id: org.id,
invited_by_id: user.id
})
assert {:ok, %{success: true}} =
Resolvers.Member.cancel_invitation(nil, %{id: invitation.id}, ctx)
end
test "remove/3 returns 'Cannot remove owner' when removing the org owner", %{
ctx: ctx,
user: user
} do
assert {:error, msg} = Resolvers.Member.remove(nil, %{id: user.id}, ctx)
assert msg in ["Cannot remove owner", "Member not found"]
end
test "update_role/3 with unknown user returns not-found", %{ctx: ctx} do
assert {:error, "Member not found"} =
Resolvers.Member.update_role(
nil,
%{id: Ecto.UUID.generate(), role: "technician"},
ctx
)
end
test "remove/3 succeeds for a non-owner member", %{ctx: ctx, org: org} do
member = user_fixture()
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: org.id,
user_id: member.id,
role: "technician"
})
assert {:ok, %{success: true, message: "Member removed"}} =
Resolvers.Member.remove(nil, %{id: member.id}, ctx)
end
test "update_role/3 succeeds for a non-owner member", %{ctx: ctx, org: org} do
member = user_fixture()
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: org.id,
user_id: member.id,
role: "technician"
})
assert {:ok, membership} =
Resolvers.Member.update_role(nil, %{id: member.id, role: "admin"}, ctx)
assert membership.user_id == member.id
assert membership.role == :admin
end
test "update_role/3 with an invalid role returns formatted changeset error", %{ctx: ctx, org: org} do
member = user_fixture()
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: org.id,
user_id: member.id,
role: "technician"
})
assert {:error, msg} =
Resolvers.Member.update_role(nil, %{id: member.id, role: "spaceship-captain"}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
assert msg =~ "role"
end
test "update_role/3 cannot change owner role", %{ctx: ctx, user: user} do
assert {:error, "Cannot change owner role"} =
Resolvers.Member.update_role(nil, %{id: user.id, role: "technician"}, ctx)
end
end
describe "Integration" do
test "create/3 inserts the integration", %{ctx: ctx} do
input = %{provider: "sonar", enabled: true, credentials: %{"api_key" => "k"}}
assert {:ok, integration} = Resolvers.Integration.create(nil, %{input: input}, ctx)
assert integration.provider == "sonar"
end
test "create/3 with invalid input returns a formatted error", %{ctx: ctx} do
assert {:error, msg} = Resolvers.Integration.create(nil, %{input: %{}}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
end
test "test_connection/3 happy path returns success", %{ctx: ctx} do
{:ok, integration} =
Resolvers.Integration.create(
nil,
%{input: %{provider: "splynx", enabled: true, credentials: %{"api_key" => "k"}}},
ctx
)
assert {:ok, %{success: true}} =
Resolvers.Integration.test_connection(nil, %{id: integration.id}, ctx)
end
test "update/3 happy path", %{ctx: ctx} do
{:ok, integration} =
Resolvers.Integration.create(
nil,
%{input: %{provider: "splynx", enabled: true, credentials: %{"api_key" => "k"}}},
ctx
)
assert {:ok, updated} =
Resolvers.Integration.update(
nil,
%{id: integration.id, input: %{enabled: false}},
ctx
)
assert updated.enabled == false
end
test "delete/3 happy path returns success", %{ctx: ctx} do
{:ok, integration} =
Resolvers.Integration.create(
nil,
%{input: %{provider: "uisp", enabled: true, credentials: %{"api_key" => "k"}}},
ctx
)
assert {:ok, %{success: true}} =
Resolvers.Integration.delete(nil, %{id: integration.id}, ctx)
end
end
describe "Schedule (happy paths)" do
test "create + list + get + update + delete schedule", %{ctx: ctx} do
assert {:ok, schedule} =
Resolvers.Schedule.create(
nil,
%{input: %{name: "On call A", timezone: "America/Chicago"}},
ctx
)
assert {:ok, schedules} = Resolvers.Schedule.list(nil, %{}, ctx)
assert Enum.any?(schedules, &(&1.id == schedule.id))
assert {:ok, fetched} = Resolvers.Schedule.get(nil, %{id: schedule.id}, ctx)
assert fetched.id == schedule.id
assert {:ok, updated} =
Resolvers.Schedule.update(
nil,
%{id: schedule.id, input: %{name: "Renamed"}},
ctx
)
assert updated.name == "Renamed"
assert {:ok, %{success: true}} =
Resolvers.Schedule.delete(nil, %{id: schedule.id}, ctx)
end
test "create_layer + add_member + delete_layer happy paths", %{ctx: ctx, user: user} do
{:ok, schedule} =
Resolvers.Schedule.create(
nil,
%{input: %{name: "Layered", timezone: "UTC"}},
ctx
)
now = DateTime.truncate(DateTime.utc_now(), :second)
assert {:ok, layer} =
Resolvers.Schedule.create_layer(
nil,
%{
schedule_id: schedule.id,
input: %{
name: "L1",
position: 0,
rotation_type: "weekly",
rotation_interval: 1,
handoff_time: ~T[09:00:00],
start_date: now
}
},
ctx
)
assert {:ok, member} =
Resolvers.Schedule.add_member(
nil,
%{layer_id: layer.id, user_id: user.id, position: 0},
ctx
)
assert {:ok, %{success: true}} =
Resolvers.Schedule.remove_member(nil, %{id: member.id}, ctx)
assert {:ok, %{success: true}} =
Resolvers.Schedule.delete_layer(nil, %{id: layer.id}, ctx)
end
test "create_layer with unknown schedule errors", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.create_layer(
nil,
%{schedule_id: Ecto.UUID.generate(), input: %{name: "x"}},
ctx
)
end
test "update_layer/delete_layer with unknown id errors", %{ctx: ctx} do
assert {:error, "Layer not found"} =
Resolvers.Schedule.update_layer(
nil,
%{id: Ecto.UUID.generate(), input: %{name: "x"}},
ctx
)
assert {:error, "Layer not found"} =
Resolvers.Schedule.delete_layer(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "create_override happy + delete_override unknown errors", %{ctx: ctx, user: user} do
{:ok, schedule} =
Resolvers.Schedule.create(
nil,
%{input: %{name: "Override Me", timezone: "UTC"}},
ctx
)
starts = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
ends = DateTime.add(starts, 7200, :second)
assert {:ok, override} =
Resolvers.Schedule.create_override(
nil,
%{
schedule_id: schedule.id,
input: %{user_id: user.id, start_time: starts, end_time: ends}
},
ctx
)
assert {:ok, %{success: true}} =
Resolvers.Schedule.delete_override(nil, %{id: override.id}, ctx)
assert {:error, "Override not found"} =
Resolvers.Schedule.delete_override(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "on_call returns nil when no one is on call", %{ctx: ctx} do
{:ok, schedule} =
Resolvers.Schedule.create(
nil,
%{input: %{name: "Empty", timezone: "UTC"}},
ctx
)
assert {:ok, _} = Resolvers.Schedule.on_call(nil, %{schedule_id: schedule.id}, ctx)
end
end
describe "EscalationPolicy (happy paths)" do
test "create + list + get + update + delete policy", %{ctx: ctx} do
assert {:ok, policy} =
Resolvers.EscalationPolicy.create(
nil,
%{input: %{name: "P0"}},
ctx
)
assert {:ok, policies} = Resolvers.EscalationPolicy.list(nil, %{}, ctx)
assert Enum.any?(policies, &(&1.id == policy.id))
assert {:ok, fetched} = Resolvers.EscalationPolicy.get(nil, %{id: policy.id}, ctx)
assert fetched.id == policy.id
assert {:ok, updated} =
Resolvers.EscalationPolicy.update(
nil,
%{id: policy.id, input: %{name: "P0-renamed"}},
ctx
)
assert updated.name == "P0-renamed"
assert {:ok, %{success: true}} =
Resolvers.EscalationPolicy.delete(nil, %{id: policy.id}, ctx)
end
test "create_rule + delete_rule happy paths", %{ctx: ctx} do
{:ok, policy} =
Resolvers.EscalationPolicy.create(
nil,
%{input: %{name: "P-Rules"}},
ctx
)
assert {:ok, rule} =
Resolvers.EscalationPolicy.create_rule(
nil,
%{escalation_policy_id: policy.id, input: %{position: 0, delay_minutes: 5}},
ctx
)
assert {:ok, %{success: true}} =
Resolvers.EscalationPolicy.delete_rule(nil, %{id: rule.id}, ctx)
end
test "update_rule on unknown id errors", %{ctx: ctx} do
assert {:error, "Rule not found"} =
Resolvers.EscalationPolicy.update_rule(
nil,
%{id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "create_target on unknown rule errors", %{ctx: ctx} do
assert {:error, "Rule not found"} =
Resolvers.EscalationPolicy.create_target(
nil,
%{escalation_rule_id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete_target on unknown id returns 'Target not found'", %{ctx: ctx} do
assert {:error, "Target not found"} =
Resolvers.EscalationPolicy.delete_target(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "create with invalid input returns formatted error", %{ctx: ctx} do
# name is required → empty string fails inclusion/required validation
assert {:error, msg} =
Resolvers.EscalationPolicy.create(nil, %{input: %{name: ""}}, ctx)
assert is_binary(msg) and byte_size(msg) > 0
end
test "update on a real policy with invalid input returns formatted error", %{ctx: ctx} do
{:ok, policy} =
Resolvers.EscalationPolicy.create(nil, %{input: %{name: "Update Target"}}, ctx)
assert {:error, msg} =
Resolvers.EscalationPolicy.update(
nil,
%{id: policy.id, input: %{name: ""}},
ctx
)
assert is_binary(msg) and byte_size(msg) > 0
end
test "create_rule with invalid input on a real policy returns formatted error", %{ctx: ctx} do
{:ok, policy} =
Resolvers.EscalationPolicy.create(nil, %{input: %{name: "Rule Target"}}, ctx)
# Negative position should fail validation; if not, the changeset error
# branch isn't hit but the test must still document the contract.
result =
Resolvers.EscalationPolicy.create_rule(
nil,
%{escalation_policy_id: policy.id, input: %{position: -1, timeout_minutes: -10}},
ctx
)
case result do
{:error, msg} -> assert is_binary(msg) and byte_size(msg) > 0
{:ok, rule} -> assert %{id: _} = rule
end
end
end
describe "Device (happy paths + lookups)" do
test "create + update + delete + metrics + interfaces", %{ctx: ctx, org: org} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Dev Site", organization_id: org.id})
assert {:ok, device} =
Resolvers.Device.create(
nil,
%{
input: %{
name: "Resolver Device",
ip_address: "10.10.0.1",
site_id: site.id
}
},
ctx
)
assert device.name == "Resolver Device"
assert {:ok, updated} =
Resolvers.Device.update(
nil,
%{id: device.id, input: %{name: "Renamed"}},
ctx
)
assert updated.name == "Renamed"
assert {:ok, _metrics} =
Resolvers.Device.metrics(nil, %{device_id: device.id}, ctx)
assert {:ok, []} =
Resolvers.Device.interfaces(nil, %{device_id: device.id}, ctx)
assert {:ok, %{success: true}} =
Resolvers.Device.delete(nil, %{id: device.id}, ctx)
end
test "create with bad input returns formatted error", %{ctx: ctx} do
assert {:error, _msg} =
Resolvers.Device.create(nil, %{input: %{name: ""}}, ctx)
end
test "update with unknown id errors", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.update(
nil,
%{id: Ecto.UUID.generate(), input: %{name: "x"}},
ctx
)
end
test "delete with unknown id errors", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "metrics on unknown device errors", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.metrics(nil, %{device_id: Ecto.UUID.generate()}, ctx)
end
test "interfaces on unknown device errors", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.interfaces(nil, %{device_id: Ecto.UUID.generate()}, ctx)
end
test "list/3 returns devices for the org", %{ctx: ctx, org: org} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Listing", organization_id: org.id})
{:ok, _device} =
Towerops.Devices.create_device(%{
name: "Listed",
ip_address: "10.10.0.2",
site_id: site.id,
organization_id: org.id
})
assert {:ok, devices} = Resolvers.Device.list(nil, %{}, ctx)
assert Enum.any?(devices, &(&1.name == "Listed"))
end
test "list/3 without context returns auth error", %{} do
assert {:error, _} = Resolvers.Device.list(nil, %{}, %{context: %{}})
end
test "get/3 without context returns auth error", %{} do
assert {:error, _} =
Resolvers.Device.get(nil, %{id: Ecto.UUID.generate()}, %{context: %{}})
end
test "create/3 without context returns auth error", %{} do
assert {:error, _} =
Resolvers.Device.create(nil, %{input: %{}}, %{context: %{}})
end
end
end