Seeds split, Credo strict, and member/settings UI polish #458

Merged
moritz merged 18 commits from feat/seeds into main 2026-03-04 20:19:51 +01:00
6 changed files with 132 additions and 118 deletions
Showing only changes of commit e537f4eb31 - Show all commits

View file

@ -1 +0,0 @@

View file

@ -3,8 +3,13 @@ defmodule MvWeb.MemberLive.IndexTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldValue
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.MemberLive.Index, as: MemberIndex
# Helper to create a membership fee type (shared across all tests) # Helper to create a membership fee type (shared across all tests)
defp create_fee_type(attrs, actor) do defp create_fee_type(attrs, actor) do
@ -298,10 +303,10 @@ defmodule MvWeb.MemberLive.IndexTest do
@tag :ui @tag :ui
test "member index does not render Edit or Delete actions", %{conn: conn} do test "member index does not render Edit or Delete actions", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, _member} = {:ok, _member} =
Mv.Membership.create_member( Membership.create_member(
%{first_name: "Test", last_name: "User", email: "test@example.com"}, %{first_name: "Test", last_name: "User", email: "test@example.com"},
actor: system_actor actor: system_actor
) )
@ -315,10 +320,10 @@ defmodule MvWeb.MemberLive.IndexTest do
@tag :ui @tag :ui
test "row click navigates to member show", %{conn: conn} do test "row click navigates to member show", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Membership.create_member(
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"}, %{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
actor: system_actor actor: system_actor
) )
@ -338,10 +343,10 @@ defmodule MvWeb.MemberLive.IndexTest do
@describetag :ui @describetag :ui
test "clickable rows have hover and focus-within ring classes", %{conn: conn} do test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, _member} = {:ok, _member} =
Mv.Membership.create_member( Membership.create_member(
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"}, %{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
actor: system_actor actor: system_actor
) )
@ -356,10 +361,10 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Membership.create_member(
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"}, %{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
actor: system_actor actor: system_actor
) )
@ -374,11 +379,11 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "copy_emails feature" do describe "copy_emails feature" do
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
# Create test members # Create test members
{:ok, member1} = {:ok, member1} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Max", first_name: "Max",
last_name: "Mustermann", last_name: "Mustermann",
@ -388,7 +393,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, member2} = {:ok, member2} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Erika", first_name: "Erika",
last_name: "Musterfrau", last_name: "Musterfrau",
@ -398,7 +403,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, member3} = {:ok, member3} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Hans", first_name: "Hans",
last_name: "Müller-Lüdenscheidt", last_name: "Müller-Lüdenscheidt",
@ -485,7 +490,7 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member1.id}) render_click(view, "select_member", %{"id" => member1.id})
# Delete the member from the database # Delete the member from the database
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
Ash.destroy!(member1, actor: system_actor) Ash.destroy!(member1, actor: system_actor)
# Trigger copy_emails event directly - selection still contains the deleted ID # Trigger copy_emails event directly - selection still contains the deleted ID
@ -526,10 +531,10 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# Create a member with known data # Create a member with known data
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, test_member} = {:ok, test_member} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Test", first_name: "Test",
last_name: "Format", last_name: "Format",
@ -598,10 +603,10 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "export dropdown" do describe "export dropdown" do
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, m1} = {:ok, m1} =
Mv.Membership.create_member( Membership.create_member(
%{first_name: "Export", last_name: "One", email: "export1@example.com"}, %{first_name: "Export", last_name: "One", email: "export1@example.com"},
actor: system_actor actor: system_actor
) )
@ -755,12 +760,12 @@ defmodule MvWeb.MemberLive.IndexTest do
} }
attrs = Map.merge(default_attrs, attrs) attrs = Map.merge(default_attrs, attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor) {:ok, member} = Membership.create_member(attrs, actor: actor)
member member
end end
test "filter shows only members with paid status in last cycle", %{conn: conn} do test "filter shows only members with paid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor) fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today() today = Date.utc_today()
@ -807,7 +812,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor) fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today() today = Date.utc_today()
@ -854,7 +859,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "filter shows only members with paid status in current cycle", %{conn: conn} do test "filter shows only members with paid status in current cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor) fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today() today = Date.utc_today()
@ -901,7 +906,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly}, system_actor) fee_type = create_fee_type(%{interval: :yearly}, system_actor)
today = Date.utc_today() today = Date.utc_today()
@ -970,11 +975,9 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
describe "boolean custom field filters" do describe "boolean custom field filters" do
alias Mv.Membership.CustomField
# Helper to create a boolean custom field (uses system actor for authorization) # Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do defp create_boolean_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
default_attrs = %{ default_attrs = %{
name: "test_boolean_#{System.unique_integer([:positive])}", name: "test_boolean_#{System.unique_integer([:positive])}",
@ -990,7 +993,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Helper to create a non-boolean custom field (uses system actor for authorization) # Helper to create a non-boolean custom field (uses system actor for authorization)
defp create_string_custom_field(attrs \\ %{}) do defp create_string_custom_field(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
default_attrs = %{ default_attrs = %{
name: "test_string_#{System.unique_integer([:positive])}", name: "test_string_#{System.unique_integer([:positive])}",
@ -1244,7 +1247,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "handle_params removes filter when custom field is deleted", %{conn: conn} do test "handle_params removes filter when custom field is deleted", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
# Set up filter via URL # Set up filter via URL
@ -1359,10 +1362,10 @@ defmodule MvWeb.MemberLive.IndexTest do
} }
|> Map.merge(member_attrs) |> Map.merge(member_attrs)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor) {:ok, member} = Membership.create_member(attrs, actor: actor)
{:ok, _cfv} = {:ok, _cfv} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member.id, member_id: member.id,
custom_field_id: custom_field.id, custom_field_id: custom_field.id,
@ -1377,33 +1380,33 @@ defmodule MvWeb.MemberLive.IndexTest do
# Tests for get_boolean_custom_field_value/2 # Tests for get_boolean_custom_field_value/2
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor) member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
# Test the function (will fail until implemented) # Test the function (will fail until implemented)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true assert result == true
end end
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor) member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == false assert result == false
end end
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys", test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
%{conn: _conn} do %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
@ -1414,7 +1417,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value) # Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
{:ok, _cfv} = {:ok, _cfv} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member.id, member_id: member.id,
custom_field_id: boolean_field.id, custom_field_id: boolean_field.id,
@ -1425,7 +1428,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Reload member with custom field values # Reload member with custom field values
member = member |> Ash.load!(:custom_field_values, 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) result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == true assert result == true
end end
@ -1433,11 +1436,11 @@ defmodule MvWeb.MemberLive.IndexTest do
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{ test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
conn: _conn conn: _conn
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
@ -1449,7 +1452,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member has no custom field value for this field # Member has no custom field value for this field
member = member |> Ash.load!(:custom_field_values, 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) result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil assert result == nil
end end
@ -1457,11 +1460,11 @@ defmodule MvWeb.MemberLive.IndexTest do
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{ test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
conn: _conn conn: _conn
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
@ -1472,7 +1475,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create CustomFieldValue with nil value (edge case) # Create CustomFieldValue with nil value (edge case)
{:ok, _cfv} = {:ok, _cfv} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member.id, member_id: member.id,
custom_field_id: boolean_field.id, custom_field_id: boolean_field.id,
@ -1482,7 +1485,7 @@ defmodule MvWeb.MemberLive.IndexTest do
member = member |> Ash.load!(:custom_field_values, 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) result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil assert result == nil
end end
@ -1490,12 +1493,12 @@ defmodule MvWeb.MemberLive.IndexTest do
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{ test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
conn: _conn conn: _conn
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
string_field = create_string_custom_field() string_field = create_string_custom_field()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
{:ok, member} = {:ok, member} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Test", first_name: "Test",
last_name: "Member", last_name: "Member",
@ -1506,7 +1509,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Create string custom field value (not boolean) # Create string custom field value (not boolean)
{:ok, _cfv} = {:ok, _cfv} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member.id, member_id: member.id,
custom_field_id: string_field.id, custom_field_id: string_field.id,
@ -1517,7 +1520,7 @@ defmodule MvWeb.MemberLive.IndexTest do
member = member |> Ash.load!(:custom_field_values, actor: system_actor) member = member |> Ash.load!(:custom_field_values, actor: system_actor)
# Try to get boolean value from string field - should return nil # Try to get boolean value from string field - should return nil
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
assert result == nil assert result == nil
end end
@ -1525,7 +1528,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Tests for apply_boolean_custom_field_filters/2 # Tests for apply_boolean_custom_field_filters/2
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values", test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
%{conn: _conn} do %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
member_with_true = member_with_true =
@ -1545,7 +1548,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, member_without_value} = {:ok, member_without_value} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "NoValue", first_name: "NoValue",
last_name: "Member", last_name: "Member",
@ -1559,10 +1562,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member_with_true, member_with_false, member_without_value] members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true} filters = %{to_string(boolean_field.id) => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result = result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( MemberIndex.apply_boolean_custom_field_filters(
members, members,
filters, filters,
all_custom_fields all_custom_fields
@ -1576,7 +1579,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values", test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
%{conn: _conn} do %{conn: _conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
member_with_true = member_with_true =
@ -1596,7 +1599,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, member_without_value} = {:ok, member_without_value} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "NoValue", first_name: "NoValue",
last_name: "Member", last_name: "Member",
@ -1610,10 +1613,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member_with_true, member_with_false, member_without_value] members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false} filters = %{to_string(boolean_field.id) => false}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result = result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( MemberIndex.apply_boolean_custom_field_filters(
members, members,
filters, filters,
all_custom_fields all_custom_fields
@ -1628,7 +1631,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{ test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
conn: _conn conn: _conn
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
member1 = member1 =
@ -1649,10 +1652,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member1, member2] members = [member1, member2]
filters = %{} filters = %{}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result = result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( MemberIndex.apply_boolean_custom_field_filters(
members, members,
filters, filters,
all_custom_fields all_custom_fields
@ -1668,13 +1671,13 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{ test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
conn: _conn conn: _conn
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
# Member with both fields = true # Member with both fields = true
{:ok, member_both_true} = {:ok, member_both_true} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "BothTrue", first_name: "BothTrue",
last_name: "Member", last_name: "Member",
@ -1684,7 +1687,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, _cfv1} = {:ok, _cfv1} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id, member_id: member_both_true.id,
custom_field_id: boolean_field1.id, custom_field_id: boolean_field1.id,
@ -1693,7 +1696,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create(actor: system_actor) |> Ash.create(actor: system_actor)
{:ok, _cfv2} = {:ok, _cfv2} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member_both_true.id, member_id: member_both_true.id,
custom_field_id: boolean_field2.id, custom_field_id: boolean_field2.id,
@ -1705,7 +1708,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with field1 = true, field2 = false # Member with field1 = true, field2 = false
{:ok, member_mixed} = {:ok, member_mixed} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "Mixed", first_name: "Mixed",
last_name: "Member", last_name: "Member",
@ -1715,7 +1718,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, _cfv3} = {:ok, _cfv3} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id, member_id: member_mixed.id,
custom_field_id: boolean_field1.id, custom_field_id: boolean_field1.id,
@ -1724,7 +1727,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create(actor: system_actor) |> Ash.create(actor: system_actor)
{:ok, _cfv4} = {:ok, _cfv4} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member_mixed.id, member_id: member_mixed.id,
custom_field_id: boolean_field2.id, custom_field_id: boolean_field2.id,
@ -1741,10 +1744,10 @@ defmodule MvWeb.MemberLive.IndexTest do
to_string(boolean_field2.id) => true to_string(boolean_field2.id) => true
} }
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result = result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( MemberIndex.apply_boolean_custom_field_filters(
members, members,
filters, filters,
all_custom_fields all_custom_fields
@ -1758,7 +1761,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{ test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
conn: _conn conn: _conn
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
fake_id = Ecto.UUID.generate() fake_id = Ecto.UUID.generate()
@ -1772,10 +1775,10 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member] members = [member]
filters = %{fake_id => true} filters = %{fake_id => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor) all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
result = result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( MemberIndex.apply_boolean_custom_field_filters(
members, members,
filters, filters,
all_custom_fields all_custom_fields
@ -1788,7 +1791,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Integration tests for boolean custom field filters in load_members # Integration tests for boolean custom field filters in load_members
test "boolean filter integration filters members by boolean custom field value via URL parameter", test "boolean filter integration filters members by boolean custom field value via URL parameter",
%{conn: conn} do %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
@ -1809,7 +1812,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, _member_without_value} = {:ok, _member_without_value} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "NoValue", first_name: "NoValue",
last_name: "Member", last_name: "Member",
@ -1836,7 +1839,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
fee_type = create_fee_type(%{interval: :yearly}, system_actor) fee_type = create_fee_type(%{interval: :yearly}, system_actor)
@ -1845,7 +1848,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with true boolean value and paid status # Member with true boolean value and paid status
{:ok, member_paid_true} = {:ok, member_paid_true} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "PaidTrue", first_name: "PaidTrue",
last_name: "Member", last_name: "Member",
@ -1856,7 +1859,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, _cfv} = {:ok, _cfv} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member_paid_true.id, member_id: member_paid_true.id,
custom_field_id: boolean_field.id, custom_field_id: boolean_field.id,
@ -1873,7 +1876,7 @@ defmodule MvWeb.MemberLive.IndexTest do
# Member with true boolean value but unpaid status # Member with true boolean value but unpaid status
{:ok, member_unpaid_true} = {:ok, member_unpaid_true} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "UnpaidTrue", first_name: "UnpaidTrue",
last_name: "Member", last_name: "Member",
@ -1884,7 +1887,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, _cfv2} = {:ok, _cfv2} =
Mv.Membership.CustomFieldValue CustomFieldValue
|> Ash.Changeset.for_create(:create, %{ |> Ash.Changeset.for_create(:create, %{
member_id: member_unpaid_true.id, member_id: member_unpaid_true.id,
custom_field_id: boolean_field.id, custom_field_id: boolean_field.id,
@ -1909,7 +1912,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "boolean filter integration works together with search query", %{conn: conn} do test "boolean filter integration works together with search query", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()
@ -1939,7 +1942,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# Create boolean field with show_in_overview: false # Create boolean field with show_in_overview: false
@ -1962,7 +1965,7 @@ defmodule MvWeb.MemberLive.IndexTest do
) )
{:ok, _member_without_value} = {:ok, _member_without_value} =
Mv.Membership.create_member( Membership.create_member(
%{ %{
first_name: "NoValue", first_name: "NoValue",
last_name: "Member", last_name: "Member",
@ -2016,7 +2019,7 @@ defmodule MvWeb.MemberLive.IndexTest do
@tag :slow @tag :slow
test "boolean filter performance with 150 members", %{conn: conn} do test "boolean filter performance with 150 members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field() boolean_field = create_boolean_custom_field()

View file

@ -17,6 +17,14 @@ defmodule MvWeb.ConnCase do
use ExUnit.CaseTemplate use ExUnit.CaseTemplate
alias AshAuthentication.Plug.Helpers, as: AuthPlugHelpers
alias Mv.Accounts
alias Mv.Authorization.Actor
alias Mv.DataCase
alias Mv.Fixtures
alias Mv.Helpers.SystemActor
alias Phoenix.ConnTest
using do using do
quote do quote do
# The default endpoint for testing # The default endpoint for testing
@ -92,8 +100,8 @@ defmodule MvWeb.ConnCase do
def sign_in_user_via_oidc(conn, user) do def sign_in_user_via_oidc(conn, user) do
# Mock OIDC sign-in by creating a token directly # Mock OIDC sign-in by creating a token directly
conn conn
|> Phoenix.ConnTest.init_test_session(%{}) |> ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user) |> AuthPlugHelpers.store_in_session(user)
end end
@doc """ @doc """
@ -114,8 +122,8 @@ defmodule MvWeb.ConnCase do
user = create_test_user(Map.merge(default_attrs, user_attrs)) user = create_test_user(Map.merge(default_attrs, user_attrs))
# Create admin role and assign it # Create admin role and assign it
admin_role = Mv.Fixtures.role_fixture("admin") admin_role = Fixtures.role_fixture("admin")
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
{:ok, user} = {:ok, user} =
user user
@ -124,7 +132,7 @@ defmodule MvWeb.ConnCase do
|> Ash.update(actor: system_actor) |> Ash.update(actor: system_actor)
# Load role for authorization # Load role for authorization
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor) user_with_role = Ash.load!(user, :role, domain: Accounts, actor: system_actor)
sign_in_user_via_oidc(conn, user_with_role) sign_in_user_via_oidc(conn, user_with_role)
end end
@ -134,8 +142,8 @@ defmodule MvWeb.ConnCase do
""" """
def conn_with_password_user(conn, user) do def conn_with_password_user(conn, user) do
conn conn
|> Phoenix.ConnTest.init_test_session(%{}) |> ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user) |> AuthPlugHelpers.store_in_session(user)
end end
@doc """ @doc """
@ -143,14 +151,14 @@ defmodule MvWeb.ConnCase do
This is useful for tests that need full access to resources. This is useful for tests that need full access to resources.
""" """
def conn_with_admin_user(conn) do def conn_with_admin_user(conn) do
admin_user = Mv.Fixtures.user_with_role_fixture("admin") admin_user = Fixtures.user_with_role_fixture("admin")
conn_with_password_user(conn, admin_user) conn_with_password_user(conn, admin_user)
end end
setup tags do setup tags do
pid = Mv.DataCase.setup_sandbox(tags) pid = DataCase.setup_sandbox(tags)
conn = Phoenix.ConnTest.build_conn() conn = ConnTest.build_conn()
# Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes # Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes
# to share the test's database connection in async tests # to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid) conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
@ -164,27 +172,27 @@ defmodule MvWeb.ConnCase do
:admin -> :admin ->
# Create admin user with role for all tests (unless test overrides with its own user) # Create admin user with role for all tests (unless test overrides with its own user)
# This ensures all tests have an authenticated user with proper authorization # This ensures all tests have an authenticated user with proper authorization
admin_user = Mv.Fixtures.user_with_role_fixture("admin") admin_user = Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user) authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user} {authenticated_conn, admin_user}
:member -> :member ->
# Create member user for role-based testing # Create member user for role-based testing
# "member" role uses "own_data" permission set (Mitglied role) # "member" role uses "own_data" permission set (Mitglied role)
member_user = Mv.Fixtures.user_with_role_fixture("own_data") member_user = Fixtures.user_with_role_fixture("own_data")
authenticated_conn = conn_with_password_user(conn, member_user) authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user} {authenticated_conn, member_user}
:read_only -> :read_only ->
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings # Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only") read_only_user = Fixtures.user_with_role_fixture("read_only")
read_only_user = Mv.Authorization.Actor.ensure_loaded(read_only_user) read_only_user = Actor.ensure_loaded(read_only_user)
authenticated_conn = conn_with_password_user(conn, read_only_user) authenticated_conn = conn_with_password_user(conn, read_only_user)
{authenticated_conn, read_only_user} {authenticated_conn, read_only_user}
:normal_user -> :normal_user ->
# Kassenwart: can read/update members, groups; cannot access users/settings/admin # Kassenwart: can read/update members, groups; cannot access users/settings/admin
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = Fixtures.user_with_role_fixture("normal_user")
authenticated_conn = conn_with_password_user(conn, normal_user) authenticated_conn = conn_with_password_user(conn, normal_user)
{authenticated_conn, normal_user} {authenticated_conn, normal_user}
@ -194,7 +202,7 @@ defmodule MvWeb.ConnCase do
_other -> _other ->
# Fallback: treat unknown role as admin for safety # Fallback: treat unknown role as admin for safety
admin_user = Mv.Fixtures.user_with_role_fixture("admin") admin_user = Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user) authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user} {authenticated_conn, admin_user}
end end

View file

@ -16,6 +16,9 @@ defmodule Mv.DataCase do
use ExUnit.CaseTemplate use ExUnit.CaseTemplate
alias Ecto.Adapters.SQL.Sandbox, as: Sandbox
alias Mv.Repo
require Ash.Query require Ash.Query
using do using do
@ -30,11 +33,11 @@ defmodule Mv.DataCase do
end end
setup tags do setup tags do
Mv.DataCase.setup_sandbox(tags) setup_sandbox(tags)
# Ensure "Mitglied" role exists for default role assignment to work in tests # Ensure "Mitglied" role exists for default role assignment to work in tests
# Note: This runs in every test because each test runs in a sandboxed database. # Note: This runs in every test because each test runs in a sandboxed database.
# The check is fast (single query) and idempotent (skips if role exists). # The check is fast (single query) and idempotent (skips if role exists).
Mv.DataCase.ensure_default_role() ensure_default_role()
:ok :ok
end end
@ -43,8 +46,8 @@ defmodule Mv.DataCase do
Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox. Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox.
""" """
def setup_sandbox(tags) do def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async]) pid = Sandbox.start_owner!(Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) on_exit(fn -> Sandbox.stop_owner(pid) end)
pid pid
end end

