test: add tdd tests for group integration in member view #373
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-29 16:43:05 +01:00
parent d7f6d1c03c
commit f0750aed9d
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
7 changed files with 1600 additions and 0 deletions

View file

@ -0,0 +1,189 @@
defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
@moduledoc """
Tests for accessibility of groups feature in the member overview.
Tests cover:
- Badges have role="status" and aria-label
- Filter dropdown has aria-label
- Sort header has aria-label for screen reader
- Keyboard navigation works (Tab through filter, sort header)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
%{
member1: member1,
group1: group1
}
end
@tag :ui
test "group badges have role and aria-label", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify badges have accessibility attributes
# Badges should have role="status" and aria-label describing the group
assert html =~ ~r/role=["']status["']/ or html =~ ~r/aria-label=.*#{group1.name}/
assert html =~ group1.name
# Verify member1's row contains the badge
assert html =~ member1.first_name
end
@tag :ui
test "filter dropdown has aria-label", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify filter dropdown has aria-label
assert html =~ ~r/select.*name=["']group_filter["'].*aria-label=/ or
html =~ ~r/aria-label=.*[Gg]roup/
# Verify dropdown is present
assert has_element?(view, "select[name='group_filter']")
end
@tag :ui
test "sort header has aria-label for screen reader", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify sort header has aria-label
# Sort header should have aria-label describing the sort state
assert html =~ ~r/aria-label=.*[Gg]roup/ or
has_element?(view, "[data-testid='sort_groups'][aria-label]")
end
@tag :ui
test "keyboard navigation works for filter dropdown", %{
conn: conn,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify dropdown is keyboard accessible
# Tab should focus the dropdown
# Arrow keys should navigate options
# Enter should select option
assert has_element?(view, "select[name='group_filter']")
# Test that dropdown can be focused and changed via keyboard
# (This is a basic accessibility check - actual keyboard testing would require browser automation)
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Verify change was applied
html = render(view)
assert html
end
@tag :ui
test "keyboard navigation works for sort header", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify sort header is keyboard accessible
# Tab should focus the sort header
# Enter/Space should activate sorting
assert has_element?(view, "[data-testid='sort_groups']")
# Test that sort header can be activated via click (simulating keyboard)
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify sort was applied
assert_patch(view, "/members?query=&sort_field=groups&sort_order=asc")
end
@tag :ui
test "screen reader announcements for filter changes", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Verify filter change is announced (via aria-live region or similar)
html = render(view)
# Should show filtered results
assert html =~ member1.first_name
# Verify member count or filter status is announced
# (Implementation might use aria-live="polite" for announcements)
assert html
end
@tag :ui
test "multiple badges are announced correctly", %{
conn: conn,
member1: member1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create multiple groups for member1
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify multiple badges are present
assert html =~ member1.first_name
# Both groups should be visible
# Screen reader should be able to distinguish between multiple badges
assert html
end
end

View file

@ -0,0 +1,213 @@
defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
@moduledoc """
Tests for displaying groups in the member overview.
Tests cover:
- Group badges are displayed for members in groups
- Multiple badges are shown for members in multiple groups
- No badge is shown for members without groups
- Badge shows group name correctly
- Members without groups show empty cell or "-"
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
{:ok, group3} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Volunteers"})
|> Ash.create(actor: system_actor)
# Create member-group associations
# member1 is in group1 and group2
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
{:ok, _mg2} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id})
|> Ash.create(actor: system_actor)
# member2 is in group1 only
{:ok, _mg3} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
# member3 has no groups
%{
member1: member1,
member2: member2,
member3: member3,
group1: group1,
group2: group2,
group3: group3
}
end
test "displays group badges for members in groups", %{
conn: conn,
member1: _member1,
group1: group1,
group2: group2
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that group names appear in the HTML
assert html =~ group1.name
assert html =~ group2.name
end
test "displays multiple badges for members in multiple groups", %{
conn: conn,
member1: member1,
group1: group1,
group2: group2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Check that both group names appear for member1
# Find the row for member1 and verify both groups are shown
assert html =~ member1.first_name
assert html =~ group1.name
assert html =~ group2.name
# Verify badges are present (check for badge class or structure)
# This tests our business logic: multiple groups = multiple badges
assert html =~ "badge" or html =~ group1.name
end
test "displays single badge for members in one group", %{
conn: conn,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that member2's group appears
assert html =~ member2.first_name
assert html =~ group1.name
end
test "shows empty cell or placeholder for members without groups", %{
conn: conn,
member3: member3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that member3 exists in the table
assert html =~ member3.first_name
# Check that member3's row does not show any group badges
# (group1 should only appear for member1 and member2)
# This tests our business logic: no groups = no badges
# We verify member3 exists but doesn't have group1 (which would be wrong)
# The actual implementation might show "-" or empty string
end
test "displays group name correctly in badge", %{
conn: conn,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify the exact group name appears (not slug or truncated version)
assert html =~ group1.name
end
test "handles members with many groups", %{
conn: conn,
member1: member1,
group1: group1,
group2: group2,
group3: group3
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Add member1 to a third group
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group3.id})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify all three groups appear for member1
assert html =~ member1.first_name
assert html =~ group1.name
assert html =~ group2.name
assert html =~ group3.name
end
test "handles groups with long names", %{
conn: conn,
member1: member1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a group with a long name (close to 100 char limit)
long_name = String.duplicate("A", 95)
{:ok, long_group} =
Group
|> Ash.Changeset.for_create(:create, %{name: long_name})
|> Ash.create(actor: system_actor)
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: long_group.id})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Verify the long group name is displayed (or truncated appropriately)
assert html =~ long_name or html =~ String.slice(long_name, 0, 50)
end
end

View file

@ -0,0 +1,272 @@
defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
@moduledoc """
Tests for filtering members by group in the member overview.
Tests cover:
- Filter "All groups" shows all members
- Filter by specific group shows only members in that group
- Filter works with search
- Filter works with other filters (e.g., cycle_status_filter)
- Filter persists in URL parameters
- Filter is restored from URL parameters on load
- Filter works with sorting
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
# member1 is in group1
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
# member2 is in group2
{:ok, _mg2} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group2.id})
|> Ash.create(actor: system_actor)
# member3 has no groups
%{
member1: member1,
member2: member2,
member3: member3,
group1: group1,
group2: group2
}
end
test "filter 'All groups' shows all members", %{
conn: conn,
member1: member1,
member2: member2,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify all members are shown when no filter is applied
assert html =~ member1.first_name
assert html =~ member2.first_name
assert html =~ member3.first_name
end
test "filter by specific group shows only members in that group", %{
conn: conn,
member1: member1,
member2: member2,
member3: member3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter for group1
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Verify only member1 is shown (member1 is in group1)
html = render(view)
assert html =~ member1.first_name
refute html =~ member2.first_name
refute html =~ member3.first_name
end
test "filter persists in URL parameters", %{
conn: conn,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Verify URL was updated with group_filter parameter
assert_patch(view, "/members?group_filter=#{group1.id}")
end
test "filter is restored from URL parameters on load", %{
conn: conn,
member1: member1,
member2: member2,
member3: member3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?group_filter=#{group1.id}")
# Verify filter is applied (only member1 shown)
assert html =~ member1.first_name
refute html =~ member2.first_name
refute html =~ member3.first_name
# Verify dropdown shows the selected group
assert has_element?(
view,
"select[name='group_filter'] option[selected][value='#{group1.id}']"
)
end
test "filter works with search", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter first
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Then apply search
view
|> element("#search-bar form")
|> render_submit(%{"query" => "Alice"})
# Verify only member1 is shown (matches both filter and search)
html = render(view)
assert html =~ member1.first_name
refute html =~ member2.first_name
end
test "filter works with sorting", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Apply sorting
view
|> element("[data-testid='sort_first_name']")
|> render_click()
# Verify filter is still applied and sorting works
html = render(view)
assert html =~ member1.first_name
assert_patch(view, "/members?group_filter=#{group1.id}&sort_field=first_name&sort_order=asc")
end
test "filter with empty group shows no members", %{
conn: conn,
member1: member1,
member2: member2,
member3: member3
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create an empty group (no members)
{:ok, empty_group} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Empty Group"})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter for empty group
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => empty_group.id})
# Verify no members are shown
html = render(view)
refute html =~ member1.first_name
refute html =~ member2.first_name
refute html =~ member3.first_name
end
test "handles invalid group ID in URL gracefully", %{
conn: conn,
member1: member1,
member2: member2,
member3: member3
} do
conn = conn_with_oidc_user(conn)
# Use a non-existent UUID
invalid_id = Ecto.UUID.generate()
{:ok, view, html} = live(conn, "/members?group_filter=#{invalid_id}")
# Verify all members are shown (filter ignored for invalid ID)
assert html =~ member1.first_name
assert html =~ member2.first_name
assert html =~ member3.first_name
end
test "filter resets when group is deleted", %{
conn: conn,
member1: member1,
group1: group1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?group_filter=#{group1.id}")
# Delete the group
Group
|> Ash.Query.filter(id == ^group1.id)
|> Ash.read_one!(actor: system_actor)
|> Ash.destroy!(actor: system_actor)
# Reload the page
{:ok, view, html} = live(conn, "/members?group_filter=#{group1.id}")
# Verify filter is reset (all members shown)
assert html =~ member1.first_name
# Filter should fall back to "All groups" when group doesn't exist
end
end

View file

@ -0,0 +1,262 @@
defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
@moduledoc """
Tests for integration of groups with existing features in the member overview.
Tests cover:
- Groups column works with Field Visibility (column can be hidden)
- Groups filter works with Custom Field filters
- Groups sorting works with other sortings
- Groups work with Membership Fee Status filter
- Groups work with existing search (but not testing search integration itself)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup, CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
# Create custom field for filter integration test
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter",
value_type: :boolean,
show_in_overview: false
})
|> Ash.create(actor: system_actor)
# Create custom field value for member1
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
%{
member1: member1,
member2: member2,
group1: group1,
custom_field: custom_field
}
end
test "groups column works with field visibility", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify groups column is visible by default
assert html =~ group1.name
assert html =~ member1.first_name
# Hide groups column via field visibility dropdown
# (This tests integration with field visibility feature)
# Note: Actual implementation depends on how field visibility works
# For now, we verify the column exists and can be toggled
assert html
end
test "groups filter works with custom field filters", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1,
custom_field: custom_field
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Apply custom field filter (boolean filter)
view
|> element("input[type='checkbox'][name='bf_#{custom_field.id}']")
|> render_change(%{"bf_#{custom_field.id}" => "true"})
# Verify both filters are applied
# member1 is in group1 AND has newsletter=true
# member2 is in group1 but has no newsletter value
html = render(view)
assert html =~ member1.first_name
# member2 might or might not be shown depending on filter logic
# (boolean filter might require the value to be true, not just present)
end
test "groups sorting works with other sortings", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Apply groups sorting (should combine with existing sort)
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify both sorts are applied (or groups sort replaces first_name sort)
html = render(view)
assert html =~ member1.first_name
assert html =~ member2.first_name
# URL should reflect the current sort
assert_patch(view, "/members?sort_field=groups&sort_order=asc")
end
test "groups work with membership fee status filter", %{
conn: conn,
member1: member1,
group1: group1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a membership fee type and cycle for member1
{:ok, fee_type} =
Mv.MembershipFees.MembershipFeeType
|> Ash.Changeset.for_create(:create, %{
name: "Test Fee",
amount: Decimal.new("50.00"),
interval: :yearly
})
|> Ash.create(actor: system_actor)
{:ok, _cycle} =
Mv.MembershipFees.MembershipFeeCycle
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
membership_fee_type_id: fee_type.id,
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
status: :paid
})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Apply membership fee status filter (paid)
view
|> element("select[name='cycle_status_filter']")
|> render_change(%{"cycle_status_filter" => "paid"})
# Verify both filters are applied
html = render(view)
assert html =~ member1.first_name
# Verify URL contains both filters
assert_patch(view, "/members?group_filter=#{group1.id}&cycle_status_filter=paid")
end
test "groups work with existing search (not testing search integration)", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Apply search (this tests that filter and search work together,
# but we're not testing the search integration itself)
view
|> element("#search-bar form")
|> render_submit(%{"query" => "Alice"})
# Verify filter and search both work
html = render(view)
assert html =~ member1.first_name
refute html =~ member2.first_name
# Note: We're not testing that group names are searchable
# (that's part of Issue #5 - Search Integration)
end
test "all filters and sortings work together", %{
conn: conn,
member1: member1,
group1: group1,
custom_field: custom_field
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Apply custom field filter
view
|> element("input[type='checkbox'][name='bf_#{custom_field.id}']")
|> render_change(%{"bf_#{custom_field.id}" => "true"})
# Apply sorting
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Apply search
view
|> element("#search-bar form")
|> render_submit(%{"query" => "Alice"})
# Verify all filters and sorting are applied
html = render(view)
assert html =~ member1.first_name
# Verify URL contains all parameters
assert_patch(
view,
"/members?query=Alice&group_filter=#{group1.id}&sort_field=groups&sort_order=asc&bf_#{custom_field.id}=true"
)
end
end

View file

@ -0,0 +1,209 @@
defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
@moduledoc """
Tests for performance and N+1 query prevention for groups in the member overview.
Tests cover:
- Groups are loaded with members in a single query (preloading)
- No N+1 queries when loading members with groups
- Filter works at database level (not in-memory)
- Sort works at database level
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members (enough to test performance)
members =
for i <- 1..10 do
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "Test#{i}",
email: "member#{i}@example.com"
},
actor: system_actor
)
member
end
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Group 1"})
|> Ash.create(actor: system_actor)
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Group 2"})
|> Ash.create(actor: system_actor)
# Assign members to groups (alternating pattern)
Enum.each(Enum.with_index(members), fn {member, index} ->
group_id = if rem(index, 2) == 0, do: group1.id, else: group2.id
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group_id})
|> Ash.create(actor: system_actor)
end)
%{
members: members,
group1: group1,
group2: group2
}
end
@tag :slow
test "groups are preloaded with members (no N+1 queries)", %{
conn: conn,
members: _members
} do
# This test verifies that groups are loaded efficiently
# We check query count by monitoring database queries
# Note: Actual query counting would require Ecto query logging
# For now, we verify the functionality works correctly
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify all members are loaded
Enum.each(1..10, fn i ->
assert html =~ "Member#{i}"
end)
# Verify groups are displayed (if preloaded correctly, this should work)
# If N+1 queries occurred, the page might be slow or fail
assert html
end
@tag :slow
test "filter works at database level", %{
conn: conn,
group1: group1,
members: members
} do
# This test verifies that filtering happens in the database query,
# not by filtering in-memory after loading all members
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Verify only filtered members are shown
html = render(view)
# Members with even indices (0, 2, 4, 6, 8) are in group1
even_members = Enum.filter(0..9, &(rem(&1, 2) == 0))
odd_members = Enum.filter(0..9, &(rem(&1, 2) == 1))
Enum.each(even_members, fn i ->
member = Enum.at(members, i)
assert html =~ member.first_name
end)
Enum.each(odd_members, fn i ->
member = Enum.at(members, i)
refute html =~ member.first_name
end)
# If filtering was done in-memory, we'd load all members first
# Database-level filtering is more efficient
end
@tag :slow
test "sorting works at database level", %{
conn: conn,
members: _members
} do
# This test verifies that sorting happens in the database query,
# not by sorting in-memory after loading all members
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply sorting
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify sorting is applied
html = render(view)
# Verify members are displayed (if sorting was done in-memory,
# we'd load all members first, which is less efficient)
assert html
# Database-level sorting is more efficient for large datasets
end
@tag :slow
test "handles many members with many groups efficiently", %{
conn: conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create many members (20) with multiple groups each
members =
for i <- 1..20 do
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "Test#{i}",
email: "member#{i}@example.com"
},
actor: system_actor
)
member
end
# Create multiple groups
groups =
for i <- 1..5 do
{:ok, group} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Group #{i}"})
|> Ash.create(actor: system_actor)
group
end
# Assign each member to 2-3 random groups
Enum.each(members, fn member ->
selected_groups = Enum.take_random(groups, Enum.random(2..3))
Enum.each(selected_groups, fn group ->
{:ok, _mg} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|> Ash.create(actor: system_actor)
end)
end)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Verify all members are loaded efficiently
Enum.each(1..20, fn i ->
assert html =~ "Member#{i}"
end)
# If preloading works correctly, this should be fast
# If N+1 queries occurred, this would be very slow
assert html
end
end

View file

@ -0,0 +1,255 @@
defmodule MvWeb.MemberLive.IndexGroupsSortingTest do
@moduledoc """
Tests for sorting by groups in the member overview.
Tests cover:
- Sorting by group name (ascending/descending)
- Sorting by number of groups (ascending/descending)
- Members with groups come before members without groups (when sorting by group name)
- Sorting works with filter
- Sorting works with search
- Sorting persists in URL parameters
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
{:ok, member3} =
Mv.Membership.create_member(
%{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"},
actor: system_actor
)
{:ok, member4} =
Mv.Membership.create_member(
%{first_name: "David", last_name: "Davis", email: "david@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group_a} =
Group
|> Ash.Changeset.for_create(:create, %{name: "A Group"})
|> Ash.create(actor: system_actor)
{:ok, group_b} =
Group
|> Ash.Changeset.for_create(:create, %{name: "B Group"})
|> Ash.create(actor: system_actor)
{:ok, group_c} =
Group
|> Ash.Changeset.for_create(:create, %{name: "C Group"})
|> Ash.create(actor: system_actor)
# Create member-group associations
# member1 is in group_a (1 group)
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group_a.id})
|> Ash.create(actor: system_actor)
# member2 is in group_b (1 group)
{:ok, _mg2} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group_b.id})
|> Ash.create(actor: system_actor)
# member3 is in group_a and group_c (2 groups)
{:ok, _mg3} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member3.id, group_id: group_a.id})
|> Ash.create(actor: system_actor)
{:ok, _mg4} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member3.id, group_id: group_c.id})
|> Ash.create(actor: system_actor)
# member4 has no groups
%{
member1: member1,
member2: member2,
member3: member3,
member4: member4,
group_a: group_a,
group_b: group_b,
group_c: group_c
}
end
test "sorts by group name ascending", %{
conn: conn,
member1: member1,
member2: member2,
member3: member3,
member4: member4,
group_a: group_a
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on groups column header to sort
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify URL was updated
assert_patch(view, "/members?query=&sort_field=groups&sort_order=asc")
# Verify sort state
html = render(view)
# When sorting by group name ascending:
# - Members with groups come first (alphabetically by first group name)
# - Members without groups come last
# member1 (A Group) should come before member2 (B Group)
# member3 (A Group, C Group) should come with A Group members
# member4 (no groups) should come last
# Check that members with groups appear before member4 (no groups)
member1_pos = String.split(html, member1.first_name) |> length()
member2_pos = String.split(html, member2.first_name) |> length()
member4_pos = String.split(html, member4.first_name) |> length()
# This is a simplified check - actual implementation might differ
assert html =~ group_a.name
end
test "sorts by group name descending", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=groups&sort_order=asc")
# Click again to toggle to descending
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Check URL was updated
assert_patch(view, "/members?query=&sort_field=groups&sort_order=desc")
html = render(view)
# Verify descending sort (implementation dependent)
assert html =~ member1.first_name
assert html =~ member2.first_name
end
test "sorts by number of groups ascending", %{
conn: conn,
member1: member1,
member3: member3,
member4: member4
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on group count column header (if separate from group name)
# Or use a different sort field like "group_count"
view
|> element("[data-testid='sort_group_count']")
|> render_click()
# Verify URL was updated
assert_patch(view, "/members?query=&sort_field=group_count&sort_order=asc")
html = render(view)
# When sorting by number of groups ascending:
# - member4 (0 groups) should come first
# - member1 (1 group) should come next
# - member3 (2 groups) should come last
# This tests our business logic: sort by count, not by name
assert html =~ member1.first_name
assert html =~ member3.first_name
assert html =~ member4.first_name
end
test "members with groups come before members without groups when sorting by group name", %{
conn: conn,
member1: member1,
member4: member4
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=groups&sort_order=asc")
html = render(view)
# Verify that member1 (has groups) appears before member4 (no groups)
# This tests our business logic: members with groups first
member1_index = String.split(html, member1.first_name) |> hd() |> String.length()
member4_index = String.split(html, member4.first_name) |> hd() |> String.length()
# member1 should appear earlier in the HTML than member4
assert member1_index < member4_index
end
test "sorting works with filter", %{
conn: conn,
member1: member1,
group_a: group_a
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?group_filter=#{group_a.id}")
# Apply sorting
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify both filter and sort are applied
html = render(view)
assert html =~ member1.first_name
assert_patch(view, "/members?group_filter=#{group_a.id}&sort_field=groups&sort_order=asc")
end
test "sorting works with search", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=Alice")
# Apply sorting
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify both search and sort are applied
html = render(view)
assert html =~ member1.first_name
assert_patch(view, "/members?query=Alice&sort_field=groups&sort_order=asc")
end
test "sorting persists in URL parameters", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=groups&sort_order=asc")
# Verify sort is applied from URL
html = render(view)
# Verify sort indicator is shown
assert has_element?(view, "[data-testid='sort_groups'][aria-label*='ascending']")
end
end

View file

@ -0,0 +1,200 @@
defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
@moduledoc """
Tests for URL parameter persistence for groups in the member overview.
Tests cover:
- Group filter is written to URL (group_filter=<group_id>)
- Group sorting is written to URL (sort_field=groups&sort_order=asc)
- URL parameters are restored on load
- URL parameters work with other parameters (query, sort_field, etc.)
- URL is bookmarkable (filter/sorting persist)
"""
# async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{Group, MemberGroup}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Mv.Membership.create_member(
%{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"},
actor: system_actor
)
{:ok, member2} =
Mv.Membership.create_member(
%{first_name: "Bob", last_name: "Brown", email: "bob@example.com"},
actor: system_actor
)
# Create test groups
{:ok, group1} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Board Members"})
|> Ash.create(actor: system_actor)
# Create member-group associations
{:ok, _mg1} =
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id})
|> Ash.create(actor: system_actor)
%{
member1: member1,
member2: member2,
group1: group1
}
end
test "group filter is written to URL", %{
conn: conn,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
view
|> element("select[name='group_filter']")
|> render_change(%{"group_filter" => group1.id})
# Verify URL was updated with group_filter parameter
assert_patch(view, "/members?group_filter=#{group1.id}")
end
test "group sorting is written to URL", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on groups column header to sort
view
|> element("[data-testid='sort_groups']")
|> render_click()
# Verify URL was updated with sort parameters
assert_patch(view, "/members?query=&sort_field=groups&sort_order=asc")
end
test "URL parameters are restored on load", %{
conn: conn,
member1: member1,
member2: member2,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
live(conn, "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc")
# Verify filter is applied
assert html =~ member1.first_name
refute html =~ member2.first_name
# Verify sort is applied
assert has_element?(view, "[data-testid='sort_groups'][aria-label*='ascending']")
end
test "URL parameters work with query parameter", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=Alice&group_filter=#{group1.id}")
# Verify both query and filter are applied
assert html =~ member1.first_name
assert_patch(view, "/members?query=Alice&group_filter=#{group1.id}")
end
test "URL parameters work with other sort fields", %{
conn: conn,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?sort_field=first_name&sort_order=desc&group_filter=#{group1.id}")
# Verify all parameters are preserved
assert_patch(view, "/members?sort_field=first_name&sort_order=desc&group_filter=#{group1.id}")
end
test "URL is bookmarkable with filter and sorting", %{
conn: conn,
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
# Simulate bookmarking a URL with filter and sort
bookmark_url = "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc"
{:ok, view, html} = live(conn, bookmark_url)
# Verify filter and sort are both applied
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='sort_groups'][aria-label*='ascending']")
# Verify URL matches bookmark
assert_patch(view, bookmark_url)
end
test "handles multiple group_filter parameters (uses last one)", %{
conn: conn,
group1: group1
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn)
# URL with duplicate parameters (should use last one)
{:ok, view, _html} =
live(conn, "/members?group_filter=#{group1.id}&group_filter=#{group2.id}")
# Verify the last filter value is used
# Implementation should handle this gracefully
html = render(view)
# Should show members from group2 (last filter)
assert html
end
test "handles invalid URL parameters gracefully", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
# URL with invalid group_filter (non-existent UUID)
invalid_id = Ecto.UUID.generate()
{:ok, view, html} = live(conn, "/members?group_filter=#{invalid_id}")
# Verify all members are shown (invalid filter ignored)
assert html =~ member1.first_name
assert html =~ member2.first_name
end
test "handles malformed URL parameters", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
# URL with malformed group_filter (not a UUID)
{:ok, view, html} = live(conn, "/members?group_filter=not-a-uuid")
# Verify all members are shown (malformed filter ignored)
assert html =~ member1.first_name
assert html =~ member2.first_name
end
end