defmodule MvWeb.MemberLive.IndexTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest require Ash.Query 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 erstellt erfolgreich") 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 create 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, :phone_number, :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 view |> element("[phx-click='select_member'][phx-value-id='#{member1.id}']") |> render_click() view |> element("[phx-click='select_member'][phx-value-id='#{member2.id}']") |> render_click() # 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 view |> element("[phx-click='select_member'][phx-value-id='#{member3.id}']") |> render_click() # 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 view |> element("[phx-click='select_member'][phx-value-id='#{member1.id}']") |> render_click() # 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 view |> element("[phx-click='select_member'][phx-value-id='#{member1.id}']") |> render_click() view |> element("[phx-click='select_member'][phx-value-id='#{member2.id}']") |> render_click() # 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 {: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 view |> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']") |> render_click() # 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 not visible when no members are selected", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") # Ensure no members are selected (default state) refute has_element?(view, "#copy-emails-btn") end test "copy button is visible when members are selected", %{ conn: conn, member1: member1 } do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") # Select a member view |> element("[phx-click='select_member'][phx-value-id='#{member1.id}']") |> render_click() # Button should now be visible assert has_element?(view, "#copy-emails-btn") 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 view |> element("[phx-click='select_member'][phx-value-id='#{member1.id}']") |> render_click() # Click copy button view |> element("#copy-emails-btn") |> render_click() # Flash message should appear assert has_element?(view, "#flash-group") end end describe "payment filter integration" do setup do # Create members with different payment status # Use unique names that won't appear elsewhere in the HTML {:ok, paid_member} = Mv.Membership.create_member(%{ first_name: "Zahler", last_name: "Mitglied", email: "zahler@example.com", paid: true }) {:ok, unpaid_member} = Mv.Membership.create_member(%{ first_name: "Nichtzahler", last_name: "Mitglied", email: "nichtzahler@example.com", paid: false }) {:ok, nil_paid_member} = Mv.Membership.create_member(%{ first_name: "Unbestimmt", last_name: "Mitglied", email: "unbestimmt@example.com" # paid is nil by default }) %{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member} end test "filter shows all members when no filter is active", %{ conn: conn, paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member } do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") assert html =~ paid_member.first_name assert html =~ unpaid_member.first_name assert html =~ nil_paid_member.first_name end test "filter shows only paid members when paid filter is active", %{ conn: conn, paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member } do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?paid_filter=paid") assert html =~ paid_member.first_name refute html =~ unpaid_member.first_name refute html =~ nil_paid_member.first_name end test "filter shows only unpaid members (including nil) when not_paid filter is active", %{ conn: conn, paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member } do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?paid_filter=not_paid") refute html =~ paid_member.first_name assert html =~ unpaid_member.first_name assert html =~ nil_paid_member.first_name end test "filter combines with search query (AND)", %{ conn: conn, paid_member: paid_member } do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid") assert html =~ paid_member.first_name end test "filter combines with sorting", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc") # Click on email sort header view |> element("[data-testid='email']") |> render_click() # Filter should be preserved in URL path = assert_patch(view) assert path =~ "paid_filter=paid" assert path =~ "sort_field=email" end test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") # Open filter dropdown view |> element("#payment-filter button[aria-haspopup='true']") |> render_click() # Select "Paid" option view |> element("#payment-filter button[phx-value-filter='paid']") |> render_click() path = assert_patch(view) assert path =~ "paid_filter=paid" end test "URL parameter is correctly read on page load", %{ conn: conn, paid_member: paid_member } do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?paid_filter=paid") # Only paid member should be visible assert html =~ paid_member.first_name # Filter badge should be visible assert html =~ "badge" end test "invalid URL parameter is ignored", %{ conn: conn, paid_member: paid_member, unpaid_member: unpaid_member } do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value") # All members should be visible (filter not applied) assert html =~ paid_member.first_name assert html =~ unpaid_member.first_name end test "search maintains filter state", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?paid_filter=paid") # Perform search view |> element("[data-testid='search-input']") |> render_change(%{"query" => "test"}) # Filter state should be maintained in URL path = assert_patch(view) assert path =~ "paid_filter=paid" end end describe "paid column in table" do setup do {:ok, paid_member} = Mv.Membership.create_member(%{ first_name: "Paid", last_name: "Member", email: "paid.column@example.com", paid: true }) {:ok, unpaid_member} = Mv.Membership.create_member(%{ first_name: "Unpaid", last_name: "Member", email: "unpaid.column@example.com", paid: false }) %{paid_member: paid_member, unpaid_member: unpaid_member} end test "paid column shows green badge for paid members", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") # Check for success badge (green) assert html =~ "badge-success" end test "paid column shows red badge for unpaid members", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") # Check for error badge (red) assert html =~ "badge-error" end test "paid column shows 'Yes' for paid members", %{conn: conn} do conn = conn_with_oidc_user(conn) Gettext.put_locale(MvWeb.Gettext, "en") {:ok, _view, html} = live(conn, "/members") # The table should contain "Yes" text inside badge assert html =~ "badge-success" assert html =~ "Yes" end test "paid column shows 'No' for unpaid members", %{conn: conn} do conn = conn_with_oidc_user(conn) Gettext.put_locale(MvWeb.Gettext, "en") {:ok, _view, html} = live(conn, "/members") # The table should contain "No" text inside badge assert html =~ "badge-error" assert html =~ "No" end end end