Merge pull request 'sync email between user and member closes #167' (#181) from feature/email-sync into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #181
Reviewed-by: carla <carla@noreply.git.local-it.org>
This commit is contained in:
moritz 2025-10-30 16:25:34 +01:00
commit 687d653fb7
14 changed files with 1381 additions and 6 deletions

49
docs/email-sync.md Normal file
View file

@ -0,0 +1,49 @@
## Core Rules
1. **User.email is source of truth** - Always overrides member email when linking
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
---
## Decision Tree
```
Action: Create/Update/Link Entity with Email X
├─ Does Email X violate DB constraint (same table)?
│ └─ YES → ❌ FAIL (two users or two members with same email)
├─ Is Entity currently linked? (or being linked?)
│ │
│ ├─ NO (unlinked entity)
│ │ └─ ✅ SUCCESS (no custom validation)
│ │
│ └─ YES (linked or linking)
│ │
│ ├─ Action: Update Linked User Email
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
│ │
│ ├─ Action: Update Linked Member Email
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
│ │
│ ├─ Action: Link User to Member (both directions)
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
│ │ └─ Otherwise → ✅ SUCCESS + override member email
```
## Sync Triggers
| Action | Sync Direction | When |
|--------|---------------|------|
| Update linked user email | User → Member | Email changed |
| Update linked member email | Member → User | Email changed |
| Link user to member | User → Member | Always (override) |
| Link member to user | User → Member | Always (override) |
| Unlink | None | Emails stay as-is |

View file

@ -66,14 +66,17 @@ defmodule Mv.Accounts.User do
end
actions do
# Default actions kept for framework/tooling integration:
# - :create -> Used by AshAdmin's generated "Create" UI and by generic
# AshPhoenix helpers that assume a default create action.
# It does NOT manage the :member relationship. For admin
# flows that may link an existing member, use :create_user.
# Default actions for framework/tooling integration:
# - :read -> Standard read used across the app and by admin tooling.
# - :destroy-> Standard delete used by admin tooling and maintenance tasks.
defaults [:read, :create, :destroy]
#
# NOTE: :create is INTENTIONALLY excluded from defaults!
# Using a default :create would bypass email-synchronization logic.
# Always use one of these explicit create actions instead:
# - :create_user (for manual user creation with optional member link)
# - :register_with_password (for password-based registration)
# - :register_with_rauthy (for OIDC-based registration)
defaults [:read, :destroy]
# Primary generic update action:
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
@ -89,6 +92,12 @@ defmodule Mv.Accounts.User do
# cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations.
require_atomic? false
# Sync email changes to linked member (User → Member)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
end
create :create_user do
@ -111,6 +120,9 @@ defmodule Mv.Accounts.User do
# If no member provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
update :update_user do
@ -137,6 +149,12 @@ defmodule Mv.Accounts.User do
# If no member provided, remove existing relationship (allows member removal)
on_missing: :unrelate
)
# Sync email changes and handle linking (User → Member)
# Runs when email OR member relationship changes
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
end
# Admin action for direct password changes in admin panel
@ -185,6 +203,9 @@ defmodule Mv.Accounts.User do
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end
end
@ -195,6 +216,10 @@ defmodule Mv.Accounts.User do
where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8"
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
# Email validation with EctoCommons.EmailValidator (same as Member)
# This ensures consistency between User and Member email validation
validate fn changeset, _ ->
@ -255,6 +280,13 @@ defmodule Mv.Accounts.User do
attributes do
uuid_primary_key :id
# IMPORTANT: Email Synchronization
# When user and member are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :ci_string do
allow_nil? false
public? true

View file

@ -48,6 +48,12 @@ defmodule Mv.Membership.Member do
# If no user provided, that's fine (optional relationship)
on_missing: :ignore
)
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end
update :update_member do
@ -89,6 +95,18 @@ defmodule Mv.Membership.Member do
# If no user provided, remove existing relationship (allows user removal)
on_missing: :unrelate
)
# Sync member email to user when email changes (Member → User)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncMemberEmailToUser do
where [changing(:email)]
end
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end
end
@ -100,6 +118,10 @@ defmodule Mv.Membership.Member do
validate present(:last_name)
validate present(:email)
# Email uniqueness check for all actions that change the email attribute
# Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
# Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member
@ -189,6 +211,13 @@ defmodule Mv.Membership.Member do
constraints min_length: 1
end
# IMPORTANT: Email Synchronization
# When member and user are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :string do
allow_nil? false
constraints min_length: 5, max_length: 254

