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, actor) do default_attrs = %{ name: "Test Fee Type #{System.unique_integer([:positive])}", amount: Decimal.new("50.00"), interval: :yearly } attrs = Map.merge(default_attrs, attrs) MembershipFeeType |> Ash.Changeset.for_create(:create, attrs) |> Ash.create!(actor: actor) end # Helper to create a cycle (shared across all tests) defp create_cycle(member, fee_type, attrs, actor) do # Delete any auto-generated cycles first to avoid conflicts existing_cycles = MembershipFeeCycle |> Ash.Query.filter(member_id == ^member.id) |> Ash.read!(actor: actor) Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end) default_attrs = %{ cycle_start: ~D[2023-01-01], amount: Decimal.new("50.00"), member_id: member.id, membership_fee_type_id: fee_type.id, status: :unpaid } attrs = Map.merge(default_attrs, attrs) MembershipFeeCycle |> Ash.Changeset.for_create(:create, attrs) |> Ash.create!(actor: actor) end 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() # Create a test member first {:ok, member} = Mv.Membership.create_member( %{ first_name: "Test", last_name: "User", email: "test@example.com" }, actor: system_actor ) 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() # Create test members {:ok, member1} = Mv.Membership.create_member( %{ first_name: "Max", last_name: "Mustermann", email: "max@example.com" }, actor: system_actor ) {:ok, member2} = Mv.Membership.create_member( %{ first_name: "Erika", last_name: "Musterfrau", email: "erika@example.com" }, actor: system_actor ) {:ok, member3} = Mv.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 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() Ash.destroy!(member1, actor: system_actor) # Trigger copy_emails event directly - selection still contains the deleted ID # but the member is no longer in @members list after reload result = render_hook(view, "copy_emails", %{}) # Should show error since no visible members match selection assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0" end test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{ conn: conn, member1: member1, member2: member2 } do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") # Select two members by sending the select_member event directly render_click(view, "select_member", %{"id" => member1.id}) render_click(view, "select_member", %{"id" => member2.id}) # Get the socket state to verify the formatted email string state = :sys.get_state(view.pid) selected_members = state.socket.assigns.selected_members # Verify MapSet is used assert %MapSet{} = selected_members assert MapSet.size(selected_members) == 2 end test "email format is 'First Last ' 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 = Mv.Helpers.SystemActor.get_system_actor() {:ok, test_member} = Mv.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 " # 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 (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} = Mv.Membership.create_member(attrs, actor: actor) member end test "filter shows only members with paid status in last cycle", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) fee_type = create_fee_type(%{interval: :yearly}, system_actor) today = Date.utc_today() last_year_start = Date.new!(today.year - 1, 1, 1) # Member with paid last cycle paid_member = create_member( %{ first_name: "PaidLast", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( paid_member, fee_type, %{cycle_start: last_year_start, status: :paid}, system_actor ) # Member with unpaid last cycle unpaid_member = create_member( %{ first_name: "UnpaidLast", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid}, system_actor ) {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid") assert html =~ "PaidLast" refute html =~ "UnpaidLast" end test "filter shows only members with unpaid status in last cycle", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) fee_type = create_fee_type(%{interval: :yearly}, system_actor) today = Date.utc_today() last_year_start = Date.new!(today.year - 1, 1, 1) # Member with paid last cycle paid_member = create_member( %{ first_name: "PaidLast", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( paid_member, fee_type, %{cycle_start: last_year_start, status: :paid}, system_actor ) # Member with unpaid last cycle unpaid_member = create_member( %{ first_name: "UnpaidLast", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid}, system_actor ) {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid") refute html =~ "PaidLast" assert html =~ "UnpaidLast" end test "filter shows only members with paid status in current cycle", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) fee_type = create_fee_type(%{interval: :yearly}, system_actor) today = Date.utc_today() current_year_start = Date.new!(today.year, 1, 1) # Member with paid current cycle paid_member = create_member( %{ first_name: "PaidCurrent", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( paid_member, fee_type, %{cycle_start: current_year_start, status: :paid}, system_actor ) # Member with unpaid current cycle unpaid_member = create_member( %{ first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid}, system_actor ) {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true") assert html =~ "PaidCurrent" refute html =~ "UnpaidCurrent" end test "filter shows only members with unpaid status in current cycle", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() conn = conn_with_oidc_user(conn) fee_type = create_fee_type(%{interval: :yearly}, system_actor) today = Date.utc_today() current_year_start = Date.new!(today.year, 1, 1) # Member with paid current cycle paid_member = create_member( %{ first_name: "PaidCurrent", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( paid_member, fee_type, %{cycle_start: current_year_start, status: :paid}, system_actor ) # Member with unpaid current cycle unpaid_member = create_member( %{ first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id }, system_actor ) create_cycle( unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid}, system_actor ) {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true") refute html =~ "PaidCurrent" assert html =~ "UnpaidCurrent" end test "toggle cycle view updates URL and preserves filter", %{conn: conn} do conn = conn_with_oidc_user(conn) # Start with last cycle view and paid filter {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid") # Toggle to current cycle - this should update URL and preserve filter # Use the button in the toolbar view |> element("button[phx-click='toggle_cycle_view']") |> render_click() # Wait for patch to complete path = assert_patch(view) # URL should contain both filter and show_current_cycle assert path =~ "cycle_status_filter=paid" assert path =~ "show_current_cycle=true" end end describe "boolean custom field filters" do alias Mv.Membership.CustomField # Helper to create a boolean custom field (uses system actor for authorization) defp create_boolean_custom_field(attrs \\ %{}) do system_actor = Mv.Helpers.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 = Mv.Helpers.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 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_ values correctly", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() # Test true value {:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true") state1 = :sys.get_state(view1.pid) filters1 = state1.socket.assigns.boolean_custom_field_filters assert filters1[boolean_field.id] == true refute filters1[boolean_field.id] == "true" # Test false value {:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false") state2 = :sys.get_state(view2.pid) filters2 = state2.socket.assigns.boolean_custom_field_filters assert filters2[boolean_field.id] == false refute filters2[boolean_field.id] == "false" end test "handle_params ignores non-existent custom field IDs", %{conn: conn} do conn = conn_with_oidc_user(conn) fake_id = Ecto.UUID.generate() {:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Filter should not be added for non-existent custom field refute Map.has_key?(filters, fake_id) assert filters == %{} end test "handle_params ignores non-boolean custom fields", %{conn: conn} do conn = conn_with_oidc_user(conn) string_field = create_string_custom_field() {:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Filter should not be added for non-boolean custom field refute Map.has_key?(filters, string_field.id) assert filters == %{} end test "handle_params ignores invalid filter values", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() # Test various invalid values invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"] for invalid_value <- invalid_values do {:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Invalid values should not be added to filters refute Map.has_key?(filters, boolean_field.id), "Invalid value '#{invalid_value}' should not be added to filters" end end test "handle_params handles multiple boolean filters simultaneously", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field1 = create_boolean_custom_field() boolean_field2 = create_boolean_custom_field() {:ok, view, _html} = live( conn, "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" ) state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters assert filters[boolean_field1.id] == true assert filters[boolean_field2.id] == false assert map_size(filters) == 2 end test "build_query_params includes active boolean filters and excludes nil filters", %{ conn: conn } do conn = conn_with_oidc_user(conn) boolean_field1 = create_boolean_custom_field() boolean_field2 = create_boolean_custom_field() # Test with active filters {:ok, view1, _html} = live( conn, "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" ) # Trigger a search to see if filters are preserved in URL view1 |> element("[data-testid='search-input']") |> render_change(%{value: "test"}) # Check that the patch includes boolean filters path1 = assert_patch(view1) assert path1 =~ "bf_#{boolean_field1.id}=true" assert path1 =~ "bf_#{boolean_field2.id}=false" # Test without filters (nil filters should not appear in URL) {:ok, view2, _html} = live(conn, "/members") # Trigger a search view2 |> element("[data-testid='search-input']") |> render_change(%{value: "test"}) # Check that no bf_ params are in URL path2 = assert_patch(view2) refute path2 =~ "bf_" end test "boolean filters are preserved during navigation actions", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() {:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=true") # Test sort toggle preserves filter view |> element("[data-testid='email']") |> render_click() path1 = assert_patch(view) assert path1 =~ "bf_#{boolean_field.id}=true" # Test search change preserves filter view |> element("[data-testid='search-input']") |> render_change(%{value: "test"}) path2 = assert_patch(view) assert path2 =~ "bf_#{boolean_field.id}=true" end test "boolean filters work together with cycle_status_filter", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() {:ok, view, _html} = live( conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true" ) state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Both filters should be set assert filters[boolean_field.id] == true assert state.socket.assigns.cycle_status_filter == :paid # Both should be in URL when triggering search view |> element("[data-testid='search-input']") |> render_change(%{value: "test"}) path = assert_patch(view) assert path =~ "cycle_status_filter=paid" assert path =~ "bf_#{boolean_field.id}=true" end test "handle_params removes filter when custom field is deleted", %{conn: conn} do conn = conn_with_oidc_user(conn) system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() # Set up filter via URL {:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=true") state_before = :sys.get_state(view.pid) filters_before = state_before.socket.assigns.boolean_custom_field_filters assert filters_before[boolean_field.id] == true # Delete the custom field (requires actor with destroy permission) Ash.destroy!(boolean_field, actor: system_actor) # Navigate again - filter should be removed since custom field no longer exists {:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=true") state_after = :sys.get_state(view2.pid) filters_after = state_after.socket.assigns.boolean_custom_field_filters # Filter should not be present for deleted custom field refute Map.has_key?(filters_after, boolean_field.id) assert filters_after == %{} end test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() # URL-encode the custom field ID (though UUIDs shouldn't need encoding normally) encoded_id = URI.encode(boolean_field.id) {:ok, view, _html} = live(conn, "/members?bf_#{encoded_id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Filter should work with URL-encoded ID # Phoenix should decode it automatically, so we check with original ID assert filters[boolean_field.id] == true end test "handle_params ignores malformed prefix (bf_bf_)", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() # Try to send parameter with double prefix {:ok, view, _html} = live(conn, "/members?bf_bf_#{boolean_field.id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Should not parse as valid filter (UUID validation should fail) refute Map.has_key?(filters, boolean_field.id) assert filters == %{} end test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do conn = conn_with_oidc_user(conn) # Create 60 boolean custom fields (more than the limit) boolean_fields = Enum.map(1..60, fn _ -> create_boolean_custom_field() end) # Build URL with all 60 filters filter_params = Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end) {:ok, view, _html} = live(conn, "/members?#{filter_params}") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Should limit to maximum 50 filters assert map_size(filters) <= 50 # All filters in the result should be valid Enum.each(filters, fn {id, value} -> assert value in [true, false] # Verify the ID corresponds to one of our boolean fields assert id in Enum.map(boolean_fields, &to_string(&1.id)) end) end test "handle_params ignores extremely long custom field IDs", %{conn: conn} do conn = conn_with_oidc_user(conn) boolean_field = create_boolean_custom_field() # Create a fake ID that's way too long (UUIDs are max 36 chars) fake_long_id = String.duplicate("a", 100) {:ok, view, _html} = live(conn, "/members?bf_#{fake_long_id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters # Should not accept the extremely long ID refute Map.has_key?(filters, fake_long_id) # Valid boolean field should still work refute Map.has_key?(filters, boolean_field.id) assert filters == %{} end # Helper to create a member with a boolean custom field value defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do attrs = %{ first_name: "Test", last_name: "Member", email: "test.member.#{System.unique_integer([:positive])}@example.com" } |> Map.merge(member_attrs) {:ok, member} = Mv.Membership.create_member(attrs, actor: actor) {: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(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 = Mv.Helpers.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 = 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor) 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() {:ok, member} = Mv.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} = 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(actor: system_actor) # Reload member with custom field values member = member |> Ash.load!(:custom_field_values, actor: system_actor) 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() {:ok, member} = Mv.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 = 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() boolean_field = create_boolean_custom_field() {:ok, member} = Mv.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} = Mv.Membership.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 = 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 system_actor = Mv.Helpers.SystemActor.get_system_actor() string_field = create_string_custom_field() boolean_field = create_boolean_custom_field() {:ok, member} = Mv.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} = 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(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 = 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 system_actor = Mv.Helpers.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} = Mv.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 = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) 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 system_actor = Mv.Helpers.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} = Mv.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 = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) 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 system_actor = Mv.Helpers.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 = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) 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 system_actor = Mv.Helpers.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} = Mv.Membership.create_member( %{ first_name: "BothTrue", last_name: "Member", email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com" }, actor: system_actor ) {: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(actor: system_actor) {: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(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} = Mv.Membership.create_member( %{ first_name: "Mixed", last_name: "Member", email: "mixed.member.#{System.unique_integer([:positive])}@example.com" }, actor: system_actor ) {: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(actor: system_actor) {: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(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 = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) 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 system_actor = Mv.Helpers.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 = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) 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 system_actor = Mv.Helpers.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} = Mv.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 = Mv.Helpers.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} = Mv.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} = 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(actor: system_actor) create_cycle( member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid}, system_actor ) # Member with true boolean value but unpaid status {:ok, member_unpaid_true} = Mv.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} = 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(actor: system_actor) create_cycle( member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid}, system_actor ) # Test both filters together {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true") # Only member_paid_true should match both filters assert html =~ "PaidTrue" refute html =~ "UnpaidTrue" end test "boolean filter integration works together with search query", %{conn: conn} do system_actor = Mv.Helpers.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 = Mv.Helpers.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} = Mv.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 test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do conn = conn_with_oidc_user(conn) # Start with no boolean custom fields {:ok, view, _html} = live(conn, "/members") state_before = :sys.get_state(view.pid) boolean_fields_before = state_before.socket.assigns.boolean_custom_fields assert boolean_fields_before == [] # Create a new boolean custom field new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"}) # Navigate again - the new field should appear {:ok, view2, _html} = live(conn, "/members") state_after = :sys.get_state(view2.pid) boolean_fields_after = state_after.socket.assigns.boolean_custom_fields # New boolean field should be present assert length(boolean_fields_after) == 1 assert Enum.any?(boolean_fields_after, &(&1.id == new_boolean_field.id)) assert Enum.any?(boolean_fields_after, &(&1.name == "Newly Added Field")) end @tag :slow test "boolean filter performance with 150 members", %{conn: conn} do system_actor = Mv.Helpers.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