Merge branch 'main' into feature/286_export_pdf
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
carla 2026-02-13 17:40:05 +01:00
commit 22458cd52b
34 changed files with 4931 additions and 76 deletions

227
test/mv/statistics_test.exs Normal file
View file

@ -0,0 +1,227 @@
defmodule Mv.StatisticsTest do
@moduledoc """
Tests for Mv.Statistics module (member and membership fee cycle statistics).
"""
use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr
alias Mv.Membership.Member
alias Mv.Statistics
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: actor}
end
defp create_fee_type(actor, attrs) do
MembershipFeeType
|> Ash.Changeset.for_create(
:create,
Map.merge(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
},
attrs
)
)
|> Ash.create!(actor: actor)
end
describe "first_join_year/1" do
test "returns the year of the earliest join_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
assert Statistics.first_join_year(actor: actor) == 2019
end
test "returns the only member's join year when one member exists", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2021-06-01]})
assert Statistics.first_join_year(actor: actor) == 2021
end
test "returns nil when no members exist", %{actor: actor} do
# Guarantee empty member table so the assertion is deterministic
Member
|> Ash.read!(actor: actor)
|> Enum.each(&Ash.destroy!(&1, actor: actor))
result = Statistics.first_join_year(actor: actor)
assert is_nil(result)
end
end
describe "active_member_count/1" do
test "returns 0 when there are no members", %{actor: actor} do
assert Statistics.active_member_count(actor: actor) == 0
end
test "returns 1 when one member has no exit_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15]})
assert Statistics.active_member_count(actor: actor) == 1
end
test "returns 0 for that member when exit_date is set", %{actor: actor} do
_member =
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15], exit_date: ~D[2024-06-01]})
assert Statistics.active_member_count(actor: actor) == 0
end
test "counts only active members when mix of active and inactive", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01], exit_date: ~D[2024-01-01]})
assert Statistics.active_member_count(actor: actor) == 1
end
end
describe "inactive_member_count/1" do
test "returns 0 when all members are active", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
assert Statistics.inactive_member_count(actor: actor) == 0
end
test "returns 1 when one member has exit_date set", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01], exit_date: ~D[2024-06-01]})
assert Statistics.inactive_member_count(actor: actor) == 1
end
end
describe "joins_by_year/2" do
test "returns 0 for year with no joins", %{actor: actor} do
assert Statistics.joins_by_year(1999, actor: actor) == 0
end
test "returns 1 when one member has join_date in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-06-15]})
assert Statistics.joins_by_year(2023, actor: actor) == 1
end
test "returns 2 when two members joined in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-12-31]})
assert Statistics.joins_by_year(2023, actor: actor) == 2
end
end
describe "exits_by_year/2" do
test "returns 0 for year with no exits", %{actor: actor} do
assert Statistics.exits_by_year(1999, actor: actor) == 0
end
test "returns 1 when one member has exit_date in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2020-01-01], exit_date: ~D[2023-06-15]})
assert Statistics.exits_by_year(2023, actor: actor) == 1
end
end
describe "cycle_totals_by_year/2" do
test "returns zero totals for year with no cycles", %{actor: actor} do
result = Statistics.cycle_totals_by_year(1999, actor: actor)
assert result.total == Decimal.new(0)
assert result.paid == Decimal.new(0)
assert result.unpaid == Decimal.new(0)
assert result.suspended == Decimal.new(0)
end
test "returns totals by status for cycles in that year", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
# Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles.
_member1 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
_member2 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
# Get 2024 cycles and set status (each member has one 2024 yearly cycle from generator)
cycles_2024 =
MembershipFeeCycle
|> Ash.Query.filter(
expr(cycle_start >= ^~D[2024-01-01] and cycle_start < ^~D[2025-01-01])
)
|> Ash.read!(actor: actor)
|> Enum.sort_by(& &1.member_id)
[c1, c2] = cycles_2024
assert {:ok, _} = Ash.update(c1, %{status: :paid}, domain: MembershipFees, actor: actor)
assert {:ok, _} =
Ash.update(c2, %{status: :suspended}, domain: MembershipFees, actor: actor)
result = Statistics.cycle_totals_by_year(2024, actor: actor)
assert Decimal.equal?(result.total, Decimal.new("100.00"))
assert Decimal.equal?(result.paid, Decimal.new("50.00"))
assert Decimal.equal?(result.unpaid, Decimal.new(0))
assert Decimal.equal?(result.suspended, Decimal.new("50.00"))
end
test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{
actor: actor
} do
fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")})
fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")})
_m1 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type_a.id
})
_m2 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type_b.id
})
# Without filter: both fee types' cycles (2024)
all_result = Statistics.cycle_totals_by_year(2024, actor: actor)
assert Decimal.equal?(all_result.total, Decimal.new("100.00"))
# With fee_type_id as string (as from form/URL): only that type's cycles
opts_a = [actor: actor, fee_type_id: to_string(fee_type_a.id)]
result_a = Statistics.cycle_totals_by_year(2024, opts_a)
assert Decimal.equal?(result_a.total, Decimal.new("30.00"))
opts_b = [actor: actor, fee_type_id: to_string(fee_type_b.id)]
result_b = Statistics.cycle_totals_by_year(2024, opts_b)
assert Decimal.equal?(result_b.total, Decimal.new("70.00"))
end
end
describe "open_amount_total/1" do
test "returns 0 when there are no unpaid cycles", %{actor: actor} do
assert Statistics.open_amount_total(actor: actor) == Decimal.new(0)
end
test "returns sum of amount for all unpaid cycles", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
_member =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
# Cycle generator creates yearly cycles (2020..today), all unpaid by default
unpaid_sum = Statistics.open_amount_total(actor: actor)
assert Decimal.compare(unpaid_sum, Decimal.new(0)) == :gt
# Should be 50 * number of years from 2020 to current year
current_year = Date.utc_today().year
expected_count = current_year - 2020 + 1
assert Decimal.equal?(unpaid_sum, Decimal.new(50 * expected_count))
end
end
end

