diff --git a/docs/email-sync.md b/docs/email-sync.md new file mode 100644 index 0000000..c191ff4 --- /dev/null +++ b/docs/email-sync.md @@ -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 | + + diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 668ddd4..0fc5ab0 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 4cec072..56549fc 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -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 diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex new file mode 100644 index 0000000..d42b2c1 --- /dev/null +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -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 diff --git a/lib/mv/email_sync/changes/sync_member_email_to_user.ex b/lib/mv/email_sync/changes/sync_member_email_to_user.ex new file mode 100644 index 0000000..c1e5aea --- /dev/null +++ b/lib/mv/email_sync/changes/sync_member_email_to_user.ex @@ -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 diff --git a/lib/mv/email_sync/changes/sync_user_email_to_member.ex b/lib/mv/email_sync/changes/sync_user_email_to_member.ex new file mode 100644 index 0000000..be7dd2c --- /dev/null +++ b/lib/mv/email_sync/changes/sync_user_email_to_member.ex @@ -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 diff --git a/lib/mv/email_sync/helpers.ex b/lib/mv/email_sync/helpers.ex new file mode 100644 index 0000000..7feaf57 --- /dev/null +++ b/lib/mv/email_sync/helpers.ex @@ -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 diff --git a/lib/mv/email_sync/loader.ex b/lib/mv/email_sync/loader.ex new file mode 100644 index 0000000..ecb1038 --- /dev/null +++ b/lib/mv/email_sync/loader.ex @@ -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 diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex new file mode 100644 index 0000000..54fa243 --- /dev/null +++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex @@ -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 diff --git a/test/accounts/email_sync_edge_cases_test.exs b/test/accounts/email_sync_edge_cases_test.exs new file mode 100644 index 0000000..b872235 --- /dev/null +++ b/test/accounts/email_sync_edge_cases_test.exs @@ -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 diff --git a/test/accounts/email_uniqueness_test.exs b/test/accounts/email_uniqueness_test.exs new file mode 100644 index 0000000..a16ebdd --- /dev/null +++ b/test/accounts/email_uniqueness_test.exs @@ -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 diff --git a/test/accounts/user_email_sync_test.exs b/test/accounts/user_email_sync_test.exs new file mode 100644 index 0000000..6d08d61 --- /dev/null +++ b/test/accounts/user_email_sync_test.exs @@ -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 diff --git a/test/membership/member_email_sync_test.exs b/test/membership/member_email_sync_test.exs new file mode 100644 index 0000000..eeef210 --- /dev/null +++ b/test/membership/member_email_sync_test.exs @@ -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 diff --git a/test/seeds_test.exs b/test/seeds_test.exs new file mode 100644 index 0000000..5c589ae --- /dev/null +++ b/test/seeds_test.exs @@ -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