spec/integration/migration_service_spec.rb
# frozen_string_literal: true

require "integration/federation/federation_helper"
require "integration/archive_shared"

describe MigrationService do
  let(:old_pod_hostname) { "originalhomepod.tld" }
  let(:archive_author) { "previous_username@#{old_pod_hostname}" }
  let(:archive_private_key) { OpenSSL::PKey::RSA.generate(1024) }
  let(:contact1_diaspora_id) { known_contact_person.diaspora_handle }
  let(:contact2_diaspora_id) { Fabricate.sequence(:diaspora_id) }
  let(:unknown_subscription_guid) { UUID.generate(:compact) }
  let(:existing_subscription_guid) { UUID.generate(:compact) }
  let(:reshare_entity) { Fabricate(:reshare_entity, author: archive_author) }
  let(:reshare_entity_with_no_root) {
    Fabricate(:reshare_entity, author: archive_author, root_guid: nil, root_author: nil)
  }
  let(:unknown_status_message_entity) { Fabricate(:status_message_entity, author: archive_author, public: false) }
  let(:known_status_message_entity) { Fabricate(:status_message_entity, author: archive_author, public: false) }
  let(:colliding_status_message_entity) { Fabricate(:status_message_entity, author: archive_author) }
  let(:status_message_with_poll_entity) {
    Fabricate(:status_message_entity,
              author: archive_author,
              poll:   Fabricate(:poll_entity))
  }
  let(:status_message_with_location_entity) {
    Fabricate(:status_message_entity,
              author:   archive_author,
              location: Fabricate(:location_entity))
  }
  let(:status_message_with_photos_entity) {
    Fabricate(:status_message_entity,
              author: archive_author,
              photos: [
                Fabricate(:photo_entity, author: archive_author),
                Fabricate(:photo_entity, author: archive_author)
              ])
  }
  let(:comment_entity) {
    Fabricate(:comment_entity, author: archive_author, author_signature: "ignored XXXXXXXXXXXXXXXXXXXXXXXXXXX")
  }
  let(:like_entity) {
    Fabricate(:like_entity,
              author:           archive_author,
              author_signature: "ignored XXXXXXXXXXXXXXXXXXXXXXXXXXX",
              parent_guid:      FactoryBot.create(:status_message).guid)
  }
  let(:poll_participation_entity) {
    poll = FactoryBot.create(:status_message_with_poll).poll
    Fabricate(:poll_participation_entity,
              author:           archive_author,
              author_signature: "ignored XXXXXXXXXXXXXXXXXXXXXXXXXXX",
              poll_answer_guid: poll.poll_answers.first.guid,
              parent_guid:      poll.guid)
  }
  let(:unknown_poll_guid) { UUID.generate(:compact) }
  let(:unknown_poll_answer_guid) { UUID.generate(:compact) }
  let(:poll_participation_entity_unknown_root) {
    Fabricate(:poll_participation_entity,
              author:           archive_author,
              author_signature: "ignored XXXXXXXXXXXXXXXXXXXXXXXXXXX",
              poll_answer_guid: unknown_poll_answer_guid,
              parent_guid:      unknown_poll_guid)
  }
  let(:others_comment_entity) {
    data = Fabricate.attributes_for(:comment_entity,
                                    author:      remote_user_on_pod_b.diaspora_handle,
                                    parent_guid: unknown_status_message_entity.guid)
    data[:author_signature] = Fabricate(:comment_entity, data).sign_with_key(remote_user_on_pod_b.encryption_key)
    Fabricate(:comment_entity, data)
  }

  let(:post_subscriber) { FactoryBot.create(:person) }
  let(:known_contact_person) { FactoryBot.create(:person) }
  let!(:collided_status_message) { FactoryBot.create(:status_message, guid: colliding_status_message_entity.guid) }
  let!(:collided_like) { FactoryBot.create(:like, guid: like_entity.guid) }
  let!(:reshare_root_author) { FactoryBot.create(:person, diaspora_handle: reshare_entity.root_author) }

  # This is for testing migrated contacts handling
  let(:account_migration) { FactoryBot.create(:account_migration).tap(&:perform!) }
  let(:migrated_contact_diaspora_id) { account_migration.old_person.diaspora_handle }
  let(:migrated_contact_new_diaspora_id) { account_migration.new_person.diaspora_handle }

  let(:posts_in_archive) {
    [
      reshare_entity,
      unknown_status_message_entity,
      known_status_message_entity,
      reshare_entity_with_no_root,
      colliding_status_message_entity,
      status_message_with_poll_entity,
      status_message_with_location_entity,
      status_message_with_photos_entity
    ]
  }

  let(:posts_in_archive_json) {
    posts = posts_in_archive.map {|post|
      post.to_json.as_json
    }
    posts[0]["subscribed_pods_uris"] = []
    posts[1]["subscribed_users_ids"] = [post_subscriber.diaspora_handle]
    posts[2]["subscribed_users_ids"] = [post_subscriber.diaspora_handle]
    posts[3]["subscribed_pods_uris"] = []
    posts[4]["subscribed_pods_uris"] = []
    posts[5]["subscribed_pods_uris"] = []
    posts[6]["subscribed_pods_uris"] = []
    posts[7]["subscribed_pods_uris"] = []
    posts.to_json
  }

  let(:archive_json) { <<~JSON }
    {
      "user": {
        "username": "previous_username",
        "email": "mail@example.com",
        "private_key": #{archive_private_key.export.dump},
        "profile": {
          "entity_type": "profile",
          "entity_data": {
            "author": "#{archive_author}"
          }
        },
        "contacts": [
          {
            "sharing": true,
            "receiving": false,
            "following": true,
            "followed": false,
            "account_id": "#{contact1_diaspora_id}",
            "contact_groups_membership": []
          },
          {
            "sharing": true,
            "receiving": true,
            "following": true,
            "followed": true,
            "account_id": "#{migrated_contact_diaspora_id}",
            "contact_groups_membership": ["Family"]
          },
          {
            "sharing": true,
            "receiving": true,
            "following": true,
            "followed": true,
            "account_id": "#{contact2_diaspora_id}",
            "contact_groups_membership": ["Family"]
          }
        ],
        "contact_groups": [
          {"name":"Friends"}
        ],
        "post_subscriptions": [
          "#{unknown_subscription_guid}",
          "#{existing_subscription_guid}"
        ],
        "posts": #{posts_in_archive_json},
        "relayables": [
              #{comment_entity.to_json.as_json.to_json},
              #{like_entity.to_json.as_json.to_json},
              #{poll_participation_entity.to_json.as_json.to_json},
              #{poll_participation_entity_unknown_root.to_json.as_json.to_json}
        ]
      },
      "others_data": {
        "relayables": [
           #{others_comment_entity.to_json.as_json.to_json}
        ]
      },
      "version": "2.0"
    }
  JSON

  def expect_reshare_root_fetch(root_author, root_guid)
    expect(DiasporaFederation::Federation::Fetcher)
      .to receive(:fetch_public)
        .with(root_author.diaspora_handle, "Post", root_guid) {
          FactoryBot.create(:status_message, guid: root_guid, author: root_author, public: true)
        }
  end

  def expect_relayable_parent_fetch(relayable_author, parent_guid, parent_type="Post", &block)
    expect(DiasporaFederation::Federation::Fetcher)
      .to receive(:fetch_public)
      .with(relayable_author, parent_type, parent_guid, &block)
  end

  let(:new_username) { "newuser" }
  let(:new_user_handle) { "#{new_username}@#{AppConfig.bare_pod_uri}" }

  let(:archive_file) { Tempfile.new(%w[archive .json]) }

  def setup_validation_time_expectations
    expect_person_fetch(contact2_diaspora_id, nil)

    # This is expected to be called during relayable validation
    expect_relayable_parent_fetch(archive_author, comment_entity.parent_guid) {
      FactoryBot.create(:status_message, guid: comment_entity.parent_guid)
    }

    expect_relayable_parent_fetch(archive_author, unknown_poll_guid, "Poll") {
      FactoryBot.create(
        :poll_answer,
        poll: FactoryBot.create(:poll, guid: unknown_poll_guid),
        guid: unknown_poll_answer_guid
      )
    }
  end

  before do
    archive_file.write(archive_json)
    archive_file.close
    allow_callbacks(
      %i[queue_public_receive fetch_related_entity fetch_person_url_to fetch_public_key receive_entity
         fetch_private_key]
    )
  end

  shared_examples "imports archive" do
    it "imports archive" do
      expect_relayable_parent_fetch(archive_author, unknown_subscription_guid) {
        FactoryBot.create(:status_message, guid: unknown_subscription_guid)
      }

      expect_reshare_root_fetch(reshare_root_author, reshare_entity.root_guid)

      service = MigrationService.new(archive_file.path, new_username)
      service.validate
      expect(service.warnings).to eq(
        ["reshare Reshare:#{reshare_entity_with_no_root.guid} doesn't have a root, ignored"]
      )
      service.perform!
      user = User.find_by(username: new_username)
      expect(user).not_to be_nil

      unless Person.by_account_identifier(archive_author).nil?
        expect(AccountMigration.where(new_person: user.person).any?).to be_truthy

        existing_contact.reload
        expect(existing_contact.person).to eq(user.person)
        expect(existing_contact.sharing).to be_truthy
        expect(existing_contact.receiving).to be_truthy
      end

      status_message = StatusMessage.find_by(guid: unknown_status_message_entity.guid)
      expect(status_message.author).to eq(user.person)
      # TODO: rewrite this expectation when new subscription implementation is there
      # expect(status_message.participants).to include(post_subscriber)

      status_message = StatusMessage.find_by(guid: known_status_message_entity.guid)
      expect(status_message.author).to eq(user.person)
      # TODO: rewrite this expectation when new subscription implementation is there
      # expect(status_message.participants).to include(post_subscriber)

      status_message = StatusMessage.find_by(guid: status_message_with_poll_entity.guid)
      expect(status_message.author).to eq(user.person)
      poll = status_message.poll
      expect(poll).not_to be_nil
      expect(poll.guid).to eq(status_message_with_poll_entity.poll.guid)
      expect(poll.question).to eq(status_message_with_poll_entity.poll.question)
      expect(poll.poll_answers.pluck(:answer, :guid)).to eq(
        status_message_with_poll_entity.poll.poll_answers.map {|answer| [answer.answer, answer.guid] }
      )

      status_message = StatusMessage.find_by(guid: status_message_with_location_entity.guid)
      expect(status_message.author).to eq(user.person)
      expect(status_message.location.address).to eq(status_message_with_location_entity.location.address)
      expect(status_message.location.lat).to eq(status_message_with_location_entity.location.lat)
      expect(status_message.location.lng).to eq(status_message_with_location_entity.location.lng)

      status_message = StatusMessage.find_by(guid: status_message_with_photos_entity.guid)
      expect(status_message.author).to eq(user.person)
      expect(
        status_message.photos.pluck(:guid, :text, :remote_photo_path, :remote_photo_name, :width, :height)
      ).to match_array(
        status_message_with_photos_entity.photos.map {|photo|
          [photo.guid, photo.text, photo.remote_photo_path, photo.remote_photo_name, photo.width, photo.height]
        }
      )

      comment = Comment.find_by(guid: comment_entity.guid)
      expect(comment.author).to eq(user.person)

      # Here we're testing the case when the like in the archive has the guid colliding with another known like
      like = Like.find_by(guid: like_entity.guid)
      expect(like.author).not_to eq(user.person)

      contact = user.contacts.find_by(person: Person.by_account_identifier(contact1_diaspora_id))
      expect(contact).to be_nil

      contact = user.contacts.find_by(person: Person.by_account_identifier(contact2_diaspora_id))
      expect(contact).not_to be_nil
      expect(contact.sharing).to be_falsey
      expect(contact.receiving).to be_truthy

      contact = user.contacts.find_by(person: Person.by_account_identifier(migrated_contact_new_diaspora_id))
      expect(contact).not_to be_nil
      expect(contact.sharing).to be_falsey
      expect(contact.receiving).to be_truthy

      aspect = user.aspects.find_by(name: "Friends")
      expect(aspect).not_to be_nil

      poll_participation = PollParticipation.find_by(author: user.person, guid: poll_participation_entity.guid)
      expect(poll_participation).not_to be_nil
      expect(poll_participation.parent.guid).to eq(poll_participation_entity.parent_guid)
      expect(poll_participation.poll_answer.guid).to eq(poll_participation_entity.poll_answer_guid)

      comment = Comment.find_by(guid: others_comment_entity.guid)
      expect(comment.author.diaspora_handle).to eq(others_comment_entity.author)
      expect(comment.parent.author.diaspora_handle).to eq(user.diaspora_handle)
    end
  end

  context "photo migration" do
    it "doesn't include a new remote_photo_path" do
      service = MigrationService.new(archive_file.path, new_username)
      service.send(:find_or_create_user)
      account_migration = service.send(:account_migration)
      expect(account_migration.remote_photo_path).to be_nil
    end

    it "includes url to new pod image upload folder in remote_photo_path" do
      service = MigrationService.new(archive_file.path, new_username, photo_migration: true)
      service.send(:find_or_create_user)
      account_migration = service.send(:account_migration)
      expect(account_migration.remote_photo_path).to eq("#{AppConfig.pod_uri}uploads/images/")
    end

    it "includes url to S3 image upload folder in remote_photo_path when S3 is enabled" do
      AppConfig.environment.s3.enable = true
      AppConfig.environment.s3.bucket = "test-bucket"

      service = MigrationService.new(archive_file.path, new_username, photo_migration: true)
      service.send(:find_or_create_user)
      account_migration = service.send(:account_migration)
      expect(account_migration.remote_photo_path).to eq("https://test-bucket.s3.amazonaws.com/uploads/images/")
    end
  end

  context "compressed archives" do
    it "uncompresses gz archive" do
      gz_compressed_file = create_gz_archive
      service = MigrationService.new(gz_compressed_file, new_username)
      uncompressed_file = service.send(:archive_file)
      json = uncompressed_file.read
      expect {
        JSON.parse(json)
      }.not_to raise_error
    end

    it "uncompresses zip archive" do
      zip_compressed_file = create_zip_archive
      service = MigrationService.new(zip_compressed_file, new_username)
      uncompressed_file = service.send(:archive_file)
      json = uncompressed_file.read
      expect {
        JSON.parse(json)
      }.not_to raise_error
    end

    def create_gz_archive
      target_file = Tempfile.new(%w[archive .json.gz]).path
      Zlib::GzipWriter.open(target_file) do |gz|
        File.open(archive_file.path).each do |line|
          gz.write line
        end
      end
      target_file
    end

    def create_zip_archive
      target_file = Tempfile.new(%w[archive .zip]).path
      Zip::OutputStream.open(target_file) do |zip|
        zip.put_next_entry("archive.json")
        File.open(archive_file.path).each do |line|
          zip.write line
        end
      end
      target_file
    end
  end

  context "old user is a known remote user" do
    let(:old_person) {
      FactoryBot.create(:person,
                        profile:               FactoryBot.build(:profile),
                        serialized_public_key: archive_private_key.public_key.export,
                        diaspora_handle:       archive_author)
    }

    # Some existing data for old_person to test data merge/migration
    let!(:existing_contact) { FactoryBot.create(:contact, person: old_person, sharing: true, receiving: true) }

    let!(:existing_subscription) {
      FactoryBot.create(:participation,
                        author: old_person,
                        target: FactoryBot.create(:status_message, guid: existing_subscription_guid))
    }
    let!(:existing_status_message) {
      FactoryBot.create(:status_message,
                         author: old_person,
                         guid:   known_status_message_entity.guid).tap {|status_message|
        status_message.participants << post_subscriber
      }
    }

    it_behaves_like "imports archive" do
      before do
        setup_validation_time_expectations
      end
    end

    context "when account migration already exists" do
      before do
        setup_validation_time_expectations
        FactoryBot.create(:account_migration, old_person: old_person)
      end

      it "raises exception" do
        expect {
          MigrationService.new(archive_file.path, new_username).validate
        }.to raise_error(MigrationService::MigrationAlreadyExists)
      end
    end

    describe "#only_import?" do
      it "returns false" do
        service = MigrationService.new(archive_file.path, new_username)
        expect(service.only_import?).to be_falsey
      end
    end
  end

  context "old user is unknown" do
    context "and non-fetchable" do
      before do
        expect(DiasporaFederation::Discovery::Discovery).to receive(:new).with(archive_author).and_call_original
        stub_request(:get, "https://#{old_pod_hostname}/.well-known/webfinger?resource=acct:#{archive_author}")
          .to_return(status: 404)

        expect_relayable_parent_fetch(archive_author, existing_subscription_guid)
          .and_raise(DiasporaFederation::Federation::Fetcher::NotFetchable)

        setup_validation_time_expectations
      end

      include_examples "imports archive"
    end

    describe "#only_import?" do
      it "returns true" do
        service = MigrationService.new(archive_file.path, new_username)
        expect(service.only_import?).to be_truthy
      end
    end
  end
end
