From d65da2f4983a223c01f60cc7397e8d80aaea6ff3 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 17:03:58 +0100 Subject: [PATCH] test: add tdd tests for custom boolean field filter logic --- test/mv_web/member_live/index_test.exs | 512 ++++++++++++++++++++++--- 1 file changed, 469 insertions(+), 43 deletions(-) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 7e7249e..31aba05 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -3,6 +3,49 @@ defmodule MvWeb.MemberLive.IndexTest do 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) 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!() + end + + # Helper to create a cycle (shared across all tests) + defp create_cycle(member, fee_type, attrs) do + # Delete any auto-generated cycles first to avoid conflicts + existing_cycles = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id) + |> Ash.read!() + + Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) 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!() + 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") @@ -457,24 +500,6 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "cycle status filter" do - alias Mv.MembershipFees.MembershipFeeType - alias Mv.MembershipFees.MembershipFeeCycle - - # Helper to create a membership fee type - defp create_fee_type(attrs) 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!() - end - # Helper to create a member defp create_member(attrs) do default_attrs = %{ @@ -490,31 +515,6 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!() end - # Helper to create a cycle - defp create_cycle(member, fee_type, attrs) do - # Delete any auto-generated cycles first to avoid conflicts - existing_cycles = - MembershipFeeCycle - |> Ash.Query.filter(member_id == ^member.id) - |> Ash.read!() - - Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) 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!() - end - test "filter shows only members with paid status in last cycle", %{conn: conn} do conn = conn_with_oidc_user(conn) fee_type = create_fee_type(%{interval: :yearly}) @@ -983,5 +983,431 @@ defmodule MvWeb.MemberLive.IndexTest do 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) 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() + + {: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() + + # Reload member with custom field values + member + |> Ash.load!(:custom_field_values) + end + + # Tests for get_boolean_custom_field_value/2 + test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do + boolean_field = create_boolean_custom_field() + member = create_member_with_boolean_value(%{}, boolean_field, true) + + # 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 + boolean_field = create_boolean_custom_field() + member = create_member_with_boolean_value(%{}, boolean_field, false) + + 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 type and value keys", %{conn: _conn} do + 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() + + # Create CustomFieldValue with map format + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: boolean_field.id, + value: %{"type" => "boolean", "value" => true} + }) + |> Ash.create() + + # Reload member with custom field values + member = member |> Ash.load!(:custom_field_values) + + 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 + 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() + + # Member has no custom field value for this field + member = member |> Ash.load!(:custom_field_values) + + 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 + 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() + + # 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() + + member = member |> Ash.load!(:custom_field_values) + + 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 + 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() + + # 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() + + member = member |> Ash.load!(:custom_field_values) + + # 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 + boolean_field = create_boolean_custom_field() + + member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + {: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() + + member_without_value = member_without_value |> Ash.load!(:custom_field_values) + + members = [member_with_true, member_with_false, member_without_value] + filters = %{to_string(boolean_field.id) => true} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + 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 + boolean_field = create_boolean_custom_field() + + member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + {: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() + + member_without_value = member_without_value |> Ash.load!(:custom_field_values) + + members = [member_with_true, member_with_false, member_without_value] + filters = %{to_string(boolean_field.id) => false} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + 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 + boolean_field = create_boolean_custom_field() + + member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true) + member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false) + + members = [member1, member2] + filters = %{} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + 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 + 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() + + {: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() + + {: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() + + member_both_true = member_both_true |> Ash.load!(:custom_field_values) + + # 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() + + {: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() + + {: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() + + member_mixed = member_mixed |> Ash.load!(:custom_field_values) + + members = [member_both_true, member_mixed] + filters = %{ + to_string(boolean_field1.id) => true, + to_string(boolean_field2.id) => true + } + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + # 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 + boolean_field = create_boolean_custom_field() + fake_id = Ecto.UUID.generate() + + member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true) + + members = [member] + filters = %{fake_id => true} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + # 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 + 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) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + {: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() + + # 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 + conn = conn_with_oidc_user(conn) + boolean_field = create_boolean_custom_field() + fee_type = create_fee_type(%{interval: :yearly}) + 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() + + {: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() + + create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid}) + + # 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() + + {: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() + + create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid}) + + # 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 + 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) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + + # 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 end end