1040 lines
31 KiB
Elixir
1040 lines
31 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 "filters schedules by name when search is provided", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
_ = schedule_fixture(organization.id, %{name: "Network Primary"})
|
|
_ = schedule_fixture(organization.id, %{name: "Server Backup"})
|
|
|
|
{:ok, _view, html} = live(conn, ~p"/schedules?search=network")
|
|
|
|
assert html =~ "Network Primary"
|
|
refute html =~ "Server Backup"
|
|
end
|
|
|
|
test "search input search box is rendered", %{conn: conn, organization: organization} do
|
|
_ = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules")
|
|
|
|
assert has_element?(view, ~s|input[name="search"]|)
|
|
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 "auto-opens the Add Layer form when no layers exist", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
# Schedule with zero layers (just-created flow)
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
# The form fields are inline when show_add_layer is true
|
|
assert html =~ ~r/name="layer\[/
|
|
end
|
|
|
|
test "does not auto-open Add Layer when layers already exist", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
_ =
|
|
layer_fixture(schedule.id, %{
|
|
name: "Existing",
|
|
position: 0,
|
|
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}")
|
|
|
|
# The form should not be visible inline by default
|
|
refute html =~ ~r/name="layer\[/
|
|
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 "reorder_layers event applies the new order", %{conn: conn, organization: organization} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
l0 =
|
|
layer_fixture(schedule.id, %{
|
|
name: "L0",
|
|
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: "L1",
|
|
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}")
|
|
|
|
render_hook(view, "reorder_layers", %{"ordered_ids" => [l1.id, l0.id]})
|
|
reloaded = OnCall.get_schedule!(schedule.id)
|
|
by_id = Map.new(reloaded.layers, &{&1.id, &1.position})
|
|
assert by_id[l1.id] == 0
|
|
assert by_id[l0.id] == 1
|
|
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 once a layer exists", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
_ =
|
|
layer_fixture(schedule.id, %{
|
|
name: "Existing",
|
|
position: 0,
|
|
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}")
|
|
|
|
# With at least one layer the form is hidden by default
|
|
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 (form is pre-opened on a fresh schedule)", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
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
|
|
|
|
test "timeline_prev shifts the visible window backwards", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "timeline_prev", %{})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "timeline_next shifts the visible window forwards", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "timeline_next", %{})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "timeline_today resets the visible window", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
# Move twice, then snap back to today
|
|
render_hook(view, "timeline_next", %{})
|
|
render_hook(view, "timeline_next", %{})
|
|
html = render_hook(view, "timeline_today", %{})
|
|
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "validate_layer applied to invalid params surfaces errors", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
_ =
|
|
layer_fixture(schedule.id, %{
|
|
name: "Existing",
|
|
position: 0,
|
|
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("button", "Add Layer") |> render_click()
|
|
|
|
# Drive validate event directly to bypass select option whitelisting in
|
|
# the test framework form helper.
|
|
html =
|
|
render_hook(view, "validate_layer", %{
|
|
"layer" => %{
|
|
"name" => "",
|
|
"rotation_type" => "",
|
|
"handoff_time" => "",
|
|
"start_date" => ""
|
|
}
|
|
})
|
|
|
|
assert html =~ "can't be blank"
|
|
end
|
|
|
|
test "save_layer with invalid params keeps the form open", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
_ =
|
|
layer_fixture(schedule.id, %{
|
|
name: "Existing",
|
|
position: 0,
|
|
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("button", "Add Layer") |> render_click()
|
|
|
|
render_hook(view, "save_layer", %{
|
|
"layer" => %{
|
|
"name" => "",
|
|
"rotation_type" => "",
|
|
"handoff_time" => "",
|
|
"start_date" => ""
|
|
}
|
|
})
|
|
|
|
# The form should still be in the DOM — invalid submit re-renders with
|
|
# the changeset, it does NOT close the form.
|
|
assert has_element?(view, "form[phx-submit='save_layer']")
|
|
end
|
|
|
|
test "toggle_add_layer hides and re-shows the form", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
_ =
|
|
layer_fixture(schedule.id, %{
|
|
name: "Existing",
|
|
position: 0,
|
|
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}")
|
|
|
|
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']")
|
|
# Toggle off via direct event
|
|
render_hook(view, "toggle_add_layer", %{})
|
|
refute has_element?(view, "form[phx-submit='save_layer']")
|
|
end
|
|
|
|
test "new_layer_add_user with empty id is a no-op", %{conn: conn, organization: organization} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "new_layer_add_user", %{"user_id" => ""})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "new_layer_add_user adds a member then ignores duplicates", %{
|
|
conn: conn,
|
|
organization: organization,
|
|
user: user
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
render_hook(view, "new_layer_add_user", %{"user_id" => user.id})
|
|
# Adding the same user twice should not crash
|
|
html = render_hook(view, "new_layer_add_user", %{"user_id" => user.id})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "new_layer_remove_user is a no-op for unknown user", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "new_layer_remove_user", %{"id" => Ecto.UUID.generate()})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "set_layer_restriction with time_of_day persists restrictions", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
layer =
|
|
layer_fixture(schedule.id, %{
|
|
name: "L1",
|
|
position: 0,
|
|
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}")
|
|
|
|
render_hook(view, "set_layer_restriction", %{
|
|
"layer_id" => layer.id,
|
|
"restriction_type" => "time_of_day",
|
|
"start_time" => "08:00",
|
|
"end_time" => "17:00"
|
|
})
|
|
|
|
reloaded = OnCall.get_schedule!(schedule.id)
|
|
target = Enum.find(reloaded.layers, &(&1.id == layer.id))
|
|
assert target.restriction_type == "time_of_day"
|
|
end
|
|
|
|
test "set_layer_restriction with empty type clears restrictions", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
layer =
|
|
layer_fixture(schedule.id, %{
|
|
name: "L1",
|
|
position: 0,
|
|
rotation_type: "weekly",
|
|
handoff_time: ~T[09:00:00],
|
|
start_date: ~U[2026-01-01 09:00:00Z]
|
|
})
|
|
|
|
# Apply restriction first
|
|
{:ok, _} =
|
|
OnCall.update_layer_restrictions(layer, %{
|
|
restriction_type: "time_of_day",
|
|
restrictions: %{"start_time" => "09:00", "end_time" => "17:00"}
|
|
})
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
render_hook(view, "set_layer_restriction", %{
|
|
"layer_id" => layer.id,
|
|
"restriction_type" => ""
|
|
})
|
|
|
|
reloaded = OnCall.get_schedule!(schedule.id)
|
|
target = Enum.find(reloaded.layers, &(&1.id == layer.id))
|
|
assert is_nil(target.restriction_type) or target.restriction_type == ""
|
|
end
|
|
|
|
test "set_layer_restriction is a no-op when layer not found", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html =
|
|
render_hook(view, "set_layer_restriction", %{
|
|
"layer_id" => Ecto.UUID.generate(),
|
|
"restriction_type" => "time_of_day"
|
|
})
|
|
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "move_layer is a no-op when layer not found", %{conn: conn, organization: organization} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html =
|
|
render_hook(view, "move_layer", %{
|
|
"id" => Ecto.UUID.generate(),
|
|
"direction" => "up"
|
|
})
|
|
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "delete_layer is a no-op when layer not found", %{conn: conn, organization: organization} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "delete_layer", %{"id" => Ecto.UUID.generate()})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "validate_override surfaces errors when invalid", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
view |> element("button", "Add Override") |> render_click()
|
|
|
|
html =
|
|
render_hook(view, "validate_override", %{
|
|
"override" => %{"user_id" => "", "start_time" => "", "end_time" => ""}
|
|
})
|
|
|
|
assert html =~ "can't be blank"
|
|
end
|
|
|
|
test "save_override with invalid params keeps form open", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
view |> element("button", "Add Override") |> render_click()
|
|
|
|
render_hook(view, "save_override", %{
|
|
"override" => %{"user_id" => "", "start_time" => "", "end_time" => ""}
|
|
})
|
|
|
|
assert has_element?(view, "form[phx-submit='save_override']")
|
|
end
|
|
|
|
test "delete_override is a no-op for unknown override", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "delete_override", %{"id" => Ecto.UUID.generate()})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "add_member with empty user_id is a no-op", %{conn: conn, organization: organization} do
|
|
schedule = schedule_fixture(organization.id)
|
|
_layer = layer_fixture(schedule.id, %{name: "L1"})
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "add_member", %{"user_id" => ""})
|
|
assert is_binary(html)
|
|
end
|
|
|
|
test "remove_member is a no-op for unknown member", %{
|
|
conn: conn,
|
|
organization: organization
|
|
} do
|
|
schedule = schedule_fixture(organization.id)
|
|
_layer = layer_fixture(schedule.id, %{name: "L1"})
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}")
|
|
|
|
html = render_hook(view, "remove_member", %{"id" => Ecto.UUID.generate()})
|
|
assert is_binary(html)
|
|
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
|