View file

@ -25,12 +25,13 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with admin user" do
test "shows Members, Fee Types and Administration with all subitems" do
test "shows Members, Fee Types, Statistics and Administration with all subitems" do
user = Fixtures.user_with_role_fixture("admin")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/membership_fee_types")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(data-testid="sidebar-administration")
assert html =~ ~s(href="/users")
assert html =~ ~s(href="/groups")
@ -41,11 +42,12 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
test "shows Members and Groups (from Administration)" do
test "shows Members, Statistics and Groups (from Administration)" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(href="/groups")
end
@ -61,11 +63,12 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with normal_user (Kassenwart)" do
test "shows Members and Groups" do
test "shows Members, Statistics and Groups" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(href="/groups")
end
@ -88,10 +91,11 @@ defmodule MvWeb.SidebarAuthorizationTest do
refute html =~ ~s(href="/members")
end
test "does not show Fee Types or Administration" do
test "does not show Statistics, Fee Types or Administration" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/statistics")
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(data-testid="sidebar-administration")

View file

@ -0,0 +1,301 @@
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 "search input has proper ARIA attributes", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Search input should have proper ARIA attributes
assert html =~ ~r/aria-label/ ||
html =~ ~r/aria-autocomplete/ ||
html =~ ~r/role=["']combobox["']/
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 inline input
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 inline input
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 inline add member area", %{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 inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Inline add member area should have focusable elements
assert html =~ ~r/input|button/ ||
html =~ "#member-search-input"
end
test "inline input can be closed", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
assert has_element?(view, "#member-search-input")
# Click Add Member button again to close (or add a member to close it)
# For now, we verify the input is visible when opened
html = render(view)
assert html =~ "#member-search-input" || has_element?(view, "#member-search-input")
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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Select member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Add button should be enabled and clickable
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Should succeed (member should appear in list)
html = render(view)
assert html =~ "Bob"
end
test "focus management: focus is set to input when opened", %{conn: conn} do
# This test verifies that focus is properly managed
# When inline input opens, focus should move to input field
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end
end
describe "screen reader support" do
test "search input has proper label for screen readers", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Input should have aria-label
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-label/
end
test "search results 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: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> 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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
html = render(view)
# Member should appear in list (no flash message)
assert html =~ "David"
end
end
end

View file

@ -0,0 +1,460 @@
defmodule MvWeb.GroupLive.ShowAddMemberTest do
@moduledoc """
Tests for adding members to groups via the inline Add Member combobox.
Tests successful add, error handling, and edge cases.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import MvWeb.GroupLiveHelpers
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_add_member(view)
search_member(view, "Alice")
select_member(view, member)
add_selected(view)
html = render(view)
assert html =~ "Alice"
assert html =~ "Johnson"
end
test "member is successfully added to group (verified in list)", %{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 inline input and add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
html = render(view)
# Verify member appears in group list (no success flash message)
assert html =~ "Bob"
assert html =~ "Smith"
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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> 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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Count should have increased
html = render(view)
new_count = extract_member_count(html)
assert new_count == initial_count + 1
end
test "inline add member area 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 inline input
view
|> element("button", "Add Member")
|> render_click()
assert has_element?(view, "#member-search-input")
# Add member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Eve"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Inline input should be closed (Add Member button should be visible again)
refute has_element?(view, "#member-search-input")
end
test "Cancel button closes inline add member area without adding", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
open_add_member(view)
assert has_element?(view, "#member-search-input")
assert has_element?(view, "button[phx-click='hide_add_member_input']")
cancel_add_member(view)
refute has_element?(view, "#member-search-input")
assert has_element?(view, "button", "Add Member")
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
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> 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 inline input
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 "inline input 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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Inline input should be open
assert has_element?(view, "#member-search-input")
# If error occurs, inline input 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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Add button should be disabled
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Henry"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> 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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Isabel"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> 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

View file

@ -0,0 +1,135 @@
defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
@moduledoc """
UI tests for Add/Remove Member buttons visibility and inline add member 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
@tag role: :read_only
test "read_only user can access group show page (page permission)", %{conn: conn} do
group = Fixtures.group_fixture()
conn = get(conn, "/groups/#{group.slug}")
assert conn.status == 200
end
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: :read_only
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: :read_only
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 (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
end
describe "inline add member input" do
test "inline input appears 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()
# Inline input should be visible
assert has_element?(view, "#member-search-input")
end
test "search input has correct placeholder", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
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 "Add button (plus icon) is disabled until member selected", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", gettext("Add Member"))
|> render_click()
html = render(view)
# Add button should exist and be disabled initially
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") ||
html =~ ~r/disabled/
end
end
end

View file

@ -0,0 +1,285 @@
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 inline input and try to add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Try to add (should succeed for admin)
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Should succeed (admin has :update permission, member should appear in list)
html = render(view)
assert html =~ "Alice"
end
@tag role: :read_only
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'][phx-value-member_id='#{member.id}']")
|> render_click()
# Should succeed (member should no longer be in list)
html = render(view)
refute html =~ "Charlie"
end
@tag role: :read_only
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)
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
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: :read_only
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: :read_only
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 (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
@tag role: :read_only
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Inline input should not be accessible (button hidden)
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end
end
describe "member (own_data) page access" do
# Members have no page permission for /groups or /groups/:slug; they are redirected.
# This tests that limited access for the member role is enforced.
@tag role: :member
test "member is redirected when accessing group show page", %{conn: conn} do
group = Fixtures.group_fixture()
result = live(conn, "/groups/#{group.slug}")
assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result
assert path =~ ~r|^/users/[^/]+$|
end
@tag role: :member
test "member is redirected when accessing groups index", %{conn: conn} do
result = live(conn, "/groups")
assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result
assert path =~ ~r|^/users/[^/]+$|
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
@tag :skip
test "non-existent member IDs are handled", %{conn: conn} do
# Future: test add_selected_members with invalid ID (would require pushing event with forged selected_member_ids)
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "button", "Add Member")
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

View file

@ -0,0 +1,432 @@
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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> 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'][phx-value-member_id='#{member.id}']")
|> 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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> 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'][phx-value-member_id='#{member.id}']")
|> 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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Frank"})
view
|> element("[data-member-id='#{member1.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Add second member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Grace"})
view
|> element("[data-member-id='#{member2.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> 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'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Remove second member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member2.id}']")
|> 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()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Kate"})
view
|> element("[data-member-id='#{member2.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Remove member1
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Only member2 should remain
html = render(view)
refute html =~ "Jack"
assert html =~ "Kate"
end
end
end

View file

@ -0,0 +1,339 @@
defmodule MvWeb.GroupLive.ShowMemberSearchTest do
@moduledoc """
UI tests for member search functionality in inline Add Member combobox.
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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Type exact name
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> 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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Type partial name
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> 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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Search by email
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> 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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Focus and search
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> 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 inline input
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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Search for "David"
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
# Assert only on dropdown (available members), not the members table
dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Anderson"
refute dropdown_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, m} =
Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "InGroup",
email: "member#{i}@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: m.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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Available"})
# Assert only on dropdown (available members), not the members table
dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Available"
assert dropdown_html =~ "Member"
refute dropdown_html =~ "Member1"
refute dropdown_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 inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Only"})
# When no available members, dropdown is not rendered (length(@available_members) == 0)
refute has_element?(view, "#member-dropdown")
end
end
end

View file

@ -0,0 +1,334 @@
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'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should no longer be in list (no success flash message)
html = render(view)
refute html =~ "Alice"
end
test "member is successfully removed from group (verified in list)", %{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}")
# Member should be in list initially
assert html =~ "Bob"
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
html = render(view)
# Member should no longer be in list (no success flash message)
refute html =~ "Bob"
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'][phx-value-member_id='#{member.id}']")
|> 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 (need to get member_id from HTML or use first available)
# For this test, we'll remove the first member
_html_before = render(view)
# Extract first member ID from the rendered HTML or use a different approach
# Since we have member1 and member2, we can target member1 specifically
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> 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'][phx-value-member_id='#{member.id}']")
|> render_click()
# No confirmation dialog should appear (immediate removal)
# This is verified by the member being removed without any dialog
# 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'][phx-value-member_id='#{member.id}']")
|> 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'][phx-value-member_id='#{member.id}']")
|> 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'][phx-value-member_id='#{member.id}']")
|> 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'][phx-value-member_id='#{member.id}']")
|> 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

View file

@ -0,0 +1,78 @@
defmodule MvWeb.StatisticsLiveTest do
@moduledoc """
Tests for the Statistics LiveView at /statistics.
Uses explicit auth: conn is authenticated with a role that has access to
the statistics page (read_only by default; override with @tag :role).
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Mv.MembershipFees.MembershipFeeType
describe "statistics page" do
@describetag role: :read_only
test "renders statistics page with title and key labels for authenticated user with access",
%{
conn: conn
} do
{:ok, _view, html} = live(conn, ~p"/statistics")
assert html =~ "Statistics"
assert html =~ "Active members"
assert html =~ "Unpaid"
assert html =~ "Contributions by year"
assert html =~ "Member numbers by year"
end
test "page shows overview of all relevant years without year selector", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/statistics")
# No year dropdown: single select for year should not be present as main control
assert html =~ "Overview" or html =~ "overview"
# table header or legend
assert html =~ "Year"
end
test "fee_type_id in URL updates selected filter and contributions", %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
fee_type =
case List.first(fee_types) do
nil ->
MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create!(actor: actor)
ft ->
ft
end
path = ~p"/statistics" <> "?" <> URI.encode_query(%{"fee_type_id" => fee_type.id})
{:ok, view, html} = live(conn, path)
assert view |> element("select#fee-type-filter") |> has_element?()
assert html =~ fee_type.name
assert html =~ "Contributions by year"
end
end
describe "statistics page with own_data role" do
@describetag role: :member
test "redirects when user has only own_data (no access to statistics page)", %{conn: conn} do
# member role uses own_data permission set; /statistics is not in own_data pages
conn = get(conn, ~p"/statistics")
assert redirected_to(conn) != ~p"/statistics"
end
end
end

View file

@ -107,6 +107,37 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
end
describe "statistics route /statistics" do
test "read_only can access /statistics" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "normal_user can access /statistics" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "admin can access /statistics" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "own_data cannot access /statistics" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "read_only and normal_user denied on admin routes" do
test "read_only cannot access /admin/roles" do
user = Fixtures.user_with_role_fixture("read_only")

View file

@ -178,6 +178,7 @@ defmodule MvWeb.ConnCase do
:read_only ->
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
read_only_user = Mv.Authorization.Actor.ensure_loaded(read_only_user)
authenticated_conn = conn_with_password_user(conn, read_only_user)
{authenticated_conn, read_only_user}

View file

@ -0,0 +1,59 @@
defmodule MvWeb.GroupLiveHelpers do
@moduledoc """
Helpers for Group LiveView tests (e.g. group show add/remove member flow).
Use these to reduce duplication in tests that open the add member area,
search, select, and add members.
"""
import Phoenix.LiveViewTest
@doc """
Opens the inline add member area by clicking "Add Member".
"""
def open_add_member(view) do
view
|> element("button", "Add Member")
|> render_click()
end
@doc """
Triggers member search by focusing the input and sending a form change with the given query.
"""
def search_member(view, query) do
view
|> element("#member-search-input")
|> render_focus()
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => query})
end
@doc """
Clicks the option for the given member in the dropdown (by data-member-id).
"""
def select_member(view, member) do
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
end
@doc """
Clicks the "Add" button (add_selected_members).
"""
def add_selected(view) do
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
end
@doc """
Clicks the "Cancel" button to close the inline add member area.
"""
def cancel_add_member(view) do
view
|> element("button[phx-click='hide_add_member_input']")
|> render_click()
end
end