617 lines
20 KiB
Elixir
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
|