towerops/test/towerops_web/live/escalation_policy_live_test.exs
Graham McIntire 0cd2ed3567
feat: add on-call schedules and escalation policies
Built-in PagerDuty-equivalent system for on-call scheduling and alert
escalation. Users can now manage schedules, rotation layers, overrides,
and escalation policies directly in the app alongside PagerDuty.

- On-call schedules with rotation layers (daily/weekly/custom), member
  management, and temporary overrides
- Escalation policies with ordered rules, timeout-based escalation,
  and user/schedule targets
- Automatic escalation via Oban worker with configurable repeat count
- Email notifications via Swoosh for on-call alerts
- Resolver computes who's on-call from layer stacking and overrides
- AlertNotificationWorker integration: starts escalation alongside
  PagerDuty, acknowledges/resolves incidents on alert state changes
- Device and organization schemas support escalation_policy_id
- Escalation policy picker on device form
- Schedules nav item with tabbed index (schedules + escalation policies)
- Full CRUD UI for schedules, layers, members, overrides, rules, targets
- 62 LiveView tests, 56 context/schema/resolver/escalation tests
- 26 E2E Playwright tests for smoke and critical path coverage
2026-03-11 12:32:54 -05:00

332 lines
10 KiB
Elixir

defmodule ToweropsWeb.EscalationPolicyLiveTest 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 (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"
assert html =~ "#{policy.repeat_count}"
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 rules", %{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 rules configured"
end
test "can toggle add rule 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 Rule")
|> render_click()
assert html =~ "Escalate after (minutes)"
end
test "can add a rule 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 Rule") |> render_click()
html =
view
|> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"})
|> render_submit()
assert html =~ "Step 1"
assert html =~ "15 min"
end
test "can delete a rule", %{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 min"
view
|> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|)
|> render_click()
html = render(view)
assert html =~ "No rules configured"
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 =~ "Repeat Count"
assert html =~ "Save Policy"
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 "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