defmodule Mv.Membership.MemberAvailableForLinkingTest do @moduledoc """ Tests for the Member.available_for_linking action. This action returns members that can be linked to a user account: - Only members without existing user links (user_id == nil) - Limited to 10 results - Special email-match logic: if user_email matches member email, only return that member - Optional search query filtering by name and email """ use Mv.DataCase, async: false alias Mv.Membership describe "available_for_linking/2" do setup do # Create 5 unlinked members with distinct names {:ok, member1} = Membership.create_member(%{ first_name: "Alice", last_name: "Anderson", email: "alice@example.com" }) {:ok, member2} = Membership.create_member(%{ first_name: "Bob", last_name: "Williams", email: "bob@example.com" }) {:ok, member3} = Membership.create_member(%{ first_name: "Charlie", last_name: "Davis", email: "charlie@example.com" }) {:ok, member4} = Membership.create_member(%{ first_name: "Diana", last_name: "Martinez", email: "diana@example.com" }) {:ok, member5} = Membership.create_member(%{ first_name: "Emma", last_name: "Taylor", email: "emma@example.com" }) unlinked_members = [member1, member2, member3, member4, member5] # Create 2 linked members (with users) {:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"}) {:ok, linked_member1} = Membership.create_member(%{ first_name: "Linked", last_name: "Member1", email: "linked1@example.com", user: %{id: user1.id} }) {:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"}) {:ok, linked_member2} = Membership.create_member(%{ first_name: "Linked", last_name: "Member2", email: "linked2@example.com", user: %{id: user2.id} }) %{ unlinked_members: unlinked_members, linked_members: [linked_member1, linked_member2] } end test "returns only unlinked members and limits to 10", %{ unlinked_members: unlinked_members, linked_members: _linked_members } do # Call the action without any arguments members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{}) |> Ash.read!() # Should return only the 5 unlinked members, not the 2 linked ones assert length(members) == 5 returned_ids = Enum.map(members, & &1.id) |> MapSet.new() expected_ids = Enum.map(unlinked_members, & &1.id) |> MapSet.new() assert MapSet.equal?(returned_ids, expected_ids) # Verify none of the returned members have a user_id Enum.each(members, fn member -> member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user]) assert is_nil(member_with_user.user) end) end test "limits results to 10 members even when more exist" do # Create 15 additional unlinked members (total 20 unlinked) for i <- 6..20 do Membership.create_member(%{ first_name: "Extra#{i}", last_name: "Member#{i}", email: "extra#{i}@example.com" }) end members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{}) |> Ash.read!() # Should be limited to 10 assert length(members) == 10 end test "email match: returns only member with matching email when exists", %{ unlinked_members: unlinked_members } do # Get one of the unlinked members' email target_member = List.first(unlinked_members) user_email = target_member.email raw_members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{user_email: user_email}) |> Ash.read!() # Apply email match filtering (sorted results come from query) # When user_email matches, only that member should be returned members = Mv.Membership.Member.filter_by_email_match(raw_members, user_email) # Should return only the member with matching email assert length(members) == 1 assert List.first(members).id == target_member.id assert List.first(members).email == user_email end test "email match: returns all unlinked members when no email match" do # Use an email that doesn't match any member non_matching_email = "nonexistent@example.com" raw_members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email}) |> Ash.read!() # Apply email match filtering members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email) # Should return all 5 unlinked members since no match assert length(members) == 5 end test "search query: filters by first_name, last_name, and email", %{ unlinked_members: _unlinked_members } do # Search by first name members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"}) |> Ash.read!() assert length(members) == 1 assert List.first(members).first_name == "Alice" # Search by last name members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"}) |> Ash.read!() assert length(members) == 1 assert List.first(members).last_name == "Williams" # Search by email members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"}) |> Ash.read!() assert length(members) == 1 assert List.first(members).email == "charlie@example.com" # Search returns empty when no matches members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"}) |> Ash.read!() assert Enum.empty?(members) end test "search query takes precedence over email match", %{unlinked_members: unlinked_members} do target_member = List.first(unlinked_members) # Pass both email match and search query that would match different members raw_members = Mv.Membership.Member |> Ash.Query.for_read(:available_for_linking, %{ user_email: target_member.email, search_query: "Bob" }) |> Ash.read!() # Search query takes precedence, should match "Bob" in the first name # user_email is used for POST-filtering only, not in the query assert length(raw_members) == 1 # Should find the member with "Bob" first name, not target_member (Alice) assert List.first(raw_members).first_name == "Bob" refute List.first(raw_members).id == target_member.id end end end