2386 lines
80 KiB
Elixir
2386 lines
80 KiB
Elixir
defmodule MvWeb.MemberLive.IndexTest do
|
||
# async: false on purpose: the @slow "boolean filter performance with 150 members"
|
||
# test asserts a wall-clock budget (duration < 1000ms). Running this module in
|
||
# parallel with others adds CPU contention that inflates that measurement and makes
|
||
# the timing assertion flaky, so this module stays synchronous.
|
||
use MvWeb.ConnCase, async: false
|
||
import Phoenix.LiveViewTest
|
||
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
|
||
|
||
alias Mv.Helpers.SystemActor
|
||
alias Mv.Membership
|
||
alias Mv.Membership.CustomField
|
||
alias Mv.Membership.CustomFieldValue
|
||
alias MvWeb.MemberLive.Index, as: MemberIndex
|
||
|
||
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 "drag-select vs click guard (§3.3)" do
|
||
@describetag :ui
|
||
|
||
test "members table carries the row-selection guard hook", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, ~p"/members")
|
||
|
||
assert has_element?(view, "[phx-hook=RowSelectionGuard][id=members-table-guard]")
|
||
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"})
|
||
|
||
# Rationale: this exercises the handle_info(:search_changed) callback in isolation.
|
||
# The search box value is owned by SearchBarComponent (assign_new), and scope is
|
||
# recomputed on handle_params rather than this handle_info, so the updated :query
|
||
# and :members assigns have no faithful rendered proxy here. The assigns are
|
||
# asserted on internal state to preserve the original coverage of the callback.
|
||
assigns = :sys.get_state(view.pid).socket.assigns
|
||
assert assigns.query == "Friedrich"
|
||
assert is_list(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
|
||
|
||
# Opens the bulk-actions dropdown and returns the mailto link's BCC payload
|
||
# (everything after "mailto:?bcc="). This is the observable carrier of the
|
||
# recipient set / recipient_count, replacing direct socket-assign inspection.
|
||
defp mailto_bcc(view) do
|
||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||
|
||
href =
|
||
render(view)
|
||
|> LazyHTML.from_fragment()
|
||
|> LazyHTML.query(~s([data-testid="bulk-actions-mailto"]))
|
||
|> LazyHTML.attribute("href")
|
||
|> List.first()
|
||
|
||
case href do
|
||
"mailto:?bcc=" <> bcc -> bcc
|
||
other -> other || ""
|
||
end
|
||
end
|
||
|
||
# Opens the member-filter dropdown so its boolean filter controls are rendered.
|
||
defp open_member_filter(view) do
|
||
view
|
||
|> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
|
||
|> render_click()
|
||
end
|
||
|
||
# Returns the rendered HTML of the member-filter dropdown (with it open).
|
||
defp member_filter_html(view) do
|
||
open_member_filter(view)
|
||
render(view)
|
||
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})
|
||
html = render_click(view, "select_member", %{"id" => member2.id})
|
||
|
||
# Both selected members are observably reflected: their row checkboxes are
|
||
# checked and the scope badge shows the selection count ("2").
|
||
assert has_element?(view, ~s(input[role="checkbox"][name="#{member1.id}"][checked]))
|
||
assert has_element?(view, ~s(input[role="checkbox"][name="#{member2.id}"][checked]))
|
||
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "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")
|
||
|
||
# :all scope renders the muted "all" badge.
|
||
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "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")
|
||
|
||
# An active search narrows the list, so the scope badge reads "filtered".
|
||
assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "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")
|
||
|
||
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")
|
||
|
||
html = render_click(view, "select_member", %{"id" => member1.id})
|
||
|
||
# A selection switches the badge to the emphasized (primary) variant whose
|
||
# label is the selected count ("1"), which is the observable proxy for scope == :selection.
|
||
badge = scope_badge(html)
|
||
assert badge |> LazyHTML.text() |> String.trim() == "1"
|
||
assert badge |> LazyHTML.attribute("class") |> List.first() =~ "badge-primary"
|
||
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")
|
||
|
||
# The mailto link's BCC is the observable carrier of recipient_count/mailto_bcc.
|
||
bcc = mailto_bcc(view)
|
||
# Both seeded members have an email, so the no-selection scope covers both.
|
||
assert bcc =~ "scope1%40example.com"
|
||
assert 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})
|
||
|
||
bcc = mailto_bcc(view)
|
||
assert bcc =~ "scope1%40example.com"
|
||
refute 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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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")
|
||
|
||
# Rationale: with no boolean fields and no active filter there is no rendered
|
||
# filter control or active-count badge to observe, so the empty initial filter
|
||
# map is asserted on internal state directly.
|
||
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")
|
||
|
||
# Rationale: the absence of boolean fields means the filter dropdown renders
|
||
# no boolean fieldsets; "empty list" has no positive rendered signal, so it is
|
||
# asserted on internal state directly.
|
||
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")
|
||
open_member_filter(view)
|
||
|
||
# Only the boolean fields render a tri-state filter control; the string field does not.
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
|
||
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
|
||
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
|
||
field_z = create_boolean_custom_field(%{name: "Zebra Field"})
|
||
field_a = create_boolean_custom_field(%{name: "Alpha Field"})
|
||
field_m = create_boolean_custom_field(%{name: "Middle Field"})
|
||
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
html = member_filter_html(view)
|
||
|
||
# The rendered boolean filter controls appear in name-ascending order.
|
||
rendered_order =
|
||
html
|
||
|> LazyHTML.from_fragment()
|
||
|> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"]))
|
||
|> LazyHTML.attribute("id")
|
||
|> Enum.map(
|
||
&(&1
|
||
|> String.replace_prefix("custom-boolean-filter-", "")
|
||
|> String.replace_suffix("-all", ""))
|
||
)
|
||
|
||
assert rendered_order == [field_a.id, field_m.id, field_z.id]
|
||
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: the "Yes" radio is checked (the boolean true, not the string "true").
|
||
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
|
||
open_member_filter(view1)
|
||
assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||
refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||
|
||
# Test false value: the "No" radio is checked.
|
||
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
|
||
open_member_filter(view2)
|
||
assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
|
||
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||
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")
|
||
open_member_filter(view)
|
||
|
||
# No filter control exists for a non-existent field, and no active-filter badge appears.
|
||
refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
|
||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||
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")
|
||
open_member_filter(view)
|
||
|
||
# A string field is never rendered as a boolean filter, and no filter becomes active.
|
||
refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
|
||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||
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}")
|
||
open_member_filter(view)
|
||
|
||
# An invalid value leaves the field's filter at "All" (no filter applied).
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
|
||
"Invalid value '#{invalid_value}' should leave the filter at All"
|
||
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"
|
||
)
|
||
|
||
open_member_filter(view)
|
||
|
||
# Both filters are reflected: field1 at "Yes", field2 at "No", and the
|
||
# active-filter count badge shows 2.
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]")
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]")
|
||
|
||
assert view
|
||
|> element(~s(button[aria-label="Filter members"] .badge), "2")
|
||
|> has_element?()
|
||
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"
|
||
)
|
||
|
||
open_member_filter(view)
|
||
|
||
# Both filters are reflected in the rendered controls: the boolean field at
|
||
# "Yes" and the payment-status filter at "paid".
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||
assert has_element?(view, "#payment-filter-paid[checked]")
|
||
|
||
# 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")
|
||
|
||
open_member_filter(view)
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||
|
||
# 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")
|
||
|
||
open_member_filter(view2)
|
||
|
||
# The deleted field renders no filter control and no filter is active.
|
||
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}")
|
||
refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active))
|
||
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")
|
||
|
||
open_member_filter(view)
|
||
|
||
# Phoenix decodes the param, so the filter applies under the original ID:
|
||
# the "Yes" radio for the field is checked.
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
|
||
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")
|
||
|
||
open_member_filter(view)
|
||
|
||
# The double-prefixed param is not a valid filter: the field stays at "All"
|
||
# and no filter is active.
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||
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}")
|
||
|
||
# The active-filter count badge is the observable carrier of the filter count.
|
||
# With 60 requested filters, the DoS cap clamps it to exactly 50.
|
||
assert view
|
||
|> element(~s(button[aria-label="Filter members"] .badge), "50")
|
||
|> has_element?()
|
||
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")
|
||
|
||
open_member_filter(view)
|
||
|
||
# The over-long ID is rejected: the real field stays at "All" and no filter
|
||
# is active.
|
||
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
|
||
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
|
||
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, replace_existing: true},
|
||
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, replace_existing: true},
|
||
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")
|
||
open_member_filter(view)
|
||
|
||
# No boolean field control is rendered yet.
|
||
refute has_element?(view, ~s(input[name^="custom_boolean"]))
|
||
|
||
# Create a new boolean custom field
|
||
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
|
||
|
||
# Navigate again - the new field should appear in the filter dropdown.
|
||
{:ok, view2, _html} = live(conn, "/members")
|
||
html_after = member_filter_html(view2)
|
||
|
||
assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
|
||
assert html_after =~ "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
|