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) and byte_size(id) > 0 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 Enum.any?(["name", "timezone"], &Map.has_key?(errors, &1)) 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 404 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" => "Schedule not found"} = json_response(conn, 404) 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 404 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" => "Schedule not found"} = json_response(conn, 404) 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 404 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" => "Schedule not found"} = json_response(conn, 404) 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