From 88bed75ac9254b761b1b17d8b3ebcc42761f8caf Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:51:44 +0100 Subject: [PATCH] test: add tests for user-member linking and fuzzy search (#168) --- .../user_member_linking_email_test.exs | 169 +++++++++++++ test/accounts/user_member_linking_test.exs | 130 ++++++++++ .../member_available_for_linking_test.exs | 222 ++++++++++++++++++ .../member_fuzzy_search_linking_test.exs | 158 +++++++++++++ 4 files changed, 679 insertions(+) create mode 100644 test/accounts/user_member_linking_email_test.exs create mode 100644 test/accounts/user_member_linking_test.exs create mode 100644 test/membership/member_available_for_linking_test.exs create mode 100644 test/membership/member_fuzzy_search_linking_test.exs diff --git a/test/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs new file mode 100644 index 0000000..5d72ac9 --- /dev/null +++ b/test/accounts/user_member_linking_email_test.exs @@ -0,0 +1,169 @@ +defmodule Mv.Accounts.UserMemberLinkingEmailTest do + @moduledoc """ + Tests email validation during user-member linking. + Implements rules from docs/email-sync.md. + Tests for Issue #168, specifically Problem #4: Email validation bug. + """ + + use Mv.DataCase, async: true + + alias Mv.Accounts + alias Mv.Membership + + describe "link with same email" do + test "succeeds when user.email == member.email" do + # Create member with specific email + {:ok, member} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + # Create user with same email and link to member + result = + Accounts.create_user(%{ + email: "alice@example.com", + member: %{id: member.id} + }) + + # Should succeed without errors + assert {:ok, user} = result + assert to_string(user.email) == "alice@example.com" + + # Reload to verify link + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.id == member.id + assert user.member.email == "alice@example.com" + end + + test "no validation error triggered when updating linked pair with same email" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Smith", + email: "bob@example.com" + }) + + # Create user and link + {:ok, user} = + Accounts.create_user(%{ + email: "bob@example.com", + member: %{id: member.id} + }) + + # Update user (should not trigger email validation error) + result = Accounts.update_user(user, %{email: "bob@example.com"}) + + assert {:ok, updated_user} = result + assert to_string(updated_user.email) == "bob@example.com" + end + end + + describe "link with different emails" do + test "fails if member.email is used by a DIFFERENT linked user" do + # Create first user and link to a different member + {:ok, other_member} = + Membership.create_member(%{ + first_name: "Other", + last_name: "Member", + email: "other@example.com" + }) + + {:ok, _user1} = + Accounts.create_user(%{ + email: "user1@example.com", + member: %{id: other_member.id} + }) + + # Reload to ensure email sync happened + _other_member = Ash.reload!(other_member) + + # Create a NEW member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + # Try to create user2 with email that matches the linked other_member + result = + Accounts.create_user(%{ + email: "user1@example.com", + member: %{id: member.id} + }) + + # Should fail because user1@example.com is already used by other_member (which is linked to user1) + assert {:error, _error} = result + end + + test "succeeds for unique emails" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }) + + # Create user with different but unique email + result = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member.id} + }) + + # Should succeed + assert {:ok, user} = result + + # Email sync should update member's email to match user's + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.email == "user@example.com" + end + end + + describe "edge cases" do + test "unlinking and relinking with same email works (Problem #4)" do + # This is the exact scenario from Problem #4: + # 1. Link user and member (both have same email) + # 2. Unlink them (member keeps the email) + # 3. Try to relink (validation should NOT fail) + + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + # Create user and link + {:ok, user} = + Accounts.create_user(%{ + email: "emma@example.com", + member: %{id: member.id} + }) + + # Verify they are linked + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.id == member.id + assert user.member.email == "emma@example.com" + + # Unlink + {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}) + assert is_nil(unlinked_user.member_id) + + # Member still has the email after unlink + member = Ash.reload!(member) + assert member.email == "emma@example.com" + + # Relink (should work - this is Problem #4) + result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}}) + + assert {:ok, relinked_user} = result + assert relinked_user.member_id == member.id + end + end +end diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs new file mode 100644 index 0000000..1111436 --- /dev/null +++ b/test/accounts/user_member_linking_test.exs @@ -0,0 +1,130 @@ +defmodule Mv.Accounts.UserMemberLinkingTest do + @moduledoc """ + Integration tests for User-Member linking functionality. + + Tests the complete workflow of linking and unlinking members to users, + including email synchronization and validation rules. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "User-Member Linking with Email Sync" do + test "link user to member with different email syncs member email" do + # Create user with one email + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + # Create member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Link user to member + {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Verify link exists + user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member]) + assert user_with_member.member.id == member.id + + # Verify member email was synced to match user email + synced_member = Ash.get!(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + end + + test "unlink member from user sets member to nil" do + # Create and link user and member + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + {:ok, member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Verify link exists + user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member]) + assert user_with_member.member.id == member.id + + # Unlink by setting member to nil + {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) + + # Verify link is removed + user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member]) + assert is_nil(user_without_member.member) + + # Verify member still exists independently + member_still_exists = Ash.get!(Mv.Membership.Member, member.id) + assert member_still_exists.id == member.id + end + + test "cannot link member already linked to another user" do + # Create first user and link to member + {:ok, user1} = Accounts.create_user(%{email: "user1@example.com"}) + + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Wilson", + email: "bob@example.com" + }) + + {:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}}) + + # Create second user and try to link to same member + {:ok, user2} = Accounts.create_user(%{email: "user2@example.com"}) + + # Should fail because member is already linked + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user2, %{member: %{id: member.id}}) + end + + test "cannot change member link directly, must unlink first" do + # Create user and link to first member + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + {:ok, member1} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}}) + + # Create second member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + # Try to directly change member link (should fail) + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Accounts.update_user(linked_user, %{member: %{id: member2.id}}) + + # Verify error message mentions "Remove existing member first" + error_messages = Enum.map(errors, & &1.message) + assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first")) + + # Two-step process: first unlink, then link new member + {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) + + # After unlinking, member1 still has the user's email + # Change member1's email to avoid conflict when relinking to member2 + {:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"}) + + {:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}}) + + # Verify new link is established + user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member]) + assert user_with_new_member.member.id == member2.id + end + end +end diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs new file mode 100644 index 0000000..602fdfd --- /dev/null +++ b/test/membership/member_available_for_linking_test.exs @@ -0,0 +1,222 @@ +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: true + 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 diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs new file mode 100644 index 0000000..fcaf5fd --- /dev/null +++ b/test/membership/member_fuzzy_search_linking_test.exs @@ -0,0 +1,158 @@ +defmodule Mv.Membership.MemberFuzzySearchLinkingTest do + @moduledoc """ + Tests fuzzy search in Member.available_for_linking action. + Verifies PostgreSQL trigram matching for member search. + """ + + use Mv.DataCase, async: true + + alias Mv.Accounts + alias Mv.Membership + + describe "available_for_linking with fuzzy search" do + test "finds member despite typo" do + # Create member with specific name + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan@example.com" + }) + + # Search with typo + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Jonatan" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should find Jonathan despite typo + assert length(members) == 1 + assert hd(members).id == member.id + end + + test "finds member with partial match" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + # Search with partial + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Alex" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should find Alexander + assert length(members) == 1 + assert hd(members).id == member.id + end + + test "email match overrides fuzzy search" do + # Create two members + {:ok, member1} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + {:ok, _member2} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Search with user_email that matches member1, but search_query that would match member2 + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: "john@example.com", + search_query: "Jane" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Apply email filter + filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com") + + # Should only return member1 (email match takes precedence) + assert length(filtered_members) == 1 + assert hd(filtered_members).id == member1.id + end + + test "limits to 10 results" do + # Create 15 members with similar names + for i <- 1..15 do + Membership.create_member(%{ + first_name: "Test#{i}", + last_name: "Member", + email: "test#{i}@example.com" + }) + end + + # Search for "Test" + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Test" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should return max 10 members + assert length(members) == 10 + end + + test "excludes linked members" do + # Create member and link to user + {:ok, member1} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member", + email: "linked@example.com" + }) + + {:ok, _user} = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member1.id} + }) + + # Create unlinked member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Unlinked", + last_name: "Member", + email: "unlinked@example.com" + }) + + # Search for "Member" + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Member" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should only return unlinked member + member_ids = Enum.map(members, & &1.id) + refute member1.id in member_ids + assert member2.id in member_ids + end + end +end