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.
600 lines
18 KiB
Elixir
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'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'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
|