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 setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() %{actor: system_actor} end 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", %{actor: actor} do # Create a member {:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor) 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}), actor: actor) # Verify initial state - member email should be overridden by user email {:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id, actor: actor) assert member_after_link.email == "user@example.com" # Update user email {:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"}, actor: actor) 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, actor: actor) assert synced_member.email == "newemail@example.com" end test "creating user linked to member overrides member email", %{actor: actor} do # Create a member with their own email {:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor) 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}), actor: actor) 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, actor: actor) assert updated_member.email == "user@example.com" end test "linking user to existing member syncs user email to member", %{actor: actor} do # Create a standalone member {:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor) assert member.email == "member@example.com" # Create a standalone user {:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor) 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}}, actor: actor) 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, actor: actor) assert synced_member.email == "user@example.com" end test "updating user email when no member linked does not error", %{actor: actor} do # Create a standalone user without member link {:ok, user} = Accounts.create_user(@valid_user_attrs, actor: actor) 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"}, actor: actor) 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", %{actor: actor} do # Create member {:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor) # Create user linked to member {:ok, user} = Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}), actor: actor) assert user.member_id == member.id # Verify member email was synced {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor) assert synced_member.email == "user@example.com" # Unlink user from member {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}, actor: actor) assert unlinked_user.member_id == nil # Member email should remain unchanged after unlinking {:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id, actor: actor) assert member_after_unlink.email == "user@example.com" end test "admin_set_password with email change syncs to linked member", %{actor: actor} do # Create member and user linked to it (with password so admin_set_password applies) {:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor) {:ok, user} = Mv.Accounts.User |> Ash.Changeset.for_create(:register_with_password, %{ email: "user@example.com", password: "initialpass123" }) |> Ash.create(actor: actor) {:ok, user} = user |> Ash.Changeset.for_update(:update_user, %{member: %{id: member.id}}) |> Ash.update(actor: actor) assert user.member_id == member.id {:ok, m} = Ash.get(Mv.Membership.Member, member.id, actor: actor) assert m.email == "user@example.com" # Change both email and password via admin_set_password (e.g. user form "Change Password") {:ok, updated_user} = user |> Ash.Changeset.for_update(:admin_set_password, %{ email: "newemail@example.com", password: "newpassword123" }) |> Ash.update(actor: actor) assert to_string(updated_user.email) == "newemail@example.com" # Member email must be synced (Option A: SyncUserEmailToMember on admin_set_password) {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor) assert synced_member.email == "newemail@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" system_actor = Mv.Helpers.SystemActor.get_system_actor() # 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(actor: system_actor) 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(actor: system_actor) 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"} system_actor = Mv.Helpers.SystemActor.get_system_actor() # 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(actor: system_actor) assert to_string(user.email) == "oidc@example.com" assert user.oidc_id == "oidc-user-123" end end end