From a536485b303c0df82d59ee129770896c2f48fa39 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 29 Jan 2026 17:12:43 +0100 Subject: [PATCH] test: add tdd tests for group member add functionality --- .../group_live/show_accessibility_test.exs | 305 ++++++++++++ .../live/group_live/show_add_member_test.exs | 465 ++++++++++++++++++ .../show_add_remove_members_test.exs | 159 ++++++ .../group_live/show_authorization_test.exs | 265 ++++++++++ .../live/group_live/show_integration_test.exs | 427 ++++++++++++++++ .../group_live/show_member_search_test.exs | 339 +++++++++++++ .../group_live/show_remove_member_test.exs | 333 +++++++++++++ 7 files changed, 2293 insertions(+) create mode 100644 test/mv_web/live/group_live/show_accessibility_test.exs create mode 100644 test/mv_web/live/group_live/show_add_member_test.exs create mode 100644 test/mv_web/live/group_live/show_add_remove_members_test.exs create mode 100644 test/mv_web/live/group_live/show_authorization_test.exs create mode 100644 test/mv_web/live/group_live/show_integration_test.exs create mode 100644 test/mv_web/live/group_live/show_member_search_test.exs create mode 100644 test/mv_web/live/group_live/show_remove_member_test.exs diff --git a/test/mv_web/live/group_live/show_accessibility_test.exs b/test/mv_web/live/group_live/show_accessibility_test.exs new file mode 100644 index 0000000..ff8a5dd --- /dev/null +++ b/test/mv_web/live/group_live/show_accessibility_test.exs @@ -0,0 +1,305 @@ +defmodule MvWeb.GroupLive.ShowAccessibilityTest do + @moduledoc """ + Accessibility tests for Add/Remove Member functionality. + Tests ARIA labels, keyboard navigation, and screen reader support. + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + describe "ARIA labels and roles" do + test "modal has role='dialog' with aria-labelledby and aria-describedby", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + html = render(view) + + # Modal should have proper ARIA attributes + assert html =~ ~r/role=["']dialog["']/ || + html =~ ~r/aria-labelledby/ || + html =~ ~r/aria-describedby/ + end + + test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + html = render(view) + + # Search input should have ARIA attributes + assert html =~ ~r/aria-label.*[Ss]earch.*member/ || + html =~ ~r/aria-autocomplete=["']list["']/ + end + + test "remove button has aria-label with tooltip text", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Alice", + last_name: "Smith", + email: "alice@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + html = render(view) + + # Remove button should have aria-label + assert html =~ ~r/aria-label.*[Rr]emove/ || + html =~ ~r/aria-label.*member/i + end + + test "add button has correct aria-label", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + html = render(view) + + # Add button should have aria-label + assert html =~ ~r/aria-label.*[Aa]dd/ || + html =~ ~r/button.*[Aa]dd/ + end + end + + describe "keyboard navigation" do + test "tab navigation works in modal", %{conn: conn} do + # This test verifies that keyboard navigation is possible + # Actual tab order testing would require more complex setup + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + html = render(view) + + # Modal should have focusable elements + assert html =~ ~r/input|button/ || + html =~ "#member-search-input" + end + + test "escape key closes modal", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + assert has_element?(view, "#add-member-modal") || + has_element?(view, "[role='dialog']") + + # Send escape key event (if implemented) + # Note: Implementation should handle phx-window-keydown="escape" or similar + # For now, we verify modal can be closed via Cancel button + view + |> element("button", "Cancel") + |> render_click() + + refute has_element?(view, "#add-member-modal") + end + + test "enter/space activates buttons when focused", %{conn: conn} do + # This test verifies that buttons can be activated via keyboard + # Actual keyboard event testing would require more complex setup + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Bob", + last_name: "Jones", + email: "bob@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Select member + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Bob"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Add button should be enabled and clickable + view + |> element("button", "Add") + |> render_click() + + # Should succeed + html = render(view) + assert html =~ "Bob" || html =~ gettext("Member added successfully") + end + + test "focus management: focus is set to modal when opened", %{conn: conn} do + # This test verifies that focus is properly managed + # When modal opens, focus should move to modal (first focusable element) + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + html = render(view) + + # Modal should be visible and focusable + assert html =~ "#member-search-input" || + html =~ ~r/autofocus|tabindex/ + end + end + + describe "screen reader support" do + test "modal title is properly associated", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + html = render(view) + + # Modal should have title + assert html =~ gettext("Add Member to Group") || + html =~ ~r/ element("button", "Add Member") + |> render_click() + + # Search + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Charlie"}) + + html = render(view) + + # Search results should have proper ARIA attributes + assert html =~ ~r/role=["']listbox["']/ || + html =~ ~r/role=["']option["']/ || + html =~ "Charlie" + end + + test "flash messages are properly announced", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Add member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "David"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + html = render(view) + + # Flash message should have proper ARIA attributes for screen readers + assert html =~ gettext("Member added successfully") || + html =~ ~r/role=["']status["']/ || + html =~ ~r/aria-live/ + end + end +end diff --git a/test/mv_web/live/group_live/show_add_member_test.exs b/test/mv_web/live/group_live/show_add_member_test.exs new file mode 100644 index 0000000..417695d --- /dev/null +++ b/test/mv_web/live/group_live/show_add_member_test.exs @@ -0,0 +1,465 @@ +defmodule MvWeb.GroupLive.ShowAddMemberTest do + @moduledoc """ + Tests for adding members to groups via the Add Member modal. + Tests successful add, error handling, and edge cases. + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + describe "successful add member" do + test "member is added to group after selection and clicking Add", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Search and select member + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Alice"}) + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Click Add button + view + |> element("button", "Add") + |> render_click() + + # Success flash message should be displayed + assert render(view) =~ gettext("Member added successfully") || + render(view) =~ ~r/member.*added.*successfully/i + + # Verify member appears in group list + html = render(view) + assert html =~ "Alice" + assert html =~ "Johnson" + end + + test "success flash message is displayed when member is added", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Bob", + last_name: "Smith", + email: "bob@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal and add member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Bob"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + html = render(view) + + assert html =~ gettext("Member added successfully") || + html =~ ~r/member.*added.*successfully/i + end + + test "group member list updates automatically after add", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }, + actor: system_actor + ) + + {:ok, view, html} = live(conn, "/groups/#{group.slug}") + + # Initially member should NOT be in list + refute html =~ "Charlie" + + # Add member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Charlie"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Member should now appear in list + html = render(view) + assert html =~ "Charlie" + assert html =~ "Brown" + end + + test "member count updates automatically after add", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }, + actor: system_actor + ) + + {:ok, view, html} = live(conn, "/groups/#{group.slug}") + + # Get initial count (should be 0) + initial_count = extract_member_count(html) + + # Add member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "David"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Count should have increased + html = render(view) + new_count = extract_member_count(html) + assert new_count == initial_count + 1 + end + + test "modal closes after successful member addition", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Eve", + last_name: "Davis", + email: "eve@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + assert has_element?(view, "#add-member-modal") || + has_element?(view, "[role='dialog']") + + # Add member + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Eve"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Modal should be closed + refute has_element?(view, "#add-member-modal") + end + end + + describe "error handling" do + test "error flash message when member is already in group", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Frank", + last_name: "Moore", + email: "frank@example.com" + }, + actor: system_actor + ) + + # Add member to group first + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Try to add same member again + view + |> element("button", "Add Member") + |> render_click() + + # Member should not appear in search (filtered out) + # But if they do appear somehow, try to add them + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Frank"}) + + # If member appears in results (shouldn't), try to add + # This tests the server-side duplicate prevention + if has_element?(view, "[data-member-id='#{member.id}']") do + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Should show error + html = render(view) + assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i + end + end + + test "error flash message for other errors", %{conn: conn} do + # This test verifies that error handling works for unexpected errors + # We can't easily simulate all error cases, but we test the error path exists + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Try to add with invalid member ID (if possible) + # This tests error handling path + # Note: Actual implementation will handle this + end + + test "modal remains open on error (user can correct)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Grace", + last_name: "Taylor", + email: "grace@example.com" + }, + actor: system_actor + ) + + # Add member first + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Modal should be open + assert has_element?(view, "#add-member-modal") || + has_element?(view, "[role='dialog']") + + # If error occurs, modal should remain open + # (Implementation will handle this) + end + + test "Add button remains disabled until member selected", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Add button should be disabled + html = render(view) + + assert html =~ ~r/disabled.*Add|Add.*disabled/ || + has_element?(view, "button[disabled]", "Add") + end + end + + describe "edge cases" do + test "add works for group with 0 members", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Henry", + last_name: "Anderson", + email: "henry@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Add member to empty group + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Henry"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Member should be added + html = render(view) + assert html =~ "Henry" + end + + test "add works when member is already in other groups", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group1 = Fixtures.group_fixture() + group2 = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Isabel", + last_name: "Martinez", + email: "isabel@example.com" + }, + actor: system_actor + ) + + # Add member to group1 + Membership.create_member_group(%{member_id: member.id, group_id: group1.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group2.slug}") + + # Add same member to group2 (should work) + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Isabel"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Member should be added to group2 + html = render(view) + assert html =~ "Isabel" + end + end + + # Helper function to extract member count from HTML + defp extract_member_count(html) do + case Regex.run(~r/Total:\s*(\d+)/, html) do + [_, count_str] -> String.to_integer(count_str) + _ -> 0 + end + end +end diff --git a/test/mv_web/live/group_live/show_add_remove_members_test.exs b/test/mv_web/live/group_live/show_add_remove_members_test.exs new file mode 100644 index 0000000..6bc5996 --- /dev/null +++ b/test/mv_web/live/group_live/show_add_remove_members_test.exs @@ -0,0 +1,159 @@ +defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do + @moduledoc """ + UI tests for Add/Remove Member buttons visibility and modal display. + Tests UI rendering and permission-based visibility. + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + describe "Add Member button visibility" do + test "Add Member button is visible for users with :update permission", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + assert html =~ gettext("Add Member") or html =~ "Add Member" + end + + @tag role: :member + test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + refute html =~ gettext("Add Member") + end + + test "Add Member button is positioned above member table", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Button should exist + assert has_element?(view, "button", gettext("Add Member")) || + has_element?(view, "a", gettext("Add Member")) + end + end + + describe "Remove button visibility" do + test "Remove button is visible for each member for users with :update permission", %{ + conn: conn + } do + group = Fixtures.group_fixture() + member = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove button should exist (can be icon button with trash icon) + html = render(view) + + assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or + html =~ ~r/hero-trash|hero-x-mark/ + end + + @tag role: :member + test "Remove button is NOT visible for users without :update permission", %{conn: conn} do + group = Fixtures.group_fixture() + member = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + # Remove button should NOT exist + refute html =~ "Remove" or html =~ "remove" + end + end + + describe "modal display" do + test "modal opens when Add Member button is clicked", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Click Add Member button + view + |> element("button", gettext("Add Member")) + |> render_click() + + # Modal should be visible + assert has_element?(view, "#add-member-modal") || + has_element?(view, "[role='dialog']") + end + + test "modal has correct title: Add Member to Group", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", gettext("Add Member")) + |> render_click() + + html = render(view) + assert html =~ gettext("Add Member to Group") + end + + test "modal has search input with correct placeholder", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", gettext("Add Member")) + |> render_click() + + html = render(view) + + assert html =~ gettext("Search for a member...") || + html =~ ~r/search.*member/i + end + + test "modal has Add button (disabled until member selected)", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", gettext("Add Member")) + |> render_click() + + html = render(view) + # Add button should exist and be disabled initially + assert html =~ gettext("Add") || html =~ "Add" + # Button should be disabled + assert has_element?(view, "button[disabled]", gettext("Add")) || + html =~ ~r/disabled.*Add|Add.*disabled/ + end + + test "modal has Cancel button", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", gettext("Add Member")) + |> render_click() + + html = render(view) + assert html =~ gettext("Cancel") || html =~ "Cancel" + end + end +end diff --git a/test/mv_web/live/group_live/show_authorization_test.exs b/test/mv_web/live/group_live/show_authorization_test.exs new file mode 100644 index 0000000..0869c21 --- /dev/null +++ b/test/mv_web/live/group_live/show_authorization_test.exs @@ -0,0 +1,265 @@ +defmodule MvWeb.GroupLive.ShowAuthorizationTest do + @moduledoc """ + Tests for authorization and security in Add/Remove Member functionality. + Tests server-side authorization checks and UI permission enforcement. + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + describe "server-side authorization" do + test "add member event handler checks :update permission", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Alice", + last_name: "Smith", + email: "alice@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal and try to add member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Alice"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Try to add (should succeed for admin) + view + |> element("button", "Add") + |> render_click() + + # Should succeed (admin has :update permission) + html = render(view) + + assert html =~ gettext("Member added successfully") || + html =~ ~r/member.*added.*successfully/i || + html =~ "Alice" + end + + @tag role: :member + test "unauthorized user cannot add member (server-side check)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Bob", + last_name: "Jones", + email: "bob@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Try to trigger add event directly (even if button is hidden) + # This tests server-side authorization + # Note: If button is hidden, we can't click it, but we test the event handler + # by trying to send the event directly if possible + + # For now, we verify that the button is not visible + html = render(view) + refute html =~ "Add Member" + end + + test "remove member event handler checks :update permission", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove member (should succeed for admin) + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Should succeed + html = render(view) + + assert html =~ gettext("Member removed successfully") || + html =~ ~r/member.*removed.*successfully/i + end + + @tag role: :member + test "unauthorized user cannot remove member (server-side check)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove button should not be visible + html = render(view) + refute html =~ "Remove" || html =~ "remove" + end + + test "error flash message on unauthorized access", %{conn: conn} do + # This test verifies that error messages are shown for unauthorized access + # Implementation will handle this in event handlers + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, _view, _html} = live(conn, "/groups/#{group.slug}") + + # For admin, should not see error + # For non-admin, buttons are hidden (UI-level check) + # Server-side check will show error if event is somehow triggered + end + end + + describe "UI permission checks" do + test "buttons are hidden for unauthorized users", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Eve", + last_name: "Davis", + email: "eve@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + # Admin should see buttons + assert html =~ "Add Member" || html =~ "Remove" + end + + @tag role: :member + test "Add Member button is hidden for read-only users", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + # Read-only user should NOT see Add Member button + refute html =~ "Add Member" + end + + @tag role: :member + test "Remove button is hidden for read-only users", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Frank", + last_name: "Moore", + email: "frank@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + # Read-only user should NOT see Remove button + refute html =~ "Remove" || html =~ "remove" + end + + @tag role: :member + test "modal cannot be opened for unauthorized users", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, _view, html} = live(conn, "/groups/#{group.slug}") + + # Modal should not be accessible (button hidden) + refute html =~ "Add Member" + refute html =~ "#add-member-modal" + end + end + + describe "security edge cases" do + test "slug injection attempts are prevented", %{conn: conn} do + # Try to inject 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 "non-existent member IDs are handled", %{conn: conn} do + group = Fixtures.group_fixture() + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Try to add non-existent member (if possible) + # Implementation should handle this gracefully + # This tests error handling for invalid IDs + end + + test "non-existent group IDs are handled", %{conn: conn} do + # Accessing non-existent group should redirect + non_existent_slug = "non-existent-group-#{System.unique_integer([:positive])}" + + result = live(conn, "/groups/#{non_existent_slug}") + + 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/show_integration_test.exs b/test/mv_web/live/group_live/show_integration_test.exs new file mode 100644 index 0000000..9509f2a --- /dev/null +++ b/test/mv_web/live/group_live/show_integration_test.exs @@ -0,0 +1,427 @@ +defmodule MvWeb.GroupLive.ShowIntegrationTest do + @moduledoc """ + Integration tests for Add/Remove Member functionality. + Tests data consistency, database operations, and multiple operations. + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + describe "data consistency" do + test "member appears in group after add (verified in database)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Alice", + last_name: "Smith", + email: "alice@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Add member via UI + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Alice"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Verify in database + require Ash.Query + + query = + Mv.Membership.Group + |> Ash.Query.filter(slug == ^group.slug) + |> Ash.Query.load([:members]) + + {:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership) + + # Member should be in group + assert Enum.any?(updated_group.members, &(&1.id == member.id)) + end + + test "member disappears from group after remove (verified in database)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Bob", + last_name: "Jones", + email: "bob@example.com" + }, + actor: system_actor + ) + + # Add member to group + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove member via UI + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Verify in database + require Ash.Query + + query = + Mv.Membership.Group + |> Ash.Query.filter(slug == ^group.slug) + |> Ash.Query.load([:members]) + + {:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership) + + # Member should NOT be in group + refute Enum.any?(updated_group.members, &(&1.id == member.id)) + end + + test "MemberGroup association is created correctly", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Add member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Charlie"}) + + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Verify MemberGroup association exists + require Ash.Query + + {:ok, member_groups} = + Ash.read( + Mv.Membership.MemberGroup + |> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id), + actor: system_actor, + domain: Mv.Membership + ) + + assert length(member_groups) == 1 + assert hd(member_groups).member_id == member.id + assert hd(member_groups).group_id == group.id + end + + test "MemberGroup association is deleted correctly", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }, + actor: system_actor + ) + + # Add member first + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Verify MemberGroup association is deleted + require Ash.Query + + {:ok, member_groups} = + Ash.read( + Mv.Membership.MemberGroup + |> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id), + actor: system_actor, + domain: Mv.Membership + ) + + assert member_groups == [] + end + + test "member itself is NOT deleted (only association)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Eve", + last_name: "Davis", + email: "eve@example.com" + }, + actor: system_actor + ) + + # Add member to group + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove member from group + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Verify member still exists + {:ok, member_after_remove} = + Ash.get(Mv.Membership.Member, member.id, actor: system_actor) + + assert member_after_remove.id == member.id + assert member_after_remove.first_name == "Eve" + end + end + + describe "multiple operations" do + test "multiple members can be added sequentially", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member1} = + Membership.create_member( + %{ + first_name: "Frank", + last_name: "Moore", + email: "frank@example.com" + }, + actor: system_actor + ) + + {:ok, member2} = + Membership.create_member( + %{ + first_name: "Grace", + last_name: "Taylor", + email: "grace@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Add first member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Frank"}) + + view + |> element("[data-member-id='#{member1.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Add second member + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Grace"}) + + view + |> element("[data-member-id='#{member2.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Both members should be in list + html = render(view) + assert html =~ "Frank" + assert html =~ "Grace" + end + + test "multiple members can be removed sequentially", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member1} = + Membership.create_member( + %{ + first_name: "Henry", + last_name: "Anderson", + email: "henry@example.com" + }, + actor: system_actor + ) + + {:ok, member2} = + Membership.create_member( + %{ + first_name: "Isabel", + last_name: "Martinez", + email: "isabel@example.com" + }, + actor: system_actor + ) + + # Add both members + 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}") + + # Both should be in list initially + assert html =~ "Henry" + assert html =~ "Isabel" + + # Remove first member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Remove second member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Both should be removed + html = render(view) + refute html =~ "Henry" + refute html =~ "Isabel" + end + + test "add and remove can be mixed", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member1} = + Membership.create_member( + %{ + first_name: "Jack", + last_name: "White", + email: "jack@example.com" + }, + actor: system_actor + ) + + {:ok, member2} = + Membership.create_member( + %{ + first_name: "Kate", + last_name: "Black", + email: "kate@example.com" + }, + actor: system_actor + ) + + # Add member1 first + Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Add member2 + view + |> element("button", "Add Member") + |> render_click() + + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Kate"}) + + view + |> element("[data-member-id='#{member2.id}']") + |> render_click() + + view + |> element("button", "Add") + |> render_click() + + # Remove member1 + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Only member2 should remain + html = render(view) + refute html =~ "Jack" + assert html =~ "Kate" + end + end +end diff --git a/test/mv_web/live/group_live/show_member_search_test.exs b/test/mv_web/live/group_live/show_member_search_test.exs new file mode 100644 index 0000000..e6c8712 --- /dev/null +++ b/test/mv_web/live/group_live/show_member_search_test.exs @@ -0,0 +1,339 @@ +defmodule MvWeb.GroupLive.ShowMemberSearchTest do + @moduledoc """ + UI tests for member search functionality in Add Member modal. + Tests search behavior and filtering of members already in group. + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + # Helper to setup authenticated connection for admin + defp setup_admin_conn(conn) do + conn_with_oidc_user(conn, %{email: "admin@example.com"}) + end + + describe "search functionality" do + test "search finds member by exact name", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Type exact name + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Jonathan"}) + + html = render(view) + + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "search finds member by partial name (fuzzy)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + {:ok, _member} = + Membership.create_member( + %{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Type partial name + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Jon"}) + + html = render(view) + + # Fuzzy search should find Jonathan + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "search finds member by email", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Alice", + last_name: "Johnson", + email: "alice.johnson@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Search by email + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "alice.johnson"}) + + html = render(view) + + assert html =~ "Alice" + assert html =~ "Johnson" + assert html =~ "alice.johnson@example.com" + end + + test "dropdown shows member name and email", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Bob", + last_name: "Williams", + email: "bob@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Focus and search + view + |> element("#member-search-input") + |> render_focus() + + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Bob"}) + + html = render(view) + + assert html =~ "Bob" + assert html =~ "Williams" + assert html =~ "bob@example.com" + end + + test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + {:ok, _member} = + Membership.create_member( + %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Dropdown should be visible + assert html =~ ~r/role="listbox"/ || html =~ "listbox" + end + end + + describe "filtering members already in group" do + test "members already in group are NOT shown in search results", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + # Create member and add to group + {:ok, member_in_group} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Miller", + email: "david@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member_in_group.id, group_id: group.id}, + actor: system_actor + ) + + # Create another member NOT in group + {:ok, member_not_in_group} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Anderson", + email: "david.anderson@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Search for "David" + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "David"}) + + html = render(view) + + # Should show David Anderson (not in group) + assert html =~ "Anderson" + # Should NOT show David Miller (already in group) + refute html =~ "Miller" + end + + test "search filters correctly when group has many members", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + # Add multiple members to group + Enum.each(1..5, fn i -> + {:ok, member} = + Membership.create_member( + %{ + first_name: "Member#{i}", + last_name: "InGroup", + email: "member#{i}@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + end) + + # Create member NOT in group + {:ok, member_not_in_group} = + Membership.create_member( + %{ + first_name: "Available", + last_name: "Member", + email: "available@example.com" + }, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Search + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Available"}) + + html = render(view) + + # Should show available member + assert html =~ "Available" + assert html =~ "Member" + # Should NOT show any of the members already in group + refute html =~ "Member1" + refute html =~ "Member2" + end + + test "search shows no results when all available members are already in group", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = setup_admin_conn(conn) + group = Fixtures.group_fixture() + + # Create and add all members to group + {:ok, member} = + Membership.create_member( + %{ + first_name: "Only", + last_name: "Member", + email: "only@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Open modal + view + |> element("button", "Add Member") + |> render_click() + + # Search + view + |> element("#member-search-input") + |> render_change(%{"member_search" => "Only"}) + + html = render(view) + + # Should show no results or empty state + refute html =~ "Only" || html =~ gettext("No members found") || + html =~ ~r/no.*results/i + end + end +end diff --git a/test/mv_web/live/group_live/show_remove_member_test.exs b/test/mv_web/live/group_live/show_remove_member_test.exs new file mode 100644 index 0000000..52c9dc8 --- /dev/null +++ b/test/mv_web/live/group_live/show_remove_member_test.exs @@ -0,0 +1,333 @@ +defmodule MvWeb.GroupLive.ShowRemoveMemberTest do + @moduledoc """ + Tests for removing members from groups via the Remove button. + Tests successful remove, edge cases, and immediate removal (no confirmation). + """ + + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership + alias Mv.Fixtures + + describe "successful remove member" do + test "member is removed from group after clicking Remove", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Alice", + last_name: "Smith", + email: "alice@example.com" + }, + actor: system_actor + ) + + # Add member to group + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, html} = live(conn, "/groups/#{group.slug}") + + # Member should be in list initially + assert html =~ "Alice" + + # Click Remove button + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Success flash message should be displayed + html = render(view) + + assert html =~ gettext("Member removed successfully") || + html =~ ~r/member.*removed.*successfully/i + + # Member should no longer be in list + refute html =~ "Alice" + end + + test "success flash message is displayed when member is removed", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Bob", + last_name: "Jones", + email: "bob@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + html = render(view) + + assert html =~ gettext("Member removed successfully") || + html =~ ~r/member.*removed.*successfully/i + end + + test "group member list updates automatically after remove", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, html} = live(conn, "/groups/#{group.slug}") + + # Member should be in list initially + assert html =~ "Charlie" + + # Remove member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Member should no longer be in list + html = render(view) + refute html =~ "Charlie" + end + + test "member count updates automatically after remove", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member1} = + Membership.create_member( + %{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }, + actor: system_actor + ) + + {:ok, member2} = + Membership.create_member( + %{ + first_name: "Eve", + last_name: "Davis", + email: "eve@example.com" + }, + actor: system_actor + ) + + # Add both members + 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}") + + # Get initial count (should be 2) + initial_count = extract_member_count(html) + assert initial_count >= 2 + + # Remove one member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Count should have decreased + html = render(view) + new_count = extract_member_count(html) + assert new_count == initial_count - 1 + end + + test "no confirmation dialog appears (immediate removal)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Frank", + last_name: "Moore", + email: "frank@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Click Remove - should remove immediately without confirmation + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # No confirmation modal should appear + refute has_element?(view, "[role='dialog']") || + has_element?(view, "#confirm-remove-modal") + + # Member should be removed + html = render(view) + refute html =~ "Frank" + end + end + + describe "edge cases" do + test "remove works for last member in group (group becomes empty)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Grace", + last_name: "Taylor", + email: "grace@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, html} = live(conn, "/groups/#{group.slug}") + + # Member should be in list + assert html =~ "Grace" + + # Remove last member + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Group should show empty state + html = render(view) + + assert html =~ gettext("No members in this group") || + html =~ ~r/no.*members/i + + # Count should be 0 + count = extract_member_count(html) + assert count == 0 + end + + test "remove works when member is in multiple groups (only this group affected)", %{ + conn: conn + } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group1 = Fixtures.group_fixture() + group2 = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Henry", + last_name: "Anderson", + email: "henry@example.com" + }, + actor: system_actor + ) + + # Add member to both groups + Membership.create_member_group(%{member_id: member.id, group_id: group1.id}, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group2.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group1.slug}") + + # Remove from group1 + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Member should be removed from group1 + html = render(view) + refute html =~ "Henry" + + # Verify member is still in group2 + {:ok, view2, html2} = live(conn, "/groups/#{group2.slug}") + assert html2 =~ "Henry" + end + + test "remove is idempotent (no error if member already removed)", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + group = Fixtures.group_fixture() + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Isabel", + last_name: "Martinez", + email: "isabel@example.com" + }, + actor: system_actor + ) + + Membership.create_member_group(%{member_id: member.id, group_id: group.id}, + actor: system_actor + ) + + {:ok, view, _html} = live(conn, "/groups/#{group.slug}") + + # Remove member first time + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Try to remove again (should not error, just be idempotent) + # Note: Implementation should handle this gracefully + # If button is still visible somehow, try to click again + html = render(view) + + if html =~ "Isabel" do + view + |> element("button[phx-click='remove_member']", "") + |> render_click() + + # Should not crash + assert render(view) + end + end + end + + # Helper function to extract member count from HTML + defp extract_member_count(html) do + case Regex.run(~r/Total:\s*(\d+)/, html) do + [_, count_str] -> String.to_integer(count_str) + _ -> 0 + end + end +end