View file

@ -1,11 +1,13 @@
defmodule Mv.Fixtures do defmodule Mv.Fixtures do
@moduledoc """ @moduledoc """
Shared test fixtures for consistent test data creation. Shared test fixtures for consistent test data creation.
This module provides factory functions for creating test data across
different test suites, ensuring consistency and reducing duplication.
""" """
alias Mv.Accounts
alias Mv.Authorization
alias Mv.Helpers.SystemActor
alias Mv.Membership
@doc """ @doc """
Creates a member with default or custom attributes. Creates a member with default or custom attributes.
@ -27,7 +29,7 @@ defmodule Mv.Fixtures do
""" """
def member_fixture(attrs \\ %{}) do def member_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
attrs attrs
|> Enum.into(%{ |> Enum.into(%{
@ -35,7 +37,7 @@ defmodule Mv.Fixtures do
last_name: "Member", last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com" email: "test#{System.unique_integer([:positive])}@example.com"
}) })
|> Mv.Membership.create_member(actor: system_actor) |> Membership.create_member(actor: system_actor)
|> case do |> case do
{:ok, member} -> member {:ok, member} -> member
{:error, error} -> raise "Failed to create member: #{inspect(error)}" {:error, error} -> raise "Failed to create member: #{inspect(error)}"
@ -66,13 +68,13 @@ defmodule Mv.Fixtures do
""" """
def user_fixture(attrs \\ %{}) do def user_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
attrs attrs
|> Enum.into(%{ |> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com" email: "user#{System.unique_integer([:positive])}@example.com"
}) })
|> Mv.Accounts.create_user(actor: system_actor) |> Accounts.create_user(actor: system_actor)
|> case do |> case do
{:ok, user} -> user {:ok, user} -> user
{:error, error} -> raise "Failed to create user: #{inspect(error)}" {:error, error} -> raise "Failed to create user: #{inspect(error)}"
@ -123,10 +125,10 @@ defmodule Mv.Fixtures do
""" """
def role_fixture(permission_set_name) do def role_fixture(permission_set_name) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}" role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Mv.Authorization.create_role( case Authorization.create_role(
%{ %{
name: role_name, name: role_name,
description: "Test role for #{permission_set_name}", description: "Test role for #{permission_set_name}",
@ -157,7 +159,7 @@ defmodule Mv.Fixtures do
""" """
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
# Create role with permission set # Create role with permission set
role = role_fixture(permission_set_name) role = role_fixture(permission_set_name)
@ -168,7 +170,7 @@ defmodule Mv.Fixtures do
|> Enum.into(%{ |> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com" email: "user#{System.unique_integer([:positive])}@example.com"
}) })
|> Mv.Accounts.create_user(actor: system_actor) |> Accounts.create_user(actor: system_actor)
# Assign role to user # Assign role to user
{:ok, user} = {:ok, user} =
@ -178,7 +180,7 @@ defmodule Mv.Fixtures do
|> Ash.update(actor: system_actor) |> Ash.update(actor: system_actor)
# Reload user with role preloaded (critical for authorization!) # Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: system_actor) {:ok, user_with_role} = Ash.load(user, :role, domain: Accounts, actor: system_actor)
user_with_role user_with_role
end end
@ -284,14 +286,14 @@ defmodule Mv.Fixtures do
""" """
def group_fixture(attrs \\ %{}) do def group_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = SystemActor.get_system_actor()
attrs attrs
|> Enum.into(%{ |> Enum.into(%{
name: "Test Group #{System.unique_integer([:positive])}", name: "Test Group #{System.unique_integer([:positive])}",
description: "Test description" description: "Test description"
}) })
|> Mv.Membership.create_group(actor: system_actor) |> Membership.create_group(actor: system_actor)
|> case do |> case do
{:ok, group} -> group {:ok, group} -> group
{:error, error} -> raise "Failed to create group: #{inspect(error)}" {:error, error} -> raise "Failed to create group: #{inspect(error)}"