test: add tdd tests for groups administration #372
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-27 18:24:42 +01:00
parent 0a2aa3bad0
commit f05fae3ea3
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
6 changed files with 962 additions and 4 deletions

View file

@ -304,9 +304,9 @@ lib/
- Add/remove groups inline - Add/remove groups inline
- Link to group detail page - 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:** **Features:**
- Display group name and description - Display group name and description
@ -315,7 +315,7 @@ lib/
- Edit group button - Edit group button
- Delete group button (with confirmation) - 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 ### 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 - Automatically generated from the `name` attribute on create
- Immutable after creation (don't change when name is updated) - Immutable after creation (don't change when name is updated)
- Unique and URL-friendly - 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. The implementation reuses the existing `GenerateSlug` change from CustomFields, ensuring consistency across the codebase.

View 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

View 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

View 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

View 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

View file

@ -262,4 +262,39 @@ defmodule Mv.Fixtures do
{:error, error} -> raise "Failed to create member: #{inspect(error)}" {:error, error} -> raise "Failed to create member: #{inspect(error)}"
end end
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 end