From f05fae3ea31fa2d34e1f884f87a00d622764a14d Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 27 Jan 2026 18:24:42 +0100 Subject: [PATCH] test: add tdd tests for groups administration #372 --- docs/groups-architecture.md | 8 +- test/mv_web/live/group_live/form_test.exs | 285 ++++++++++++++++++ test/mv_web/live/group_live/index_test.exs | 148 +++++++++ .../live/group_live/integration_test.exs | 209 +++++++++++++ test/mv_web/live/group_live/show_test.exs | 281 +++++++++++++++++ test/support/fixtures.ex | 35 +++ 6 files changed, 962 insertions(+), 4 deletions(-) create mode 100644 test/mv_web/live/group_live/form_test.exs create mode 100644 test/mv_web/live/group_live/index_test.exs create mode 100644 test/mv_web/live/group_live/integration_test.exs create mode 100644 test/mv_web/live/group_live/show_test.exs diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 023df5b..860d308 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -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. diff --git a/test/mv_web/live/group_live/form_test.exs b/test/mv_web/live/group_live/form_test.exs new file mode 100644 index 0000000..ceca883 --- /dev/null +++ b/test/mv_web/live/group_live/form_test.exs @@ -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 diff --git a/test/mv_web/live/group_live/index_test.exs b/test/mv_web/live/group_live/index_test.exs new file mode 100644 index 0000000..c2c164f --- /dev/null +++ b/test/mv_web/live/group_live/index_test.exs @@ -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 diff --git a/test/mv_web/live/group_live/integration_test.exs b/test/mv_web/live/group_live/integration_test.exs new file mode 100644 index 0000000..4d58be1 --- /dev/null +++ b/test/mv_web/live/group_live/integration_test.exs @@ -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 diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs new file mode 100644 index 0000000..bf94e85 --- /dev/null +++ b/test/mv_web/live/group_live/show_test.exs @@ -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 diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 23d4aa7..fd4166d 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -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