towerops/test/towerops_web/live/escalation_policy_live_test.exs
Graham McIntire 434386816e feat(on_call): drag-drop reorder + restrictions editor + new-schedule polish
Three deferred followups from the chunk-4 plan, all together:

A. Restrictions editor
- Resolver: time_of_week support (weekday set + time-of-day window),
  in addition to the existing time_of_day.
- New OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1.
- schedule_live/show: per-layer Restrict on-call shifts form
  (Always on call / Time of day / Time of week with Mon-Sun checkboxes),
  changes patched live and persisted via the resolver.

B. Drag-and-drop reorder
- New OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic
  transaction; validates id count + membership).
- assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook (no
  external dep; mirrors the existing DeviceListReorder pattern),
  registered as SortableList in app.ts.
- schedule_live/show + escalation_policy_live/show: layer/level cards
  now carry data-id + draggable + a visible drag handle. Up/down arrow
  buttons remain as a fallback for users on assistive tech.

C. Unified new-schedule UX
- schedule_live/show pre-opens the Add Layer form when the schedule
  has zero layers, so the new-schedule -> add-layers flow lands ready
  to act on. Two existing tests updated to seed a layer first; two
  new tests verify the auto-open behaviour both ways.

Full suite 10,231 / 0 failures. Format/dialyzer clean.
2026-04-28 14:27:38 -05:00

526 lines
17 KiB
Elixir

