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 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, :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 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 ' 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 " # 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 alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeCycle # Helper to create a membership fee type 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 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 # Helper to create a cycle 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 "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 membership fee status column header view |> element("button[phx-click='toggle_cycle_view'].btn-xs") |> 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 end