towerops/test/towerops_web/live/schedule_live_test.exs
Graham McIntire 23304e108c feat(on_call): schedule editor layer reorder + unified resolver (chunk 3)
OnCall context
- move_layer/2: up/down position swap for schedule layers, mirror of
  move_escalation_rule/2 (tested for both directions + boundary no-op).

ScheduleLive.Show
- New move_layer event handler.
- Up/down arrow buttons on each layer card; the top arrow is disabled on
  the first layer, the bottom on the last (matches the escalation policy
  level reorder UX shipped in chunk 1).
- Replaced two per-hour walks (build_layer_spans + build_final_spans -
  ~672 resolve calls per render at 14 days) with Resolver.resolve_range/3
  + a small segments_to_spans/3 mapper. Per-layer view wraps each layer
  in a temporary single-layer %Schedule{} so the resolver code path is
  shared and overrides don't bleed into the per-layer strip.

Tests: 3 new context tests for move_layer, 1 new integration test for
the layer reorder UI. Full suite 10,214 / 0 failures. Format/dialyzer
clean.
2026-04-28 14:05:40 -05:00

600 lines
18 KiB
Elixir

defmodule ToweropsWeb.ScheduleLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.OnCallFixtures
alias Towerops.OnCall
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
%{conn: conn, organization: organization}
end
describe "Index" do
test "mounts and lists schedules", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id, %{name: "Primary Rotation"})
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "Schedules"
assert html =~ "Primary Rotation"
assert html =~ schedule.timezone
end
test "shows empty state when no schedules", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "No schedules"
assert html =~ "Create an on-call schedule"
end
test "shows who is currently on-call for each schedule", %{
conn: conn,
organization: organization,
user: user
} do
schedule = schedule_fixture(organization.id)
layer =
layer_fixture(schedule.id, %{
start_date: ~U[2025-01-01 09:00:00Z],
handoff_time: ~T[09:00:00],
rotation_type: "weekly"
})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ user.email
end
test "renders date navigation controls", %{conn: conn, organization: organization} do
_ = schedule_fixture(organization.id)
{:ok, view, html} = live(conn, ~p"/schedules")
assert html =~ "Today"
assert html =~ "2 Weeks"
assert has_element?(view, "button[phx-click=\"prev\"]")
assert has_element?(view, "button[phx-click=\"next\"]")
end
test "renders a per-schedule gantt strip with the on-call user's name", %{
conn: conn,
organization: organization,
user: user
} do
schedule = schedule_fixture(organization.id, %{name: "Primary"})
layer =
layer_fixture(schedule.id, %{
start_date: ~U[2025-01-01 09:00:00Z],
handoff_time: ~T[09:00:00],
rotation_type: "weekly"
})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "On-Call Now"
assert html =~ user.email
# Gantt cell uses inline grid-column style derived from segment offsets.
assert html =~ ~r/grid-column:\s*\d+\s*\/\s*span\s*\d+/
end
test "navigates the date window when prev/next/today are clicked", %{
conn: conn,
organization: organization
} do
_ = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules?start=2026-04-01&range=14")
view |> element("button[phx-click=\"next\"]") |> render_click()
assert_patched(view, ~p"/schedules?#{[tab: "schedules", start: "2026-04-15", range: 14]}")
view |> element("button[phx-click=\"prev\"]") |> render_click()
assert_patched(view, ~p"/schedules?#{[tab: "schedules", start: "2026-04-01", range: 14]}")
end
test "respects the range query param", %{conn: conn, organization: organization} do
_ = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules?range=7")
assert html =~ "1 Week"
# The range select should preselect 1 Week
assert html =~ ~r/<option value="7" selected/
end
test "falls back to defaults for malformed range/start params", %{
conn: conn,
organization: organization
} do
_ = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules?start=not-a-date&range=99")
assert html =~ "2 Weeks"
end
test "shows 'No one' when nobody is on-call", %{conn: conn, organization: organization} do
schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "No one"
end
test "has link to create new schedule", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules")
assert has_element?(view, "a", "New Schedule")
end
test "has tabs for Schedules and Escalation Policies", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules")
assert html =~ "Schedules"
assert html =~ "Escalation Policies"
end
test "switching to escalation policies tab shows policies", %{
conn: conn,
organization: organization
} do
escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
{:ok, view, _html} = live(conn, ~p"/schedules")
html = view |> element("a", "Escalation Policies") |> render_click()
assert html =~ "Critical Alerts Policy"
end
test "escalation policies tab shows empty state when none exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "No escalation policies"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Show" do
test "mounts and displays schedule details", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
name: "Weekend Rotation",
timezone: "America/New_York"
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Weekend Rotation"
assert html =~ "America/New_York"
end
test "shows description if present", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
name: "Described Schedule",
description: "Covers weekday shifts"
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Covers weekday shifts"
end
test "can reorder layers via the up/down buttons", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
l0 =
layer_fixture(schedule.id, %{
name: "Top Layer",
position: 0,
rotation_type: "weekly",
handoff_time: ~T[09:00:00],
start_date: ~U[2026-01-01 09:00:00Z]
})
l1 =
layer_fixture(schedule.id, %{
name: "Bottom Layer",
position: 1,
rotation_type: "weekly",
handoff_time: ~T[09:00:00],
start_date: ~U[2026-01-01 09:00:00Z]
})
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view
|> element(~s|button[phx-click="move_layer"][phx-value-id="#{l1.id}"][phx-value-direction="up"]|)
|> render_click()
reloaded = OnCall.get_schedule!(schedule.id)
by_id = Map.new(reloaded.layers, &{&1.id, &1.position})
assert by_id[l0.id] == 1
assert by_id[l1.id] == 0
end
test "shows Currently On-Call section with no one on-call", %{
conn: conn,
organization: organization
} do
schedule = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Currently On-Call"
assert html =~ "No one is currently on-call"
end
test "shows currently on-call user", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer =
layer_fixture(schedule.id, %{
start_date: ~U[2025-01-01 09:00:00Z],
handoff_time: ~T[09:00:00],
rotation_type: "weekly"
})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Currently On-Call"
assert html =~ user.email
end
test "shows layers with rotation details", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer_fixture(schedule.id, %{
name: "Primary Layer",
rotation_type: "weekly",
rotation_interval: 2,
handoff_time: ~T[09:00:00]
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Primary Layer"
assert html =~ "Weekly"
assert html =~ "09:00"
end
test "shows layer members", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{name: "Layer Alpha"})
layer_member_fixture(layer.id, user.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ user.email
end
test "shows empty state for layers", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "No layers configured"
end
test "shows empty state for overrides", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "No overrides configured"
end
test "can toggle add layer form", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
refute has_element?(view, "form[phx-submit='save_layer']")
view |> element("button", "Add Layer") |> render_click()
assert has_element?(view, "form[phx-submit='save_layer']")
end
test "can add a layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view |> element("button", "Add Layer") |> render_click()
view
|> form("form[phx-submit='save_layer']",
layer: %{
name: "New Layer",
rotation_type: "daily",
rotation_interval: 1,
handoff_time: "09:00",
start_date: "2026-03-01T09:00"
}
)
|> render_submit()
html = render(view)
assert html =~ "New Layer"
refute has_element?(view, "form[phx-submit='save_layer']")
end
test "can add a member to a layer", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{name: "Staffing Layer"})
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view
|> element("select[phx-change='add_member'][phx-value-layer_id='#{layer.id}']")
|> render_change(%{"user_id" => user.id, "layer_id" => layer.id})
html = render(view)
assert html =~ user.email
end
test "can delete a layer", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id, %{name: "Temporary Layer"})
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ "Temporary Layer"
view
|> element("button[phx-click='delete_layer'][phx-value-id='#{layer.id}']")
|> render_click()
html = render(view)
refute html =~ "Temporary Layer"
end
test "can remove a member", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
layer = layer_fixture(schedule.id)
member = layer_member_fixture(layer.id, user.id)
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ user.email
view
|> element("button[phx-click='remove_member'][phx-value-id='#{member.id}']")
|> render_click()
# The user email should no longer appear as a member badge
refute has_element?(view, "span", user.email)
end
test "can toggle add override form", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
refute has_element?(view, "form[phx-submit='save_override']")
view |> element("button", "Add Override") |> render_click()
assert has_element?(view, "form[phx-submit='save_override']")
end
test "can add an override", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view |> element("button", "Add Override") |> render_click()
view
|> form("form[phx-submit='save_override']",
override: %{
user_id: user.id,
start_time: "2026-04-01T09:00",
end_time: "2026-04-02T09:00"
}
)
|> render_submit()
html = render(view)
assert html =~ user.email
assert html =~ "2026-04-01"
refute has_element?(view, "form[phx-submit='save_override']")
end
test "can delete an override", %{conn: conn, organization: organization, user: user} do
schedule = schedule_fixture(organization.id)
override = override_fixture(schedule.id, user.id)
{:ok, view, html} = live(conn, ~p"/schedules/#{schedule.id}")
assert html =~ user.email
view
|> element("button[phx-click='delete_override'][phx-value-id='#{override.id}']")
|> render_click()
html = render(view)
assert html =~ "No overrides configured"
end
test "has edit and delete buttons", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
assert has_element?(view, "a", "Edit")
assert has_element?(view, "button", "Delete")
end
test "delete redirects to index", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
view |> element("button", "Delete") |> render_click()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules"
assert flash["info"] =~ "Schedule deleted"
end
test "requires authentication", %{organization: organization} do
schedule = schedule_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/#{schedule.id}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - New" do
test "renders new schedule form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/new")
assert html =~ "New Schedule"
assert html =~ "Name"
assert html =~ "Timezone"
assert html =~ "Save Schedule"
end
test "validates required fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/new")
html =
view
|> form("form", schedule: %{name: "", timezone: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "creates schedule and redirects to show", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/new")
view
|> form("form",
schedule: %{
name: "New On-Call Schedule",
timezone: "America/Chicago"
}
)
|> render_submit()
{path, flash} = assert_redirect(view)
assert path =~ ~r|^/schedules/.+|
assert flash["info"] =~ "Schedule created"
end
test "creates schedule with optional description", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/schedules/new")
view
|> form("form",
schedule: %{
name: "Documented Schedule",
timezone: "America/Chicago",
description: "Handles weekend coverage"
}
)
|> render_submit()
assert_redirect(view)
schedules = OnCall.list_schedules(organization.id)
schedule = Enum.find(schedules, &(&1.name == "Documented Schedule"))
assert schedule
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/new")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - Edit" do
test "renders edit form with existing data", %{conn: conn, organization: organization} do
schedule =
schedule_fixture(organization.id, %{
name: "Existing Schedule",
timezone: "America/New_York"
})
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
assert html =~ "Edit Schedule"
assert html =~ "Existing Schedule"
assert html =~ "America/New_York"
end
test "validates required fields on change", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
html =
view
|> form("form", schedule: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "updates schedule and redirects to show", %{conn: conn, organization: organization} do
schedule = schedule_fixture(organization.id, %{name: "Old Name"})
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}/edit")
view
|> form("form", schedule: %{name: "Updated Name"})
|> render_submit()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules/#{schedule.id}"
assert flash["info"] =~ "Schedule updated"
updated = OnCall.get_schedule!(schedule.id)
assert updated.name == "Updated Name"
end
test "requires authentication", %{organization: organization} do
schedule = schedule_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/#{schedule.id}/edit")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
end