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.
2395 lines
78 KiB
Elixir
2395 lines
78 KiB
Elixir
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
|