defmodule ToweropsWeb.EscalationPolicyLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.DevicesFixtures
import Towerops.OnCallFixtures
import Towerops.OrganizationsFixtures
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 (via /schedules?tab=escalation-policies)" do
test "shows escalation policies tab content", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "Escalation Policies"
assert html =~ "New Policy"
end
test "lists escalation policies", %{conn: conn, organization: organization} do
_policy = escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"})
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "Critical Alerts Policy"
end
test "shows the level count instead of a Repeat Count column", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id, %{name: "p", repeat_count: 5})
_ = escalation_rule_fixture(policy.id, %{position: 0})
_ = escalation_rule_fixture(policy.id, %{position: 1})
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
refute html =~ "Repeat Count"
assert html =~ "Levels"
assert html =~ ~r/<td[^>]*>\s*2\s*</
end
test "shows empty state when no policies", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert html =~ "No escalation policies"
end
test "has link to create new policy", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules?tab=escalation-policies")
assert has_element?(view, "a", "New Policy")
end
end
describe "Show" do
test "mounts and displays policy details", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{name: "Ops Policy", repeat_count: 5})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Ops Policy"
assert html =~ "5"
end
test "shows description if present", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{
name: "Described Policy",
description: "Handles critical network alerts"
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Handles critical network alerts"
end
test "shows empty state when no levels", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "No levels configured"
end
test "can toggle add level form", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
refute html =~ "Escalate after (minutes)"
html =
view
|> element("button", "Add Level")
|> render_click()
assert html =~ "Escalate after (minutes)"
end
test "can add a level with timeout_minutes", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
view |> element("button", "Add Level") |> render_click()
html =
view
|> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"})
|> render_submit()
assert html =~ "Level 1"
assert html =~ "15"
assert html =~ "minutes"
end
test "can delete a level", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id, %{timeout_minutes: 20})
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "20"
view
|> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|)
|> render_click()
html = render(view)
assert html =~ "No levels configured"
end
test "renders levels as numbered cards with escalate-after copy", %{
conn: conn,
organization: organization,
user: user
} do
policy = escalation_policy_fixture(organization.id, %{name: "Net"})
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 15
})
{:ok, _} =
OnCall.create_escalation_target(%{
escalation_rule_id: r0.id,
target_type: "user",
user_id: user.id
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Level 1"
assert html =~ "escalate after"
assert html =~ "15"
assert html =~ "minutes"
end
test "surfaces repeat_count in the REPEAT footer", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id, %{repeat_count: 4})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "If no one acknowledges, repeat this policy"
assert html =~ "4"
end
test "shows the handoff notifications mode label", %{conn: conn, organization: organization} do
policy =
escalation_policy_fixture(organization.id, %{handoff_notifications_mode: "always"})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Send On-Call Handoff Notifications"
assert html =~ "always"
end
test "lists devices using the policy in the Services sidebar", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id)
site = site_fixture(organization.id)
device =
device_fixture(organization.id, site.id, %{
escalation_policy_id: policy.id,
name: "core-router-7"
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "Services"
assert html =~ device.name
end
test "Services sidebar shows empty state when nothing uses the policy", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id)
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ "No devices use this policy"
end
test "reorder_rules event applies the new order", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
{:ok, r1} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 1,
timeout_minutes: 60
})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
render_hook(view, "reorder_rules", %{"ordered_ids" => [r1.id, r0.id]})
reloaded = OnCall.get_escalation_policy!(policy.id)
by_id = Map.new(reloaded.rules, &{&1.id, &1.position})
assert by_id[r1.id] == 0
assert by_id[r0.id] == 1
end
test "can move a level up via the move_rule event", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, r0} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 0,
timeout_minutes: 30
})
{:ok, r1} =
OnCall.create_escalation_rule(%{
escalation_policy_id: policy.id,
position: 1,
timeout_minutes: 60
})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
view
|> element(~s|button[phx-click="move_rule"][phx-value-id="#{r1.id}"][phx-value-direction="up"]|)
|> render_click()
reloaded = OnCall.get_escalation_policy!(policy.id)
by_id = Map.new(reloaded.rules, &{&1.id, &1.position})
assert by_id[r0.id] == 1
assert by_id[r1.id] == 0
end
test "shows rule targets", %{conn: conn, organization: organization, user: user} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
escalation_target_fixture(rule.id, %{
target_type: "user",
user_id: user.id
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ user.email
end
test "can add a user target to a rule", %{conn: conn, organization: organization, user: user} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
html =
view
|> form("form[phx-submit=\"add_target\"]", %{
"rule_id" => rule.id,
"target_type" => "user",
"target_id" => user.id
})
|> render_submit()
assert html =~ user.email
end
test "can add a schedule target to a rule", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
schedule = schedule_fixture(organization.id, %{name: "On-Call Rotation"})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
html =
view
|> form("form[phx-submit=\"add_target\"]", %{
"rule_id" => rule.id,
"target_type" => "schedule",
"target_id" => schedule.id
})
|> render_submit()
assert html =~ "On-Call Rotation"
end
test "can delete a target", %{conn: conn, organization: organization, user: user} do
policy = escalation_policy_fixture(organization.id)
rule = escalation_rule_fixture(policy.id)
target =
escalation_target_fixture(rule.id, %{
target_type: "user",
user_id: user.id
})
{:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert html =~ user.email
view
|> element(~s|button[phx-click="delete_target"][phx-value-id="#{target.id}"]|)
|> render_click()
html = render(view)
assert html =~ "No targets yet"
end
test "has edit and delete buttons", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert has_element?(view, "a", "Edit")
assert has_element?(view, "button", "Delete")
end
test "delete redirects to index with correct tab", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
view
|> element("button", "Delete")
|> render_click()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules?tab=escalation-policies"
assert flash["info"] =~ "deleted"
end
test "requires authentication", %{organization: organization} do
policy = escalation_policy_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Form - New" do
test "renders new policy form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
assert html =~ "New Escalation Policy"
assert html =~ "Name"
assert html =~ "Description"
assert html =~ "Save Policy"
end
test "no longer renders a standalone Repeat Count label", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
refute html =~ ~r/<label[^>]*>\s*Repeat Count\s*</
end
test "renders the Send On-Call Handoff Notifications select", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
assert html =~ "Send On-Call Handoff Notifications"
assert html =~ "When in use by a service"
assert html =~ "Always"
assert html =~ "Never"
end
test "renders the inline repeat block", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new")
assert html =~ "If no one acknowledges, repeat this policy"
assert html =~ ~r/name="escalation_policy\[repeat_count\]"/
end
test "validates required fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
html =
view
|> form("form", escalation_policy: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "creates policy and redirects to show", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/new")
view
|> form("form", escalation_policy: %{name: "New Alert Policy", repeat_count: 5})
|> render_submit()
{path, flash} = assert_redirect(view)
assert path =~ ~r|^/schedules/escalation-policies/.+|
assert flash["info"] =~ "created"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/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
policy =
escalation_policy_fixture(organization.id, %{
name: "Existing Policy",
description: "Some description",
repeat_count: 7
})
{:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
assert html =~ "Edit Escalation Policy"
assert html =~ "Existing Policy"
assert html =~ "Some description"
assert html =~ "7"
end
test "validates required fields on change", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
html =
view
|> form("form", escalation_policy: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "updates policy and redirects to show", %{conn: conn, organization: organization} do
policy = escalation_policy_fixture(organization.id, %{name: "Old Name"})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
view
|> form("form", escalation_policy: %{name: "Updated Name"})
|> render_submit()
{path, flash} = assert_redirect(view)
assert path == ~p"/schedules/escalation-policies/#{policy.id}"
assert flash["info"] =~ "updated"
updated = OnCall.get_escalation_policy!(policy.id)
assert updated.name == "Updated Name"
end
test "edit form persists handoff_notifications_mode and inline repeat_count", %{
conn: conn,
organization: organization
} do
policy = escalation_policy_fixture(organization.id, %{repeat_count: 2})
{:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
view
|> form("form",
escalation_policy: %{
name: policy.name,
repeat_count: 5,
handoff_notifications_mode: "always"
}
)
|> render_submit()
updated = OnCall.get_escalation_policy!(policy.id)
assert updated.repeat_count == 5
assert updated.handoff_notifications_mode == "always"
end
test "requires authentication", %{organization: organization} do
policy = escalation_policy_fixture(organization.id)
conn = build_conn()
{:error, redirect} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}/edit")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
end