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.
526 lines
17 KiB
Elixir
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'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'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
|