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:
parent
3bd55fbfec
commit
ccd1f81e3e
1 changed files with 169 additions and 147 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue