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.
398 lines
12 KiB
Elixir
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
|