diff --git a/app/services/user_merger.rb b/app/services/user_merger.rb new file mode 100644 index 00000000000..e08a5c021a2 --- /dev/null +++ b/app/services/user_merger.rb @@ -0,0 +1,434 @@ +class UserMerger + def initialize(source_user, target_user) + @source_user = source_user + @target_user = target_user + end + + def merge! + update_notifications + move_posts + update_user_ids + merge_given_daily_likes + merge_post_timings + merge_user_visits + update_site_settings + merge_user_attributes + + DiscourseEvent.trigger(:merging_users, @source_user, @target_user) + update_user_stats + + delete_source_user + delete_source_user_references + end + + protected + + def update_notifications + params = { + source_user_id: @source_user.id, + source_username: @source_user.username, + target_username: @target_user.username, + notification_types_with_correct_user_id: [ + Notification.types[:granted_badge], + Notification.types[:group_message_summary] + ], + invitee_accepted_notification_type: Notification.types[:invitee_accepted] + } + + Notification.exec_sql(<<~SQL, params) + UPDATE notifications AS n + SET data = (data :: JSONB || + jsonb_strip_nulls( + jsonb_build_object( + 'original_username', CASE data :: JSONB ->> 'original_username' + WHEN :source_username + THEN :target_username + ELSE NULL END, + 'display_username', CASE data :: JSONB ->> 'display_username' + WHEN :source_username + THEN :target_username + ELSE NULL END, + 'username', CASE data :: JSONB ->> 'username' + WHEN :source_username + THEN :target_username + ELSE NULL END + ) + )) :: JSON + WHERE EXISTS( + SELECT 1 + FROM posts AS p + WHERE p.topic_id = n.topic_id + AND p.post_number = n.post_number + AND p.user_id = :source_user_id) + OR (n.notification_type IN (:notification_types_with_correct_user_id) AND n.user_id = :source_user_id) + OR (n.notification_type = :invitee_accepted_notification_type + AND EXISTS( + SELECT 1 + FROM invites i + WHERE i.user_id = :source_user_id AND n.user_id = i.invited_by_id + ) + ) + SQL + end + + def move_posts + posts = Post.with_deleted + .where(user_id: @source_user.id) + .order(:topic_id, :post_number) + .pluck(:topic_id, :id) + + last_topic_id = nil + post_ids = [] + + posts.each do |current_topic_id, current_post_id| + if last_topic_id != current_topic_id && post_ids.any? + change_post_owner(last_topic_id, post_ids) + post_ids = [] + end + + last_topic_id = current_topic_id + post_ids << current_post_id + end + + change_post_owner(last_topic_id, post_ids) if post_ids.any? + end + + def change_post_owner(topic_id, post_ids) + PostOwnerChanger.new( + topic_id: topic_id, + post_ids: post_ids, + new_owner: @target_user, + acting_user: Discourse.system_user, + skip_revision: true + ).change_owner! + end + + def merge_given_daily_likes + sql = <<~SQL + INSERT INTO given_daily_likes AS g (user_id, likes_given, given_date, limit_reached) + SELECT + :target_user_id AS user_id, + COUNT(1) AS likes_given, + a.created_at::DATE AS given_date, + COUNT(1) >= :max_likes_per_day AS limit_reached + FROM post_actions AS a + WHERE a.user_id = :target_user_id + AND a.deleted_at IS NULL + AND EXISTS( + SELECT 1 + FROM given_daily_likes AS g + WHERE g.user_id = :source_user_id AND a.created_at::DATE = g.given_date + ) + GROUP BY given_date + ON CONFLICT (user_id, given_date) + DO UPDATE + SET likes_given = EXCLUDED.likes_given, + limit_reached = EXCLUDED.limit_reached + SQL + + GivenDailyLike.exec_sql(sql, + source_user_id: @source_user.id, + target_user_id: @target_user.id, + max_likes_per_day: SiteSetting.max_likes_per_day, + action_type_id: PostActionType.types[:like]) + end + + def merge_post_timings + update_user_id(:post_timings, conditions: ["x.topic_id = y.topic_id", + "x.post_number = y.post_number"]) + sql = <<~SQL + UPDATE post_timings AS t + SET msecs = t.msecs + s.msecs + FROM post_timings AS s + WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id + AND t.topic_id = s.topic_id AND t.post_number = s.post_number + SQL + + PostTiming.exec_sql(sql, source_user_id: @source_user.id, target_user_id: @target_user.id) + end + + def merge_user_visits + update_user_id(:user_visits, conditions: "x.visited_at = y.visited_at") + + sql = <<~SQL + UPDATE user_visits AS t + SET posts_read = t.posts_read + s.posts_read, + mobile = t.mobile OR s.mobile, + time_read = t.time_read + s.time_read + FROM user_visits AS s + WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id + AND t.visited_at = s.visited_at + SQL + + UserVisit.exec_sql(sql, source_user_id: @source_user.id, target_user_id: @target_user.id) + end + + def update_site_settings + SiteSetting.all_settings(true).each do |setting| + if setting[:type] == "username" && setting[:value] == @source_user.username + SiteSetting.set_and_log(setting[:setting], @target_user.username) + end + end + end + + def update_user_stats + # topics_entered + UserStat.exec_sql(<<~SQL, target_user_id: @target_user.id) + UPDATE user_stats + SET topics_entered = ( + SELECT COUNT(topic_id) + FROM topic_views + WHERE user_id = :target_user_id + ) + WHERE user_id = :target_user_id + SQL + + # time_read and days_visited + UserStat.exec_sql(<<~SQL, target_user_id: @target_user.id) + UPDATE user_stats + SET time_read = COALESCE(x.time_read, 0), + days_visited = COALESCE(x.days_visited, 0) + FROM ( + SELECT + SUM(time_read) AS time_read, + COUNT(1) AS days_visited + FROM user_visits + WHERE user_id = :target_user_id + ) AS x + WHERE user_id = :target_user_id + SQL + + # posts_read_count + UserStat.exec_sql(<<~SQL, target_user_id: @target_user.id) + UPDATE user_stats + SET posts_read_count = ( + SELECT COUNT(1) + FROM post_timings AS pt + WHERE pt.user_id = :target_user_id AND EXISTS( + SELECT 1 + FROM topics AS t + WHERE t.archetype = 'regular' AND t.deleted_at IS NULL + )) + WHERE user_id = :target_user_id + SQL + + # likes_given, likes_received, new_since, read_faq, first_post_created_at + UserStat.exec_sql(<<~SQL, source_user_id: @source_user.id, target_user_id: @target_user.id) + UPDATE user_stats AS t + SET likes_given = t.likes_given + s.likes_given, + likes_received = t.likes_received + s.likes_received, + new_since = LEAST(t.new_since, s.new_since), + read_faq = LEAST(t.read_faq, s.read_faq), + first_post_created_at = LEAST(t.first_post_created_at, s.first_post_created_at) + FROM user_stats AS s + WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id + SQL + end + + def merge_user_attributes + User.exec_sql(<<~SQL, source_user_id: @source_user.id, target_user_id: @target_user.id) + UPDATE users AS t + SET created_at = LEAST(t.created_at, s.created_at), + updated_at = LEAST(t.updated_at, s.updated_at), + seen_notification_id = GREATEST(t.seen_notification_id, s.seen_notification_id), + last_posted_at = GREATEST(t.last_seen_at, s.last_seen_at), + last_seen_at = GREATEST(t.last_seen_at, s.last_seen_at), + admin = t.admin OR s.admin, + last_emailed_at = GREATEST(t.last_emailed_at, s.last_emailed_at), + trust_level = GREATEST(t.trust_level, s.trust_level), + previous_visit_at = GREATEST(t.previous_visit_at, s.previous_visit_at), + date_of_birth = COALESCE(t.date_of_birth, s.date_of_birth), + ip_address = COALESCE(t.ip_address, s.ip_address), + moderator = t.moderator OR s.moderator, + title = COALESCE(t.title, s.title), + primary_group_id = COALESCE(t.primary_group_id, s.primary_group_id), + registration_ip_address = COALESCE(t.registration_ip_address, s.registration_ip_address), + first_seen_at = LEAST(t.first_seen_at, s.first_seen_at), + group_locked_trust_level = GREATEST(t.group_locked_trust_level, s.group_locked_trust_level), + manual_locked_trust_level = GREATEST(t.manual_locked_trust_level, s.manual_locked_trust_level) + FROM users AS s + WHERE t.id = :target_user_id AND s.id = :source_user_id + SQL + + UserProfile.exec_sql(<<~SQL, source_user_id: @source_user.id, target_user_id: @target_user.id) + UPDATE user_profiles AS t + SET location = COALESCE(t.location, s.location), + website = COALESCE(t.website, s.website), + bio_raw = COALESCE(t.bio_raw, s.bio_raw), + bio_cooked = COALESCE(t.bio_cooked, s.bio_cooked), + bio_cooked_version = COALESCE(t.bio_cooked_version, s.bio_cooked_version), + profile_background = COALESCE(t.profile_background, s.profile_background), + dismissed_banner_key = COALESCE(t.dismissed_banner_key, s.dismissed_banner_key), + badge_granted_title = t.badge_granted_title OR s.badge_granted_title, + card_background = COALESCE(t.card_background, s.card_background), + card_image_badge_id = COALESCE(t.card_image_badge_id, s.card_image_badge_id), + views = t.views + s.views + FROM user_profiles AS s + WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id + SQL + end + + def update_user_ids + Category.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + update_user_id(:category_users, conditions: ["x.category_id = y.category_id"]) + + update_user_id(:developers) + + update_user_id(:draft_sequences, conditions: "x.draft_key = y.draft_key") + update_user_id(:drafts, conditions: "x.draft_key = y.draft_key") + + EmailLog.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + GroupHistory.where(acting_user_id: @source_user.id).update_all(acting_user_id: @target_user.id) + GroupHistory.where(target_user_id: @source_user.id).update_all(target_user_id: @target_user.id) + + update_user_id(:group_users, conditions: "x.group_id = y.group_id") + + IncomingEmail.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + IncomingLink.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + IncomingLink.where(current_user_id: @source_user.id).update_all(current_user_id: @target_user.id) + + Invite.with_deleted.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + Invite.with_deleted.where(invited_by_id: @source_user.id).update_all(invited_by_id: @target_user.id) + Invite.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id) + + update_user_id(:muted_users, conditions: "x.muted_user_id = y.muted_user_id") + update_user_id(:muted_users, user_id_column_name: "muted_user_id", conditions: "x.user_id = y.user_id") + + Notification.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + update_user_id(:post_actions, conditions: ["x.post_id = y.post_id", + "x.post_action_type_id = y.post_action_type_id", + "x.targets_topic = y.targets_topic"]) + + PostAction.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id) + PostAction.where(deferred_by_id: @source_user.id).update_all(deferred_by_id: @target_user.id) + PostAction.where(agreed_by_id: @source_user.id).update_all(agreed_by_id: @target_user.id) + PostAction.where(disagreed_by_id: @source_user.id).update_all(disagreed_by_id: @target_user.id) + + PostRevision.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + Post.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id) + Post.with_deleted.where(last_editor_id: @source_user.id).update_all(last_editor_id: @target_user.id) + Post.with_deleted.where(locked_by_id: @source_user.id).update_all(locked_by_id: @target_user.id) + Post.with_deleted.where(reply_to_user_id: @source_user.id).update_all(reply_to_user_id: @target_user.id) + + QueuedPost.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + QueuedPost.where(approved_by_id: @source_user.id).update_all(approved_by_id: @target_user.id) + QueuedPost.where(rejected_by_id: @source_user.id).update_all(rejected_by_id: @target_user.id) + + SearchLog.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + update_user_id(:tag_users, conditions: "x.tag_id = y.tag_id") + + Theme.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + update_user_id(:topic_allowed_users, conditions: "x.topic_id = y.topic_id") + + TopicEmbed.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id) + + TopicLink.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + TopicLinkClick.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + TopicTimer.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id) + + update_user_id(:topic_timers, conditions: ["x.status_type = y.status_type", + "x.topic_id = y.topic_id", + "y.deleted_at IS NULL"]) + + update_user_id(:topic_users, conditions: "x.topic_id = y.topic_id") + + update_user_id(:topic_views, conditions: "x.topic_id = y.topic_id") + + Topic.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id) + + UnsubscribeKey.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + Upload.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + update_user_id(:user_archived_messages, conditions: "x.topic_id = y.topic_id") + + update_user_id(:user_actions, + user_id_column_name: "user_id", + conditions: ["x.action_type = y.action_type", + "x.target_topic_id IS NOT DISTINCT FROM y.target_topic_id", + "x.target_post_id IS NOT DISTINCT FROM y.target_post_id", + "x.acting_user_id IN (:source_user_id, :target_user_id)"]) + update_user_id(:user_actions, + user_id_column_name: "acting_user_id", + conditions: ["x.action_type = y.action_type", + "x.user_id = y.user_id", + "x.target_topic_id IS NOT DISTINCT FROM y.target_topic_id", + "x.target_post_id IS NOT DISTINCT FROM y.target_post_id"]) + + update_user_id(:user_badges, conditions: ["x.badge_id = y.badge_id", + "x.seq = y.seq", + "x.post_id IS NOT DISTINCT FROM y.post_id"]) + + UserBadge.where(granted_by_id: @source_user.id).update_all(granted_by_id: @target_user.id) + + update_user_id(:user_custom_fields, conditions: "x.name = y.name") + + update_user_id(:user_emails, conditions: "x.email = y.email", updates: '"primary" = false') + + UserExport.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + UserHistory.where(target_user_id: @source_user.id).update_all(target_user_id: @target_user.id) + UserHistory.where(acting_user_id: @source_user.id).update_all(acting_user_id: @target_user.id) + + UserProfileView.where(user_profile_id: @source_user.id).update_all(user_profile_id: @target_user.id) + UserProfileView.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + + UserWarning.where(user_id: @source_user.id).update_all(user_id: @target_user.id) + UserWarning.where(created_by_id: @source_user.id).update_all(created_by_id: @target_user.id) + + User.where(approved_by_id: @source_user.id).update_all(approved_by_id: @target_user.id) + end + + def delete_source_user + @source_user.reload + @source_user.update_attribute(:admin, false) + UserDestroyer.new(Discourse.system_user).destroy(@source_user) + end + + def delete_source_user_references + Developer.where(user_id: @source_user.id).delete_all + DraftSequence.where(user_id: @source_user.id).delete_all + GivenDailyLike.where(user_id: @source_user.id).delete_all + MutedUser.where(user_id: @source_user.id).or(MutedUser.where(muted_user_id: @source_user.id)).delete_all + UserAuthTokenLog.where(user_id: @source_user.id).delete_all + UserAvatar.where(user_id: @source_user.id).delete_all + UserAction.where(acting_user_id: @source_user.id).delete_all + end + + def update_user_id(table_name, opts = {}) + builder = update_user_id_sql_builder(table_name, opts) + builder.exec(source_user_id: @source_user.id, target_user_id: @target_user.id) + end + + def update_user_id_sql_builder(table_name, opts = {}) + user_id_column_name = opts[:user_id_column_name] || :user_id + conditions = Array.wrap(opts[:conditions]) + updates = Array.wrap(opts[:updates]) + + builder = SqlBuilder.new(<<~SQL) + UPDATE #{table_name} AS x + /*set*/ + WHERE x.#{user_id_column_name} = :source_user_id AND NOT EXISTS( + SELECT 1 + FROM #{table_name} AS y + /*where*/ + ) + SQL + + builder.set("#{user_id_column_name} = :target_user_id") + updates.each { |u| builder.set(u) } + + builder.where("y.#{user_id_column_name} = :target_user_id") + conditions.each { |c| builder.where(c) } + + builder + end +end diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake index d01d30b744b..3776ca7064f 100644 --- a/lib/tasks/users.rake +++ b/lib/tasks/users.rake @@ -4,20 +4,14 @@ task "users:change_post_ownership", [:old_username, :new_username, :archetype] = new_username = args[:new_username] archetype = args[:archetype] archetype = archetype.downcase if archetype + if !old_username || !new_username puts "ERROR: Expecting rake posts:change_post_ownership[old_username,new_username,archetype]" exit 1 end - old_user = User.find_by(username_lower: old_username.downcase) - if !old_user - puts "ERROR: User with username #{old_username} does not exist" - exit 1 - end - new_user = User.find_by(username_lower: new_username.downcase) - if !new_user - puts "ERROR: User with username #{new_username} does not exist" - exit 1 - end + + old_user = find_user(old_username) + new_user = find_user(new_username) if archetype == "private" posts = Post.private_posts.where(user_id: old_user.id) @@ -37,3 +31,30 @@ task "users:change_post_ownership", [:old_username, :new_username, :archetype] = end puts "", "#{i} posts ownership changed!", "" end + +task "users:merge", [:source_username, :target_username] => [:environment] do |_, args| + source_username = args[:source_username] + target_username = args[:target_username] + + if !source_username || !target_username + puts "ERROR: Expecting rake posts:merge[source_username,target_username]" + exit 1 + end + + source_user = find_user(source_username) + target_user = find_user(target_username) + + UserMerger.new(source_user, target_user).merge! + puts "", "Users merged!", "" +end + +def find_user(username) + user = User.find_by_username(username) + + if !user + puts "ERROR: User with username #{username} does not exist" + exit 1 + end + + user +end diff --git a/plugins/poll/lib/votes_updater.rb b/plugins/poll/lib/votes_updater.rb new file mode 100644 index 00000000000..f8a9beac197 --- /dev/null +++ b/plugins/poll/lib/votes_updater.rb @@ -0,0 +1,59 @@ +module DiscoursePoll + class VotesUpdater + def self.merge_users!(source_user, target_user) + post_ids = PostCustomField.where(name: DiscoursePoll::VOTES_CUSTOM_FIELD) + .where("value :: JSON -> ? IS NOT NULL", source_user.id.to_s) + .pluck(:post_id) + + post_ids.each do |post_id| + DistributedMutex.synchronize("#{DiscoursePoll::MUTEX_PREFIX}-#{post_id}") do + post = Post.find_by(id: post_id) + update_votes(post, source_user, target_user) if post + end + end + end + + def self.update_votes(post, source_user, target_user) + polls = post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + votes = post.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + return if polls.nil? || votes.nil? || !votes.has_key?(source_user.id.to_s) + + if votes.has_key?(target_user.id.to_s) + remove_votes(polls, votes, source_user) + else + replace_voter_id(polls, votes, source_user, target_user) + end + + post.save_custom_fields(true) + end + + def self.remove_votes(polls, votes, source_user) + votes.delete(source_user.id.to_s).each do |poll_name, option_ids| + poll = polls[poll_name] + next unless poll && option_ids + + poll["options"].each do |option| + if option_ids.include?(option["id"]) + option["votes"] -= 1 + + voter_ids = option["voter_ids"] + voter_ids.delete(source_user.id) if voter_ids + end + end + end + end + + def self.replace_voter_id(polls, votes, source_user, target_user) + votes[target_user.id.to_s] = votes.delete(source_user.id.to_s) + + polls.each_value do |poll| + next unless poll["public"] == "true" + + poll["options"].each do |option| + voter_ids = option["voter_ids"] + voter_ids << target_user.id if voter_ids&.delete(source_user.id) + end + end + end + end +end diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index e1434343741..45fa5686c56 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -18,10 +18,12 @@ after_initialize do DEFAULT_POLL_NAME ||= "poll".freeze POLLS_CUSTOM_FIELD ||= "polls".freeze VOTES_CUSTOM_FIELD ||= "polls-votes".freeze + MUTEX_PREFIX ||= PLUGIN_NAME autoload :PostValidator, "#{Rails.root}/plugins/poll/lib/post_validator" autoload :PollsValidator, "#{Rails.root}/plugins/poll/lib/polls_validator" autoload :PollsUpdater, "#{Rails.root}/plugins/poll/lib/polls_updater" + autoload :VotesUpdater, "#{Rails.root}/plugins/poll/lib/votes_updater" class Engine < ::Rails::Engine engine_name PLUGIN_NAME @@ -385,6 +387,10 @@ after_initialize do polls: post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) end + on(:merging_users) do |source_user, target_user| + DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user) + end + add_to_serializer(:post, :polls, false) do polls = post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].dup diff --git a/plugins/poll/spec/lib/votes_updater_spec.rb b/plugins/poll/spec/lib/votes_updater_spec.rb new file mode 100644 index 00000000000..243c5f3b853 --- /dev/null +++ b/plugins/poll/spec/lib/votes_updater_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +describe DiscoursePoll::VotesUpdater do + let(:target_user) { Fabricate(:user_single_email, username: 'alice', email: 'alice@example.com') } + let(:source_user) { Fabricate(:user_single_email, username: 'alice1', email: 'alice@work.com') } + let(:walter) { Fabricate(:walter_white) } + + let(:target_user_id) { target_user.id.to_s } + let(:source_user_id) { source_user.id.to_s } + let(:walter_id) { walter.id.to_s } + + let(:post_with_two_polls) do + raw = <<~RAW + [poll type=multiple min=2 max=3 public=true] + - Option 1 + - Option 2 + - Option 3 + [/poll] + + [poll name=private_poll] + - Option 1 + - Option 2 + - Option 3 + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:option1_id) { "63eb791ab5d08fc4cc855a0703ac0dd1" } + let(:option2_id) { "773a193533027393806fff6edd6c04f7" } + let(:option3_id) { "f42f567ca3136ee1322d71d7745084c7" } + + def vote(post, user, option_ids, poll_name = nil) + poll_name ||= DiscoursePoll::DEFAULT_POLL_NAME + DiscoursePoll::Poll.vote(post.id, poll_name, option_ids, user) + end + + it "should move votes to the target_user when only the source_user voted" do + vote(post_with_two_polls, source_user, [option1_id, option3_id]) + vote(post_with_two_polls, walter, [option1_id, option2_id]) + + DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user) + post_with_two_polls.reload + + polls = post_with_two_polls.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + expect(polls["poll"]["options"][0]["votes"]).to eq(2) + expect(polls["poll"]["options"][1]["votes"]).to eq(1) + expect(polls["poll"]["options"][2]["votes"]).to eq(1) + + expect(polls["poll"]["options"][0]["voter_ids"]).to contain_exactly(target_user.id, walter.id) + expect(polls["poll"]["options"][1]["voter_ids"]).to contain_exactly(walter.id) + expect(polls["poll"]["options"][2]["voter_ids"]).to contain_exactly(target_user.id) + + votes = post_with_two_polls.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + expect(votes.keys).to contain_exactly(target_user_id, walter_id) + expect(votes[target_user_id]["poll"]).to contain_exactly(option1_id, option3_id) + expect(votes[walter_id]["poll"]).to contain_exactly(option1_id, option2_id) + end + + it "should delete votes of the source_user if the target_user voted" do + vote(post_with_two_polls, source_user, [option1_id, option3_id]) + vote(post_with_two_polls, target_user, [option2_id, option3_id]) + vote(post_with_two_polls, walter, [option1_id, option2_id]) + + DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user) + post_with_two_polls.reload + + polls = post_with_two_polls.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + expect(polls["poll"]["options"][0]["votes"]).to eq(1) + expect(polls["poll"]["options"][1]["votes"]).to eq(2) + expect(polls["poll"]["options"][2]["votes"]).to eq(1) + + expect(polls["poll"]["options"][0]["voter_ids"]).to contain_exactly(walter.id) + expect(polls["poll"]["options"][1]["voter_ids"]).to contain_exactly(target_user.id, walter.id) + expect(polls["poll"]["options"][2]["voter_ids"]).to contain_exactly(target_user.id) + + votes = post_with_two_polls.custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD] + expect(votes.keys).to contain_exactly(target_user_id, walter_id) + expect(votes[target_user_id]["poll"]).to contain_exactly(option2_id, option3_id) + expect(votes[walter_id]["poll"]).to contain_exactly(option1_id, option2_id) + end + + it "does not add voter_ids unless the poll is public" do + vote(post_with_two_polls, source_user, [option1_id, option3_id], "private_poll") + vote(post_with_two_polls, walter, [option1_id, option2_id], "private_poll") + + DiscoursePoll::VotesUpdater.merge_users!(source_user, target_user) + post_with_two_polls.reload + + polls = post_with_two_polls.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + polls["private_poll"]["options"].each { |o| expect(o).to_not have_key("voter_ids") } + end +end diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb new file mode 100644 index 00000000000..8a4b3fcdf94 --- /dev/null +++ b/spec/services/user_merger_spec.rb @@ -0,0 +1,1008 @@ +require 'rails_helper' + +describe UserMerger do + let!(:target_user) { Fabricate(:user_single_email, username: 'alice', email: 'alice@example.com') } + let!(:source_user) { Fabricate(:user_single_email, username: 'alice1', email: 'alice@work.com') } + let(:walter) { Fabricate(:walter_white) } + + def merge_users! + UserMerger.new(source_user, target_user).merge! + end + + it "changes owner of topics and posts" do + topic1 = Fabricate(:topic, user: source_user) + post1 = Fabricate(:post, topic: topic1, user: source_user) + post2 = Fabricate(:post, topic: topic1, user: walter) + post3 = Fabricate(:post, topic: topic1, user: target_user) + post4 = Fabricate(:post, topic: topic1, user: walter) + post5 = Fabricate(:post, topic: topic1, user: source_user) + + topic2 = Fabricate(:topic, user: walter) + post6 = Fabricate(:post, topic: topic2, user: walter) + post7 = Fabricate(:post, topic: topic2, user: source_user) + post8 = Fabricate(:post, topic: topic2, user: source_user, deleted_at: Time.now) + + merge_users! + + [topic1, post1, post3, post5, post7, post8].each do |x| + expect(x.reload.user).to eq(target_user) + end + + [post2, post4, topic2, post6].each do |x| + expect(x.reload.user).to eq(walter) + end + end + + it "changes owner of personal messages" do + pm_topic = Fabricate(:private_message_topic) + + post1 = Fabricate(:post, topic: pm_topic, user: source_user) + post2 = Fabricate(:post, topic: pm_topic, user: walter) + post3 = Fabricate(:post, topic: pm_topic, user: target_user) + post4 = Fabricate(:post, topic: pm_topic, user: source_user, deleted_at: Time.now) + + merge_users! + + expect(post1.reload.user).to eq(target_user) + expect(post2.reload.user).to eq(walter) + expect(post3.reload.user).to eq(target_user) + expect(post4.reload.user).to eq(target_user) + end + + it "changes owner of categories" do + category = Fabricate(:category, user: source_user) + merge_users! + + expect(category.reload.user).to eq(target_user) + end + + it "merges category notification settings" do + category1 = Fabricate(:category) + category2 = Fabricate(:category) + category3 = Fabricate(:category) + watching = CategoryUser.notification_levels[:watching] + + CategoryUser.batch_set(source_user, :watching, [category1.id, category2.id]) + CategoryUser.batch_set(target_user, :watching, [category2.id, category3.id]) + + merge_users! + + category_ids = CategoryUser.where(user_id: target_user.id, notification_level: watching).pluck(:category_id) + expect(category_ids).to contain_exactly(category1.id, category2.id, category3.id) + + category_ids = CategoryUser.where(user_id: source_user.id, notification_level: watching).pluck(:category_id) + expect(category_ids).to be_empty + end + + context "developer flag" do + it "moves the developer flag when the target user isn't a developer yet" do + Developer.create!(user_id: source_user.id) + merge_users! + + expect(Developer.where(user_id: source_user.id).count).to eq(0) + expect(Developer.where(user_id: target_user.id).count).to eq(1) + end + + it "deletes the source's developer flag when the target user is already a developer" do + Developer.create!(user_id: source_user.id) + Developer.create!(user_id: target_user.id) + merge_users! + + expect(Developer.where(user_id: source_user.id).count).to eq(0) + expect(Developer.where(user_id: target_user.id).count).to eq(1) + end + end + + context "drafts" do + def create_draft(user, key, text) + seq = DraftSequence.next!(user, key) + Draft.set(user, key, seq, text) + end + + def current_target_user_draft(key) + seq = DraftSequence.current(target_user, key) + Draft.get(target_user, key, seq) + end + + it "merges drafts" do + key_topic_17 = "#{Draft::EXISTING_TOPIC}#{17}" + key_topic_19 = "#{Draft::EXISTING_TOPIC}#{19}" + + create_draft(source_user, Draft::NEW_TOPIC, 'new topic draft by alice1') + create_draft(source_user, key_topic_17, 'draft by alice1') + create_draft(source_user, key_topic_19, 'draft by alice1') + create_draft(target_user, key_topic_19, 'draft by alice') + + merge_users! + + expect(current_target_user_draft(Draft::NEW_TOPIC)).to eq('new topic draft by alice1') + expect(current_target_user_draft(key_topic_17)).to eq('draft by alice1') + expect(current_target_user_draft(key_topic_19)).to eq('draft by alice') + + expect(DraftSequence.where(user_id: source_user.id).count).to eq(0) + expect(Draft.where(user_id: source_user.id).count).to eq(0) + end + end + + it "updates email logs" do + Fabricate(:email_log, user: source_user) + merge_users! + + expect(EmailLog.where(user_id: source_user.id).count).to eq(0) + expect(EmailLog.where(user_id: target_user.id).count).to eq(1) + end + + context "likes" do + def given_daily_like_count_for(user, date) + GivenDailyLike.find_for(user.id, date).pluck(:likes_given)[0] || 0 + end + + it "merges likes" do + p1 = Fabricate(:post) + p2 = Fabricate(:post) + p3 = Fabricate(:post) + p4 = Fabricate(:post) + p5 = Fabricate(:post) + p6 = Fabricate(:post) + + now = Time.zone.now + + freeze_time(now - 1.day) + PostAction.act(source_user, p1, PostActionType.types[:like]) + PostAction.act(source_user, p2, PostActionType.types[:like]) + PostAction.act(target_user, p2, PostActionType.types[:like]) + PostAction.act(target_user, p3, PostActionType.types[:like]) + + freeze_time(now) + PostAction.act(source_user, p4, PostActionType.types[:like]) + PostAction.act(source_user, p5, PostActionType.types[:like]) + PostAction.act(target_user, p5, PostActionType.types[:like]) + PostAction.act(source_user, p6, PostActionType.types[:like]) + PostAction.remove_act(source_user, p6, PostActionType.types[:like]) + + merge_users! + + [p1, p2, p3, p4, p5].each { |p| expect(p.reload.like_count).to eq(1) } + expect(PostAction.with_deleted.where(user_id: source_user.id).count).to eq(0) + expect(PostAction.with_deleted.where(user_id: target_user.id).count).to eq(6) + + expect(given_daily_like_count_for(source_user, Date.yesterday)).to eq(0) + expect(given_daily_like_count_for(target_user, Date.yesterday)).to eq(3) + expect(given_daily_like_count_for(source_user, Date.today)).to eq(0) + expect(given_daily_like_count_for(target_user, Date.today)).to eq(2) + end + end + + it "updates group history" do + group = Fabricate(:group) + group.add_owner(source_user) + logger = GroupActionLogger.new(source_user, group) + logger.log_add_user_to_group(walter) + logger.log_add_user_to_group(target_user) + + group = Fabricate(:group) + group.add_owner(target_user) + logger = GroupActionLogger.new(target_user, group) + logger.log_add_user_to_group(walter) + logger.log_add_user_to_group(source_user) + + merge_users! + + expect(GroupHistory.where(acting_user_id: source_user.id).count).to eq(0) + expect(GroupHistory.where(acting_user_id: target_user.id).count).to eq(4) + + expect(GroupHistory.where(target_user_id: source_user.id).count).to eq(0) + expect(GroupHistory.where(target_user_id: target_user.id).count).to eq(2) + end + + it "merges group memberships" do + group1 = Fabricate(:group) + group1.add_owner(target_user) + group1.bulk_add([walter.id, source_user.id]) + + group2 = Fabricate(:group) + group2.bulk_add([walter.id, target_user.id]) + + group3 = Fabricate(:group) + group3.add_owner(source_user) + group3.add(walter) + + merge_users! + + [group1, group2, group3].each do |g| + owner = [group1, group3].include?(g) + expect(GroupUser.where(group_id: g.id, user_id: target_user.id, owner: owner).count).to eq(1) + expect(Group.where(id: g.id).pluck(:user_count).first).to eq(2) + end + expect(GroupUser.where(user_id: source_user.id).count).to eq(0) + end + + it "updates incoming emails" do + email = Fabricate(:incoming_email, user: source_user) + merge_users! + + expect(email.reload.user).to eq(target_user) + end + + it "updates incoming links" do + link1 = Fabricate(:incoming_link, user: source_user) + link2 = Fabricate(:incoming_link, current_user_id: source_user.id) + + merge_users! + + expect(link1.reload.user).to eq(target_user) + expect(link2.reload.current_user_id).to eq(target_user.id) + end + + it "updates invites" do + invite1 = Fabricate(:invite, invited_by: walter, user: source_user) + invite2 = Fabricate(:invite, invited_by: source_user) + invite3 = Fabricate(:invite, invited_by: source_user) + invite3.trash!(source_user) + + merge_users! + + [invite1, invite2, invite3].each { |x| x.reload } + + expect(invite1.user).to eq(target_user) + expect(invite2.invited_by).to eq(target_user) + expect(invite3.invited_by).to eq(target_user) + expect(invite3.deleted_by).to eq(target_user) + end + + it "merges muted users" do + muted1 = Fabricate(:user) + muted2 = Fabricate(:user) + muted3 = Fabricate(:user) + coding_horror = Fabricate(:coding_horror) + + MutedUser.create!(user_id: source_user.id, muted_user_id: muted1.id) + MutedUser.create!(user_id: source_user.id, muted_user_id: muted2.id) + MutedUser.create!(user_id: target_user.id, muted_user_id: muted2.id) + MutedUser.create!(user_id: target_user.id, muted_user_id: muted3.id) + MutedUser.create!(user_id: walter.id, muted_user_id: source_user.id) + MutedUser.create!(user_id: coding_horror.id, muted_user_id: source_user.id) + MutedUser.create!(user_id: coding_horror.id, muted_user_id: target_user.id) + + merge_users! + + [muted1, muted2, muted3].each do |m| + expect(MutedUser.where(user_id: target_user.id, muted_user_id: m.id).count).to eq(1) + end + expect(MutedUser.where(user_id: source_user.id).count).to eq(0) + + expect(MutedUser.where(user_id: walter.id, muted_user_id: target_user.id).count).to eq(1) + expect(MutedUser.where(user_id: coding_horror.id, muted_user_id: target_user.id).count).to eq(1) + expect(MutedUser.where(muted_user_id: source_user.id).count).to eq(0) + end + + context "notifications" do + it "updates notifications" do + Fabricate(:notification, user: source_user) + Fabricate(:notification, user: source_user) + Fabricate(:notification, user: walter) + + merge_users! + + expect(Notification.where(user_id: target_user.id).count).to eq(2) + expect(Notification.where(user_id: source_user.id).count).to eq(0) + end + + def create_notification(type, notified_user, post, data = {}) + Fabricate( + :notification, + notification_type: Notification.types[type], + user: notified_user, + data: data.to_json, + topic: post&.topic, + post_number: post&.post_number + ) + end + + def notification_data(notification) + JSON.parse(notification.reload.data, symbolize_names: true) + end + + def original_and_display_username(user) + { original_username: user.username, display_username: user.username, foo: "bar" } + end + + def original_username_and_some_text_as_display_username(user) + { original_username: user.username, display_username: "some text", foo: "bar" } + end + + def only_display_username(user) + { display_username: user.username } + end + + def username_and_something_else(user) + { username: user.username, foo: "bar" } + end + + it "updates notification data" do + notified_user = Fabricate(:user) + p1 = Fabricate(:post, post_number: 1, user: source_user) + p2 = Fabricate(:post, post_number: 1, user: walter) + Fabricate(:invite, invited_by: notified_user, user: source_user) + Fabricate(:invite, invited_by: notified_user, user: walter) + + n01 = create_notification(:mentioned, notified_user, p1, original_and_display_username(source_user)) + n02 = create_notification(:mentioned, notified_user, p2, original_and_display_username(walter)) + n03 = create_notification(:mentioned, notified_user, p1, original_username_and_some_text_as_display_username(source_user)) + n04 = create_notification(:mentioned, notified_user, p1, only_display_username(source_user)) + n05 = create_notification(:invitee_accepted, notified_user, nil, only_display_username(source_user)) + n06 = create_notification(:invitee_accepted, notified_user, nil, only_display_username(walter)) + n07 = create_notification(:granted_badge, source_user, nil, username_and_something_else(source_user)) + n08 = create_notification(:granted_badge, walter, nil, username_and_something_else(walter)) + n09 = create_notification(:group_message_summary, source_user, nil, username_and_something_else(source_user)) + n10 = create_notification(:group_message_summary, walter, nil, username_and_something_else(walter)) + + merge_users! + + expect(notification_data(n01)).to eq(original_and_display_username(target_user)) + expect(notification_data(n02)).to eq(original_and_display_username(walter)) + expect(notification_data(n03)).to eq(original_username_and_some_text_as_display_username(target_user)) + expect(notification_data(n04)).to eq(only_display_username(target_user)) + expect(notification_data(n05)).to eq(only_display_username(target_user)) + expect(notification_data(n06)).to eq(only_display_username(walter)) + expect(notification_data(n07)).to eq(username_and_something_else(target_user)) + expect(notification_data(n08)).to eq(username_and_something_else(walter)) + expect(notification_data(n09)).to eq(username_and_something_else(target_user)) + expect(notification_data(n10)).to eq(username_and_something_else(walter)) + end + end + + context "post actions" do + it "merges post actions" do + p1 = Fabricate(:post) + p2 = Fabricate(:post) + p3 = Fabricate(:post) + type_ids = PostActionType.public_type_ids + [PostActionType.flag_types.values.first] + + type_ids.each do |type| + PostAction.act(source_user, p1, type) + PostAction.act(source_user, p2, type) + PostAction.act(target_user, p2, type) + PostAction.act(target_user, p3, type) + end + + merge_users! + + type_ids.each do |type| + expect(PostAction.where(user_id: target_user.id, post_action_type_id: type) + .pluck(:post_id)).to contain_exactly(p1.id, p2.id, p3.id) + end + + expect(PostAction.where(user_id: source_user.id).count).to eq(0) + end + + it "updates post actions" do + p1 = Fabricate(:post) + p2 = Fabricate(:post) + p3 = Fabricate(:post) + p4 = Fabricate(:post) + + action1 = PostAction.act(source_user, p1, PostActionType.flag_types[:off_topic]) + action1.update_attribute(:deleted_by_id, source_user.id) + + action2 = PostAction.act(source_user, p2, PostActionType.flag_types[:off_topic]) + action2.update_attribute(:deferred_by_id, source_user.id) + + action3 = PostAction.act(source_user, p3, PostActionType.flag_types[:off_topic]) + action3.update_attribute(:agreed_by_id, source_user.id) + + action4 = PostAction.act(source_user, p4, PostActionType.flag_types[:off_topic]) + action4.update_attribute(:disagreed_by_id, source_user.id) + + merge_users! + + expect(action1.reload.deleted_by_id).to eq(target_user.id) + expect(action2.reload.deferred_by_id).to eq(target_user.id) + expect(action3.reload.agreed_by_id).to eq(target_user.id) + expect(action4.reload.disagreed_by_id).to eq(target_user.id) + end + end + + it "updates post revisions" do + post = Fabricate(:post) + post_revision = Fabricate(:post_revision, post: post, user: source_user) + + merge_users! + expect(post_revision.reload.user).to eq(target_user) + end + + context "post timings" do + def create_post_timing(post, user, msecs) + PostTiming.create!( + topic_id: post.topic_id, + post_number: post.post_number, + user_id: user.id, + msecs: msecs + ) + end + + def post_timing_msecs_for(post, user) + PostTiming.where( + topic_id: post.topic_id, + post_number: post.post_number, + user_id: user.id + ).pluck(:msecs)[0] || 0 + end + + it "merges post timings" do + post1 = Fabricate(:post) + post2 = Fabricate(:post) + post3 = Fabricate(:post) + + create_post_timing(post1, source_user, 12345) + create_post_timing(post2, source_user, 9876) + create_post_timing(post2, target_user, 3333) + create_post_timing(post3, target_user, 10000) + + merge_users! + + expect(post_timing_msecs_for(post1, target_user)).to eq(12345) + expect(post_timing_msecs_for(post2, target_user)).to eq(13209) + expect(post_timing_msecs_for(post3, target_user)).to eq(10000) + + expect(PostTiming.where(user_id: source_user.id).count).to eq(0) + end + end + + context "posts" do + it "updates user ids of posts" do + source_user.update_attribute(:moderator, true) + + topic = Fabricate(:topic) + Fabricate(:post, topic: topic, user: source_user) + + post2 = Fabricate(:basic_reply, topic: topic, user: walter) + post2.revise(source_user, raw: "#{post2.raw} foo") + PostLocker.new(post2, source_user).lock + post2.trash!(source_user) + + merge_users! + post2.reload + + expect(post2.deleted_by).to eq(target_user) + expect(post2.last_editor).to eq(target_user) + expect(post2.locked_by_id).to eq(target_user.id) + expect(post2.reply_to_user).to eq(target_user) + end + + it "updates post action counts" do + posts = {} + + PostActionType.types.each do |type_name, type_id| + posts[type_name] = post = Fabricate(:post, user: walter) + PostAction.act(source_user, post, type_id) + PostAction.act(target_user, post, type_id) + end + + merge_users! + + posts.each do |type, post| + post.reload + expect(post.send("#{type}_count")).to eq(1) + end + end + end + + it "updates queued posts" do + topic = Fabricate(:topic) + post1 = Fabricate(:queued_post, topic: topic, user: source_user) + post2 = Fabricate(:queued_post, topic: topic, approved_by: source_user) + post3 = Fabricate(:queued_post, topic: topic, rejected_by: source_user) + + merge_users! + + expect(post1.reload.user).to eq(target_user) + expect(post2.reload.approved_by).to eq(target_user) + expect(post3.reload.rejected_by).to eq(target_user) + end + + it "updates search log entries" do + SearchLog.log(term: 'hello', search_type: :full_page, ip_address: '192.168.0.1', user_id: source_user.id) + SearchLog.log(term: 'world', search_type: :full_page, ip_address: '192.168.0.1', user_id: source_user.id) + SearchLog.log(term: 'star trek', search_type: :full_page, ip_address: '192.168.0.2', user_id: target_user.id) + SearchLog.log(term: 'bad', search_type: :full_page, ip_address: '192.168.0.3', user_id: walter.id) + + merge_users! + + expect(SearchLog.where(user_id: target_user.id).count).to eq(3) + expect(SearchLog.where(user_id: source_user.id).count).to eq(0) + expect(SearchLog.where(user_id: walter.id).count).to eq(1) + end + + it "merges tag notification settings" do + tag1 = Fabricate(:tag) + tag2 = Fabricate(:tag) + tag3 = Fabricate(:tag) + watching = TagUser.notification_levels[:watching] + + TagUser.batch_set(source_user, :watching, [tag1.name, tag2.name]) + TagUser.batch_set(target_user, :watching, [tag2.name, tag3.name]) + + merge_users! + + tag_ids = TagUser.where(user_id: target_user.id, notification_level: watching).pluck(:tag_id) + expect(tag_ids).to contain_exactly(tag1.id, tag2.id, tag3.id) + + tag_ids = TagUser.where(user_id: source_user.id, notification_level: watching).pluck(:tag_id) + expect(tag_ids).to be_empty + end + + it "updates themes" do + theme = Theme.create!(name: 'my name', user_id: source_user.id) + merge_users! + + expect(theme.reload.user_id).to eq(target_user.id) + end + + it "merges allowed users for topics" do + pm_topic1 = Fabricate(:private_message_topic, topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: target_user), + Fabricate.build(:topic_allowed_user, user: walter), + Fabricate.build(:topic_allowed_user, user: source_user) + ]) + + pm_topic2 = Fabricate(:private_message_topic, topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: walter), + Fabricate.build(:topic_allowed_user, user: source_user) + ]) + + merge_users! + + expect(pm_topic1.allowed_users).to contain_exactly(target_user, walter) + expect(pm_topic2.allowed_users).to contain_exactly(target_user, walter) + expect(TopicAllowedUser.where(user_id: source_user.id).count).to eq(0) + end + + it "updates topic embeds" do + topic_embed = Fabricate(:topic_embed, embed_url: "http://example.com/post/248") + topic_embed.trash!(source_user) + + merge_users! + + expect(topic_embed.reload.deleted_by).to eq(target_user) + end + + it "updates topic links" do + topic = Fabricate(:topic, user: source_user) + post = Fabricate(:post_with_external_links, user: source_user, topic: topic) + TopicLink.extract_from(post) + link = topic.topic_links.first + + TopicLinkClick.create!(topic_link_id: link.id, user_id: source_user.id, ip_address: '127.0.0.1') + TopicLinkClick.create!(topic_link_id: link.id, user_id: target_user.id, ip_address: '127.0.0.1') + TopicLinkClick.create!(topic_link_id: link.id, user_id: walter.id, ip_address: '127.0.0.1') + + merge_users! + + expect(TopicLink.where(user_id: target_user.id).count).to be > 0 + expect(TopicLink.where(user_id: source_user.id).count).to eq(0) + + expect(TopicLinkClick.where(user_id: target_user.id).count).to eq(2) + expect(TopicLinkClick.where(user_id: source_user.id).count).to eq(0) + expect(TopicLinkClick.where(user_id: walter.id).count).to eq(1) + end + + context "topic timers" do + def create_topic_timer(topic, user, status_type, deleted_by = nil) + timer = Fabricate(:topic_timer, topic: topic, user: user, status_type: TopicTimer.types[status_type]) + timer.trash!(deleted_by) if deleted_by + timer.reload + end + + it "merges topic timers" do + topic1 = Fabricate(:topic) + timer1 = create_topic_timer(topic1, source_user, :close, Discourse.system_user) + timer2 = create_topic_timer(topic1, source_user, :close) + timer3 = create_topic_timer(topic1, source_user, :reminder, source_user) + timer4 = create_topic_timer(topic1, target_user, :reminder, target_user) + timer5 = create_topic_timer(topic1, source_user, :reminder) + + topic2 = Fabricate(:topic) + timer6 = create_topic_timer(topic2, target_user, :close) + timer7 = create_topic_timer(topic2, target_user, :reminder, Discourse.system_user) + create_topic_timer(topic2, source_user, :reminder, Discourse.system_user) + + merge_users! + + [timer1, timer2, timer3, timer4, timer5, timer6, timer7].each do |t| + expect(t.reload.user).to eq(target_user) + end + + expect(TopicTimer.with_deleted.where(user_id: source_user.id).count).to eq(0) + expect(TopicTimer.with_deleted.where(deleted_by_id: target_user.id).count).to eq(2) + expect(TopicTimer.with_deleted.where(deleted_by_id: source_user.id).count).to eq(0) + end + end + + it "merges topic notification settings" do + topic1 = Fabricate(:topic) + topic2 = Fabricate(:topic) + topic3 = Fabricate(:topic) + watching = TopicUser.notification_levels[:watching] + + Fabricate(:topic_user, notification_level: watching, topic: topic1, user: source_user) + Fabricate(:topic_user, notification_level: watching, topic: topic2, user: source_user) + Fabricate(:topic_user, notification_level: watching, topic: topic2, user: target_user) + Fabricate(:topic_user, notification_level: watching, topic: topic3, user: target_user) + + merge_users! + + topic_ids = TopicUser.where(user_id: target_user.id, notification_level: watching).pluck(:topic_id) + expect(topic_ids).to contain_exactly(topic1.id, topic2.id, topic3.id) + + topic_ids = TopicUser.where(user_id: source_user.id, notification_level: watching).pluck(:topic_id) + expect(topic_ids).to be_empty + end + + it "merges topic views" do + topic1 = Fabricate(:topic) + topic2 = Fabricate(:topic) + topic3 = Fabricate(:topic) + ip = '127.0.0.1' + + TopicViewItem.add(topic1.id, ip, source_user.id) + TopicViewItem.add(topic2.id, ip, source_user.id) + TopicViewItem.add(topic2.id, ip, target_user.id) + TopicViewItem.add(topic3.id, ip, target_user.id) + + merge_users! + + topic_ids = TopicViewItem.where(user_id: target_user.id).pluck(:topic_id) + expect(topic_ids).to contain_exactly(topic1.id, topic2.id, topic3.id) + expect(TopicViewItem.where(user_id: source_user.id).count).to eq(0) + end + + it "updates topics" do + topic = Fabricate(:topic) + Fabricate(:post, user: walter, topic: topic) + Fabricate(:post, user: source_user, topic: topic) + topic.trash!(source_user) + + merge_users! + topic.reload + + expect(topic.deleted_by).to eq(target_user) + expect(topic.last_poster).to eq(target_user) + end + + it "updates unsubscribe keys" do + UnsubscribeKey.create_key_for(source_user, "digest") + UnsubscribeKey.create_key_for(target_user, "digest") + UnsubscribeKey.create_key_for(walter, "digest") + + merge_users! + + expect(UnsubscribeKey.where(user_id: target_user.id).count).to eq(2) + expect(UnsubscribeKey.where(user_id: source_user.id).count).to eq(0) + end + + it "updates uploads" do + Fabricate(:upload, user: source_user) + Fabricate(:upload, user: target_user) + Fabricate(:upload, user: walter) + + merge_users! + + expect(Upload.where(user_id: target_user.id).count).to eq(2) + expect(Upload.where(user_id: source_user.id).count).to eq(0) + end + + context "user actions" do + # action_type and user_id are not nullable + # target_topic_id and acting_user_id are nullable, but always a value + + let(:post1) { Fabricate(:post) } + let(:post2) { Fabricate(:post) } + let(:post3) { Fabricate(:post) } + + def log_pending_action(user, post) + UserAction.log_action!(action_type: UserAction::PENDING, + user_id: user.id, + acting_user_id: user.id, + target_topic_id: post.topic.id, + queued_post_id: post.id) + end + + def log_like_action(acting_user, user, post) + UserAction.log_action!(action_type: UserAction::LIKE, + user_id: user.id, + acting_user_id: acting_user.id, + target_topic_id: post.topic_id, + target_post_id: post.id) + end + + it "merges when target_post_id is not set" do + a1 = log_pending_action(source_user, post1) + a2 = log_pending_action(source_user, post2) + a3 = log_pending_action(target_user, post2) + a4 = log_pending_action(target_user, post3) + + merge_users! + + expect(UserAction.count).to eq(3) + + action_ids = UserAction.where(action_type: UserAction::PENDING, + user_id: target_user.id, + acting_user_id: target_user.id).pluck(:id) + expect(action_ids).to contain_exactly(a1.id, a3.id, a4.id) + end + + it "merges when target_post_id is set" do + a1 = log_like_action(source_user, walter, post1) + a2 = log_like_action(target_user, walter, post1) + a3 = log_like_action(source_user, walter, post2) + + merge_users! + + expect(UserAction.count).to eq(2) + + action_ids = UserAction.where(action_type: UserAction::LIKE, + user_id: walter.id, + acting_user_id: target_user.id).pluck(:id) + expect(action_ids).to contain_exactly(a2.id, a3.id) + end + end + + it "merges archived messages" do + pm_topic1 = Fabricate(:private_message_topic, topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: target_user), + Fabricate.build(:topic_allowed_user, user: walter), + Fabricate.build(:topic_allowed_user, user: source_user) + ]) + + pm_topic2 = Fabricate(:private_message_topic, topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: walter), + Fabricate.build(:topic_allowed_user, user: source_user) + ]) + + UserArchivedMessage.archive!(source_user.id, pm_topic1.id) + UserArchivedMessage.archive!(target_user.id, pm_topic1.id) + UserArchivedMessage.archive!(source_user.id, pm_topic2.id) + UserArchivedMessage.archive!(walter.id, pm_topic2.id) + + merge_users! + + topic_ids = UserArchivedMessage.where(user_id: target_user.id).pluck(:topic_id) + expect(topic_ids).to contain_exactly(pm_topic1.id, pm_topic2.id) + expect(UserArchivedMessage.where(user_id: source_user.id).count).to eq(0) + end + + context "badges" do + def create_badge(badge, user, opts = {}) + UserBadge.create!( + badge: badge, + user: user, + granted_by: opts[:granted_by] || Discourse.system_user, + granted_at: opts[:granted_at] || Time.now, + post: opts[:post], + seq: opts[:seq] || 0 + ) + end + + it "merges user badges" do + anniversary_badge = Badge.find(Badge::Anniversary) + create_badge(anniversary_badge, source_user, seq: 1) + b1 = create_badge(anniversary_badge, target_user, seq: 1) + b2 = create_badge(anniversary_badge, source_user, seq: 2) + + great_post_badge = Badge.find(Badge::GreatPost) + b3 = create_badge(great_post_badge, target_user, post: Fabricate(:post, user: target_user)) + b4 = create_badge(great_post_badge, source_user, post: Fabricate(:post, user: source_user)) + + autobiographer_badge = Badge.find(Badge::Autobiographer) + b5 = create_badge(autobiographer_badge, source_user) + + merge_users! + + user_badge_ids = UserBadge.where(user_id: target_user.id).pluck(:id) + expect(user_badge_ids).to contain_exactly(b1.id, b2.id, b3.id, b4.id, b5.id) + expect(UserBadge.where(user_id: source_user.id).count).to eq(0) + end + + it "updates granted_by for user badges" do + badge = Badge.create!(name: 'Hero', badge_type_id: BadgeType::Gold) + user_badge = create_badge(badge, walter, seq: 1, granted_by: source_user) + + merge_users! + + expect(user_badge.reload.granted_by).to eq(target_user) + end + end + + it "merges user custom fields" do + UserCustomField.create!(user_id: source_user.id, name: 'foo', value: '123') + UserCustomField.create!(user_id: source_user.id, name: 'bar', value: '456') + UserCustomField.create!(user_id: source_user.id, name: 'duplicate', value: 'source') + UserCustomField.create!(user_id: target_user.id, name: 'duplicate', value: 'target') + UserCustomField.create!(user_id: target_user.id, name: 'baz', value: '789') + + merge_users! + + fields = UserCustomField.where(user_id: target_user.id).pluck(:name, :value) + expect(fields).to contain_exactly(['foo', '123'], ['bar', '456'], ['duplicate', 'target'], ['baz', '789']) + expect(UserCustomField.where(user_id: source_user.id).count).to eq(0) + end + + it "merges email addresses" do + merge_users! + + emails = UserEmail.where(user_id: target_user.id).pluck(:email, :primary) + expect(emails).to contain_exactly(['alice@example.com', true], ['alice@work.com', false]) + expect(UserEmail.where(user_id: source_user.id).count).to eq(0) + end + + it "updates exports" do + UserExport.create(file_name: "user-archive-alice1-190218-003249", user_id: source_user.id) + + merge_users! + + expect(UserExport.where(user_id: target_user.id).count).to eq(1) + expect(UserExport.where(user_id: source_user.id).count).to eq(0) + end + + it "updates user history" do + UserHistory.create(action: UserHistory.actions[:notified_about_get_a_room], target_user_id: source_user.id) + UserHistory.create(action: UserHistory.actions[:anonymize_user], target_user_id: walter.id, acting_user_id: source_user.id) + + merge_users! + + expect(UserHistory.where(target_user_id: target_user.id).count).to eq(1) + expect(UserHistory.where(target_user_id: source_user.id).count).to eq(0) + + expect(UserHistory.where(acting_user_id: target_user.id).count).to eq(1) + expect(UserHistory.where(acting_user_id: source_user.id).count).to eq(0) + end + + it "updates user profile views" do + ip = '127.0.0.1' + UserProfileView.add(source_user.id, ip, walter.id, Time.now, true) + UserProfileView.add(source_user.id, ip, target_user.id, Time.now, true) + UserProfileView.add(target_user.id, ip, source_user.id, Time.now, true) + UserProfileView.add(walter.id, ip, source_user.id, Time.now, true) + + merge_users! + + expect(UserProfileView.where(user_profile_id: target_user.id).count).to eq(3) + expect(UserProfileView.where(user_profile_id: walter.id).count).to eq(1) + expect(UserProfileView.where(user_profile_id: source_user.id).count).to eq(0) + + expect(UserProfileView.where(user_id: target_user.id).count).to eq(3) + expect(UserProfileView.where(user_id: walter.id).count).to eq(1) + expect(UserProfileView.where(user_id: source_user.id).count).to eq(0) + end + + it "merges user visits" do + UserVisit.create!(user_id: source_user.id, visited_at: 2.days.ago, posts_read: 22, mobile: false, time_read: 400) + UserVisit.create!(user_id: source_user.id, visited_at: Date.yesterday, posts_read: 8, mobile: false, time_read: 100) + UserVisit.create!(user_id: target_user.id, visited_at: Date.yesterday, posts_read: 12, mobile: true, time_read: 270) + UserVisit.create!(user_id: target_user.id, visited_at: Date.today, posts_read: 10, mobile: true, time_read: 150) + + merge_users! + + expect(UserVisit.where(user_id: target_user.id).count).to eq(3) + expect(UserVisit.where(user_id: source_user.id).count).to eq(0) + + expect(UserVisit.where(user_id: target_user.id, visited_at: 2.days.ago, posts_read: 22, mobile: false, time_read: 400).count).to eq(1) + expect(UserVisit.where(user_id: target_user.id, visited_at: Date.yesterday, posts_read: 20, mobile: true, time_read: 370).count).to eq(1) + expect(UserVisit.where(user_id: target_user.id, visited_at: Date.today, posts_read: 10, mobile: true, time_read: 150).count).to eq(1) + end + + it "updates user warnings" do + UserWarning.create!(topic: Fabricate(:topic), user: source_user, created_by: walter) + UserWarning.create!(topic: Fabricate(:topic), user: target_user, created_by: walter) + UserWarning.create!(topic: Fabricate(:topic), user: walter, created_by: source_user) + + merge_users! + + expect(UserWarning.where(user_id: target_user.id).count).to eq(2) + expect(UserWarning.where(user_id: source_user.id).count).to eq(0) + + expect(UserWarning.where(created_by_id: target_user.id).count).to eq(1) + expect(UserWarning.where(created_by_id: source_user.id).count).to eq(0) + end + + it "triggers :merging_users event" do + events = DiscourseEvent.track_events do + merge_users! + end + + expect(events).to include(event_name: :merging_users, params: [source_user, target_user]) + end + + context "site settings" do + it "updates usernames in site settings" do + SiteSetting.site_contact_username = source_user.username + SiteSetting.embed_by_username = source_user.username + + merge_users! + + expect(SiteSetting.site_contact_username).to eq(target_user.username) + expect(SiteSetting.embed_by_username).to eq(target_user.username) + end + + it "updates only the old username in site settings" do + SiteSetting.site_contact_username = source_user.username + SiteSetting.embed_by_username = walter.username + + merge_users! + + expect(SiteSetting.site_contact_username).to eq(target_user.username) + expect(SiteSetting.embed_by_username).to eq(walter.username) + end + end + + it "updates users" do + walter.update_attribute(:approved_by, source_user) + merge_users! + + expect(walter.reload.approved_by).to eq(target_user) + end + + it "deletes the source user even when it's an admin" do + source_user.update_attribute(:admin, true) + + expect(User.find_by_username(source_user.username)).to be_present + merge_users! + expect(User.find_by_username(source_user.username)).to be_nil + end + + it "deletes external auth infos of source user" do + FacebookUserInfo.create(user_id: source_user.id, facebook_user_id: "example") + GithubUserInfo.create(user_id: source_user.id, screen_name: "example", github_user_id: "examplel123123") + GoogleUserInfo.create(user_id: source_user.id, google_user_id: "google@gmail.com") + InstagramUserInfo.create(user_id: source_user.id, screen_name: "example", instagram_user_id: "examplel123123") + Oauth2UserInfo.create(user_id: source_user.id, uid: "example", provider: "example") + SingleSignOnRecord.create(user_id: source_user.id, external_id: "example", last_payload: "looks good") + TwitterUserInfo.create(user_id: source_user.id, screen_name: "example", twitter_user_id: "examplel123123") + UserOpenId.create(user_id: source_user.id, email: source_user.email, url: "http://example.com/openid", active: true) + + merge_users! + + expect(FacebookUserInfo.where(user_id: source_user.id).count).to eq(0) + expect(GithubUserInfo.where(user_id: source_user.id).count).to eq(0) + expect(GoogleUserInfo.where(user_id: source_user.id).count).to eq(0) + expect(InstagramUserInfo.where(user_id: source_user.id).count).to eq(0) + expect(Oauth2UserInfo.where(user_id: source_user.id).count).to eq(0) + expect(SingleSignOnRecord.where(user_id: source_user.id).count).to eq(0) + expect(TwitterUserInfo.where(user_id: source_user.id).count).to eq(0) + expect(UserOpenId.where(user_id: source_user.id).count).to eq(0) + end + + it "deletes auth tokens" do + Fabricate(:api_key, user: source_user) + Fabricate(:readonly_user_api_key, user: source_user) + Fabricate(:user_second_factor, user: source_user) + + SiteSetting.verbose_auth_token_logging = true + UserAuthToken.generate!(user_id: source_user.id, user_agent: "Firefox", client_ip: "127.0.0.1") + + merge_users! + + expect(ApiKey.where(user_id: source_user.id).count).to eq(0) + expect(UserApiKey.where(user_id: source_user.id).count).to eq(0) + expect(UserSecondFactor.where(user_id: source_user.id).count).to eq(0) + expect(UserAuthToken.where(user_id: source_user.id).count).to eq(0) + expect(UserAuthTokenLog.where(user_id: source_user.id).count).to eq(0) + end + + it "cleans up all remaining references to the source user" do + DirectoryItem.refresh! + Fabricate(:email_change_request, user: source_user) + Fabricate(:email_token, user: source_user) + Fabricate(:user_avatar, user: source_user) + + merge_users! + + expect(DirectoryItem.where(user_id: source_user.id).count).to eq(0) + expect(EmailChangeRequest.where(user_id: source_user.id).count).to eq(0) + expect(EmailToken.where(user_id: source_user.id).count).to eq(0) + expect(UserAvatar.where(user_id: source_user.id).count).to eq(0) + + expect(User.find_by_username(source_user.username)).to be_nil + end +end