diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index be61c9c..cd166cc 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -1,4 +1,8 @@ defmodule MvWeb.MemberLive.IndexTest do + # async: false on purpose: the @slow "boolean filter performance with 150 members" + # test asserts a wall-clock budget (duration < 1000ms). Running this module in + # parallel with others adds CPU contention that inflates that measurement and makes + # the timing assertion flaky, so this module stays synchronous. use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest require Ash.Query @@ -297,10 +301,14 @@ defmodule MvWeb.MemberLive.IndexTest do 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) + # Rationale: this exercises the handle_info(:search_changed) callback in isolation. + # The search box value is owned by SearchBarComponent (assign_new), and scope is + # recomputed on handle_params rather than this handle_info, so the updated :query + # and :members assigns have no faithful rendered proxy here. The assigns are + # asserted on internal state to preserve the original coverage of the callback. + assigns = :sys.get_state(view.pid).socket.assigns + assert assigns.query == "Friedrich" + assert is_list(assigns.members) end @tag :ui @@ -393,6 +401,38 @@ defmodule MvWeb.MemberLive.IndexTest do |> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"])) end + # Opens the bulk-actions dropdown and returns the mailto link's BCC payload + # (everything after "mailto:?bcc="). This is the observable carrier of the + # recipient set / recipient_count, replacing direct socket-assign inspection. + defp mailto_bcc(view) do + view |> element(~s([data-testid="bulk-actions-button"])) |> render_click() + + href = + render(view) + |> LazyHTML.from_fragment() + |> LazyHTML.query(~s([data-testid="bulk-actions-mailto"])) + |> LazyHTML.attribute("href") + |> List.first() + + case href do + "mailto:?bcc=" <> bcc -> bcc + other -> other || "" + end + end + + # Opens the member-filter dropdown so its boolean filter controls are rendered. + defp open_member_filter(view) do + view + |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"])) + |> render_click() + end + + # Returns the rendered HTML of the member-filter dropdown (with it open). + defp member_filter_html(view) do + open_member_filter(view) + render(view) + end + describe "copy_emails feature" do setup do system_actor = SystemActor.get_system_actor() @@ -528,15 +568,13 @@ defmodule MvWeb.MemberLive.IndexTest do # 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}) + html = 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 + # Both selected members are observably reflected: their row checkboxes are + # checked and the scope badge shows the selection count ("2"). + assert has_element?(view, ~s(input[role="checkbox"][name="#{member1.id}"][checked])) + assert has_element?(view, ~s(input[role="checkbox"][name="#{member2.id}"][checked])) + assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "2" end test "email format is 'First Last ' with comma separator", %{ @@ -969,26 +1007,23 @@ defmodule MvWeb.MemberLive.IndexTest do test "scope is :all when nothing selected and no filter", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, _view, html} = live(conn, "/members") - assigns = :sys.get_state(view.pid).socket.assigns - assert assigns.scope == :all + # :all scope renders the muted "all" badge. + assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "all" end test "scope is :filtered when a search term is active", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=Scope") + {:ok, _view, html} = live(conn, "/members?query=Scope") - assigns = :sys.get_state(view.pid).socket.assigns - assert assigns.scope == :filtered + # An active search narrows the list, so the scope badge reads "filtered". + assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "filtered" end test "scope is :filtered when a non-search filter is active", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?cycle_status_filter=paid") - - assigns = :sys.get_state(view.pid).socket.assigns - assert assigns.scope == :filtered + {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid") badge = scope_badge(html) assert badge |> LazyHTML.text() |> String.trim() == "filtered" @@ -1001,10 +1036,13 @@ defmodule MvWeb.MemberLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - render_click(view, "select_member", %{"id" => member1.id}) + html = render_click(view, "select_member", %{"id" => member1.id}) - assigns = :sys.get_state(view.pid).socket.assigns - assert assigns.scope == :selection + # A selection switches the badge to the emphasized (primary) variant whose + # label is the selected count ("1"), which is the observable proxy for scope == :selection. + badge = scope_badge(html) + assert badge |> LazyHTML.text() |> String.trim() == "1" + assert badge |> LazyHTML.attribute("class") |> List.first() =~ "badge-primary" end test "with no selection, recipient_count and mailto_bcc cover all members", %{ @@ -1013,11 +1051,11 @@ defmodule MvWeb.MemberLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - assigns = :sys.get_state(view.pid).socket.assigns + # The mailto link's BCC is the observable carrier of recipient_count/mailto_bcc. + bcc = mailto_bcc(view) # Both seeded members have an email, so the no-selection scope covers both. - assert assigns.recipient_count == 2 - assert assigns.mailto_bcc =~ "scope1%40example.com" - assert assigns.mailto_bcc =~ "scope2%40example.com" + assert bcc =~ "scope1%40example.com" + assert bcc =~ "scope2%40example.com" end test "with a selection, recipient_count and mailto_bcc cover only the selection", %{ @@ -1029,10 +1067,9 @@ defmodule MvWeb.MemberLive.IndexTest do render_click(view, "select_member", %{"id" => member1.id}) - assigns = :sys.get_state(view.pid).socket.assigns - assert assigns.recipient_count == 1 - assert assigns.mailto_bcc =~ "scope1%40example.com" - refute assigns.mailto_bcc =~ "scope2%40example.com" + bcc = mailto_bcc(view) + assert bcc =~ "scope1%40example.com" + refute bcc =~ "scope2%40example.com" end end @@ -1298,6 +1335,9 @@ defmodule MvWeb.MemberLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") + # Rationale: with no boolean fields and no active filter there is no rendered + # filter control or active-count badge to observe, so the empty initial filter + # map is asserted on internal state directly. state = :sys.get_state(view.pid) assert state.socket.assigns.boolean_custom_field_filters == %{} end @@ -1309,6 +1349,9 @@ defmodule MvWeb.MemberLive.IndexTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") + # Rationale: the absence of boolean fields means the filter dropdown renders + # no boolean fieldsets; "empty list" has no positive rendered signal, so it is + # asserted on internal state directly. state = :sys.get_state(view.pid) assert state.socket.assigns.boolean_custom_fields == [] end @@ -1319,89 +1362,82 @@ defmodule MvWeb.MemberLive.IndexTest do # 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"}) + string_field = create_string_custom_field(%{name: "Phone Number"}) {:ok, view, _html} = live(conn, "/members") + open_member_filter(view) - 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)) + # Only the boolean fields render a tri-state filter control; the string field does not. + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}") + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}") + refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}") end test "mount sorts boolean custom fields by name ascending", %{conn: conn} do conn = conn_with_oidc_user(conn) # Create boolean fields with specific names to test sorting - _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"}) + field_z = create_boolean_custom_field(%{name: "Zebra Field"}) + field_a = create_boolean_custom_field(%{name: "Alpha Field"}) + field_m = create_boolean_custom_field(%{name: "Middle Field"}) {:ok, view, _html} = live(conn, "/members") + html = member_filter_html(view) - state = :sys.get_state(view.pid) - boolean_custom_fields = state.socket.assigns.boolean_custom_fields + # The rendered boolean filter controls appear in name-ascending order. + rendered_order = + html + |> LazyHTML.from_fragment() + |> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"])) + |> LazyHTML.attribute("id") + |> Enum.map( + &(&1 + |> String.replace_prefix("custom-boolean-filter-", "") + |> String.replace_suffix("-all", "")) + ) - # Should be sorted by name ascending - names = Enum.map(boolean_custom_fields, & &1.name) - assert names == ["Alpha Field", "Middle Field", "Zebra Field"] + assert rendered_order == [field_a.id, field_m.id, field_z.id] 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") + # Test true value: the "Yes" radio is checked (the boolean true, not the string "true"). + {:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true") + open_member_filter(view1) + assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]") + refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]") - 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" + # Test false value: the "No" radio is checked. + {:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false") + open_member_filter(view2) + assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]") + refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]") end test "handle_params ignores non-existent custom field IDs", %{conn: conn} do conn = conn_with_oidc_user(conn) fake_id = Ecto.UUID.generate() - {:ok, view, _html} = - live(conn, "/members?bf_#{fake_id}=true") + {:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true") + open_member_filter(view) - 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 == %{} + # No filter control exists for a non-existent field, and no active-filter badge appears. + refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}") + refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active)) end test "handle_params ignores non-boolean custom fields", %{conn: conn} do conn = conn_with_oidc_user(conn) string_field = create_string_custom_field() - {:ok, view, _html} = - live(conn, "/members?bf_#{string_field.id}=true") + {:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true") + open_member_filter(view) - 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 == %{} + # A string field is never rendered as a boolean filter, and no filter becomes active. + refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}") + refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active)) end test "handle_params ignores invalid filter values", %{conn: conn} do @@ -1412,15 +1448,12 @@ defmodule MvWeb.MemberLive.IndexTest do 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}") + {:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}") + open_member_filter(view) - 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" + # An invalid value leaves the field's filter at "All" (no filter applied). + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"), + "Invalid value '#{invalid_value}' should leave the filter at All" end end @@ -1435,12 +1468,16 @@ defmodule MvWeb.MemberLive.IndexTest do "/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 + open_member_filter(view) - assert filters[boolean_field1.id] == true - assert filters[boolean_field2.id] == false - assert map_size(filters) == 2 + # Both filters are reflected: field1 at "Yes", field2 at "No", and the + # active-filter count badge shows 2. + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]") + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]") + + assert view + |> element(~s(button[aria-label="Filter members"] .badge), "2") + |> has_element?() end test "build_query_params includes active boolean filters and excludes nil filters", %{ @@ -1514,12 +1551,12 @@ defmodule MvWeb.MemberLive.IndexTest do "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true" ) - state = :sys.get_state(view.pid) - filters = state.socket.assigns.boolean_custom_field_filters + open_member_filter(view) - # Both filters should be set - assert filters[boolean_field.id] == true - assert state.socket.assigns.cycle_status_filter == :paid + # Both filters are reflected in the rendered controls: the boolean field at + # "Yes" and the payment-status filter at "paid". + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]") + assert has_element?(view, "#payment-filter-paid[checked]") # Both should be in URL when triggering search view @@ -1540,9 +1577,8 @@ defmodule MvWeb.MemberLive.IndexTest do {: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 + open_member_filter(view) + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]") # Delete the custom field (requires actor with destroy permission) Ash.destroy!(boolean_field, actor: system_actor) @@ -1551,12 +1587,11 @@ defmodule MvWeb.MemberLive.IndexTest do {: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 + open_member_filter(view2) - # Filter should not be present for deleted custom field - refute Map.has_key?(filters_after, boolean_field.id) - assert filters_after == %{} + # The deleted field renders no filter control and no filter is active. + refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}") + refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active)) end test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do @@ -1569,12 +1604,11 @@ defmodule MvWeb.MemberLive.IndexTest do {:ok, view, _html} = live(conn, "/members?bf_#{encoded_id}=true") - state = :sys.get_state(view.pid) - filters = state.socket.assigns.boolean_custom_field_filters + open_member_filter(view) - # Filter should work with URL-encoded ID - # Phoenix should decode it automatically, so we check with original ID - assert filters[boolean_field.id] == true + # Phoenix decodes the param, so the filter applies under the original ID: + # the "Yes" radio for the field is checked. + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]") end test "handle_params ignores malformed prefix (bf_bf_)", %{conn: conn} do @@ -1585,12 +1619,12 @@ defmodule MvWeb.MemberLive.IndexTest do {: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 + open_member_filter(view) - # Should not parse as valid filter (UUID validation should fail) - refute Map.has_key?(filters, boolean_field.id) - assert filters == %{} + # The double-prefixed param is not a valid filter: the field stays at "All" + # and no filter is active. + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]") + refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active)) end test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do @@ -1605,17 +1639,11 @@ defmodule MvWeb.MemberLive.IndexTest do {: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) + # The active-filter count badge is the observable carrier of the filter count. + # With 60 requested filters, the DoS cap clamps it to exactly 50. + assert view + |> element(~s(button[aria-label="Filter members"] .badge), "50") + |> has_element?() end test "handle_params ignores extremely long custom field IDs", %{conn: conn} do @@ -1628,14 +1656,12 @@ defmodule MvWeb.MemberLive.IndexTest do {: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 + open_member_filter(view) - # 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 == %{} + # The over-long ID is rejected: the real field stays at "All" and no filter + # is active. + assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]") + refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active)) end # Helper to create a member with a boolean custom field value @@ -2283,24 +2309,20 @@ defmodule MvWeb.MemberLive.IndexTest do # Start with no boolean custom fields {:ok, view, _html} = live(conn, "/members") + open_member_filter(view) - state_before = :sys.get_state(view.pid) - boolean_fields_before = state_before.socket.assigns.boolean_custom_fields - assert boolean_fields_before == [] + # No boolean field control is rendered yet. + refute has_element?(view, ~s(input[name^="custom_boolean"])) # Create a new boolean custom field new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"}) - # Navigate again - the new field should appear + # Navigate again - the new field should appear in the filter dropdown. {:ok, view2, _html} = live(conn, "/members") + html_after = member_filter_html(view2) - 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")) + assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}") + assert html_after =~ "Newly Added Field" end @tag :slow