test: add tests for user-member linking and fuzzy search (#168)

This commit is contained in:
Moritz 2025-11-20 15:51:44 +01:00
parent 8ba15eb16b
commit 2781f77a72
4 changed files with 679 additions and 0 deletions

View file

@ -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: false
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

View file

@ -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

View file

@ -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: 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

View file

@ -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: false
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