mitgliederverwaltung/test/membership/member_search_groups_integration_test.exs
Simon 63b8e70e62
Some checks failed
continuous-integration/drone/push Build is failing
fix: adress review comments
2026-02-18 13:05:31 +01:00

386 lines
12 KiB
Elixir

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