Groups Admin UI closes #372 #382
6 changed files with 962 additions and 4 deletions
|
|
@ -304,9 +304,9 @@ lib/
|
|||
- Add/remove groups inline
|
||||
- Link to group detail page
|
||||
|
||||
### Group Detail View (`/groups/:id`)
|
||||
### Group Detail View (`/groups/:slug`)
|
||||
|
||||
**Route:** `/groups/:id` - Group detail page (uses UUID, slug can be used for future `/groups/:slug` routes)
|
||||
**Route:** `/groups/:slug` - Group detail page (uses slug for URL-friendly routing)
|
||||
|
||||
**Features:**
|
||||
- Display group name and description
|
||||
|
|
@ -315,7 +315,7 @@ lib/
|
|||
- Edit group button
|
||||
- Delete group button (with confirmation)
|
||||
|
||||
**Note:** Currently uses UUID for routing. Slug is available for future URL-friendly routes (`/groups/:slug`).
|
||||
**Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`).
|
||||
|
||||
### Accessibility (A11y) Considerations
|
||||
|
||||
|
|
@ -1106,7 +1106,7 @@ Groups include automatic slug generation, following the same pattern as CustomFi
|
|||
- Automatically generated from the `name` attribute on create
|
||||
- Immutable after creation (don't change when name is updated)
|
||||
- Unique and URL-friendly
|
||||
- Available for future route enhancements (e.g., `/groups/:slug` instead of `/groups/:id`)
|
||||
- Used for routing (e.g., `/groups/:slug` for group detail pages)
|
||||
|
||||
The implementation reuses the existing `GenerateSlug` change from CustomFields, ensuring consistency across the codebase.
|
||||
|
||||
|
|
|
|||
285
test/mv_web/live/group_live/form_test.exs
Normal file
285
test/mv_web/live/group_live/form_test.exs
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
defmodule MvWeb.GroupLive.FormTest do
|
||||
@moduledoc """
|
||||
Tests for the group create/edit form.
|
||||
|
||||
Tests cover:
|
||||
- Creating groups
|
||||
- Editing groups
|
||||
- Form validations
|
||||
- Edge cases
|
||||
- Security
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "create form" do
|
||||
test "form renders with empty fields", %{conn: conn} do
|
||||
{:ok, view, html} = live(conn, "/groups/new")
|
||||
|
||||
assert html =~ gettext("Create Group") || html =~ "create"
|
||||
assert has_element?(view, "form")
|
||||
end
|
||||
|
||||
test "creates group successfully with name and description", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "New Group",
|
||||
"description" => "Group description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Should redirect to groups list or show page
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
|
||||
test "creates group successfully with name only (description optional)", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Group Without Description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
|
||||
test "shows error when name is missing", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"description" => "Description without name"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ gettext("required") || html =~ "name" || html =~ "error"
|
||||
end
|
||||
|
||||
test "shows error when name exceeds 100 characters", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
long_name = String.duplicate("a", 101)
|
||||
form_data = %{"name" => long_name}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "100" || html =~ "length" || html =~ "error"
|
||||
end
|
||||
|
||||
test "shows error when description exceeds 500 characters", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
long_description = String.duplicate("a", 501)
|
||||
form_data = %{
|
||||
"name" => "Test Group",
|
||||
"description" => long_description
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "500" || html =~ "length" || html =~ "error"
|
||||
end
|
||||
|
||||
test "shows error when name already exists (case-insensitive)", %{conn: conn} do
|
||||
_existing_group = Fixtures.group_fixture(%{name: "Existing Group"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "EXISTING GROUP"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "already" || html =~ "taken" || html =~ "exists" || html =~ "error"
|
||||
end
|
||||
|
||||
test "shows error when name generates empty slug", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "!!!"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "error" || html =~ "invalid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit form" do
|
||||
test "form renders with existing group data", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"})
|
||||
|
||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
assert html =~ "Original Name"
|
||||
assert html =~ "Original Description"
|
||||
assert has_element?(view, "form")
|
||||
end
|
||||
|
||||
test "updates group name successfully (slug remains unchanged)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Original Name"})
|
||||
original_slug = group.slug
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Updated Name"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Verify slug didn't change by checking redirect URL or reloading
|
||||
assert_redirect(view, "/groups/#{original_slug}")
|
||||
end
|
||||
|
||||
test "updates group description successfully", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{description: "Old Description"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"description" => "New Description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/groups/#{group.slug}")
|
||||
end
|
||||
|
||||
test "shows error when updating to duplicate name (case-insensitive)", %{conn: conn} do
|
||||
_group1 = Fixtures.group_fixture(%{name: "Group One"})
|
||||
group2 = Fixtures.group_fixture(%{name: "Group Two"})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group2.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"name" => "GROUP ONE"
|
||||
}
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "already" || html =~ "taken" || html =~ "exists" || html =~ "error"
|
||||
end
|
||||
|
||||
test "slug is not displayed in form (immutable)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
# Slug should not be in form (it's immutable)
|
||||
refute html =~ ~r/slug.*input/i || html =~ ~r/input.*slug/i
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles whitespace trimming correctly", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => " Trimmed Group "
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Name should be trimmed
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
|
||||
test "handles umlauts in name correctly", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Müller Gruppe"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/groups")
|
||||
end
|
||||
end
|
||||
|
||||
describe "security" do
|
||||
@tag role: :member
|
||||
test "non-admin users cannot access create form", %{conn: conn} do
|
||||
result = live(conn, "/groups/new")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: _}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: _}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "non-admin users cannot access edit form", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
result = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: _}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: _}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated users are redirected to login", %{conn: conn} do
|
||||
result = live(conn, "/groups/new")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/auth/sign_in"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/auth/sign_in"}}}, result)
|
||||
end
|
||||
|
||||
test "user cannot edit group with non-existent slug", %{conn: conn} do
|
||||
non_existent_slug = "non-existent-group-slug"
|
||||
|
||||
result = live(conn, "/groups/#{non_existent_slug}/edit")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "user cannot edit group with wrong slug (case mismatch)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
wrong_case_slug = String.upcase(group.slug)
|
||||
|
||||
result = live(conn, "/groups/#{wrong_case_slug}/edit")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
end
|
||||
end
|
||||
148
test/mv_web/live/group_live/index_test.exs
Normal file
148
test/mv_web/live/group_live/index_test.exs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
defmodule MvWeb.GroupLive.IndexTest do
|
||||
@moduledoc """
|
||||
Tests for the groups overview page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying list of groups
|
||||
- Creating new groups
|
||||
- Deleting groups with confirmation
|
||||
- Permission checks
|
||||
- Edge cases
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "mount and display" do
|
||||
test "page renders successfully for admin user", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ gettext("Groups")
|
||||
end
|
||||
|
||||
test "lists all groups", %{conn: conn} do
|
||||
group1 = Fixtures.group_fixture(%{name: "Group One"})
|
||||
group2 = Fixtures.group_fixture(%{name: "Group Two"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ group1.name
|
||||
assert html =~ group2.name
|
||||
end
|
||||
|
||||
test "displays group name, description, and member count", %{conn: conn} do
|
||||
_group = Fixtures.group_fixture(%{name: "Test Group", description: "Test description"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ "Test Group"
|
||||
assert html =~ "Test description"
|
||||
# Member count should be displayed (0 for empty group)
|
||||
assert html =~ "0" || html =~ gettext("Members")
|
||||
end
|
||||
|
||||
test "displays 'Create Group' button for admin users", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ gettext("Create Group") || html =~ "create" || html =~ "new"
|
||||
end
|
||||
|
||||
test "displays empty state when no groups exist", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Should show empty state or empty list message
|
||||
assert html =~ gettext("No groups") || html =~ "0" || html =~ "empty"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles very long group names correctly", %{conn: conn} do
|
||||
long_name = String.duplicate("a", 100)
|
||||
_group = Fixtures.group_fixture(%{name: long_name})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ long_name
|
||||
end
|
||||
|
||||
test "handles very long descriptions correctly", %{conn: conn} do
|
||||
long_description = String.duplicate("a", 500)
|
||||
_group = Fixtures.group_fixture(%{description: long_description})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
assert html =~ long_description || html =~ String.slice(long_description, 0, 100)
|
||||
end
|
||||
end
|
||||
|
||||
describe "security" do
|
||||
@tag role: :member
|
||||
test "non-admin users cannot access groups page", %{conn: conn} do
|
||||
# Should redirect or show 403
|
||||
result = live(conn, "/groups")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: _}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: _}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated users are redirected to login", %{conn: conn} do
|
||||
result = live(conn, "/groups")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/auth/sign_in"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/auth/sign_in"}}}, result)
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "read-only users can view groups but not create", %{conn: conn} do
|
||||
# Create read-only user
|
||||
read_only_user = Fixtures.user_with_role_fixture("read_only")
|
||||
conn = conn_with_password_user(conn, read_only_user)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Should be able to see groups
|
||||
assert html =~ gettext("Groups")
|
||||
|
||||
# Should NOT see create button
|
||||
refute html =~ gettext("Create Group") || html =~ "create"
|
||||
end
|
||||
end
|
||||
|
||||
describe "performance" do
|
||||
test "page loads efficiently with many groups", %{conn: conn} do
|
||||
# Create multiple groups
|
||||
Enum.each(1..10, fn _ -> Fixtures.group_fixture() end)
|
||||
|
||||
# Should load without N+1 queries
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Verify all groups are displayed
|
||||
assert html =~ gettext("Groups")
|
||||
end
|
||||
|
||||
test "member count is loaded efficiently via calculation", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
member1 = Fixtures.member_fixture()
|
||||
member2 = Fixtures.member_fixture()
|
||||
|
||||
# Add members to group
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Member count should be displayed (should be 2)
|
||||
assert html =~ "2" || html =~ gettext("Members")
|
||||
end
|
||||
end
|
||||
end
|
||||
209
test/mv_web/live/group_live/integration_test.exs
Normal file
209
test/mv_web/live/group_live/integration_test.exs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
defmodule MvWeb.GroupLive.IntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for complete group management workflows.
|
||||
|
||||
Tests cover:
|
||||
- Complete workflows (Create → View → Edit → Delete)
|
||||
- Slug-based navigation
|
||||
- Member-group associations
|
||||
- URL persistence
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "complete workflow" do
|
||||
test "create → view via slug → edit → view via slug (slug unchanged)", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
# Create group
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Workflow Test Group",
|
||||
"description" => "Initial description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Get the created group by name using current user (tests authorization)
|
||||
{:ok, groups} = Membership.list_groups(actor: current_user)
|
||||
group = Enum.find(groups, &(&1.name == "Workflow Test Group"))
|
||||
original_slug = group.slug
|
||||
|
||||
# View via slug
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Workflow Test Group"
|
||||
assert html =~ "Initial description"
|
||||
|
||||
# Edit group
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Updated Workflow Test Group",
|
||||
"description" => "Updated description"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# View again via slug (should still work with original slug)
|
||||
{:ok, _view, html} = live(conn, "/groups/#{original_slug}")
|
||||
|
||||
assert html =~ "Updated Workflow Test Group"
|
||||
assert html =~ "Updated description"
|
||||
# Slug should remain unchanged
|
||||
assert html =~ original_slug || html =~ "workflow-test-group"
|
||||
end
|
||||
|
||||
test "create group → add members → view (member count updated)", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
# Create group
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Member Test Group"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Get the created group using current user (tests authorization)
|
||||
{:ok, groups} = Membership.list_groups(actor: current_user)
|
||||
group = Enum.find(groups, &(&1.name == "Member Test Group"))
|
||||
|
||||
# Add members to group (use system_actor for test data setup)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
member1 = Fixtures.member_fixture()
|
||||
member2 = Fixtures.member_fixture()
|
||||
|
||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# View group via slug
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Member count should be 2
|
||||
assert html =~ "2" || html =~ gettext("Members")
|
||||
assert html =~ member1.first_name || html =~ member1.last_name
|
||||
assert html =~ member2.first_name || html =~ member2.last_name
|
||||
end
|
||||
|
||||
test "create group → add members → delete (cascade works)", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
# Create group
|
||||
{:ok, view, _html} = live(conn, "/groups/new")
|
||||
|
||||
form_data = %{
|
||||
"name" => "Delete Test Group"
|
||||
}
|
||||
|
||||
view
|
||||
|> form("form", group: form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Get the created group using current user (tests authorization)
|
||||
{:ok, groups} = Membership.list_groups(actor: current_user)
|
||||
group = Enum.find(groups, &(&1.name == "Delete Test Group"))
|
||||
|
||||
# Add member to group (use system_actor for test data setup)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
# Delete group (via UI - would need delete confirmation modal test)
|
||||
# For now, test via API that cascade works
|
||||
# Use current_user to test authorization (admin can delete)
|
||||
:ok = Membership.destroy_group(group, actor: current_user)
|
||||
|
||||
# Verify member still exists (use system_actor for verification - this is test verification, not user action)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, member.id, actor: system_actor)
|
||||
assert member_reloaded != nil
|
||||
|
||||
# Verify member-group association is deleted
|
||||
{:ok, mgs} =
|
||||
Ash.read(
|
||||
Mv.Membership.MemberGroup
|
||||
|> Ash.Query.filter(expr(group_id == ^group.id)),
|
||||
actor: system_actor,
|
||||
domain: Mv.Membership
|
||||
)
|
||||
|
||||
assert mgs == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based navigation" do
|
||||
test "links to group detail use slug (not ID)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Navigation Test Group"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups")
|
||||
|
||||
# Links should use slug, not UUID
|
||||
assert html =~ "/groups/#{group.slug}"
|
||||
refute html =~ "/groups/#{group.id}"
|
||||
end
|
||||
|
||||
test "deep links to group detail per slug work", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Deep Link Test"})
|
||||
|
||||
# Direct navigation to slug URL
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Deep Link Test"
|
||||
end
|
||||
|
||||
test "browser back button works correctly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
# Navigate to group detail
|
||||
{:ok, _view1, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Navigate to edit
|
||||
{:ok, view2, _html} = live(conn, "/groups/#{group.slug}/edit")
|
||||
|
||||
# Browser back should work (tested via navigation)
|
||||
assert view2 != nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "URL persistence" do
|
||||
test "slug-based URLs persist across page reloads", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Persistent URL Test"})
|
||||
|
||||
# First visit
|
||||
{:ok, _view1, html1} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html1 =~ "Persistent URL Test"
|
||||
|
||||
# Simulate page reload (new connection)
|
||||
{:ok, _view2, html2} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html2 =~ "Persistent URL Test"
|
||||
end
|
||||
end
|
||||
end
|
||||
281
test/mv_web/live/group_live/show_test.exs
Normal file
281
test/mv_web/live/group_live/show_test.exs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
defmodule MvWeb.GroupLive.ShowTest do
|
||||
@moduledoc """
|
||||
Tests for the group detail page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying group information
|
||||
- Slug-based routing
|
||||
- Member list display
|
||||
- Navigation
|
||||
- Error handling
|
||||
- Delete functionality
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "mount and display" do
|
||||
test "page renders successfully", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
end
|
||||
|
||||
test "displays group name", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group Name"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Test Group Name"
|
||||
end
|
||||
|
||||
test "displays group description when present", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{description: "This is a test description"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "This is a test description"
|
||||
end
|
||||
|
||||
test "displays member count", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Member count should be displayed (might be 0 or more)
|
||||
assert html =~ "0" || html =~ gettext("Members") || html =~ "member"
|
||||
end
|
||||
|
||||
test "displays list of members in group", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
member1 = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"})
|
||||
member2 = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Alice" || html =~ "Smith"
|
||||
assert html =~ "Bob" || html =~ "Jones"
|
||||
end
|
||||
|
||||
test "displays edit button for admin users", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ gettext("Edit") || html =~ "edit"
|
||||
end
|
||||
|
||||
test "displays delete button for admin users", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ gettext("Delete") || html =~ "delete"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based routing" do
|
||||
test "route /groups/:slug works correctly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Board Members"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Board Members"
|
||||
# Verify slug is in URL
|
||||
assert html =~ group.slug || html =~ "board-members"
|
||||
end
|
||||
|
||||
test "group is found by slug via unique_slug identity", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
end
|
||||
|
||||
test "non-existent slug returns 404", %{conn: conn} do
|
||||
non_existent_slug = "non-existent-group-slug"
|
||||
|
||||
result = live(conn, "/groups/#{non_existent_slug}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "slug is case-sensitive (exact match required)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||
# Slug should be lowercase
|
||||
wrong_case_slug = String.upcase(group.slug)
|
||||
|
||||
result = live(conn, "/groups/#{wrong_case_slug}")
|
||||
|
||||
# Should not find group with wrong case
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "URL is readable and user-friendly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{name: "Board Members"})
|
||||
|
||||
{:ok, _view, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# URL should contain readable slug, not UUID
|
||||
assert group.slug == "board-members"
|
||||
refute String.contains?(group.slug, "-") == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "displays empty group correctly (0 members)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "0" || html =~ gettext("No members") || html =~ "empty"
|
||||
end
|
||||
|
||||
test "handles group without description correctly", %{conn: conn} do
|
||||
group = Fixtures.group_fixture(%{description: nil})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# Should not crash, description should be optional
|
||||
assert html =~ group.name
|
||||
end
|
||||
|
||||
test "handles slug with special characters correctly", %{conn: conn} do
|
||||
# Create group with name that generates slug with hyphens
|
||||
group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ "Test-Group-Name" || html =~ group.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "security" do
|
||||
@tag role: :member
|
||||
test "read-only users can view group detail page", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
read_only_user = Fixtures.user_with_role_fixture("read_only")
|
||||
conn = conn_with_password_user(conn, read_only_user)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
# Should NOT see edit/delete buttons
|
||||
refute html =~ gettext("Edit") || html =~ gettext("Delete")
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "unauthenticated users are redirected to login", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
result = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/auth/sign_in"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/auth/sign_in"}}}, result)
|
||||
end
|
||||
|
||||
test "slug injection attempts are prevented", %{conn: conn} do
|
||||
# Try to inject SQL or other malicious content in slug
|
||||
malicious_slug = "'; DROP TABLE groups; --"
|
||||
|
||||
result = live(conn, "/groups/#{malicious_slug}")
|
||||
|
||||
# Should not execute SQL, should return 404 or error
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
|
||||
test "user cannot delete group with non-existent slug", %{conn: conn} do
|
||||
# This test verifies that delete action (if accessed via URL manipulation)
|
||||
# cannot be performed with non-existent slug
|
||||
# Note: Actual delete functionality will be tested when delete modal is implemented
|
||||
non_existent_slug = "non-existent-group-slug"
|
||||
|
||||
# Attempting to access show page with non-existent slug should fail
|
||||
result = live(conn, "/groups/#{non_existent_slug}")
|
||||
|
||||
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
|
||||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "performance" do
|
||||
test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
# Create multiple members
|
||||
members = Enum.map(1..5, fn _ -> Fixtures.member_fixture() end)
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Add all members to group
|
||||
Enum.each(members, fn member ->
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
|
||||
actor: system_actor
|
||||
)
|
||||
end)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
# All members should be displayed
|
||||
Enum.each(members, fn member ->
|
||||
assert html =~ member.first_name || html =~ member.last_name
|
||||
end)
|
||||
end
|
||||
|
||||
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
# Should use index for fast lookup
|
||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert html =~ group.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "navigation" do
|
||||
test "back button navigates to groups list", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element("a[aria-label*='Back'], button[aria-label*='Back']")
|
||||
|> render_click()
|
||||
|
||||
assert to == "/groups"
|
||||
end
|
||||
|
||||
test "edit button navigates to edit form", %{conn: conn} do
|
||||
group = Fixtures.group_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element("a[href*='edit'], button[href*='edit']")
|
||||
|> render_click()
|
||||
|
||||
assert to == "/groups/#{group.slug}/edit"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -262,4 +262,39 @@ defmodule Mv.Fixtures do
|
|||
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a group with default or custom attributes.
|
||||
|
||||
Uses system_actor for authorization to bypass permission checks in tests.
|
||||
|
||||
## Parameters
|
||||
- `attrs` - Map or keyword list of attributes to override defaults
|
||||
|
||||
## Returns
|
||||
- Group struct
|
||||
|
||||
## Examples
|
||||
|
||||
iex> group_fixture()
|
||||
%Mv.Membership.Group{name: "Test Group", slug: "test-group", ...}
|
||||
|
||||
iex> group_fixture(%{name: "Board Members", description: "Board members group"})
|
||||
%Mv.Membership.Group{name: "Board Members", slug: "board-members", ...}
|
||||
|
||||
"""
|
||||
def group_fixture(attrs \\ %{}) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
name: "Test Group #{System.unique_integer([:positive])}",
|
||||
description: "Test description"
|
||||
})
|
||||
|> Mv.Membership.create_group(actor: system_actor)
|
||||
|> case do
|
||||
{:ok, group} -> group
|
||||
{:error, error} -> raise "Failed to create group: #{inspect(error)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue