sync email between user and member closes #167 #181
14 changed files with 1381 additions and 6 deletions
49
docs/email-sync.md
Normal file
49
docs/email-sync.md
Normal 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 |
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
37
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal file
37
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal 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
|
||||
62
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal file
62
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal 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
|
||||
93
lib/mv/email_sync/helpers.ex
Normal file
93
lib/mv/email_sync/helpers.ex
Normal 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
|
||||
40
lib/mv/email_sync/loader.ex
Normal file
40
lib/mv/email_sync/loader.ex
Normal 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
|
||||
|
|
@ -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
|
||||
93
test/accounts/email_sync_edge_cases_test.exs
Normal file
93
test/accounts/email_sync_edge_cases_test.exs
Normal 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
|
||||
480
test/accounts/email_uniqueness_test.exs
Normal file
480
test/accounts/email_uniqueness_test.exs
Normal 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
|
||||
169
test/accounts/user_email_sync_test.exs
Normal file
169
test/accounts/user_email_sync_test.exs
Normal 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
|
||||
127
test/membership/member_email_sync_test.exs
Normal file
127
test/membership/member_email_sync_test.exs
Normal 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
46
test/seeds_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue
Maybe you can explain that to us a bit in the next weekly :)
In Ash, if you call another Ash action within a
around_transactioncallback, it would run in a separate transaction. This can lead to inconsistencies if, for example, the first action is successful, but the email synchronization fails. By using Ecto directly (Mv.Repo.update()) the update remains in the same transaction as the parent action. If anything fails, the entire transaction is rolled back.