mitgliederverwaltung/test/mv_web/member_live/index_test.exs
Simon a92f503752
All checks were successful
continuous-integration/drone/push Build is passing
fix: credo warning
2026-01-21 01:24:43 +01:00

1610 lines
53 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

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

defmodule MvWeb.MemberLive.IndexTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
# Helper to create a membership fee type (shared across all tests)
defp create_fee_type(attrs) 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!()
end
# Helper to create a cycle (shared across all tests)
defp create_cycle(member, fee_type, attrs) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) 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!()
end
test "shows translated title in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/members")
# Expected German title
assert html =~ "Mitglieder"
end
test "shows translated title in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# Expected English title
assert html =~ "Members"
end
test "shows translated button text in German", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "de")
{:ok, _view, html} = live(conn, "/members/new")
assert html =~ "Speichern"
end
test "shows translated button text in English", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members/new")
assert html =~ "Save"
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
describe "sorting integration" do
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,
: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
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
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
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
test "can delete a member without error", %{conn: conn} do
# Create a test member first
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "User",
email: "test@example.com"
})
conn = conn_with_oidc_user(conn)
{:ok, index_view, _html} = live(conn, "/members")
# Verify the member is displayed
assert has_element?(index_view, "#members", "Test User")
# Click the delete link for this member
index_view
|> element("a", "Delete")
|> render_click()
# Verify the member is no longer displayed
refute has_element?(index_view, "#members", "Test User")
# Verify the member was actually deleted from the database
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end
describe "copy_emails feature" do
setup do
# Create test members
{:ok, member1} =
Mv.Membership.create_member(%{
first_name: "Max",
last_name: "Mustermann",
email: "max@example.com"
})
{:ok, member2} =
Mv.Membership.create_member(%{
first_name: "Erika",
last_name: "Musterfrau",
email: "erika@example.com"
})
{:ok, member3} =
Mv.Membership.create_member(%{
first_name: "Hans",
last_name: "Müller-Lüdenscheidt",
email: "hans@example.com"
})
%{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
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails event with no selection shows error flash", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Trigger copy_emails event directly (button not visible when no selection)
# This tests the edge case where event is triggered without selection
result = render_hook(view, "copy_emails", %{})
# Should show error flash
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
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
view |> element("#copy-emails-btn") |> render_click()
# 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
view |> element("#copy-emails-btn") |> render_click()
# 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
Ash.destroy!(member1)
# 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
{:ok, test_member} =
Mv.Membership.create_member(%{
first_name: "Test",
last_name: "Format",
email: "test.format@example.com"
})
{: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
view |> element("#copy-emails-btn") |> render_click()
assert render(view) =~ "1"
end
test "copy button is disabled when no members selected", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Copy button should be disabled (button element)
assert has_element?(view, "#copy-emails-btn[disabled]")
# Open email button should be disabled (link with tabindex and aria-disabled)
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
end
test "copy button is enabled after selection", %{
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})
# Copy button should now be enabled (no disabled attribute)
refute has_element?(view, "#copy-emails-btn[disabled]")
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
refute has_element?(view, "#open-email-btn[tabindex='-1']")
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
# Counter should show correct count
assert render(view) =~ "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
view |> element("#copy-emails-btn") |> render_click()
# Flash message should appear
assert has_element?(view, "#flash-group")
end
end
describe "cycle status filter" do
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
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
})
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
{: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
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
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
})
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
{: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
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
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
})
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
{: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
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
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
})
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
{: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
alias Mv.Membership.CustomField
# Helper to create a boolean custom field
defp create_boolean_custom_field(attrs \\ %{}) do
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!()
end
# Helper to create a non-boolean custom field
defp create_string_custom_field(attrs \\ %{}) do
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!()
end
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
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)
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
Ash.destroy!(boolean_field)
# 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) do
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(
:create_member,
%{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
|> Map.merge(member_attrs)
)
|> Ash.create()
{:ok, _cfv} =
Mv.Membership.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()
# Reload member with custom field values
member
|> Ash.load!(:custom_field_values)
end
# Tests for get_boolean_custom_field_value/2
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, true)
# Test the function (will fail until implemented)
result = MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, false)
result = MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
{:ok, _cfv} =
Mv.Membership.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()
# Reload member with custom field values
member = member |> Ash.load!(:custom_field_values)
result = MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
# Member has no custom field value for this field
member = member |> Ash.load!(:custom_field_values)
result = MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
# Create CustomFieldValue with nil value (edge case)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: boolean_field.id,
value: nil
})
|> Ash.create()
member = member |> Ash.load!(:custom_field_values)
result = MvWeb.MemberLive.Index.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
string_field = create_string_custom_field()
boolean_field = create_boolean_custom_field()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
# Create string custom field value (not boolean)
{:ok, _cfv} =
Mv.Membership.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()
member = member |> Ash.load!(:custom_field_values)
# Try to get boolean value from string field - should return nil
result = MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
member_with_true =
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
member_with_false =
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
{:ok, member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
member_without_value = member_without_value |> Ash.load!(:custom_field_values)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result =
MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
member_with_true =
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
member_with_false =
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
{:ok, member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
member_without_value = member_without_value |> Ash.load!(:custom_field_values)
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result =
MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true)
member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false)
members = [member1, member2]
filters = %{}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result =
MvWeb.MemberLive.Index.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
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} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "BothTrue",
last_name: "Member",
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
{:ok, _cfv1} =
Mv.Membership.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()
{:ok, _cfv2} =
Mv.Membership.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()
member_both_true = member_both_true |> Ash.load!(:custom_field_values)
# Member with field1 = true, field2 = false
{:ok, member_mixed} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Mixed",
last_name: "Member",
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
{:ok, _cfv3} =
Mv.Membership.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()
{:ok, _cfv4} =
Mv.Membership.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()
member_mixed = member_mixed |> Ash.load!(:custom_field_values)
members = [member_both_true, member_mixed]
filters = %{
to_string(boolean_field1.id) => true,
to_string(boolean_field2.id) => true
}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result =
MvWeb.MemberLive.Index.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
boolean_field = create_boolean_custom_field()
fake_id = Ecto.UUID.generate()
member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true)
members = [member]
filters = %{fake_id => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result =
MvWeb.MemberLive.Index.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
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)
_member_with_false =
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
{:ok, _member_without_value} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "NoValue",
last_name: "Member",
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create()
# 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
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
fee_type = create_fee_type(%{interval: :yearly})
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} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "PaidTrue",
last_name: "Member",
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create()
{:ok, _cfv} =
Mv.Membership.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()
create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with true boolean value but unpaid status
{:ok, member_unpaid_true} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "UnpaidTrue",
last_name: "Member",
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create()
{:ok, _cfv2} =
Mv.Membership.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()
create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid})
# 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
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)
_member_with_false =
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
# 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 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
test "boolean filter performance with 150 members", %{conn: conn} do
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
)
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
)
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