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
332 lines
10 KiB
Elixir
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
|