towerops/test/towerops_web/controllers/api/v1/schedules_controller_test.exs
Graham McIntire f6e9063577
feat: add REST API and GraphQL for on-call schedules and escalation policies
REST API v1 endpoints for schedules (14 endpoints) and escalation
policies (10 endpoints) with full CRUD for nested resources (layers,
members, overrides, rules, targets). GraphQL types, resolvers, and
schema mutations for both resource trees.
2026-03-11 14:01:15 -05:00

398 lines
12 KiB
Elixir

defmodule ToweropsWeb.Api.V1.SchedulesControllerTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.OnCall
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
conn =
build_conn()
|> put_req_header("authorization", "Bearer #{raw_token}")
|> put_req_header("accept", "application/json")
%{
conn: conn,
user: user,
organization: organization
}
end
describe "index/2" do
test "lists all schedules for authenticated organization", %{
conn: conn,
organization: organization
} do
schedule1 = schedule_fixture(organization.id, %{name: "Primary"})
schedule2 = schedule_fixture(organization.id, %{name: "Secondary"})
conn = get(conn, ~p"/api/v1/schedules")
assert %{"schedules" => schedules} = json_response(conn, 200)
assert length(schedules) == 2
ids = Enum.map(schedules, & &1["id"])
assert schedule1.id in ids
assert schedule2.id in ids
end
test "returns empty list when organization has no schedules", %{conn: conn} do
conn = get(conn, ~p"/api/v1/schedules")
assert %{"schedules" => []} = json_response(conn, 200)
end
test "does not return schedules from other organizations", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
_other_schedule = schedule_fixture(other_org.id)
conn = get(conn, ~p"/api/v1/schedules")
assert %{"schedules" => []} = json_response(conn, 200)
end
end
describe "create/2" do
test "creates schedule with valid attributes", %{conn: conn, organization: organization} do
params = %{
"schedule" => %{
"name" => "On-Call Rotation",
"timezone" => "America/New_York",
"description" => "Primary rotation"
}
}
conn = post(conn, ~p"/api/v1/schedules", params)
assert %{
"id" => id,
"name" => "On-Call Rotation",
"timezone" => "America/New_York",
"description" => "Primary rotation"
} = json_response(conn, 201)
assert is_binary(id)
schedule = OnCall.get_schedule!(id)
assert schedule.organization_id == organization.id
end
test "returns 422 with invalid params", %{conn: conn} do
params = %{"schedule" => %{"name" => ""}}
conn = post(conn, ~p"/api/v1/schedules", params)
assert %{"errors" => errors} = json_response(conn, 422)
assert Map.has_key?(errors, "name") or Map.has_key?(errors, "timezone")
end
test "returns 400 when schedule parameter is missing", %{conn: conn} do
conn = post(conn, ~p"/api/v1/schedules", %{})
assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400)
end
end
describe "show/2" do
test "returns schedule with layers, members, and overrides", %{
conn: conn,
user: user,
organization: organization
} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
_member = layer_member_fixture(layer.id, user.id)
_override = override_fixture(schedule.id, user.id)
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}")
assert %{
"id" => _,
"name" => _,
"timezone" => _,
"layers" => [layer_json],
"overrides" => [override_json]
} = json_response(conn, 200)
assert %{"members" => [_member_json]} = layer_json
assert Map.has_key?(override_json, "start_time")
end
test "returns 404 when schedule does not exist", %{conn: conn} do
conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 403 when schedule belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_schedule = schedule_fixture(other_org.id)
conn = get(conn, ~p"/api/v1/schedules/#{other_schedule.id}")
assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403)
end
end
describe "update/2" do
test "updates schedule with valid attributes", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id, %{name: "Original"})
params = %{"schedule" => %{"name" => "Updated"}}
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", params)
assert %{"id" => _, "name" => "Updated"} = json_response(conn, 200)
end
test "returns 404 when schedule does not exist", %{conn: conn} do
params = %{"schedule" => %{"name" => "Updated"}}
conn = patch(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}", params)
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 403 when schedule belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_schedule = schedule_fixture(other_org.id)
params = %{"schedule" => %{"name" => "Hacked"}}
conn = patch(conn, ~p"/api/v1/schedules/#{other_schedule.id}", params)
assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403)
end
test "returns 400 when schedule parameter is missing", %{
conn: conn,
organization: organization
} do
schedule = schedule_fixture(organization.id)
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}", %{})
assert %{"error" => "Missing 'schedule' parameter"} = json_response(conn, 400)
end
end
describe "delete/2" do
test "deletes schedule successfully", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}")
assert %{"success" => true} = json_response(conn, 200)
assert OnCall.get_schedule(schedule.id) == nil
end
test "returns 404 when schedule does not exist", %{conn: conn} do
conn = delete(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
test "returns 403 when schedule belongs to different organization", %{conn: conn} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_schedule = schedule_fixture(other_org.id)
conn = delete(conn, ~p"/api/v1/schedules/#{other_schedule.id}")
assert %{"error" => "Access denied to this schedule"} = json_response(conn, 403)
end
end
describe "on_call/2" do
test "returns on-call user for schedule", %{
conn: conn,
user: user,
organization: organization
} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{start_date: ~U[2025-01-01 09:00:00Z]})
_member = layer_member_fixture(layer.id, user.id)
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call")
assert %{"on_call" => on_call} = json_response(conn, 200)
assert on_call["id"] == user.id
end
test "returns null when no one is on call", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
conn = get(conn, ~p"/api/v1/schedules/#{schedule.id}/on_call")
assert %{"on_call" => nil} = json_response(conn, 200)
end
test "returns 404 when schedule does not exist", %{conn: conn} do
conn = get(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/on_call")
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
end
describe "create_layer/2" do
test "creates layer on schedule", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
params = %{
"layer" => %{
"name" => "Primary",
"position" => 1,
"rotation_type" => "weekly",
"rotation_interval" => 1,
"handoff_time" => "09:00:00",
"start_date" => "2026-01-01T09:00:00Z"
}
}
conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers", params)
assert %{"id" => _, "name" => "Primary", "rotation_type" => "weekly"} =
json_response(conn, 201)
end
test "returns 404 when schedule does not exist", %{conn: conn} do
params = %{
"layer" => %{
"name" => "Primary",
"position" => 1,
"rotation_type" => "weekly",
"rotation_interval" => 1,
"handoff_time" => "09:00:00",
"start_date" => "2026-01-01T09:00:00Z"
}
}
conn = post(conn, ~p"/api/v1/schedules/#{Ecto.UUID.generate()}/layers", params)
assert %{"error" => "Schedule not found"} = json_response(conn, 404)
end
end
describe "update_layer/2" do
test "updates layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
params = %{"layer" => %{"name" => "Updated Layer"}}
conn = patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}", params)
assert %{"name" => "Updated Layer"} = json_response(conn, 200)
end
test "returns 404 when layer does not exist", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
params = %{"layer" => %{"name" => "Updated"}}
conn =
patch(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{Ecto.UUID.generate()}", params)
assert %{"error" => "Layer not found"} = json_response(conn, 404)
end
end
describe "delete_layer/2" do
test "deletes layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
conn = delete(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}")
assert %{"success" => true} = json_response(conn, 200)
end
end
describe "create_member/2" do
test "adds member to layer", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
params = %{
"member" => %{
"user_id" => user.id,
"position" => 0
}
}
conn =
post(conn, ~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members", params)
assert %{"id" => _, "user_id" => uid, "position" => 0} = json_response(conn, 201)
assert uid == user.id
end
end
describe "delete_member/2" do
test "removes member from layer", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
member = layer_member_fixture(layer.id, user.id)
conn =
delete(
conn,
~p"/api/v1/schedules/#{schedule.id}/layers/#{layer.id}/members/#{member.id}"
)
assert %{"success" => true} = json_response(conn, 200)
end
end
describe "create_override/2" do
test "creates override on schedule", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
params = %{
"override" => %{
"user_id" => user.id,
"start_time" => "2026-03-15T09:00:00Z",
"end_time" => "2026-03-16T09:00:00Z"
}
}
conn = post(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides", params)
assert %{"id" => _, "start_time" => _, "end_time" => _} = json_response(conn, 201)
end
end
describe "delete_override/2" do
test "deletes override", %{conn: conn, user: user, organization: organization} do
schedule = schedule_fixture(organization.id)
override = override_fixture(schedule.id, user.id)
conn =
delete(conn, ~p"/api/v1/schedules/#{schedule.id}/overrides/#{override.id}")
assert %{"success" => true} = json_response(conn, 200)
end
end
describe "authentication" do
test "returns 401 without authorization header" do
conn =
build_conn()
|> put_req_header("accept", "application/json")
|> get(~p"/api/v1/schedules")
assert %{"error" => _message} = json_response(conn, 401)
end
end
end