require "test_helper" class InvitationTest < ActiveSupport::TestCase setup do @invitation = invitations(:one) @family = @invitation.family @inviter = @invitation.inviter end test "accept_for adds user to family when email matches" do user = users(:empty) user.update_columns(family_id: families(:empty).id, role: "admin") assert user.family_id != @family.id invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter) assert invitation.pending? result = invitation.accept_for(user) assert result user.reload assert_equal @family.id, user.family_id assert_equal "member", user.role invitation.reload assert invitation.accepted_at.present? end test "accept_for returns false when user email does not match" do user = users(:family_member) assert user.email != @invitation.email result = @invitation.accept_for(user) assert_not result user.reload assert_equal families(:dylan_family).id, user.family_id @invitation.reload assert_nil @invitation.accepted_at end test "accept_for updates role when user already in family" do user = users(:family_member) user.update!(family_id: @family.id, role: "member") invitation = @family.invitations.create!(email: user.email, role: "admin", inviter: @inviter) original_family_id = user.family_id result = invitation.accept_for(user) assert result user.reload assert_equal original_family_id, user.family_id assert_equal "admin", user.role invitation.reload assert invitation.accepted_at.present? end test "accept_for returns false when invitation not pending" do @invitation.update!(accepted_at: 1.hour.ago) user = users(:empty) result = @invitation.accept_for(user) assert_not result end test "cannot create invitation when email has pending invitation from another family" do other_family = families(:empty) other_inviter = users(:empty) other_inviter.update_columns(family_id: other_family.id, role: "admin") email = "cross-family-test@example.com" # Create a pending invitation in the first family @family.invitations.create!(email: email, role: "member", inviter: @inviter) # Attempting to create a pending invitation in a different family should fail invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) assert_not invitation.valid? assert_includes invitation.errors[:email], "already has a pending invitation from another family" end test "can create invitation when existing invitation from another family is accepted" do other_family = families(:empty) other_inviter = users(:empty) other_inviter.update_columns(family_id: other_family.id, role: "admin") email = "cross-family-accepted@example.com" # Create an accepted invitation in the first family accepted_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter) accepted_invitation.update!(accepted_at: Time.current) # Should be able to create a pending invitation in a different family invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) assert invitation.valid? end test "can create invitation when existing invitation from another family is expired" do other_family = families(:empty) other_inviter = users(:empty) other_inviter.update_columns(family_id: other_family.id, role: "admin") email = "cross-family-expired@example.com" # Create an expired invitation in the first family expired_invitation = @family.invitations.create!(email: email, role: "member", inviter: @inviter) expired_invitation.update_columns(expires_at: 1.day.ago) # Should be able to create a pending invitation in a different family invitation = other_family.invitations.build(email: email, role: "member", inviter: other_inviter) assert invitation.valid? end test "re-inviting an email with an expired unaccepted invitation replaces it without raising" do email = "expired-reinvite@example.com" expired = @family.invitations.create!(email: email, role: "member", inviter: @inviter) expired.update_column(:expires_at, 1.day.ago) invitation = nil assert_nothing_raised do invitation = @family.invitations.create!(email: email, role: "admin", inviter: @inviter) end assert invitation.pending? assert_not Invitation.exists?(expired.id), "stale expired invitation should be removed" assert_equal 1, @family.invitations.where(email: email).count end test "re-invite does not remove a still-pending duplicate (validation blocks first)" do email = "still-pending@example.com" pending = @family.invitations.create!(email: email, role: "member", inviter: @inviter) duplicate = @family.invitations.build(email: email, role: "admin", inviter: @inviter) assert_not duplicate.valid? assert Invitation.exists?(pending.id), "an unexpired pending invitation must be preserved" end test "can create invitation in same family (uniqueness scoped to family)" do email = "same-family-test@example.com" # Create a pending invitation in the family @family.invitations.create!(email: email, role: "member", inviter: @inviter) # Attempting to create another in the same family should fail due to the existing scope validation invitation = @family.invitations.build(email: email, role: "admin", inviter: @inviter) assert_not invitation.valid? assert_includes invitation.errors[:email], "has already been invited to this family" end test "accept_for refuses when invitee owns accounts that would be orphaned" do owner = users(:empty) owner_family = families(:empty) owner.update_columns(family_id: owner_family.id, role: "admin") account = owner_family.accounts.create!( name: "Prior savings", balance: 100, currency: "USD", accountable: Depository.new ) account.update_columns(owner_id: owner.id) invitation = @family.invitations.create!(email: owner.email, role: "member", inviter: @inviter) result = invitation.accept_for(owner) assert_not result, "accept_for must refuse to rehome a user away from accounts they own" owner.reload assert_equal owner_family.id, owner.family_id, "user.family_id must not be silently overwritten" invitation.reload assert_nil invitation.accepted_at, "invitation must remain pending so a new flow can recover" assert owner_family.accounts.exists?, "original family's accounts must remain intact" end test "accept_for allows a member who owns no accounts to join another family" do member = users(:empty) other_owner = users(:sure_support_staff) source_family = families(:empty) member.update_columns(family_id: source_family.id, role: "member") other_owner.update_columns(family_id: source_family.id, role: "admin") account = source_family.accounts.create!( name: "Shared savings", balance: 100, currency: "USD", accountable: Depository.new ) account.update_columns(owner_id: other_owner.id) invitation = @family.invitations.create!(email: member.email, role: "member", inviter: @inviter) result = invitation.accept_for(member) assert result, "a non-owner member must be free to join another family" member.reload assert_equal @family.id, member.family_id end test "would_orphan_owned_accounts? is false when invitee owns no accounts" do user = users(:empty) user.update_columns(family_id: families(:empty).id, role: "admin") invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter) assert_not invitation.would_orphan_owned_accounts?(user) end test "would_orphan_owned_accounts? is false when same-family role change" do user = users(:family_member) user.update!(family_id: @family.id, role: "member") invitation = @family.invitations.create!(email: user.email, role: "admin", inviter: @inviter) assert_not invitation.would_orphan_owned_accounts?(user) end test "accept_for applies guest role defaults" do user = users(:family_member) user.update!( family_id: @family.id, role: "member", ui_layout: "dashboard", show_sidebar: true, show_ai_sidebar: true, ai_enabled: false ) invitation = @family.invitations.create!(email: user.email, role: "guest", inviter: @inviter) result = invitation.accept_for(user) assert result user.reload assert_equal "guest", user.role assert user.ui_layout_intro? assert_not user.show_sidebar? assert_not user.show_ai_sidebar? assert user.ai_enabled? end test "accept_for auto-shares existing family accounts when family shares by default" do @family.update!(default_account_sharing: "shared") user = users(:empty) user.update_columns(family_id: families(:empty).id, role: "admin") invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter) expected_ids = @family.accounts.where.not(owner_id: user.id).pluck(:id).sort assert expected_ids.any? assert invitation.accept_for(user) assert_equal expected_ids, AccountShare.where(user: user).pluck(:account_id).sort end test "accept_for auto-shares read_only for guest invitations" do @family.update!(default_account_sharing: "shared") user = users(:empty) user.update_columns(family_id: families(:empty).id, role: "admin") invitation = @family.invitations.create!(email: user.email, role: "guest", inviter: @inviter) expected_ids = @family.accounts.where.not(owner_id: user.id).pluck(:id).sort assert expected_ids.any? assert invitation.accept_for(user) user.reload shares = AccountShare.where(user: user) assert_equal "guest", user.role assert_equal expected_ids, shares.pluck(:account_id).sort assert shares.all?(&:read_only?), "guest invitation shares must grant read_only" end test "accept_for does not auto-share when family sharing is private" do @family.update!(default_account_sharing: "private") user = users(:empty) user.update_columns(family_id: families(:empty).id, role: "admin") invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter) assert_no_difference "AccountShare.count" do assert invitation.accept_for(user) end end end