fix existing flakiness + cut runtime closes #533 #544

Merged
moritz merged 11 commits from issue/mitgliederverwaltung-533 into main 2026-06-16 18:30:14 +02:00
Showing only changes of commit ccd1f81e3e - Show all commits

View file

@ -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 <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
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_<id> 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_<uuid>)", %{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