- Tests and UI pass actor for CustomField create/read/destroy; seeds use actor - Member required-custom-fields validation uses context.actor only (no fallback) - CODE_GUIDELINES: add rule forbidding system-actor fallbacks
1843 lines
59 KiB
Elixir
1843 lines
59 KiB
Elixir
defmodule MvWeb.MemberLive.IndexTest do
|
||
use MvWeb.ConnCase, async: true
|
||
import Phoenix.LiveViewTest
|
||
require Ash.Query
|
||
|
||
alias Mv.MembershipFees.MembershipFeeType
|
||
alias Mv.MembershipFees.MembershipFeeCycle
|
||
|
||
# Helper to create a membership fee type (shared across all tests)
|
||
defp create_fee_type(attrs, actor) do
|
||
default_attrs = %{
|
||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||
amount: Decimal.new("50.00"),
|
||
interval: :yearly
|
||
}
|
||
|
||
attrs = Map.merge(default_attrs, attrs)
|
||
|
||
MembershipFeeType
|
||
|> Ash.Changeset.for_create(:create, attrs)
|
||
|> Ash.create!(actor: actor)
|
||
end
|
||
|
||
# Helper to create a cycle (shared across all tests)
|
||
defp create_cycle(member, fee_type, attrs, actor) do
|
||
# Delete any auto-generated cycles first to avoid conflicts
|
||
existing_cycles =
|
||
MembershipFeeCycle
|
||
|> Ash.Query.filter(member_id == ^member.id)
|
||
|> Ash.read!(actor: actor)
|
||
|
||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle, actor: actor) end)
|
||
|
||
default_attrs = %{
|
||
cycle_start: ~D[2023-01-01],
|
||
amount: Decimal.new("50.00"),
|
||
member_id: member.id,
|
||
membership_fee_type_id: fee_type.id,
|
||
status: :unpaid
|
||
}
|
||
|
||
attrs = Map.merge(default_attrs, attrs)
|
||
|
||
MembershipFeeCycle
|
||
|> Ash.Changeset.for_create(:create, attrs)
|
||
|> Ash.create!(actor: actor)
|
||
end
|
||
|
||
test "shows translated title in German", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||
{:ok, _view, html} = live(conn, "/members")
|
||
# Expected German title
|
||
assert html =~ "Mitglieder"
|
||
end
|
||
|
||
test "shows translated title in English", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||
{:ok, _view, html} = live(conn, "/members")
|
||
# Expected English title
|
||
assert html =~ "Members"
|
||
end
|
||
|
||
test "shows translated button text in German", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||
{:ok, _view, html} = live(conn, "/members/new")
|
||
assert html =~ "Speichern"
|
||
end
|
||
|
||
test "shows translated button text in English", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||
{:ok, _view, html} = live(conn, "/members/new")
|
||
assert html =~ "Save"
|
||
end
|
||
|
||
test "shows translated flash message after creating a member in German", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||
|
||
form_data = %{
|
||
"member[first_name]" => "Max",
|
||
"member[last_name]" => "Mustermann",
|
||
"member[email]" => "max@example.com"
|
||
}
|
||
|
||
# Submit form and follow the redirect to get the flash message
|
||
{:ok, index_view, _html} =
|
||
form_view
|
||
|> form("#member-form", form_data)
|
||
|> render_submit()
|
||
|> follow_redirect(conn, "/members")
|
||
|
||
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
|
||
end
|
||
|
||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||
|
||
form_data = %{
|
||
"member[first_name]" => "Max",
|
||
"member[last_name]" => "Mustermann",
|
||
"member[email]" => "max@example.com"
|
||
}
|
||
|
||
# Submit form and follow the redirect to get the flash message
|
||
{:ok, index_view, _html} =
|
||
form_view
|
||
|> form("#member-form", form_data)
|
||
|> render_submit()
|
||
|> follow_redirect(conn, "/members")
|
||
|
||
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||
end
|
||
|
||
describe "sorting integration" do
|
||
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# The component data test ids are built with the name of the field
|
||
# First click – should sort ASC
|
||
view
|
||
|> element("[data-testid='email']")
|
||
|> render_click()
|
||
|
||
# The LiveView pushes a patch with the new query params
|
||
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
|
||
|
||
# Second click – toggles to DESC
|
||
view
|
||
|> element("[data-testid='email']")
|
||
|> render_click()
|
||
|
||
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
|
||
end
|
||
|
||
test "clicking different column header resets order to ascending", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
|
||
|
||
# Click on a different column
|
||
view
|
||
|> element("[data-testid='first_name']")
|
||
|> render_click()
|
||
|
||
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
|
||
end
|
||
|
||
test "all sortable columns work correctly", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# default ascending sorting with first name
|
||
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
|
||
|
||
sortable_fields = [
|
||
:email,
|
||
:street,
|
||
:house_number,
|
||
:postal_code,
|
||
:city,
|
||
:join_date
|
||
]
|
||
|
||
for field <- sortable_fields do
|
||
view
|
||
|> element("[data-testid='#{field}']")
|
||
|> render_click()
|
||
|
||
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
|
||
end
|
||
end
|
||
|
||
test "sorting works with search query", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=test")
|
||
|
||
view
|
||
|> element("[data-testid='email']")
|
||
|> render_click()
|
||
|
||
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
|
||
end
|
||
|
||
test "sorting maintains search query when toggling order", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
|
||
|
||
view
|
||
|> element("[data-testid='email']")
|
||
|> render_click()
|
||
|
||
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
|
||
end
|
||
end
|
||
|
||
describe "URL param handling" do
|
||
test "handle_params reads sort query and applies it", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||
|
||
# Check that the sort state is correctly applied
|
||
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
||
end
|
||
|
||
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
|
||
|
||
# Should not crash and should show default first name order
|
||
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
|
||
end
|
||
|
||
test "handle_params preserves search query with sort params", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
|
||
|
||
# Both search and sort should be preserved
|
||
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
||
end
|
||
end
|
||
|
||
describe "search and sort integration" do
|
||
test "search maintains sort state", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||
|
||
# Perform search
|
||
view
|
||
|> element("[data-testid='search-input']")
|
||
|> render_change(%{value: "test"})
|
||
|
||
# Sort state should be maintained
|
||
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
|
||
end
|
||
|
||
test "sort maintains search state", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
|
||
|
||
# Perform sort
|
||
view
|
||
|> element("[data-testid='email']")
|
||
|> render_click()
|
||
|
||
# Search state should be maintained
|
||
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
|
||
end
|
||
end
|
||
|
||
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
send(view.pid, {:search_changed, "Friedrich"})
|
||
|
||
state = :sys.get_state(view.pid)
|
||
|
||
assert state.socket.assigns.query == "Friedrich"
|
||
assert is_list(state.socket.assigns.members)
|
||
end
|
||
|
||
test "can delete a member without error", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
|
||
# Create a test member first
|
||
{:ok, member} =
|
||
Mv.Membership.create_member(
|
||
%{
|
||
first_name: "Test",
|
||
last_name: "User",
|
||
email: "test@example.com"
|
||
},
|
||
actor: system_actor
|
||
)
|
||
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, index_view, _html} = live(conn, "/members")
|
||
|
||
# Verify the member is displayed
|
||
assert has_element?(index_view, "#members", "Test User")
|
||
|
||
# Click the delete link for this member
|
||
index_view
|
||
|> element("a", "Delete")
|
||
|> render_click()
|
||
|
||
# Verify the member is no longer displayed
|
||
refute has_element?(index_view, "#members", "Test User")
|
||
|
||
# Verify the member was actually deleted from the database
|
||
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
|
||
end
|
||
|
||
describe "copy_emails feature" do
|
||
setup do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
|
||
# Create test members
|
||
{:ok, member1} =
|
||
Mv.Membership.create_member(
|
||
%{
|
||
first_name: "Max",
|
||
last_name: "Mustermann",
|
||
email: "max@example.com"
|
||
},
|
||
actor: system_actor
|
||
)
|
||
|
||
{:ok, member2} =
|
||
Mv.Membership.create_member(
|
||
%{
|
||
first_name: "Erika",
|
||
last_name: "Musterfrau",
|
||
email: "erika@example.com"
|
||
},
|
||
actor: system_actor
|
||
)
|
||
|
||
{:ok, member3} =
|
||
Mv.Membership.create_member(
|
||
%{
|
||
first_name: "Hans",
|
||
last_name: "Müller-Lüdenscheidt",
|
||
email: "hans@example.com"
|
||
},
|
||
actor: system_actor
|
||
)
|
||
|
||
%{member1: member1, member2: member2, member3: member3}
|
||
end
|
||
|
||
test "copy_emails event formats selected members correctly", %{
|
||
conn: conn,
|
||
member1: member1,
|
||
member2: member2
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select two members by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => member1.id})
|
||
render_click(view, "select_member", %{"id" => member2.id})
|
||
|
||
# Trigger copy_emails event
|
||
view |> element("#copy-emails-btn") |> render_click()
|
||
|
||
# Verify flash message shows correct count
|
||
assert render(view) =~ "2"
|
||
end
|
||
|
||
test "copy_emails event with no selection shows error flash", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Trigger copy_emails event directly (button not visible when no selection)
|
||
# This tests the edge case where event is triggered without selection
|
||
result = render_hook(view, "copy_emails", %{})
|
||
|
||
# Should show error flash
|
||
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
|
||
end
|
||
|
||
test "copy_emails event with all members selected formats all emails", %{
|
||
conn: conn
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select all members via select_all
|
||
view |> element("[phx-click='select_all']") |> render_click()
|
||
|
||
# Trigger copy_emails event
|
||
view |> element("#copy-emails-btn") |> render_click()
|
||
|
||
# Verify flash message shows correct count (3 members)
|
||
assert render(view) =~ "3"
|
||
end
|
||
|
||
test "copy_emails handles members with special characters in names", %{
|
||
conn: conn,
|
||
member3: member3
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select member with umlauts by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => member3.id})
|
||
|
||
# Trigger copy_emails event - should not crash
|
||
view |> element("#copy-emails-btn") |> render_click()
|
||
|
||
# Verify flash message shows success
|
||
assert render(view) =~ "1"
|
||
end
|
||
|
||
test "copy_emails handles case where selected member is deleted before copy", %{
|
||
conn: conn,
|
||
member1: member1
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select a member by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => member1.id})
|
||
|
||
# Delete the member from the database
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
Ash.destroy!(member1, actor: system_actor)
|
||
|
||
# Trigger copy_emails event directly - selection still contains the deleted ID
|
||
# but the member is no longer in @members list after reload
|
||
result = render_hook(view, "copy_emails", %{})
|
||
|
||
# Should show error since no visible members match selection
|
||
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
|
||
end
|
||
|
||
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
|
||
conn: conn,
|
||
member1: member1,
|
||
member2: member2
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select two members by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => member1.id})
|
||
render_click(view, "select_member", %{"id" => member2.id})
|
||
|
||
# Get the socket state to verify the formatted email string
|
||
state = :sys.get_state(view.pid)
|
||
selected_members = state.socket.assigns.selected_members
|
||
|
||
# Verify MapSet is used
|
||
assert %MapSet{} = selected_members
|
||
assert MapSet.size(selected_members) == 2
|
||
end
|
||
|
||
test "email format is 'First Last <email>' with comma separator", %{
|
||
conn: conn,
|
||
member1: _member1
|
||
} do
|
||
# Test the format_member_email function indirectly
|
||
# by checking the push_event payload structure
|
||
conn = conn_with_oidc_user(conn)
|
||
|
||
# Create a member with known data
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
|
||
{:ok, test_member} =
|
||
Mv.Membership.create_member(
|
||
%{
|
||
first_name: "Test",
|
||
last_name: "Format",
|
||
email: "test.format@example.com"
|
||
},
|
||
actor: system_actor
|
||
)
|
||
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select the test member by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => test_member.id})
|
||
|
||
# The format should be "Test Format <test.format@example.com>"
|
||
# We verify this by checking the flash shows 1 email was copied
|
||
view |> element("#copy-emails-btn") |> render_click()
|
||
assert render(view) =~ "1"
|
||
end
|
||
|
||
test "copy button is disabled when no members selected", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Copy button should be disabled (button element)
|
||
assert has_element?(view, "#copy-emails-btn[disabled]")
|
||
# Open email button should be disabled (link with tabindex and aria-disabled)
|
||
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
|
||
end
|
||
|
||
test "copy button is enabled after selection", %{
|
||
conn: conn,
|
||
member1: member1
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select a member by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => member1.id})
|
||
|
||
# Copy button should now be enabled (no disabled attribute)
|
||
refute has_element?(view, "#copy-emails-btn[disabled]")
|
||
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
|
||
refute has_element?(view, "#open-email-btn[tabindex='-1']")
|
||
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
|
||
# Counter should show correct count
|
||
assert render(view) =~ "1"
|
||
end
|
||
|
||
test "copy button click triggers event and shows flash", %{
|
||
conn: conn,
|
||
member1: member1
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
# Select a member by sending the select_member event directly
|
||
render_click(view, "select_member", %{"id" => member1.id})
|
||
|
||
# Click copy button
|
||
view |> element("#copy-emails-btn") |> render_click()
|
||
|
||
# Flash message should appear
|
||
assert has_element?(view, "#flash-group")
|
||
end
|
||
end
|
||
|
||
describe "cycle status filter" do
|
||
# Helper to create a member (only used in this describe block)
|
||
defp create_member(attrs, actor) do
|
||
default_attrs = %{
|
||
first_name: "Test",
|
||
last_name: "Member",
|
||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||
}
|
||
|
||
attrs = Map.merge(default_attrs, attrs)
|
||
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||
|> Ash.create!(actor: actor)
|
||
end
|
||
|
||
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||
today = Date.utc_today()
|
||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||
|
||
# Member with paid last cycle
|
||
paid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "PaidLast",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
paid_member,
|
||
fee_type,
|
||
%{cycle_start: last_year_start, status: :paid},
|
||
system_actor
|
||
)
|
||
|
||
# Member with unpaid last cycle
|
||
unpaid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "UnpaidLast",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
unpaid_member,
|
||
fee_type,
|
||
%{cycle_start: last_year_start, status: :unpaid},
|
||
system_actor
|
||
)
|
||
|
||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||
|
||
assert html =~ "PaidLast"
|
||
refute html =~ "UnpaidLast"
|
||
end
|
||
|
||
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||
today = Date.utc_today()
|
||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||
|
||
# Member with paid last cycle
|
||
paid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "PaidLast",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
paid_member,
|
||
fee_type,
|
||
%{cycle_start: last_year_start, status: :paid},
|
||
system_actor
|
||
)
|
||
|
||
# Member with unpaid last cycle
|
||
unpaid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "UnpaidLast",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
unpaid_member,
|
||
fee_type,
|
||
%{cycle_start: last_year_start, status: :unpaid},
|
||
system_actor
|
||
)
|
||
|
||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
|
||
|
||
refute html =~ "PaidLast"
|
||
assert html =~ "UnpaidLast"
|
||
end
|
||
|
||
test "filter shows only members with paid status in current cycle", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||
today = Date.utc_today()
|
||
current_year_start = Date.new!(today.year, 1, 1)
|
||
|
||
# Member with paid current cycle
|
||
paid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "PaidCurrent",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
paid_member,
|
||
fee_type,
|
||
%{cycle_start: current_year_start, status: :paid},
|
||
system_actor
|
||
)
|
||
|
||
# Member with unpaid current cycle
|
||
unpaid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "UnpaidCurrent",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
unpaid_member,
|
||
fee_type,
|
||
%{cycle_start: current_year_start, status: :unpaid},
|
||
system_actor
|
||
)
|
||
|
||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
|
||
|
||
assert html =~ "PaidCurrent"
|
||
refute html =~ "UnpaidCurrent"
|
||
end
|
||
|
||
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||
today = Date.utc_today()
|
||
current_year_start = Date.new!(today.year, 1, 1)
|
||
|
||
# Member with paid current cycle
|
||
paid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "PaidCurrent",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
paid_member,
|
||
fee_type,
|
||
%{cycle_start: current_year_start, status: :paid},
|
||
system_actor
|
||
)
|
||
|
||
# Member with unpaid current cycle
|
||
unpaid_member =
|
||
create_member(
|
||
%{
|
||
first_name: "UnpaidCurrent",
|
||
membership_fee_type_id: fee_type.id
|
||
},
|
||
system_actor
|
||
)
|
||
|
||
create_cycle(
|
||
unpaid_member,
|
||
fee_type,
|
||
%{cycle_start: current_year_start, status: :unpaid},
|
||
system_actor
|
||
)
|
||
|
||
{:ok, _view, html} =
|
||
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
||
|
||
refute html =~ "PaidCurrent"
|
||
assert html =~ "UnpaidCurrent"
|
||
end
|
||
|
||
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
|
||
# Start with last cycle view and paid filter
|
||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
|
||
|
||
# Toggle to current cycle - this should update URL and preserve filter
|
||
# Use the button in the toolbar
|
||
view
|
||
|> element("button[phx-click='toggle_cycle_view']")
|
||
|> render_click()
|
||
|
||
# Wait for patch to complete
|
||
path = assert_patch(view)
|
||
|
||
# URL should contain both filter and show_current_cycle
|
||
assert path =~ "cycle_status_filter=paid"
|
||
assert path =~ "show_current_cycle=true"
|
||
end
|
||
end
|
||
|
||
describe "boolean custom field filters" do
|
||
alias Mv.Membership.CustomField
|
||
|
||
# Helper to create a boolean custom field (uses system actor for authorization)
|
||
defp create_boolean_custom_field(attrs \\ %{}) do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
|
||
default_attrs = %{
|
||
name: "test_boolean_#{System.unique_integer([:positive])}",
|
||
value_type: :boolean
|
||
}
|
||
|
||
attrs = Map.merge(default_attrs, attrs)
|
||
|
||
CustomField
|
||
|> Ash.Changeset.for_create(:create, attrs)
|
||
|> Ash.create!(actor: system_actor)
|
||
end
|
||
|
||
# Helper to create a non-boolean custom field (uses system actor for authorization)
|
||
defp create_string_custom_field(attrs \\ %{}) do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
|
||
default_attrs = %{
|
||
name: "test_string_#{System.unique_integer([:positive])}",
|
||
value_type: :string
|
||
}
|
||
|
||
attrs = Map.merge(default_attrs, attrs)
|
||
|
||
CustomField
|
||
|> Ash.Changeset.for_create(:create, attrs)
|
||
|> Ash.create!(actor: system_actor)
|
||
end
|
||
|
||
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
state = :sys.get_state(view.pid)
|
||
assert state.socket.assigns.boolean_custom_field_filters == %{}
|
||
end
|
||
|
||
test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
|
||
conn: conn
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
state = :sys.get_state(view.pid)
|
||
assert state.socket.assigns.boolean_custom_fields == []
|
||
end
|
||
|
||
test "mount loads and filters boolean custom fields correctly", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
|
||
# 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"})
|
||
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
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))
|
||
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"})
|
||
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
state = :sys.get_state(view.pid)
|
||
boolean_custom_fields = state.socket.assigns.boolean_custom_fields
|
||
|
||
# Should be sorted by name ascending
|
||
names = Enum.map(boolean_custom_fields, & &1.name)
|
||
assert names == ["Alpha Field", "Middle Field", "Zebra Field"]
|
||
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")
|
||
|
||
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"
|
||
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")
|
||
|
||
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 == %{}
|
||
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")
|
||
|
||
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 == %{}
|
||
end
|
||
|
||
test "handle_params ignores invalid filter values", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
# Test various invalid values
|
||
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}")
|
||
|
||
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"
|
||
end
|
||
end
|
||
|
||
test "handle_params handles multiple boolean filters simultaneously", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field1 = create_boolean_custom_field()
|
||
boolean_field2 = create_boolean_custom_field()
|
||
|
||
{:ok, view, _html} =
|
||
live(
|
||
conn,
|
||
"/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
|
||
|
||
assert filters[boolean_field1.id] == true
|
||
assert filters[boolean_field2.id] == false
|
||
assert map_size(filters) == 2
|
||
end
|
||
|
||
test "build_query_params includes active boolean filters and excludes nil filters", %{
|
||
conn: conn
|
||
} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field1 = create_boolean_custom_field()
|
||
boolean_field2 = create_boolean_custom_field()
|
||
|
||
# Test with active filters
|
||
{:ok, view1, _html} =
|
||
live(
|
||
conn,
|
||
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
|
||
)
|
||
|
||
# Trigger a search to see if filters are preserved in URL
|
||
view1
|
||
|> element("[data-testid='search-input']")
|
||
|> render_change(%{value: "test"})
|
||
|
||
# Check that the patch includes boolean filters
|
||
path1 = assert_patch(view1)
|
||
assert path1 =~ "bf_#{boolean_field1.id}=true"
|
||
assert path1 =~ "bf_#{boolean_field2.id}=false"
|
||
|
||
# Test without filters (nil filters should not appear in URL)
|
||
{:ok, view2, _html} = live(conn, "/members")
|
||
|
||
# Trigger a search
|
||
view2
|
||
|> element("[data-testid='search-input']")
|
||
|> render_change(%{value: "test"})
|
||
|
||
# Check that no bf_ params are in URL
|
||
path2 = assert_patch(view2)
|
||
refute path2 =~ "bf_"
|
||
end
|
||
|
||
test "boolean filters are preserved during navigation actions", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
{:ok, view, _html} =
|
||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||
|
||
# Test sort toggle preserves filter
|
||
view
|
||
|> element("[data-testid='email']")
|
||
|> render_click()
|
||
|
||
path1 = assert_patch(view)
|
||
assert path1 =~ "bf_#{boolean_field.id}=true"
|
||
|
||
# Test search change preserves filter
|
||
view
|
||
|> element("[data-testid='search-input']")
|
||
|> render_change(%{value: "test"})
|
||
|
||
path2 = assert_patch(view)
|
||
assert path2 =~ "bf_#{boolean_field.id}=true"
|
||
end
|
||
|
||
test "boolean filters work together with cycle_status_filter", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
{:ok, view, _html} =
|
||
live(
|
||
conn,
|
||
"/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
|
||
)
|
||
|
||
state = :sys.get_state(view.pid)
|
||
filters = state.socket.assigns.boolean_custom_field_filters
|
||
|
||
# Both filters should be set
|
||
assert filters[boolean_field.id] == true
|
||
assert state.socket.assigns.cycle_status_filter == :paid
|
||
|
||
# Both should be in URL when triggering search
|
||
view
|
||
|> element("[data-testid='search-input']")
|
||
|> render_change(%{value: "test"})
|
||
|
||
path = assert_patch(view)
|
||
assert path =~ "cycle_status_filter=paid"
|
||
assert path =~ "bf_#{boolean_field.id}=true"
|
||
end
|
||
|
||
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
# Set up filter via URL
|
||
{: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
|
||
|
||
# Delete the custom field (requires actor with destroy permission)
|
||
Ash.destroy!(boolean_field, actor: system_actor)
|
||
|
||
# Navigate again - filter should be removed since custom field no longer exists
|
||
{: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
|
||
|
||
# Filter should not be present for deleted custom field
|
||
refute Map.has_key?(filters_after, boolean_field.id)
|
||
assert filters_after == %{}
|
||
end
|
||
|
||
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
# URL-encode the custom field ID (though UUIDs shouldn't need encoding normally)
|
||
encoded_id = URI.encode(boolean_field.id)
|
||
|
||
{:ok, view, _html} =
|
||
live(conn, "/members?bf_#{encoded_id}=true")
|
||
|
||
state = :sys.get_state(view.pid)
|
||
filters = state.socket.assigns.boolean_custom_field_filters
|
||
|
||
# Filter should work with URL-encoded ID
|
||
# Phoenix should decode it automatically, so we check with original ID
|
||
assert filters[boolean_field.id] == true
|
||
end
|
||
|
||
test "handle_params ignores malformed prefix (bf_bf_<uuid>)", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
# Try to send parameter with double prefix
|
||
{: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
|
||
|
||
# Should not parse as valid filter (UUID validation should fail)
|
||
refute Map.has_key?(filters, boolean_field.id)
|
||
assert filters == %{}
|
||
end
|
||
|
||
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
|
||
# Create 60 boolean custom fields (more than the limit)
|
||
boolean_fields = Enum.map(1..60, fn _ -> create_boolean_custom_field() end)
|
||
|
||
# Build URL with all 60 filters
|
||
filter_params =
|
||
Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end)
|
||
|
||
{: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)
|
||
end
|
||
|
||
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
# Create a fake ID that's way too long (UUIDs are max 36 chars)
|
||
fake_long_id = String.duplicate("a", 100)
|
||
|
||
{: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
|
||
|
||
# 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 == %{}
|
||
end
|
||
|
||
# Helper to create a member with a boolean custom field value
|
||
defp create_member_with_boolean_value(member_attrs, custom_field, value, actor) do
|
||
{:ok, member} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(
|
||
:create_member,
|
||
%{
|
||
first_name: "Test",
|
||
last_name: "Member",
|
||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||
}
|
||
|> Map.merge(member_attrs)
|
||
)
|
||
|> Ash.create(actor: actor)
|
||
|
||
{:ok, _cfv} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member.id,
|
||
custom_field_id: custom_field.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => value}
|
||
})
|
||
|> Ash.create(actor: actor)
|
||
|
||
# Reload member with custom field values
|
||
member
|
||
|> Ash.load!(:custom_field_values, actor: actor)
|
||
end
|
||
|
||
# Tests for get_boolean_custom_field_value/2
|
||
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
|
||
|
||
# Test the function (will fail until implemented)
|
||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||
|
||
assert result == true
|
||
end
|
||
|
||
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
|
||
|
||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||
|
||
assert result == false
|
||
end
|
||
|
||
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
|
||
%{conn: _conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
{:ok, member} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "Test",
|
||
last_name: "Member",
|
||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
||
{:ok, _cfv} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member.id,
|
||
custom_field_id: boolean_field.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Reload member with custom field values
|
||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||
|
||
assert result == true
|
||
end
|
||
|
||
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
||
conn: _conn
|
||
} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
{:ok, member} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "Test",
|
||
last_name: "Member",
|
||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Member has no custom field value for this field
|
||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||
|
||
assert result == nil
|
||
end
|
||
|
||
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
|
||
conn: _conn
|
||
} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
{:ok, member} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "Test",
|
||
last_name: "Member",
|
||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Create CustomFieldValue with nil value (edge case)
|
||
{:ok, _cfv} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member.id,
|
||
custom_field_id: boolean_field.id,
|
||
value: nil
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||
|
||
assert result == nil
|
||
end
|
||
|
||
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
|
||
conn: _conn
|
||
} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
string_field = create_string_custom_field()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
{:ok, member} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "Test",
|
||
last_name: "Member",
|
||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Create string custom field value (not boolean)
|
||
{:ok, _cfv} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member.id,
|
||
custom_field_id: string_field.id,
|
||
value: %{"_union_type" => "string", "_union_value" => "test"}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
# Try to get boolean value from string field - should return nil
|
||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||
|
||
assert result == nil
|
||
end
|
||
|
||
# Tests for apply_boolean_custom_field_filters/2
|
||
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
||
%{conn: _conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
member_with_true =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "TrueMember"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
member_with_false =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "FalseMember"},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
|
||
{:ok, member_without_value} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "NoValue",
|
||
last_name: "Member",
|
||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
member_without_value =
|
||
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
members = [member_with_true, member_with_false, member_without_value]
|
||
filters = %{to_string(boolean_field.id) => true}
|
||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||
|
||
result =
|
||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||
members,
|
||
filters,
|
||
all_custom_fields
|
||
)
|
||
|
||
assert length(result) == 1
|
||
assert List.first(result).id == member_with_true.id
|
||
refute Enum.any?(result, &(&1.id == member_with_false.id))
|
||
refute Enum.any?(result, &(&1.id == member_without_value.id))
|
||
end
|
||
|
||
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
|
||
%{conn: _conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
member_with_true =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "TrueMember"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
member_with_false =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "FalseMember"},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
|
||
{:ok, member_without_value} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "NoValue",
|
||
last_name: "Member",
|
||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
member_without_value =
|
||
member_without_value |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
members = [member_with_true, member_with_false, member_without_value]
|
||
filters = %{to_string(boolean_field.id) => false}
|
||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||
|
||
result =
|
||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||
members,
|
||
filters,
|
||
all_custom_fields
|
||
)
|
||
|
||
assert length(result) == 1
|
||
assert List.first(result).id == member_with_false.id
|
||
refute Enum.any?(result, &(&1.id == member_with_true.id))
|
||
refute Enum.any?(result, &(&1.id == member_without_value.id))
|
||
end
|
||
|
||
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
|
||
conn: _conn
|
||
} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
member1 =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "Member1"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
member2 =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "Member2"},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
|
||
members = [member1, member2]
|
||
filters = %{}
|
||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||
|
||
result =
|
||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||
members,
|
||
filters,
|
||
all_custom_fields
|
||
)
|
||
|
||
assert length(result) == 2
|
||
|
||
assert Enum.all?([member1.id, member2.id], fn id ->
|
||
Enum.any?(result, &(&1.id == id))
|
||
end)
|
||
end
|
||
|
||
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
|
||
conn: _conn
|
||
} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
||
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
||
|
||
# Member with both fields = true
|
||
{:ok, member_both_true} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "BothTrue",
|
||
last_name: "Member",
|
||
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
{:ok, _cfv1} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member_both_true.id,
|
||
custom_field_id: boolean_field1.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
{:ok, _cfv2} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member_both_true.id,
|
||
custom_field_id: boolean_field2.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
member_both_true = member_both_true |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
# Member with field1 = true, field2 = false
|
||
{:ok, member_mixed} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "Mixed",
|
||
last_name: "Member",
|
||
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
{:ok, _cfv3} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member_mixed.id,
|
||
custom_field_id: boolean_field1.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
{:ok, _cfv4} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member_mixed.id,
|
||
custom_field_id: boolean_field2.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => false}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
member_mixed = member_mixed |> Ash.load!(:custom_field_values, actor: system_actor)
|
||
|
||
members = [member_both_true, member_mixed]
|
||
|
||
filters = %{
|
||
to_string(boolean_field1.id) => true,
|
||
to_string(boolean_field2.id) => true
|
||
}
|
||
|
||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||
|
||
result =
|
||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||
members,
|
||
filters,
|
||
all_custom_fields
|
||
)
|
||
|
||
# Only member_both_true should match (both fields = true)
|
||
assert length(result) == 1
|
||
assert List.first(result).id == member_both_true.id
|
||
end
|
||
|
||
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
|
||
conn: _conn
|
||
} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
boolean_field = create_boolean_custom_field()
|
||
fake_id = Ecto.UUID.generate()
|
||
|
||
member =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "Member"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
members = [member]
|
||
filters = %{fake_id => true}
|
||
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||
|
||
result =
|
||
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||
members,
|
||
filters,
|
||
all_custom_fields
|
||
)
|
||
|
||
# Should return all members since fake_id doesn't match any custom field
|
||
assert length(result) == 1
|
||
end
|
||
|
||
# Integration tests for boolean custom field filters in load_members
|
||
test "boolean filter integration filters members by boolean custom field value via URL parameter",
|
||
%{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
_member_with_true =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "TrueMember"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
_member_with_false =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "FalseMember"},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
|
||
{:ok, _member_without_value} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "NoValue",
|
||
last_name: "Member",
|
||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Test true filter
|
||
{:ok, _view, html_true} =
|
||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||
|
||
assert html_true =~ "TrueMember"
|
||
refute html_true =~ "FalseMember"
|
||
refute html_true =~ "NoValue"
|
||
|
||
# Test false filter
|
||
{:ok, _view, html_false} =
|
||
live(conn, "/members?bf_#{boolean_field.id}=false")
|
||
|
||
assert html_false =~ "FalseMember"
|
||
refute html_false =~ "TrueMember"
|
||
refute html_false =~ "NoValue"
|
||
end
|
||
|
||
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||
today = Date.utc_today()
|
||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||
|
||
# Member with true boolean value and paid status
|
||
{:ok, member_paid_true} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "PaidTrue",
|
||
last_name: "Member",
|
||
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||
membership_fee_type_id: fee_type.id
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
{:ok, _cfv} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member_paid_true.id,
|
||
custom_field_id: boolean_field.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
create_cycle(
|
||
member_paid_true,
|
||
fee_type,
|
||
%{cycle_start: last_year_start, status: :paid},
|
||
system_actor
|
||
)
|
||
|
||
# Member with true boolean value but unpaid status
|
||
{:ok, member_unpaid_true} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "UnpaidTrue",
|
||
last_name: "Member",
|
||
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||
membership_fee_type_id: fee_type.id
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
{:ok, _cfv2} =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Changeset.for_create(:create, %{
|
||
member_id: member_unpaid_true.id,
|
||
custom_field_id: boolean_field.id,
|
||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
create_cycle(
|
||
member_unpaid_true,
|
||
fee_type,
|
||
%{cycle_start: last_year_start, status: :unpaid},
|
||
system_actor
|
||
)
|
||
|
||
# Test both filters together
|
||
{:ok, _view, html} =
|
||
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
|
||
|
||
# Only member_paid_true should match both filters
|
||
assert html =~ "PaidTrue"
|
||
refute html =~ "UnpaidTrue"
|
||
end
|
||
|
||
test "boolean filter integration works together with search query", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
_member_with_true =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "TrueMember"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
_member_with_false =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "FalseMember"},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
|
||
# Test search + boolean filter
|
||
{:ok, _view, html} =
|
||
live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
|
||
|
||
# Only member_with_true should match both search and filter
|
||
assert html =~ "TrueMember"
|
||
refute html =~ "FalseMember"
|
||
end
|
||
|
||
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
|
||
# Create boolean field with show_in_overview: false
|
||
boolean_field = create_boolean_custom_field(%{show_in_overview: false})
|
||
|
||
_member_with_true =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "TrueMember"},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
|
||
_member_with_false =
|
||
create_member_with_boolean_value(
|
||
%{first_name: "FalseMember"},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
|
||
{:ok, _member_without_value} =
|
||
Mv.Membership.Member
|
||
|> Ash.Changeset.for_create(:create_member, %{
|
||
first_name: "NoValue",
|
||
last_name: "Member",
|
||
email: "novalue.member.#{System.unique_integer([:positive])}@example.com"
|
||
})
|
||
|> Ash.create(actor: system_actor)
|
||
|
||
# Test that filter works even though field is not visible in overview
|
||
{:ok, _view, html_true} =
|
||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||
|
||
assert html_true =~ "TrueMember"
|
||
refute html_true =~ "FalseMember"
|
||
refute html_true =~ "NoValue"
|
||
|
||
# Test false filter
|
||
{:ok, _view, html_false} =
|
||
live(conn, "/members?bf_#{boolean_field.id}=false")
|
||
|
||
assert html_false =~ "FalseMember"
|
||
refute html_false =~ "TrueMember"
|
||
refute html_false =~ "NoValue"
|
||
end
|
||
|
||
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
|
||
conn = conn_with_oidc_user(conn)
|
||
|
||
# Start with no boolean custom fields
|
||
{:ok, view, _html} = live(conn, "/members")
|
||
|
||
state_before = :sys.get_state(view.pid)
|
||
boolean_fields_before = state_before.socket.assigns.boolean_custom_fields
|
||
assert boolean_fields_before == []
|
||
|
||
# Create a new boolean custom field
|
||
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
|
||
|
||
# Navigate again - the new field should appear
|
||
{:ok, view2, _html} = live(conn, "/members")
|
||
|
||
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"))
|
||
end
|
||
|
||
@tag :slow
|
||
test "boolean filter performance with 150 members", %{conn: conn} do
|
||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||
conn = conn_with_oidc_user(conn)
|
||
boolean_field = create_boolean_custom_field()
|
||
|
||
# Create 150 members - 75 with true, 75 with false
|
||
members_with_true =
|
||
Enum.map(1..75, fn i ->
|
||
create_member_with_boolean_value(
|
||
%{
|
||
first_name: "TrueMember#{i}",
|
||
email: "truemember#{i}@example.com"
|
||
},
|
||
boolean_field,
|
||
true,
|
||
system_actor
|
||
)
|
||
end)
|
||
|
||
members_with_false =
|
||
Enum.map(1..75, fn i ->
|
||
create_member_with_boolean_value(
|
||
%{
|
||
first_name: "FalseMember#{i}",
|
||
email: "falsemember#{i}@example.com"
|
||
},
|
||
boolean_field,
|
||
false,
|
||
system_actor
|
||
)
|
||
end)
|
||
|
||
# Verify all members were created
|
||
assert length(members_with_true) == 75
|
||
assert length(members_with_false) == 75
|
||
|
||
# Test filter performance - should complete in reasonable time (< 1 second)
|
||
start_time = System.monotonic_time(:millisecond)
|
||
|
||
{:ok, _view, html} =
|
||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||
|
||
end_time = System.monotonic_time(:millisecond)
|
||
duration = end_time - start_time
|
||
|
||
# Should complete in less than 1 second (1000ms)
|
||
assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms"
|
||
|
||
# Verify filtering worked correctly - should show all true members
|
||
Enum.each(1..75, fn i ->
|
||
assert html =~ "TrueMember#{i}"
|
||
end)
|
||
|
||
# Should not show false members
|
||
Enum.each(1..75, fn i ->
|
||
refute html =~ "FalseMember#{i}"
|
||
end)
|
||
end
|
||
end
|
||
end
|