diff --git a/test/towerops_web/graphql/resolvers/device_test.exs b/test/towerops_web/graphql/resolvers/device_test.exs new file mode 100644 index 00000000..d6575aae --- /dev/null +++ b/test/towerops_web/graphql/resolvers/device_test.exs @@ -0,0 +1,221 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.DeviceTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias ToweropsWeb.GraphQL.Resolvers.Device, as: Resolver + + setup do + user = user_fixture() + org = organization_fixture(user.id) + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + {:ok, site} = Towerops.Sites.create_site(%{name: "S1", organization_id: org.id}) + + %{ + user: user, + org: org, + other_org: other_org, + site: site, + ctx: %{organization_id: org.id} + } + end + + describe "list/3" do + test "returns devices for the org", %{org: org, site: site, ctx: ctx} do + _ = + device_fixture(%{ + name: "Listed", + organization_id: org.id, + site_id: site.id + }) + + assert {:ok, devices} = Resolver.list(nil, %{}, %{context: ctx}) + assert Enum.any?(devices, &(&1.name == "Listed")) + end + + test "without context returns auth error" do + assert {:error, _} = Resolver.list(nil, %{}, %{}) + end + end + + describe "get/3" do + test "returns device with site preloaded", %{org: org, site: site, ctx: ctx} do + device = + device_fixture(%{name: "Found", organization_id: org.id, site_id: site.id}) + + assert {:ok, found} = Resolver.get(nil, %{id: device.id}, %{context: ctx}) + assert found.id == device.id + end + + test "returns 'not found' for cross-org id", %{other_org: other_org, ctx: ctx} do + foreign = device_fixture(%{organization_id: other_org.id}) + + assert {:error, "Device not found"} = + Resolver.get(nil, %{id: foreign.id}, %{context: ctx}) + end + + test "without context returns auth error" do + assert {:error, _} = Resolver.get(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end + + describe "create/3, update/3, delete/3" do + test "creates a device under the org", %{site: site, ctx: ctx} do + assert {:ok, device} = + Resolver.create( + nil, + %{ + input: %{ + name: "Created", + site_id: site.id, + ip_address: "10.10.10.10" + } + }, + %{context: ctx} + ) + + assert device.name == "Created" + assert device.organization_id == ctx.organization_id + end + + test "create returns formatted errors on invalid input", %{ctx: ctx} do + assert {:error, errors} = + Resolver.create(nil, %{input: %{name: ""}}, %{context: ctx}) + + assert is_list(errors) or is_binary(errors) or is_map(errors) + end + + test "create without context returns auth error" do + assert {:error, _} = Resolver.create(nil, %{input: %{name: "x"}}, %{}) + end + + test "update + delete round-trip", %{org: org, site: site, ctx: ctx} do + device = + device_fixture(%{name: "Old", organization_id: org.id, site_id: site.id}) + + assert {:ok, updated} = + Resolver.update(nil, %{id: device.id, input: %{name: "New"}}, %{ + context: ctx + }) + + assert updated.name == "New" + + assert {:ok, %{success: true}} = + Resolver.delete(nil, %{id: device.id}, %{context: ctx}) + end + + test "update on cross-org id returns 'not found'", + %{other_org: other_org, ctx: ctx} do + foreign = device_fixture(%{organization_id: other_org.id}) + + assert {:error, "Device not found"} = + Resolver.update(nil, %{id: foreign.id, input: %{name: "x"}}, %{ + context: ctx + }) + end + + test "update / delete without context returns auth errors" do + assert {:error, _} = + Resolver.update(nil, %{id: Ecto.UUID.generate(), input: %{}}, %{}) + + assert {:error, _} = Resolver.delete(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end + + describe "metrics/3" do + test "returns [] for a device with no checks", %{org: org, site: site, ctx: ctx} do + device = + device_fixture(%{name: "NoChecks", organization_id: org.id, site_id: site.id}) + + assert {:ok, []} = + Resolver.metrics( + nil, + %{device_id: device.id, time_range: "24h"}, + %{context: ctx} + ) + end + + test "returns [] when device has a check but no results yet", + %{org: org, site: site, ctx: ctx} do + device = + device_fixture(%{name: "PendingMetric", organization_id: org.id, site_id: site.id}) + + {:ok, _check} = + Towerops.Monitoring.create_check(%{ + name: "Inline Ping", + check_type: "ping", + device_id: device.id, + organization_id: org.id, + enabled: true, + interval_seconds: 60, + timeout_ms: 1000, + config: %{"host" => "127.0.0.1"} + }) + + assert {:ok, []} = + Resolver.metrics( + nil, + %{device_id: device.id, time_range: "24h"}, + %{context: ctx} + ) + end + + test "metrics without context returns auth error" do + assert {:error, _} = + Resolver.metrics(nil, %{device_id: Ecto.UUID.generate()}, %{}) + end + + test "metrics on cross-org id returns 'not found'", + %{other_org: other_org, ctx: ctx} do + foreign = device_fixture(%{organization_id: other_org.id}) + + assert {:error, "Device not found"} = + Resolver.metrics( + nil, + %{device_id: foreign.id, time_range: "1h"}, + %{context: ctx} + ) + end + + test "metrics falls back to 24h when time_range is unparseable", + %{org: org, site: site, ctx: ctx} do + device = + device_fixture(%{name: "Fallback", organization_id: org.id, site_id: site.id}) + + assert {:ok, _} = + Resolver.metrics( + nil, + %{device_id: device.id, time_range: "garbage"}, + %{context: ctx} + ) + end + end + + describe "interfaces/3" do + test "returns [] when device has no snmp_device", + %{org: org, site: site, ctx: ctx} do + device = + device_fixture(%{name: "NoSnmp", organization_id: org.id, site_id: site.id}) + + assert {:ok, []} = + Resolver.interfaces(nil, %{device_id: device.id}, %{context: ctx}) + end + + test "interfaces returns 'not found' for cross-org id", + %{other_org: other_org, ctx: ctx} do + foreign = device_fixture(%{organization_id: other_org.id}) + + assert {:error, "Device not found"} = + Resolver.interfaces(nil, %{device_id: foreign.id}, %{context: ctx}) + end + + test "interfaces without context returns auth error" do + assert {:error, _} = + Resolver.interfaces(nil, %{device_id: Ecto.UUID.generate()}, %{}) + end + end +end diff --git a/test/towerops_web/graphql/resolvers/schedule_test.exs b/test/towerops_web/graphql/resolvers/schedule_test.exs new file mode 100644 index 00000000..b89a021a --- /dev/null +++ b/test/towerops_web/graphql/resolvers/schedule_test.exs @@ -0,0 +1,262 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.ScheduleTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OnCallFixtures + import Towerops.OrganizationsFixtures + + alias ToweropsWeb.GraphQL.Resolvers.Schedule, as: Resolver + + setup do + user = user_fixture() + org = organization_fixture(user.id) + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + %{user: user, org: org, other_org: other_org, ctx: %{organization_id: org.id}} + end + + describe "list/3" do + test "returns schedules for the org", %{org: org, ctx: ctx} do + _ = schedule_fixture(org.id, %{name: "Primary Schedule"}) + + assert {:ok, schedules} = Resolver.list(nil, %{}, %{context: ctx}) + assert Enum.any?(schedules, &(&1.name == "Primary Schedule")) + end + + test "without org context returns auth error" do + assert {:error, _} = Resolver.list(nil, %{}, %{}) + end + end + + describe "get/3" do + test "returns the schedule preloaded with overrides + layers", %{org: org, ctx: ctx} do + schedule = schedule_fixture(org.id) + + assert {:ok, found} = Resolver.get(nil, %{id: schedule.id}, %{context: ctx}) + assert found.id == schedule.id + end + + test "returns 'not found' for cross-org id", %{other_org: other_org, ctx: ctx} do + foreign = schedule_fixture(other_org.id) + + assert {:error, "Schedule not found"} = + Resolver.get(nil, %{id: foreign.id}, %{context: ctx}) + end + + test "without org context returns auth error" do + assert {:error, _} = Resolver.get(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end + + describe "on_call/3" do + test "returns nil when no one is on call (empty schedule)", %{org: org, ctx: ctx} do + schedule = schedule_fixture(org.id) + assert {:ok, nil} = Resolver.on_call(nil, %{schedule_id: schedule.id}, %{context: ctx}) + end + + test "returns 'not found' for cross-org id", %{other_org: other_org, ctx: ctx} do + foreign = schedule_fixture(other_org.id) + + assert {:error, "Schedule not found"} = + Resolver.on_call(nil, %{schedule_id: foreign.id}, %{context: ctx}) + end + + test "without org context returns auth error" do + assert {:error, _} = + Resolver.on_call(nil, %{schedule_id: Ecto.UUID.generate()}, %{}) + end + end + + describe "create/3, update/3, delete/3" do + test "creates a schedule scoped to the resolved org", %{ctx: ctx} do + assert {:ok, schedule} = + Resolver.create(nil, %{input: %{name: "Created", timezone: "UTC"}}, %{ + context: ctx + }) + + assert schedule.name == "Created" + assert schedule.organization_id == ctx.organization_id + end + + test "create returns formatted errors on invalid input", %{ctx: ctx} do + assert {:error, errors} = + Resolver.create(nil, %{input: %{name: ""}}, %{context: ctx}) + + assert is_list(errors) or is_binary(errors) or is_map(errors) + end + + test "create without org context returns auth error" do + assert {:error, _} = Resolver.create(nil, %{input: %{name: "x"}}, %{}) + end + + test "update + delete round-trip", %{org: org, ctx: ctx} do + schedule = schedule_fixture(org.id, %{name: "Original"}) + + assert {:ok, updated} = + Resolver.update(nil, %{id: schedule.id, input: %{name: "Renamed"}}, %{ + context: ctx + }) + + assert updated.name == "Renamed" + + assert {:ok, %{success: true}} = + Resolver.delete(nil, %{id: schedule.id}, %{context: ctx}) + end + + test "update on cross-org id returns 'not found'", + %{other_org: other_org, ctx: ctx} do + foreign = schedule_fixture(other_org.id) + + assert {:error, "Schedule not found"} = + Resolver.update(nil, %{id: foreign.id, input: %{name: "x"}}, %{context: ctx}) + end + + test "update / delete / create without context returns auth errors" do + assert {:error, _} = + Resolver.update(nil, %{id: Ecto.UUID.generate(), input: %{}}, %{}) + + assert {:error, _} = Resolver.delete(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end + + describe "layer mutations" do + test "create_layer attaches a layer to a schedule", %{org: org, ctx: ctx} do + schedule = schedule_fixture(org.id) + + assert {:ok, layer} = + Resolver.create_layer( + nil, + %{ + schedule_id: schedule.id, + input: %{ + name: "Primary", + position: 1, + rotation_type: "weekly", + rotation_interval: 1, + handoff_time: ~T[09:00:00], + handoff_day: 1, + start_date: ~U[2026-01-01 09:00:00Z] + } + }, + %{context: ctx} + ) + + assert layer.schedule_id == schedule.id + end + + test "create_layer auth error without context" do + assert {:error, _} = + Resolver.create_layer( + nil, + %{schedule_id: Ecto.UUID.generate(), input: %{}}, + %{} + ) + end + + test "update_layer + delete_layer round-trip", %{org: org, ctx: ctx} do + schedule = schedule_fixture(org.id) + layer = layer_fixture(schedule.id, %{name: "Old"}) + + assert {:ok, updated} = + Resolver.update_layer(nil, %{id: layer.id, input: %{name: "New"}}, %{ + context: ctx + }) + + assert updated.name == "New" + + assert {:ok, %{success: true}} = + Resolver.delete_layer(nil, %{id: layer.id}, %{context: ctx}) + end + + test "update_layer on missing layer returns 'not found'", %{ctx: ctx} do + assert {:error, "Layer not found"} = + Resolver.update_layer(nil, %{id: Ecto.UUID.generate(), input: %{}}, %{ + context: ctx + }) + end + + test "update_layer auth error without context" do + assert {:error, _} = + Resolver.update_layer(nil, %{id: Ecto.UUID.generate(), input: %{}}, %{}) + end + + test "delete_layer auth error without context" do + assert {:error, _} = + Resolver.delete_layer(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end + + describe "member mutations" do + test "add_member + remove_member round-trip", %{org: org, user: user, ctx: ctx} do + schedule = schedule_fixture(org.id) + layer = layer_fixture(schedule.id) + + assert {:ok, member} = + Resolver.add_member( + nil, + %{layer_id: layer.id, user_id: user.id, position: 0}, + %{context: ctx} + ) + + assert {:ok, %{success: true}} = + Resolver.remove_member(nil, %{id: member.id}, %{context: ctx}) + end + + test "remove_member returns 'not found' for unknown id", %{ctx: ctx} do + assert {:error, "Member not found"} = + Resolver.remove_member(nil, %{id: Ecto.UUID.generate()}, %{context: ctx}) + end + + test "add_member + remove_member auth errors without context" do + assert {:error, _} = + Resolver.add_member( + nil, + %{layer_id: Ecto.UUID.generate(), user_id: Ecto.UUID.generate(), position: 0}, + %{} + ) + + assert {:error, _} = Resolver.remove_member(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end + + describe "override mutations" do + test "create_override + delete_override round-trip", + %{org: org, user: user, ctx: ctx} do + schedule = schedule_fixture(org.id) + + assert {:ok, override} = + Resolver.create_override( + nil, + %{ + schedule_id: schedule.id, + input: %{ + user_id: user.id, + start_time: ~U[2026-04-01 09:00:00Z], + end_time: ~U[2026-04-02 09:00:00Z] + } + }, + %{context: ctx} + ) + + assert {:ok, %{success: true}} = + Resolver.delete_override(nil, %{id: override.id}, %{context: ctx}) + end + + test "delete_override returns 'not found' for unknown id", %{ctx: ctx} do + assert {:error, "Override not found"} = + Resolver.delete_override(nil, %{id: Ecto.UUID.generate()}, %{context: ctx}) + end + + test "create_override + delete_override auth errors without context" do + assert {:error, _} = + Resolver.create_override( + nil, + %{schedule_id: Ecto.UUID.generate(), input: %{}}, + %{} + ) + + assert {:error, _} = Resolver.delete_override(nil, %{id: Ecto.UUID.generate()}, %{}) + end + end +end