diff --git a/docs/email-sync.md b/docs/email-sync.md deleted file mode 100644 index c191ff4..0000000 --- a/docs/email-sync.md +++ /dev/null @@ -1,49 +0,0 @@ -## 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 0fc5ab0..668ddd4 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -66,17 +66,14 @@ defmodule Mv.Accounts.User do end actions do - # Default actions for framework/tooling integration: + # 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. # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - # - # 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] + defaults [:read, :create, :destroy] # Primary generic update action: # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix @@ -92,12 +89,6 @@ 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 @@ -120,9 +111,6 @@ 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 @@ -149,12 +137,6 @@ 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 @@ -203,9 +185,6 @@ 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 @@ -216,10 +195,6 @@ 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, _ -> @@ -280,13 +255,6 @@ 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 56549fc..4cec072 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -48,12 +48,6 @@ 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 @@ -95,18 +89,6 @@ 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 @@ -118,10 +100,6 @@ 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 @@ -211,13 +189,6 @@ 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 deleted file mode 100644 index d42b2c1..0000000 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index c1e5aea..0000000 --- a/lib/mv/email_sync/changes/sync_member_email_to_user.ex +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index be7dd2c..0000000 --- a/lib/mv/email_sync/changes/sync_user_email_to_member.ex +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 7feaf57..0000000 --- a/lib/mv/email_sync/helpers.ex +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index ecb1038..0000000 --- a/lib/mv/email_sync/loader.ex +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 54fa243..0000000 --- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index b872235..0000000 --- a/test/accounts/email_sync_edge_cases_test.exs +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index a16ebdd..0000000 --- a/test/accounts/email_uniqueness_test.exs +++ /dev/null @@ -1,480 +0,0 @@ -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 deleted file mode 100644 index 6d08d61..0000000 --- a/test/accounts/user_email_sync_test.exs +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index eeef210..0000000 --- a/test/membership/member_email_sync_test.exs +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 5c589ae..0000000 --- a/test/seeds_test.exs +++ /dev/null @@ -1,46 +0,0 @@ -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