defmodule Mv.Membership.MemberSearchGroupsIntegrationTest do @moduledoc """ Tests for member search integration with group names (Issue #375). Verifies that: - Group names are included in member search (via search_vector / FTS) - Searching by group name returns all members in that group - Search vector updates when member-group associations change (trigger on member_groups) - Edge cases (multiple groups, no groups, special characters) and authorization Implementation: search_vector trigger and trigger on member_groups (see migration 20260217120000_add_group_names_to_member_search_vector.exs, Issue #375). """ use Mv.DataCase, async: false alias Mv.Helpers.SystemActor alias Mv.Membership.{Group, Member, MemberGroup} setup do system_actor = SystemActor.get_system_actor() %{system_actor: system_actor} end describe "search by group name" do test "search by group name finds member in that group", %{system_actor: actor} do {:ok, member} = Mv.Membership.create_member( %{first_name: "Anna", last_name: "Arbeiter", email: "anna@example.com"}, actor: actor ) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "Vorstand"}) |> Ash.create(actor: actor) {:ok, _mg} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) results = Member |> Member.fuzzy_search(%{query: "Vorstand"}) |> Ash.read!(actor: actor) assert length(results) == 1 assert List.first(results).id == member.id end test "search by group name finds all members in that group", %{system_actor: actor} do {:ok, m1} = Mv.Membership.create_member( %{first_name: "Bob", last_name: "Brown", email: "bob1@example.com"}, actor: actor ) {:ok, m2} = Mv.Membership.create_member( %{first_name: "Beth", last_name: "Blue", email: "beth@example.com"}, actor: actor ) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) |> Ash.create(actor: actor) for member <- [m1, m2] do {:ok, _} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) end results = Member |> Member.fuzzy_search(%{query: "Board Members"}) |> Ash.read!(actor: actor) ids = Enum.map(results, & &1.id) assert m1.id in ids assert m2.id in ids assert length(results) == 2 end test "member in multiple groups is findable by any of those group names", %{ system_actor: actor } do {:ok, member} = Mv.Membership.create_member( %{first_name: "Clara", last_name: "Clark", email: "clara@example.com"}, actor: actor ) {:ok, g1} = Group |> Ash.Changeset.for_create(:create, %{name: "Alpha Team"}) |> Ash.create(actor: actor) {:ok, g2} = Group |> Ash.Changeset.for_create(:create, %{name: "Beta Team"}) |> Ash.create(actor: actor) for {m, g} <- [{member, g1}, {member, g2}] do {:ok, _} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: m.id, group_id: g.id}) |> Ash.create(actor: actor) end for group_name <- ["Alpha Team", "Beta Team"] do results = Member |> Member.fuzzy_search(%{query: group_name}) |> Ash.read!(actor: actor) assert Enum.any?(results, fn r -> r.id == member.id end), "Search for #{group_name} should find member" end end test "search by group name does not return members not in that group", %{ system_actor: actor } do {:ok, member_in_x} = Mv.Membership.create_member( %{first_name: "Xavier", last_name: "X", email: "xavier@example.com"}, actor: actor ) {:ok, member_in_y} = Mv.Membership.create_member( %{first_name: "Yvonne", last_name: "Y", email: "yvonne@example.com"}, actor: actor ) {:ok, group_x} = Group |> Ash.Changeset.for_create(:create, %{name: "GroupXOnly"}) |> Ash.create(actor: actor) {:ok, group_y} = Group |> Ash.Changeset.for_create(:create, %{name: "GroupYOnly"}) |> Ash.create(actor: actor) {:ok, _} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member_in_x.id, group_id: group_x.id}) |> Ash.create(actor: actor) {:ok, _} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member_in_y.id, group_id: group_y.id}) |> Ash.create(actor: actor) results_x = Member |> Member.fuzzy_search(%{query: "GroupXOnly"}) |> Ash.read!(actor: actor) assert Enum.any?(results_x, fn r -> r.id == member_in_x.id end) refute Enum.any?(results_x, fn r -> r.id == member_in_y.id end) results_y = Member |> Member.fuzzy_search(%{query: "GroupYOnly"}) |> Ash.read!(actor: actor) assert Enum.any?(results_y, fn r -> r.id == member_in_y.id end) refute Enum.any?(results_y, fn r -> r.id == member_in_x.id end) end test "member with no groups is not found by unrelated group name", %{system_actor: actor} do {:ok, member} = Mv.Membership.create_member( %{first_name: "Solo", last_name: "User", email: "solo@example.com"}, actor: actor ) {:ok, _group} = Group |> Ash.Changeset.for_create(:create, %{name: "SomeOtherGroup"}) |> Ash.create(actor: actor) # Member is not in any group; search for the group name should not return this member results = Member |> Member.fuzzy_search(%{query: "SomeOtherGroup"}) |> Ash.read!(actor: actor) refute Enum.any?(results, fn r -> r.id == member.id end) end end describe "search vector update on member_groups changes" do test "adding member to group updates search vector (INSERT on member_groups)", %{ system_actor: actor } do {:ok, member} = Mv.Membership.create_member( %{first_name: "New", last_name: "Member", email: "new@example.com"}, actor: actor ) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "NewlyAddedGroup"}) |> Ash.create(actor: actor) # Before adding to group, search should not find by group name results_before = Member |> Member.fuzzy_search(%{query: "NewlyAddedGroup"}) |> Ash.read!(actor: actor) refute Enum.any?(results_before, fn r -> r.id == member.id end) {:ok, _mg} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) # After adding, search should find member (trigger on member_groups INSERT) results_after = Member |> Member.fuzzy_search(%{query: "NewlyAddedGroup"}) |> Ash.read!(actor: actor) assert Enum.any?(results_after, fn r -> r.id == member.id end) end test "removing member from group updates search vector (DELETE on member_groups)", %{ system_actor: actor } do # Use a member name that does not overlap with the group name so that the only # way to find them is via search_vector (group name). Otherwise trigram fuzzy # match on first_name would still find "Remove" when searching "RemovedGroup". {:ok, member} = Mv.Membership.create_member( %{first_name: "Zara", last_name: "None", email: "zara.remove@example.com"}, actor: actor ) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "RemovedGroup"}) |> Ash.create(actor: actor) {:ok, mg} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) results_before = Member |> Member.fuzzy_search(%{query: "RemovedGroup"}) |> Ash.read!(actor: actor) assert Enum.any?(results_before, fn r -> r.id == member.id end) :ok = Mv.Membership.destroy_member_group(mg, actor: actor) results_after = Member |> Member.fuzzy_search(%{query: "RemovedGroup"}) |> Ash.read!(actor: actor) refute Enum.any?(results_after, fn r -> r.id == member.id end) end end describe "edge cases" do test "token match: single word in group name matches (e.g. Board in Board Members)", %{ system_actor: actor } do {:ok, member} = Mv.Membership.create_member( %{first_name: "Partial", last_name: "Test", email: "partial@example.com"}, actor: actor ) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) |> Ash.create(actor: actor) {:ok, _mg} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) # FTS with 'simple' config: full word "Board" or "Members" should match results = Member |> Member.fuzzy_search(%{query: "Board"}) |> Ash.read!(actor: actor) assert Enum.any?(results, fn r -> r.id == member.id end), "Search for 'Board' should find member in group 'Board Members'" end test "search with token from group name containing special characters does not crash", %{ system_actor: actor } do {:ok, member} = Mv.Membership.create_member( %{first_name: "Special", last_name: "Char", email: "special@example.com"}, actor: actor ) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "Team A&B"}) |> Ash.create(actor: actor) {:ok, _mg} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: actor) # Search for a token from the group name; proves tokenization does not crash on "A&B" results = Member |> Member.fuzzy_search(%{query: "Team"}) |> Ash.read!(actor: actor) assert Enum.any?(results, fn r -> r.id == member.id end), "Search for 'Team' should find member in group 'Team A&B'" end end describe "authorization" do test "search respects authorization (actor sees only allowed members)", %{ system_actor: system_actor } do # own_data user linked to member1 can only read member1; member2 is in same group admin = Mv.Fixtures.user_with_role_fixture("admin") user_own_data = Mv.Fixtures.user_with_role_fixture("own_data") member1 = Mv.Fixtures.member_fixture(%{ first_name: "Linked", last_name: "User", email: "linked@example.com" }) member2 = Mv.Fixtures.member_fixture(%{ first_name: "Other", last_name: "User", email: "other@example.com" }) {:ok, user_own_data} = user_own_data |> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.force_change_attribute(:member_id, member1.id) |> Ash.update(actor: admin) {:ok, group} = Group |> Ash.Changeset.for_create(:create, %{name: "SharedGroupName"}) |> Ash.create(actor: system_actor) for member <- [member1, member2] do {:ok, _} = MemberGroup |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) |> Ash.create(actor: admin) end # Search as own_data user: should only return member1 (linked), not member2 results = Member |> Member.fuzzy_search(%{query: "SharedGroupName"}) |> Ash.read!(actor: user_own_data) ids = Enum.map(results, & &1.id) assert member1.id in ids refute member2.id in ids end end end