test(member-live): assert rendered behavior instead of socket internals in the index view

Replace :sys.get_state assertions on the LiveView socket with assertions on
rendered output, so the tests pin user-visible behavior rather than internal
state; the few sites with no observable equivalent are kept and annotated.
This commit is contained in:
Moritz 2026-06-16 17:50:57 +02:00
parent 3bd55fbfec
commit ccd1f81e3e

View file

@ -1,4 +1,8 @@
defmodule MvWeb.MemberLive.IndexTest do 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 use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query
@ -297,10 +301,14 @@ defmodule MvWeb.MemberLive.IndexTest do
send(view.pid, {:search_changed, "Friedrich"}) send(view.pid, {:search_changed, "Friedrich"})
state = :sys.get_state(view.pid) # Rationale: this exercises the handle_info(:search_changed) callback in isolation.
# The search box value is owned by SearchBarComponent (assign_new), and scope is
assert state.socket.assigns.query == "Friedrich" # recomputed on handle_params rather than this handle_info, so the updated :query
assert is_list(state.socket.assigns.members) # 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 end
@tag :ui @tag :ui
@ -393,6 +401,38 @@ defmodule MvWeb.MemberLive.IndexTest do
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"])) |> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
end 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 describe "copy_emails feature" do
setup do setup do
system_actor = SystemActor.get_system_actor() 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 # Select two members by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id}) 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 # Both selected members are observably reflected: their row checkboxes are
state = :sys.get_state(view.pid) # checked and the scope badge shows the selection count ("2").
selected_members = state.socket.assigns.selected_members assert has_element?(view, ~s(input[role="checkbox"][name="#{member1.id}"][checked]))
assert has_element?(view, ~s(input[role="checkbox"][name="#{member2.id}"][checked]))
# Verify MapSet is used assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "2"
assert %MapSet{} = selected_members
assert MapSet.size(selected_members) == 2
end end
test "email format is 'First Last <email>' with comma separator", %{ test "email format is 'First Last <email>' 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 test "scope is :all when nothing selected and no filter", %{conn: conn} do
conn = conn_with_oidc_user(conn) 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 # :all scope renders the muted "all" badge.
assert assigns.scope == :all assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "all"
end end
test "scope is :filtered when a search term is active", %{conn: conn} do test "scope is :filtered when a search term is active", %{conn: conn} do
conn = conn_with_oidc_user(conn) 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 # An active search narrows the list, so the scope badge reads "filtered".
assert assigns.scope == :filtered assert scope_badge(html) |> LazyHTML.text() |> String.trim() == "filtered"
end end
test "scope is :filtered when a non-search filter is active", %{conn: conn} do test "scope is :filtered when a non-search filter is active", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?cycle_status_filter=paid") {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
assigns = :sys.get_state(view.pid).socket.assigns
assert assigns.scope == :filtered
badge = scope_badge(html) badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered" assert badge |> LazyHTML.text() |> String.trim() == "filtered"
@ -1001,10 +1036,13 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {: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 # A selection switches the badge to the emphasized (primary) variant whose
assert assigns.scope == :selection # 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 end
test "with no selection, recipient_count and mailto_bcc cover all members", %{ 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) 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 # 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. # Both seeded members have an email, so the no-selection scope covers both.
assert assigns.recipient_count == 2 assert bcc =~ "scope1%40example.com"
assert assigns.mailto_bcc =~ "scope1%40example.com" assert bcc =~ "scope2%40example.com"
assert assigns.mailto_bcc =~ "scope2%40example.com"
end end
test "with a selection, recipient_count and mailto_bcc cover only the selection", %{ 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}) render_click(view, "select_member", %{"id" => member1.id})
assigns = :sys.get_state(view.pid).socket.assigns bcc = mailto_bcc(view)
assert assigns.recipient_count == 1 assert bcc =~ "scope1%40example.com"
assert assigns.mailto_bcc =~ "scope1%40example.com" refute bcc =~ "scope2%40example.com"
refute assigns.mailto_bcc =~ "scope2%40example.com"
end end
end end
@ -1298,6 +1335,9 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {: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) state = :sys.get_state(view.pid)
assert state.socket.assigns.boolean_custom_field_filters == %{} assert state.socket.assigns.boolean_custom_field_filters == %{}
end end
@ -1309,6 +1349,9 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {: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) state = :sys.get_state(view.pid)
assert state.socket.assigns.boolean_custom_fields == [] assert state.socket.assigns.boolean_custom_fields == []
end end
@ -1319,89 +1362,82 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create boolean and non-boolean custom fields # Create boolean and non-boolean custom fields
boolean_field1 = create_boolean_custom_field(%{name: "Active Member"}) boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
boolean_field2 = create_boolean_custom_field(%{name: "Newsletter Subscription"}) 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") {:ok, view, _html} = live(conn, "/members")
open_member_filter(view)
state = :sys.get_state(view.pid) # Only the boolean fields render a tri-state filter control; the string field does not.
boolean_custom_fields = state.socket.assigns.boolean_custom_fields assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
# Should only contain boolean fields refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
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 end
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# Create boolean fields with specific names to test sorting # Create boolean fields with specific names to test sorting
_boolean_field_z = create_boolean_custom_field(%{name: "Zebra Field"}) field_z = create_boolean_custom_field(%{name: "Zebra Field"})
_boolean_field_a = create_boolean_custom_field(%{name: "Alpha Field"}) field_a = create_boolean_custom_field(%{name: "Alpha Field"})
_boolean_field_m = create_boolean_custom_field(%{name: "Middle Field"}) field_m = create_boolean_custom_field(%{name: "Middle Field"})
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
html = member_filter_html(view)
state = :sys.get_state(view.pid) # The rendered boolean filter controls appear in name-ascending order.
boolean_custom_fields = state.socket.assigns.boolean_custom_fields 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 assert rendered_order == [field_a.id, field_m.id, field_z.id]
names = Enum.map(boolean_custom_fields, & &1.name)
assert names == ["Alpha Field", "Middle Field", "Zebra Field"]
end end
test "handle_params parses bf_<id> values correctly", %{conn: conn} do test "handle_params parses bf_<id> values correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
# Test true value # Test true value: the "Yes" radio is checked (the boolean true, not the string "true").
{:ok, view1, _html} = {:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
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) # Test false value: the "No" radio is checked.
filters1 = state1.socket.assigns.boolean_custom_field_filters {:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
assert filters1[boolean_field.id] == true open_member_filter(view2)
refute filters1[boolean_field.id] == "true" assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
# 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 end
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
fake_id = Ecto.UUID.generate() fake_id = Ecto.UUID.generate()
{:ok, view, _html} = {:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
live(conn, "/members?bf_#{fake_id}=true") open_member_filter(view)
state = :sys.get_state(view.pid) # No filter control exists for a non-existent field, and no active-filter badge appears.
filters = state.socket.assigns.boolean_custom_field_filters refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
# Filter should not be added for non-existent custom field
refute Map.has_key?(filters, fake_id)
assert filters == %{}
end end
test "handle_params ignores non-boolean custom fields", %{conn: conn} do test "handle_params ignores non-boolean custom fields", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
string_field = create_string_custom_field() string_field = create_string_custom_field()
{:ok, view, _html} = {:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
live(conn, "/members?bf_#{string_field.id}=true") open_member_filter(view)
state = :sys.get_state(view.pid) # A string field is never rendered as a boolean filter, and no filter becomes active.
filters = state.socket.assigns.boolean_custom_field_filters refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
# Filter should not be added for non-boolean custom field
refute Map.has_key?(filters, string_field.id)
assert filters == %{}
end end
test "handle_params ignores invalid filter values", %{conn: conn} do 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"] invalid_values = ["1", "0", "yes", "no", "True", "False", "", "invalid", "null"]
for invalid_value <- invalid_values do for invalid_value <- invalid_values do
{:ok, view, _html} = {:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}") open_member_filter(view)
state = :sys.get_state(view.pid) # An invalid value leaves the field's filter at "All" (no filter applied).
filters = state.socket.assigns.boolean_custom_field_filters assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
"Invalid value '#{invalid_value}' should leave the filter at All"
# 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
end end
@ -1435,12 +1468,16 @@ defmodule MvWeb.MemberLive.IndexTest do
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
) )
state = :sys.get_state(view.pid) open_member_filter(view)
filters = state.socket.assigns.boolean_custom_field_filters
assert filters[boolean_field1.id] == true # Both filters are reflected: field1 at "Yes", field2 at "No", and the
assert filters[boolean_field2.id] == false # active-filter count badge shows 2.
assert map_size(filters) == 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 end
test "build_query_params includes active boolean filters and excludes nil filters", %{ 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" "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
) )
state = :sys.get_state(view.pid) open_member_filter(view)
filters = state.socket.assigns.boolean_custom_field_filters
# Both filters should be set # Both filters are reflected in the rendered controls: the boolean field at
assert filters[boolean_field.id] == true # "Yes" and the payment-status filter at "paid".
assert state.socket.assigns.cycle_status_filter == :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 # Both should be in URL when triggering search
view view
@ -1540,9 +1577,8 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true") live(conn, "/members?bf_#{boolean_field.id}=true")
state_before = :sys.get_state(view.pid) open_member_filter(view)
filters_before = state_before.socket.assigns.boolean_custom_field_filters assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
assert filters_before[boolean_field.id] == true
# Delete the custom field (requires actor with destroy permission) # Delete the custom field (requires actor with destroy permission)
Ash.destroy!(boolean_field, actor: system_actor) Ash.destroy!(boolean_field, actor: system_actor)
@ -1551,12 +1587,11 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view2, _html} = {:ok, view2, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true") live(conn, "/members?bf_#{boolean_field.id}=true")
state_after = :sys.get_state(view2.pid) open_member_filter(view2)
filters_after = state_after.socket.assigns.boolean_custom_field_filters
# Filter should not be present for deleted custom field # The deleted field renders no filter control and no filter is active.
refute Map.has_key?(filters_after, boolean_field.id) refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}")
assert filters_after == %{} refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active))
end end
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?bf_#{encoded_id}=true") live(conn, "/members?bf_#{encoded_id}=true")
state = :sys.get_state(view.pid) open_member_filter(view)
filters = state.socket.assigns.boolean_custom_field_filters
# Filter should work with URL-encoded ID # Phoenix decodes the param, so the filter applies under the original ID:
# Phoenix should decode it automatically, so we check with original ID # the "Yes" radio for the field is checked.
assert filters[boolean_field.id] == true assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
end end
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
@ -1585,12 +1619,12 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?bf_bf_#{boolean_field.id}=true") live(conn, "/members?bf_bf_#{boolean_field.id}=true")
state = :sys.get_state(view.pid) open_member_filter(view)
filters = state.socket.assigns.boolean_custom_field_filters
# Should not parse as valid filter (UUID validation should fail) # The double-prefixed param is not a valid filter: the field stays at "All"
refute Map.has_key?(filters, boolean_field.id) # and no filter is active.
assert filters == %{} 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 end
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do 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}") {:ok, view, _html} = live(conn, "/members?#{filter_params}")
state = :sys.get_state(view.pid) # The active-filter count badge is the observable carrier of the filter count.
filters = state.socket.assigns.boolean_custom_field_filters # With 60 requested filters, the DoS cap clamps it to exactly 50.
assert view
# Should limit to maximum 50 filters |> element(~s(button[aria-label="Filter members"] .badge), "50")
assert map_size(filters) <= 50 |> has_element?()
# 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 end
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
@ -1628,14 +1656,12 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?bf_#{fake_long_id}=true") live(conn, "/members?bf_#{fake_long_id}=true")
state = :sys.get_state(view.pid) open_member_filter(view)
filters = state.socket.assigns.boolean_custom_field_filters
# Should not accept the extremely long ID # The over-long ID is rejected: the real field stays at "All" and no filter
refute Map.has_key?(filters, fake_long_id) # is active.
# Valid boolean field should still work assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
refute Map.has_key?(filters, boolean_field.id) refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
assert filters == %{}
end end
# Helper to create a member with a boolean custom field value # 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 # Start with no boolean custom fields
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
open_member_filter(view)
state_before = :sys.get_state(view.pid) # No boolean field control is rendered yet.
boolean_fields_before = state_before.socket.assigns.boolean_custom_fields refute has_element?(view, ~s(input[name^="custom_boolean"]))
assert boolean_fields_before == []
# Create a new boolean custom field # Create a new boolean custom field
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added 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") {:ok, view2, _html} = live(conn, "/members")
html_after = member_filter_html(view2)
state_after = :sys.get_state(view2.pid) assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
boolean_fields_after = state_after.socket.assigns.boolean_custom_fields assert html_after =~ "Newly Added Field"
# 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 end
@tag :slow @tag :slow