mitgliederverwaltung/test/mv_web/member_live/index_test.exs
Simon c983c8d5bb feat(member): collect member-overview bulk actions into a single dropdown
The growing row of bulk-action buttons above the member overview is replaced
by one "Aktionen" dropdown holding all four actions (open in email program,
copy addresses, export CSV, export PDF). With no selection the actions operate
on all — or the currently filtered — members; the email-program action is
disabled past a recipient cap, because the browser cannot reliably hand a very
long mailto over to the mail client. The trigger shows the active scope as a
badge: an emphasized count when members are selected, a muted "alle"/"gefiltert"
otherwise.
2026-06-04 16:44:13 +02:00

2395 lines
78 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MvWeb.MemberLive.IndexTest do
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.MemberLive.Index, as: MemberIndex
# Helper to create a membership fee type (shared across all tests)
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a cycle (shared across all tests)
defp create_cycle(member, fee_type, attrs, actor) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!(actor: actor)
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
describe "desktop layout: scroll container and sticky table header" do
@describetag :ui
test "header and filters are outside scroll container; table is in scroll container with lg:max-h and lg:overflow-auto",
%{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ ~r/data-testid="members-table-scroll"/
# Scroll container has lg: overflow and max-height for desktop-only scroll
assert html =~ "lg:overflow-auto"
assert html =~ "lg:max-h-[calc(100vh-14rem)]"
# Header (page title) is present and not inside the scroll container (scroll container comes after filters)
assert html =~ "Members"
assert html =~ "id=\"members\""
end
test "table thead has sticky classes on desktop when sticky_header is set", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# CoreComponents table with sticky_header adds lg:sticky lg:top-0 bg-base-100 z-10 to th
assert html =~ "lg:sticky"
assert html =~ "lg:top-0"
assert html =~ "bg-base-100"
end
test "members page does not nest a second overflow wrapper inside members-table-scroll", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ ~s(id="members-keyboard")
assert html =~ ~s(class="overflow-visible")
refute html =~ ~s(id="members-keyboard" class="overflow-x-auto")
refute html =~ ~s(id="members-keyboard" class="overflow-auto")
end
test "members table keeps checkbox column sticky while horizontally scrolling", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Sticky", last_name: "Column", email: "sticky-column@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# Contract: first column (select-all header + row checkbox cells) is sticky on the left
assert html =~ "left-0"
assert html =~ "sticky"
assert html =~ "z-30"
assert html =~ "z-20"
end
end
describe "translations" do
@describetag :ui
test "shows translated title and button text by locale", %{conn: conn} do
locales = [
{"de", "Mitglieder", "Speichern",
fn c -> Plug.Test.init_test_session(c, locale: "de") end},
{"en", "Members", "Save",
fn c ->
Gettext.put_locale(MvWeb.Gettext, "en")
c
end}
]
for {_locale, expected_title, expected_button, set_locale} <- locales do
base = conn_with_oidc_user(conn) |> set_locale.()
{:ok, _view, index_html} = live(base, "/members")
assert index_html =~ expected_title
base_form = conn_with_oidc_user(conn) |> set_locale.()
{:ok, _view, form_html} = live(base_form, "/members/new")
assert form_html =~ expected_button
end
end
test "shows translated flash message after creating a member in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "Max",
"member[last_name]" => "Mustermann",
"member[email]" => "max@example.com"
}
# Submit form and follow the redirect to get the flash message
{:ok, index_view, _html} =
form_view
|> form("#member-form", form_data)
|> render_submit()
|> follow_redirect(conn, "/members")
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
end
test "shows translated flash message after creating a member in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{
"member[first_name]" => "Max",
"member[last_name]" => "Mustermann",
"member[email]" => "max@example.com"
}
# Submit form and follow the redirect to get the flash message
{:ok, index_view, _html} =
form_view
|> form("#member-form", form_data)
|> render_submit()
|> follow_redirect(conn, "/members")
assert has_element?(index_view, "#flash-group", "Member created successfully")
end
end
describe "sorting integration" do
@describetag :ui
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The component data test ids are built with the name of the field
# First click should sort ASC
view
|> element("[data-testid='email']")
|> render_click()
# The LiveView pushes a patch with the new query params
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
# Second click toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
sortable_fields = [
:email,
:street,
:house_number,
:postal_code,
:city,
:country,
:join_date
]
for field <- sortable_fields do
view
|> element("[data-testid='#{field}']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
describe "URL param handling" do
@describetag :ui
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
end
describe "search and sort integration" do
@describetag :ui
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Sort state should be maintained
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
# Perform sort
view
|> element("[data-testid='email']")
|> render_click()
# Search state should be maintained
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
@tag :ui
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
send(view.pid, {:search_changed, "Friedrich"})
state = :sys.get_state(view.pid)
assert state.socket.assigns.query == "Friedrich"
assert is_list(state.socket.assigns.members)
end
@tag :ui
test "member index does not render Edit or Delete actions", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Test", last_name: "User", email: "test@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
refute has_element?(view, "[data-testid='member-edit']")
refute html =~ ~s(data-testid="member-delete")
end
@tag :ui
test "row click navigates to member show", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, member} =
Membership.create_member(
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click a data cell (e.g. second column = first name) to trigger row navigation
view
|> element("#row-#{member.id} td:nth-child(2)")
|> render_click()
assert_redirect(view, ~p"/members/#{member}")
end
describe "table row highlight (hover and selected)" do
@describetag :ui
test "clickable rows with sticky first column use hover/focus background highlight", %{
conn: conn
} do
system_actor = SystemActor.get_system_actor()
{:ok, _member} =
Membership.create_member(
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Sticky-first-column tables: hover/focus fills live in CSS; wrapper is marked for tests.
assert html =~ ~s(data-sticky-first-col-rows="true")
refute html =~ "hover:ring-2"
end
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
{:ok, member} =
Membership.create_member(
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?highlight=#{member.id}")
# Outline is only for checkbox selection; highlight param does not set data-selected
refute has_element?(view, "tr#row-#{member.id}[data-selected='true']")
end
end
# Opens the bulk-actions dropdown and clicks the copy item. The copy item only
# exists in the DOM while the menu is open, so we toggle it open first.
defp click_copy_via_dropdown(view) do
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
view |> element("#bulk-actions-copy") |> render_click()
end
defp scope_badge(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
end
describe "copy_emails feature" do
setup do
system_actor = SystemActor.get_system_actor()
# Create test members
{:ok, member1} =
Membership.create_member(
%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
},
actor: system_actor
)
{:ok, member3} =
Membership.create_member(
%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
},
actor: system_actor
)
%{member1: member1, member2: member2, member3: member3}
end
test "copy_emails event formats selected members correctly", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
render_click(view, "select_member", %{"id" => member2.id})
# Trigger copy_emails event
click_copy_via_dropdown(view)
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails with no selection copies all members' emails", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Deliberate behaviour change (§3.1): with no selection, copy operates on
# the current scope (all members) instead of erroring "No members selected".
result = render_hook(view, "copy_emails", %{})
# Three seeded members all have an email → success flash, not an error.
assert result =~ "3"
refute result =~ "No members selected"
end
test "copy_emails event with all members selected formats all emails", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select all members via select_all
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
click_copy_via_dropdown(view)
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
end
test "copy_emails handles members with special characters in names", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select member with umlauts by sending the select_member event directly
render_click(view, "select_member", %{"id" => member3.id})
# Trigger copy_emails event - should not crash
click_copy_via_dropdown(view)
# Verify flash message shows success
assert render(view) =~ "1"
end
test "copy_emails handles case where selected member is deleted before copy", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
# Delete the member from the database
system_actor = SystemActor.get_system_actor()
Ash.destroy!(member1, actor: system_actor)
# Trigger copy_emails event directly - selection still contains the deleted ID
# but the member is no longer in @members list after reload
result = render_hook(view, "copy_emails", %{})
# Should show error since no visible members match selection
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
end
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
conn: conn,
member1: member1,
member2: member2
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select two members by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
render_click(view, "select_member", %{"id" => member2.id})
# Get the socket state to verify the formatted email string
state = :sys.get_state(view.pid)
selected_members = state.socket.assigns.selected_members
# Verify MapSet is used
assert %MapSet{} = selected_members
assert MapSet.size(selected_members) == 2
end
test "email format is 'First Last <email>' with comma separator", %{
conn: conn,
member1: _member1
} do
# Test the format_member_email function indirectly
# by checking the push_event payload structure
conn = conn_with_oidc_user(conn)
# Create a member with known data
system_actor = SystemActor.get_system_actor()
{:ok, test_member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/members")
# Select the test member by sending the select_member event directly
render_click(view, "select_member", %{"id" => test_member.id})
# The format should be "Test Format <test.format@example.com>"
# We verify this by checking the flash shows 1 email was copied
click_copy_via_dropdown(view)
assert render(view) =~ "1"
end
test "copy and mailto items stay actionable with no selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open the dropdown so its items are in the DOM.
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
# Deliberate behaviour change (§3.1): items are never disabled merely
# because nothing is selected. Copy is a plain button (no disabled attr),
# and mailto is an enabled link (no aria-disabled) carrying a BCC of all
# three seeded members.
refute has_element?(view, ~s([data-testid="bulk-actions-copy"][disabled]))
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
end
test "trigger shows the selected count after a selection", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
# The scope badge on the trigger reflects the selection count.
badge = scope_badge(render(view))
assert badge |> LazyHTML.text() |> String.trim() == "1"
end
test "copy button click triggers event and shows flash", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Select a member by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id})
# Click copy button
click_copy_via_dropdown(view)
# Flash message should appear
assert has_element?(view, "#flash-group")
end
test "copy excludes a member whose email is blank from the recipient list", %{conn: conn} do
# The Member create action requires an email, so a blank-email member cannot
# be persisted; we exercise the preserved defensive filter in
# format_selected_member_emails/2 directly. One member has an email, the
# other has a blank one — only the former is a recipient (§1.10).
with_email = %{
id: Ecto.UUID.generate(),
first_name: "Has",
last_name: "Mail",
email: "has@example.com"
}
blank_email = %{id: Ecto.UUID.generate(), first_name: "Blank", last_name: "Mail", email: ""}
selected = MapSet.new([with_email.id, blank_email.id])
emails = MemberIndex.format_selected_member_emails([with_email, blank_email], selected)
assert emails == ["Has Mail <has@example.com>"]
_ = conn
end
end
describe "copy_emails empty-recipient feedback" do
test "copy with zero recipients shows 'No email addresses found'", %{conn: conn} do
# No members exist → the no-selection scope yields zero recipients, so the
# preserved empty-recipient feedback fires instead of a clipboard push (§1.11).
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
result = render_hook(view, "copy_emails", %{})
assert result =~ "No email addresses found" or result =~ "Keine E-Mail"
end
end
describe "bulk-actions dropdown" do
setup do
system_actor = SystemActor.get_system_actor()
{:ok, m1} =
Membership.create_member(
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
actor: system_actor
)
%{member1: m1}
end
test "trigger is rendered with a muted 'all' scope badge when no selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# The single bulk-actions trigger is present.
assert html =~ ~s(data-testid="bulk-actions-dropdown")
assert html =~ ~s(data-testid="bulk-actions-button")
# The scope is shown as a badge, not a parenthetical text suffix. The test
# locale renders the English msgids (German wording lives in de.po):
# "Actions" -> "Aktionen", "all" -> "alle".
assert html =~ "Actions"
refute html =~ "(all)"
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "all"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
end
test "trigger shows an emphasized count badge after select_member", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
html = render(view)
assert html =~ "Actions"
refute html =~ "(1)"
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "1"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-primary"
assert classes =~ "badge-sm"
end
test "trigger shows a muted 'filtered' badge when a search narrows the list", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Nobody")
assert html =~ "Actions"
refute html =~ "(filtered)"
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
end
test "trigger carries a trailing chevron", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
assert html =~ "hero-chevron-down"
# The bulk-actions trigger and the bespoke member-filter trigger each
# carry their own chevron; assert the filter trigger's chevron is pinned
# independently, so removing it from the filter component fails this test.
assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
end
test "dropdown opens and closes on click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Click to open
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
# Menu should be visible
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Click to close
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
# Menu should be hidden
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
end
test "dropdown has click-away and ESC handlers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
# Check that click-away handler is present
assert html =~ ~s(phx-click-away="close_dropdown")
# Check that ESC handler is present
assert html =~ ~s(phx-window-keydown="close_dropdown")
assert html =~ ~s(phx-key="Escape")
end
test "menu lists the four actions in order, flattened to one level", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
html = render(view)
# All four items present.
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"]))
assert has_element?(view, ~s([data-testid="bulk-actions-copy"]))
assert has_element?(view, ~s([data-testid="export-csv-link"]))
assert has_element?(view, ~s([data-testid="export-pdf-link"]))
# In order: mailto, copy, CSV, PDF.
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
csv = :binary.match(html, "export-csv-link") |> elem(0)
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
assert mailto < copy and copy < csv and csv < pdf
# No nested export submenu — the former standalone export dropdown is gone.
refute html =~ ~s(data-testid="export-dropdown")
end
test "menu contains CSV and PDF export forms with identical payload and CSRF", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
# Check CSV link
assert html =~ ~s(data-testid="export-csv-link")
assert html =~ "/members/export.csv"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Check PDF link
assert html =~ ~s(data-testid="export-pdf-link")
assert html =~ "/members/export.pdf"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
# Both forms should have the same payload
csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
assert csv_form_payload == pdf_form_payload
assert csv_form_payload != nil
end
test "dropdown has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Button should have aria-haspopup="menu"
assert html =~ ~s(aria-haspopup="menu")
# Button should have aria-expanded="false" when closed
assert html =~ ~s(aria-expanded="false")
# Button should have aria-controls pointing to menu
assert html =~ ~s(aria-controls="bulk-actions-dropdown-menu")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> render_click()
html = render(view)
# Button should have aria-expanded="true" when open
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
end
# Helper to extract payload value from form HTML
defp extract_payload_from_form(html, action_path) do
case Regex.run(
~r/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
html
) do
[_, payload] -> payload
_ -> nil
end
end
end
describe "bulk-actions header layout" do
test "header renders one bulk-actions trigger and no standalone copy/mailto/export controls",
%{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Exactly one bulk-actions trigger.
assert has_element?(view, ~s([data-testid="bulk-actions-button"]))
# The former standalone controls are gone as top-level header buttons.
refute html =~ ~s(id="copy-emails-btn")
refute html =~ ~s(id="open-email-btn")
refute html =~ ~s(data-testid="export-dropdown-button")
end
test "New Member stays a separate primary button outside the dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# New Member is present as its own control...
assert has_element?(view, ~s([data-testid="member-new"]))
# ...and it is not an item of the bulk-actions menu (it precedes the menu
# markup but is not nested inside it).
refute html =~ ~r/data-testid="bulk-actions-menu".*data-testid="member-new"/s
end
end
describe "mailto recipient cap on the page" do
# Seeds n members, each with a distinct email so they all count as mailto
# recipients in the no-selection scope.
defp seed_members_with_email(n) do
system_actor = SystemActor.get_system_actor()
Enum.each(1..n, fn i ->
{:ok, _} =
Membership.create_member(
%{
first_name: "Bulk",
last_name: "M#{i}",
email: "bulk#{i}@example.com"
},
actor: system_actor
)
end)
end
test "mailto item is enabled just below the threshold", %{conn: conn} do
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients() - 1)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
# 49 recipients → enabled, with an actionable BCC link, no aria-disabled.
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
end
test "mailto item is disabled with tooltip at the threshold", %{conn: conn} do
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients())
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
html = render(view)
# 50 recipients → disabled, with the explanatory tooltip and no BCC link.
# The tooltip renders the English msgid in the test locale (German wording
# is "Zu viele Empfänger für diese Funktion. ..." in de.po).
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
assert html =~ "Too many recipients for this function"
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
end
end
describe "scope-aware selection assigns" do
setup do
system_actor = SystemActor.get_system_actor()
{:ok, m1} =
Membership.create_member(
%{first_name: "Scope", last_name: "One", email: "scope1@example.com"},
actor: system_actor
)
{:ok, m2} =
Membership.create_member(
%{first_name: "Scope", last_name: "Two", email: "scope2@example.com"},
actor: system_actor
)
%{member1: m1, member2: m2}
end
test "scope is :all when nothing selected and no filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :all
end
test "scope is :filtered when a search term is active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=Scope")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :filtered
end
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?cycle_status_filter=paid")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :filtered
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
end
test "scope is :selection when a member is selected", %{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :selection
end
test "with no selection, recipient_count and mailto_bcc cover all members", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
assigns = :sys.get_state(view.pid).socket.assigns
# Both seeded members have an email, so the no-selection scope covers both.
assert assigns.recipient_count == 2
assert assigns.mailto_bcc =~ "scope1%40example.com"
assert assigns.mailto_bcc =~ "scope2%40example.com"
end
test "with a selection, recipient_count and mailto_bcc cover only the selection", %{
conn: conn,
member1: member1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.recipient_count == 1
assert assigns.mailto_bcc =~ "scope1%40example.com"
refute assigns.mailto_bcc =~ "scope2%40example.com"
end
end
describe "cycle status filter" do
# Helper to create a member (only used in this describe block)
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Membership.create_member(attrs, actor: actor)
member
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(
%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: last_year_start, status: :paid},
system_actor
)
# Member with unpaid last cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: last_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
assert html =~ "PaidLast"
refute html =~ "UnpaidLast"
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(
%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: last_year_start, status: :paid},
system_actor
)
# Member with unpaid last cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: last_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
refute html =~ "PaidLast"
assert html =~ "UnpaidLast"
end
test "filter shows only members with paid status in current cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(
%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: current_year_start, status: :paid},
system_actor
)
# Member with unpaid current cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: current_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
assert html =~ "PaidCurrent"
refute html =~ "UnpaidCurrent"
end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(
%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
paid_member,
fee_type,
%{cycle_start: current_year_start, status: :paid},
system_actor
)
# Member with unpaid current cycle
unpaid_member =
create_member(
%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
},
system_actor
)
create_cycle(
unpaid_member,
fee_type,
%{cycle_start: current_year_start, status: :unpaid},
system_actor
)
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
refute html =~ "PaidCurrent"
assert html =~ "UnpaidCurrent"
end
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with last cycle view and paid filter
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Toggle to current cycle - this should update URL and preserve filter
# Use the button in the toolbar
view
|> element("button[phx-click='toggle_cycle_view']")
|> render_click()
# Wait for patch to complete
path = assert_patch(view)
# URL should contain both filter and show_current_cycle
assert path =~ "cycle_status_filter=paid"
assert path =~ "show_current_cycle=true"
end
end
describe "boolean custom field filters" do
# Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = SystemActor.get_system_actor()
default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}",
value_type: :boolean
}
attrs = Map.merge(default_attrs, attrs)
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: system_actor)
end
# Helper to create a non-boolean custom field (uses system actor for authorization)
defp create_string_custom_field(attrs \\ %{}) do
system_actor = SystemActor.get_system_actor()
default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}",
value_type: :string
}
attrs = Map.merge(default_attrs, attrs)
CustomField
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: system_actor)
end
@tag :ui
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
state = :sys.get_state(view.pid)
assert state.socket.assigns.boolean_custom_field_filters == %{}
end
@tag :ui
test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
state = :sys.get_state(view.pid)
assert state.socket.assigns.boolean_custom_fields == []
end
test "mount loads and filters boolean custom fields correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create boolean and non-boolean custom fields
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter Subscription"})
_string_field = create_string_custom_field(%{name: "Phone Number"})
{:ok, view, _html} = live(conn, "/members")
state = :sys.get_state(view.pid)
boolean_custom_fields = state.socket.assigns.boolean_custom_fields
# Should only contain boolean fields
assert length(boolean_custom_fields) == 2
assert Enum.all?(boolean_custom_fields, &(&1.value_type == :boolean))
assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field1.id))
assert Enum.any?(boolean_custom_fields, &(&1.id == boolean_field2.id))
end
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create boolean fields with specific names to test sorting
_boolean_field_z = create_boolean_custom_field(%{name: "Zebra Field"})
_boolean_field_a = create_boolean_custom_field(%{name: "Alpha Field"})
_boolean_field_m = create_boolean_custom_field(%{name: "Middle Field"})
{:ok, view, _html} = live(conn, "/members")
state = :sys.get_state(view.pid)
boolean_custom_fields = state.socket.assigns.boolean_custom_fields
# Should be sorted by name ascending
names = Enum.map(boolean_custom_fields, & &1.name)
assert names == ["Alpha Field", "Middle Field", "Zebra Field"]
end
test "handle_params parses bf_<id> values correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Test true value
{:ok, view1, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
state1 = :sys.get_state(view1.pid)
filters1 = state1.socket.assigns.boolean_custom_field_filters
assert filters1[boolean_field.id] == true
refute filters1[boolean_field.id] == "true"
# Test false value
{:ok, view2, _html} =
live(conn, "/members?bf_#{boolean_field.id}=false")
state2 = :sys.get_state(view2.pid)
filters2 = state2.socket.assigns.boolean_custom_field_filters
assert filters2[boolean_field.id] == false
refute filters2[boolean_field.id] == "false"
end
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fake_id = Ecto.UUID.generate()
{:ok, view, _html} =
live(conn, "/members?bf_#{fake_id}=true")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Filter should not be added for non-existent custom field
refute Map.has_key?(filters, fake_id)
assert filters == %{}
end
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
string_field = create_string_custom_field()
{:ok, view, _html} =
live(conn, "/members?bf_#{string_field.id}=true")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Filter should not be added for non-boolean custom field
refute Map.has_key?(filters, string_field.id)
assert filters == %{}
end
test "handle_params ignores invalid filter values", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Test various invalid values
invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"]
for invalid_value <- invalid_values do
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Invalid values should not be added to filters
refute Map.has_key?(filters, boolean_field.id),
"Invalid value '#{invalid_value}' should not be added to filters"
end
end
test "handle_params handles multiple boolean filters simultaneously", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field()
boolean_field2 = create_boolean_custom_field()
{:ok, view, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
assert filters[boolean_field1.id] == true
assert filters[boolean_field2.id] == false
assert map_size(filters) == 2
end
test "build_query_params includes active boolean filters and excludes nil filters", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
boolean_field1 = create_boolean_custom_field()
boolean_field2 = create_boolean_custom_field()
# Test with active filters
{:ok, view1, _html} =
live(
conn,
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
# Trigger a search to see if filters are preserved in URL
view1
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Check that the patch includes boolean filters
path1 = assert_patch(view1)
assert path1 =~ "bf_#{boolean_field1.id}=true"
assert path1 =~ "bf_#{boolean_field2.id}=false"
# Test without filters (nil filters should not appear in URL)
{:ok, view2, _html} = live(conn, "/members")
# Trigger a search
view2
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Check that no bf_ params are in URL
path2 = assert_patch(view2)
refute path2 =~ "bf_"
end
test "boolean filters are preserved during navigation actions", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
# Test sort toggle preserves filter
view
|> element("[data-testid='email']")
|> render_click()
path1 = assert_patch(view)
assert path1 =~ "bf_#{boolean_field.id}=true"
# Test search change preserves filter
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
path2 = assert_patch(view)
assert path2 =~ "bf_#{boolean_field.id}=true"
end
test "boolean filters work together with cycle_status_filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(
conn,
"/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
)
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Both filters should be set
assert filters[boolean_field.id] == true
assert state.socket.assigns.cycle_status_filter == :paid
# Both should be in URL when triggering search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
path = assert_patch(view)
assert path =~ "cycle_status_filter=paid"
assert path =~ "bf_#{boolean_field.id}=true"
end
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
# Set up filter via URL
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
state_before = :sys.get_state(view.pid)
filters_before = state_before.socket.assigns.boolean_custom_field_filters
assert filters_before[boolean_field.id] == true
# Delete the custom field (requires actor with destroy permission)
Ash.destroy!(boolean_field, actor: system_actor)
# Navigate again - filter should be removed since custom field no longer exists
{:ok, view2, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
state_after = :sys.get_state(view2.pid)
filters_after = state_after.socket.assigns.boolean_custom_field_filters
# Filter should not be present for deleted custom field
refute Map.has_key?(filters_after, boolean_field.id)
assert filters_after == %{}
end
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# URL-encode the custom field ID (though UUIDs shouldn't need encoding normally)
encoded_id = URI.encode(boolean_field.id)
{:ok, view, _html} =
live(conn, "/members?bf_#{encoded_id}=true")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Filter should work with URL-encoded ID
# Phoenix should decode it automatically, so we check with original ID
assert filters[boolean_field.id] == true
end
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Try to send parameter with double prefix
{:ok, view, _html} =
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Should not parse as valid filter (UUID validation should fail)
refute Map.has_key?(filters, boolean_field.id)
assert filters == %{}
end
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Create 60 boolean custom fields (more than the limit)
boolean_fields = Enum.map(1..60, fn _ -> create_boolean_custom_field() end)
# Build URL with all 60 filters
filter_params =
Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end)
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Should limit to maximum 50 filters
assert map_size(filters) <= 50
# All filters in the result should be valid
Enum.each(filters, fn {id, value} ->
assert value in [true, false]
# Verify the ID corresponds to one of our boolean fields
assert id in Enum.map(boolean_fields, &to_string(&1.id))
end)
end
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Create a fake ID that's way too long (UUIDs are max 36 chars)
fake_long_id = String.duplicate("a", 100)
{:ok, view, _html} =
live(conn, "/members?bf_#{fake_long_id}=true")
state = :sys.get_state(view.pid)
filters = state.socket.assigns.boolean_custom_field_filters
# Should not accept the extremely long ID
refute Map.has_key?(filters, fake_long_id)
# Valid boolean field should still work
refute Map.has_key?(filters, boolean_field.id)
assert filters == %{}
end
# Helper to create a member with a boolean custom field value
defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
attrs =
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
|> Map.merge(member_attrs)
{:ok, member} = Membership.create_member(attrs, actor: actor)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "boolean", "_union_value" => value}
})
|> Ash.create(actor: actor)
# Reload member with custom field values
member
|> Ash.load!(:custom_field_values, actor: actor)
end
# Tests for get_boolean_custom_field_value/2
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
# Test the function (will fail until implemented)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true
end
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == false
end
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
%{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
# Reload member with custom field values
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true
end
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Member has no custom field value for this field
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create CustomFieldValue with nil value (edge case)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
value: nil
})
|> Ash.create(actor: system_actor)
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
string_field = create_string_custom_field()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Membership.create_member(
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Create string custom field value (not boolean)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: string_field.id,
value: %{"_union_type" => "string", "_union_value" => "test"}
})
|> Ash.create(actor: system_actor)
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
# Try to get boolean value from string field - should return nil
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil
end
# Tests for apply_boolean_custom_field_filters/2
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
%{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
member_without_value =
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 1
assert List.first(result).id == member_with_true.id
refute Enum.any?(result, &(&1.id == member_with_false.id))
refute Enum.any?(result, &(&1.id == member_without_value.id))
end
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
%{conn: _conn} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
member_without_value =
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 1
assert List.first(result).id == member_with_false.id
refute Enum.any?(result, &(&1.id == member_with_true.id))
refute Enum.any?(result, &(&1.id == member_without_value.id))
end
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
member1 =
create_member_with_boolean_value(
%{first_name: "Member1"},
boolean_field,
true,
system_actor
)
member2 =
create_member_with_boolean_value(
%{first_name: "Member2"},
boolean_field,
false,
system_actor
)
members = [member1, member2]
filters = %{}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 2
assert Enum.all?([member1.id, member2.id], fn id ->
Enum.any?(result, &(&1.id == id))
end)
end
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
# Member with both fields = true
{:ok, member_both_true} =
Membership.create_member(
%{
first_name: "BothTrue",
last_name: "Member",
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id,
custom_field_id: boolean_field1.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id,
custom_field_id: boolean_field2.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor)
# Member with field1 = true, field2 = false
{:ok, member_mixed} =
Membership.create_member(
%{
first_name: "Mixed",
last_name: "Member",
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id,
custom_field_id: boolean_field1.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
{:ok, _cfv4} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id,
custom_field_id: boolean_field2.id,
value: %{"_union_type" => "boolean", "_union_value" => false}
})
|> Ash.create(actor: system_actor)
member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor)
members = [member_both_true, member_mixed]
filters = %{
to_string(boolean_field1.id) => true,
to_string(boolean_field2.id) => true
}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
# Only member_both_true should match (both fields = true)
assert length(result) == 1
assert List.first(result).id == member_both_true.id
end
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
conn: _conn
} do
system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field()
fake_id = Ecto.UUID.generate()
member =
create_member_with_boolean_value(
%{first_name: "Member"},
boolean_field,
true,
system_actor
)
members = [member]
filters = %{fake_id => true}
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result =
MemberIndex.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
# Should return all members since fake_id doesn't match any custom field
assert length(result) == 1
end
# Integration tests for boolean custom field filters in load_members
test "boolean filter integration filters members by boolean custom field value via URL parameter",
%{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
_member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
_member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, _member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Test true filter
{:ok, _view, html_true} =
live(conn, "/members?bf_#{boolean_field.id}=true")
assert html_true =~ "TrueMember"
refute html_true =~ "FalseMember"
refute html_true =~ "NoValue"
# Test false filter
{:ok, _view, html_false} =
live(conn, "/members?bf_#{boolean_field.id}=false")
assert html_false =~ "FalseMember"
refute html_false =~ "TrueMember"
refute html_false =~ "NoValue"
end
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with true boolean value and paid status
{:ok, member_paid_true} =
Membership.create_member(
%{
first_name: "PaidTrue",
last_name: "Member",
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_paid_true.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
create_cycle(
member_paid_true,
fee_type,
%{cycle_start: last_year_start, status: :paid},
system_actor
)
# Member with true boolean value but unpaid status
{:ok, member_unpaid_true} =
Membership.create_member(
%{
first_name: "UnpaidTrue",
last_name: "Member",
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
},
actor: system_actor
)
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_unpaid_true.id,
custom_field_id: boolean_field.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create(actor: system_actor)
create_cycle(
member_unpaid_true,
fee_type,
%{cycle_start: last_year_start, status: :unpaid},
system_actor
)
# Test both filters together
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
# Only member_paid_true should match both filters
assert html =~ "PaidTrue"
refute html =~ "UnpaidTrue"
end
test "boolean filter integration works together with search query", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
_member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
_member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
# Test search + boolean filter
{:ok, _view, html} =
live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
# Only member_with_true should match both search and filter
assert html =~ "TrueMember"
refute html =~ "FalseMember"
end
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
# Create boolean field with show_in_overview: false
boolean_field = create_boolean_custom_field(%{show_in_overview: false})
_member_with_true =
create_member_with_boolean_value(
%{first_name: "TrueMember"},
boolean_field,
true,
system_actor
)
_member_with_false =
create_member_with_boolean_value(
%{first_name: "FalseMember"},
boolean_field,
false,
system_actor
)
{:ok, _member_without_value} =
Membership.create_member(
%{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
},
actor: system_actor
)
# Test that filter works even though field is not visible in overview
{:ok, _view, html_true} =
live(conn, "/members?bf_#{boolean_field.id}=true")
assert html_true =~ "TrueMember"
refute html_true =~ "FalseMember"
refute html_true =~ "NoValue"
# Test false filter
{:ok, _view, html_false} =
live(conn, "/members?bf_#{boolean_field.id}=false")
assert html_false =~ "FalseMember"
refute html_false =~ "TrueMember"
refute html_false =~ "NoValue"
end
@tag :ui
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with no boolean custom fields
{:ok, view, _html} = live(conn, "/members")
state_before = :sys.get_state(view.pid)
boolean_fields_before = state_before.socket.assigns.boolean_custom_fields
assert boolean_fields_before == []
# Create a new boolean custom field
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
# Navigate again - the new field should appear
{:ok, view2, _html} = live(conn, "/members")
state_after = :sys.get_state(view2.pid)
boolean_fields_after = state_after.socket.assigns.boolean_custom_fields
# New boolean field should be present
assert length(boolean_fields_after) == 1
assert Enum.any?(boolean_fields_after, &(&1.id == new_boolean_field.id))
assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field"))
end
@tag :slow
test "boolean filter performance with 150 members", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
# Create 150 members - 75 with true, 75 with false
members_with_true =
Enum.map(1..75, fn i ->
create_member_with_boolean_value(
%{
first_name: "TrueMember#{i}",
email: "truemember#{i}@example.com"
},
boolean_field,
true,
system_actor
)
end)
members_with_false =
Enum.map(1..75, fn i ->
create_member_with_boolean_value(
%{
first_name: "FalseMember#{i}",
email: "falsemember#{i}@example.com"
},
boolean_field,
false,
system_actor
)
end)
# Verify all members were created
assert length(members_with_true) == 75
assert length(members_with_false) == 75
# Test filter performance - should complete in reasonable time (< 1 second)
start_time = System.monotonic_time(:millisecond)
{:ok, _view, html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
# Should complete in less than 1 second (1000ms)
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
# Verify filtering worked correctly - should show all true members
Enum.each(1..75, fn i ->
assert html =~ "TrueMember#{i}"
end)
# Should not show false members
Enum.each(1..75, fn i ->
refute html =~ "FalseMember#{i}"
end)
end
end
end