View file

@ -0,0 +1,60 @@
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
@moduledoc """
Validates that the user's email is not already used by another member.
Only validates when:
- User is already linked to a member (member_id != nil) AND email is changing
- User is being linked to a member (member relationship is changing)
This allows creating users with the same email as unlinked members.
"""
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
is_linked? = not is_nil(member_id)
# Only validate if:
# 1. User is linked AND email is changing
# 2. User is being linked/unlinked (member relationship changing)
should_validate? = (is_linked? and email_changing?) or member_changing?
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
check_email_uniqueness(new_email, member_id)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(current_email, member_id)
end
else
:ok
end
end
defp check_email_uniqueness(email, exclude_member_id) do
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -0,0 +1,37 @@
defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
@moduledoc """
Synchronizes Member.email User.email
Trigger conditions are configured in resources via `where` clauses:
- Member resource: Use `where: [changing(:email)]`
Used by Member resource for bidirectional email sync.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
new_email = Ash.Changeset.get_attribute(changeset, :email)
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result
end
end)
end
end

View file

@ -0,0 +1,62 @@
defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
@moduledoc """
Synchronizes User.email Member.email
User.email is always the source of truth.
Trigger conditions are configured in resources via `where` clauses:
- User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])`
- Member resource: Use `where: [changing(:user)]`
Can be used by both User and Member resources.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
# When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only
case record do
%Mv.Membership.Member{} ->
# Member-side: Override member email in result with user email
Helpers.override_with_linked_email(result, user.email)
%Mv.Accounts.User{} ->
# User-side: Sync user email to linked member in DB
Helpers.sync_email_to_linked_record(result, member, user.email)
end
else
_ -> result
end
end)
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
{:ok, user} -> {:ok, user, member}
error -> error
end
end
end

View file

@ -0,0 +1,93 @@
defmodule Mv.EmailSync.Helpers do
@moduledoc """
Shared helper functions for email synchronization between User and Member.
Handles the complexity of `around_transaction` callback results and
provides clean abstractions for email updates within transactions.
"""
require Logger
import Ecto.Changeset
@doc """
Extracts the record from an Ash action result.
Handles both 2-tuple `{:ok, record}` and 4-tuple
`{:ok, record, changeset, notifications}` patterns.
"""
def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record}
def extract_record({:ok, record}), do: {:ok, record}
def extract_record({:error, _} = error), do: error
@doc """
Updates the result with a new record while preserving the original structure.
If the original result was a 4-tuple, returns a 4-tuple with the updated record.
If it was a 2-tuple, returns a 2-tuple with the updated record.
"""
def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do
{:ok, new_record, changeset, notifications}
end
def update_result_record({:ok, _old_record}, new_record) do
{:ok, new_record}
end
@doc """
Updates an email field directly via Ecto within the current transaction.
This bypasses Ash's action system to ensure the update happens in the
same database transaction as the parent action.
"""
def update_email_via_ecto(record, new_email) do
record
|> cast(%{email: to_string(new_email)}, [:email])
|> Mv.Repo.update()
end
@doc """
Synchronizes email to a linked record if it exists.
Returns the original result unchanged, or an error if sync fails.
"""
def sync_email_to_linked_record(result, linked_record, new_email) do
with {:ok, _source} <- extract_record(result),
record when not is_nil(record) <- linked_record,
{:ok, _updated} <- update_email_via_ecto(record, new_email) do
# Successfully synced - return original result unchanged
result
else
nil ->
# No linked record - return original result
result
{:error, error} ->
# Sync failed - log and propagate error to rollback transaction
Logger.error("Email sync failed: #{inspect(error)}")
{:error, error}
end
end
@doc """
Overrides the record's email with the linked email if emails differ.
Returns updated result with new record, or original result if no update needed.
"""
def override_with_linked_email(result, linked_email) do
with {:ok, record} <- extract_record(result),
true <- record.email != to_string(linked_email),
{:ok, updated_record} <- update_email_via_ecto(record, linked_email) do
# Email was different - return result with updated record
update_result_record(result, updated_record)
else
false ->
# Emails already match - no update needed
result
{:error, error} ->
# Override failed - log and propagate error
Logger.error("Email override failed: #{inspect(error)}")
{:error, error}
end
end
end

View file

@ -0,0 +1,40 @@
defmodule Mv.EmailSync.Loader do
@moduledoc """
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
"""
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
"""
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}) do
case Ash.get(Mv.Membership.Member, id) do
{:ok, member} -> member
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
"""
def get_linked_user(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} -> user
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
"""
def load_linked_user!(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error
end
end
end

View file

@ -0,0 +1,58 @@
defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
@moduledoc """
Validates that the member's email is not already used by another user.
Only validates when:
- Member is already linked to a user (user != nil) AND email is changing
- Member is being linked to a user (user relationship is changing)
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
# Do NOT validate when member is being linked (email will be overridden from user)
should_validate? = is_linked? and email_changing?
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id) do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do
case Ash.load(member_data, :user) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end
end
end

View file

@ -0,0 +1,93 @@
defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
@moduledoc """
Edge case tests for email synchronization between User and Member.
Tests various boundary conditions and validation scenarios.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Email sync edge cases" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "simultaneous email updates use user email as source of truth" do
# Create linked user and member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify link and initial sync
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Scenario: Both emails are updated "simultaneously"
# In practice, this tests that when a member email is updated,
# it syncs to user, and user remains the source of truth
# Update member email first
{:ok, _updated_member} =
Membership.update_member(member, %{email: "member-new@example.com"})
# Verify it synced to user
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(user_after_member_update.email) == "member-new@example.com"
# Now update user email - this should override
{:ok, _updated_user} =
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
# Reload both
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
# User email should be the final truth
assert to_string(final_user.email) == "user-final@example.com"
assert final_member.email == "user-final@example.com"
end
test "email validation works for both user and member" do
# Test that invalid emails are rejected for both resources
# Invalid email for user
invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
# Invalid email for member
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
invalid_member_result = Membership.create_member(invalid_member_attrs)
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
# Valid emails should work
{:ok, _user} = Accounts.create_user(@valid_user_attrs)
{:ok, _member} = Membership.create_member(@valid_member_attrs)
end
test "identity constraints prevent duplicate emails" do
# Create first user with an email
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
assert to_string(user1.email) == "duplicate@example.com"
# Try to create second user with same email - should fail due to unique constraint
result = Accounts.create_user(%{email: "duplicate@example.com"})
assert {:error, %Ash.Error.Invalid{}} = result
# Same for members
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
{:ok, member1} = Membership.create_member(member_attrs)
assert member1.email == "member-dup@example.com"
# Try to create second member with same email - should fail
result2 = Membership.create_member(member_attrs)
assert {:error, %Ash.Error.Invalid{}} = result2
end
end
end

View file

@ -0,0 +1,480 @@
defmodule Mv.Accounts.EmailUniquenessTest do
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Email uniqueness validation - Creation" do
test "CAN create member with existing unlinked user email" do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing@example.com"
})
# Create member with same email - should succeed
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
assert to_string(member.email) == "existing@example.com"
end
test "CAN create user with existing unlinked member email" do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
# Create user with same email - should succeed
{:ok, user} =
Accounts.create_user(%{
email: "existing@example.com"
})
assert to_string(user.email) == "existing@example.com"
end
end
describe "Email uniqueness validation - Updating unlinked entities" do
test "unlinked member email CAN be changed to an existing unlinked user email" do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing_user@example.com"
})
# Create an unlinked member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
# Change member email to existing user email - should succeed (member is unlinked)
{:ok, updated_member} =
Membership.update_member(member, %{
email: "existing_user@example.com"
})
assert to_string(updated_member.email) == "existing_user@example.com"
end
test "unlinked user email CAN be changed to an existing unlinked member email" do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
})
# Create an unlinked user with different email
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Change user email to existing member email - should succeed (user is unlinked)
{:ok, updated_user} =
Accounts.update_user(user, %{
email: "existing_member@example.com"
})
assert to_string(updated_user.email) == "existing_member@example.com"
end
test "unlinked member email CANNOT be changed to an existing linked user email" do
# Create a user and link it to a member - this makes the user "linked"
{:ok, user} =
Accounts.create_user(%{
email: "linked_user@example.com"
})
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
})
# Create an unlinked member with different email
{:ok, member_b} =
Membership.create_member(%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
})
# Try to change unlinked member's email to linked user's email - should fail
result =
Membership.update_member(member_b, %{
email: "linked_user@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "unlinked user email CANNOT be changed to an existing linked member email" do
# Create a user and link it to a member - this makes the member "linked"
{:ok, user_a} =
Accounts.create_user(%{
email: "user_a@example.com"
})
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
})
# Reload user to get updated member_id and linked member email
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
linked_member_email = to_string(user_a_with_member.member.email)
# Create an unlinked user with different email
{:ok, user_b} =
Accounts.create_user(%{
email: "user_b@example.com"
})
# Try to change unlinked user's email to linked member's email - should fail
result =
Accounts.update_user(user_b, %{
email: linked_member_email
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Creating with linked emails" do
test "CANNOT create member with existing linked user email" do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "linked@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
})
# Try to create a new member with the linked user's email - should fail
result =
Membership.create_member(%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "CANNOT create user with existing linked member email" do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
})
# Reload user to get the linked member's email
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_with_member} = Ash.load(user_reloaded, :member)
linked_member_email = to_string(user_with_member.member.email)
# Try to create a new user with the linked member's email - should fail
result =
Accounts.create_user(%{
email: linked_member_email
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Updating linked entities" do
test "linked member email CANNOT be changed to an existing user email" do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "other_user@example.com"
})
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
# Try to change linked member's email to other user's email - should fail
result =
Membership.update_member(member, %{
email: "other_user@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "linked user email CANNOT be changed to an existing member email" do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
})
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
# Reload user to get updated member_id
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
# Try to change linked user's email to other member's email - should fail
result =
Accounts.update_user(user_reloaded, %{
email: "other_member@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Linking" do
test "CANNOT link user to member if user email is already used by another unlinked member" do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
})
# Create a user with same email
{:ok, user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Create a member to link with the user
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
})
# Try to link user to member - should fail because user.email is already used by other_member
result =
Accounts.update_user(user, %{
member: %{id: member.id}
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Create a member with same email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
# Create a user to link with the member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Link member to user - should succeed because member.email will be overridden
{:ok, updated_member} =
Membership.update_member(member, %{
user: %{id: user.id}
})
# Member email should now be the same as user email
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
assert to_string(member_reloaded.email) == "user@example.com"
end
end
describe "Email syncing" do
test "member email syncs to linked user email without validation error" do
# Create a user
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Create a member linked to this user
# The override change will set member.email = user.email automatically
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
})
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same user
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
end
test "user email syncs to linked member without validation error" do
# Create a member
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
# Create a user linked to this member
# The override change will set member.email = user.email automatically
{:ok, _user} =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same member
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
end
test "two unlinked users cannot have the same email" do
# Create first user
{:ok, _user1} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Try to create second user with same email
result =
Accounts.create_user(%{
email: "duplicate@example.com"
})
assert {:error, %Ash.Error.Invalid{}} = result
end
test "two unlinked members cannot have the same email (members have unique constraint)" do
# Create first member
{:ok, _member1} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
# Try to create second member with same email - should fail
result =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
})
assert {:error, %Ash.Error.Invalid{}} = result
# Members DO have a unique email constraint at database level
end
end
end

View file

@ -0,0 +1,169 @@
defmodule Mv.Accounts.UserEmailSyncTest do
@moduledoc """
Tests for email synchronization from User to Member.
When a user and member are linked, email changes should sync bidirectionally.
User.email is the source of truth when linking occurs.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "User email synchronization to linked Member" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "updating user email syncs to linked member" do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify initial state - member email should be overridden by user email
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
# Update user email
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
assert to_string(updated_user.email) == "newemail@example.com"
# Verify member email was also updated
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "newemail@example.com"
end
test "creating user linked to member overrides member email" do
# Create a member with their own email
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a user linked to this member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
assert to_string(user.email) == "user@example.com"
assert user.member_id == member.id
# Verify member email was overridden with user email
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
assert updated_member.email == "user@example.com"
end
test "linking user to existing member syncs user email to member" do
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Link the user to the member
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
assert linked_user.member_id == member.id
# Verify member email was overridden with user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
end
test "updating user email when no member linked does not error" do
# Create a standalone user without member link
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Update user email - should work fine without error
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
assert to_string(updated_user.email) == "newemail@example.com"
assert updated_user.member_id == nil
end
test "unlinking user from member does not sync email" do
# Create member
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Create user linked to member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
assert user.member_id == member.id
# Verify member email was synced
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Unlink user from member
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
assert unlinked_user.member_id == nil
# Member email should remain unchanged after unlinking
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_unlink.email == "user@example.com"
end
end
describe "AshAuthentication compatibility" do
test "AshAuthentication password strategy still works with email" do
# This test ensures that the email field remains accessible for password auth
email = "test@example.com"
password = "securepassword123"
# Create user with password strategy (simulating registration)
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: email,
password: password
})
|> Ash.create()
assert to_string(user.email) == email
assert user.hashed_password != nil
# Verify we can sign in with email
{:ok, signed_in_user} =
Mv.Accounts.User
|> Ash.Query.for_read(:sign_in_with_password, %{
email: email,
password: password
})
|> Ash.read_one()
assert signed_in_user.id == user.id
assert to_string(signed_in_user.email) == email
end
test "AshAuthentication OIDC strategy still works with email" do
# This test ensures the OIDC flow can still set email
user_info = %{
"preferred_username" => "oidc@example.com",
"sub" => "oidc-user-123"
}
oauth_tokens = %{"access_token" => "mock_token"}
# Simulate OIDC registration
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create()
assert to_string(user.email) == "oidc@example.com"
assert user.oidc_id == "oidc-user-123"
end
end
end

View file

@ -0,0 +1,127 @@
defmodule Mv.Membership.MemberEmailSyncTest do
@moduledoc """
Tests for email synchronization from Member to User.
When a member and user are linked, email changes should sync bidirectionally.
User.email is the source of truth when linking occurs.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Member email synchronization to linked User" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "updating member email syncs to linked user" do
# Create a user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a member linked to the user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Verify initial state - member email should be overridden by user email
{:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_create.email == "user@example.com"
# Update member email
{:ok, updated_member} =
Membership.update_member(member, %{email: "newmember@example.com"})
assert updated_member.email == "newmember@example.com"
# Verify user email was also updated
{:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(synced_user.email) == "newmember@example.com"
end
test "creating member linked to user syncs user email to member" do
# Create a user with their own email
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a member linked to this user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Member should have been created with user's email (user is source of truth)
assert member.email == "user@example.com"
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user.id == user.id
end
test "linking member to existing user syncs user email to member" do
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Link the member to the user
{:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}})
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user])
assert loaded_member.user.id == user.id
# Verify member email was overridden with user email
assert loaded_member.email == "user@example.com"
end
test "updating member email when no user linked does not error" do
# Create a standalone member without user link
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Load to verify no user link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user == nil
# Update member email - should work fine without error
{:ok, updated_member} =
Membership.update_member(member, %{email: "newemail@example.com"})
assert updated_member.email == "newemail@example.com"
end
test "unlinking member from user does not sync email" do
# Create user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
# Create member linked to user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Verify member email was synced to user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Verify link exists
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user != nil
# Unlink member from user
{:ok, unlinked_member} = Membership.update_member(member, %{user: nil})
# Verify unlink
{:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user])
assert loaded_unlinked.user == nil
# User email should remain unchanged after unlinking
{:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(user_after_unlink.email) == "user@example.com"
end
end
end

46
test/seeds_test.exs Normal file
View file

@ -0,0 +1,46 @@
defmodule Mv.SeedsTest do
use Mv.DataCase, async: false
describe "Seeds script" do
test "runs successfully without errors" do
# Run the seeds script - should not raise any errors
assert Code.eval_file("priv/repo/seeds.exs")
# Basic smoke test: ensure some data was created
{:ok, users} = Ash.read(Mv.Accounts.User)
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, property_types} = Ash.read(Mv.Membership.PropertyType)
assert length(users) > 0, "Seeds should create at least one user"
assert length(members) > 0, "Seeds should create at least one member"
assert length(property_types) > 0, "Seeds should create at least one property type"
end
test "can be run multiple times (idempotent)" do
# Run seeds first time
assert Code.eval_file("priv/repo/seeds.exs")
# Count records
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType)
# Run seeds second time - should not raise errors
assert Code.eval_file("priv/repo/seeds.exs")
# Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType)
assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds"
assert length(members_count_1) == length(members_count_2),
"Members count should remain same after re-running seeds"
assert length(property_types_count_1) == length(property_types_count_2),
"PropertyTypes count should remain same after re-running seeds"
end
end
end