# frozen_string_literal: true
require 'test_helper'
require 'securerandom'

module Shipit
  class StacksTest < ActiveSupport::TestCase
    def setup
      @stack = shipit_stacks(:shipit)
      @expected_base_path = Rails.root.join('data', 'stacks', @stack.to_param).to_s
      GithubHook.any_instance.stubs(:teardown!)
    end

    test "branch defaults to default branch name" do
      @stack.branch = ""
      Shipit.github.api.expects(:repo).with("shopify/shipit-engine").returns(
        Struct.new(:default_branch).new('something')
      )
      assert @stack.save
      assert_equal 'something', @stack.branch
    end

    test "branch is blank when default cannot be determined" do
      @stack.branch = ""
      Shipit.github.api.expects(:repo).raises(Octokit::NotFound)
      assert_not @stack.save
      assert_nil @stack.branch
    end

    test "environment defaults to production" do
      @stack.environment = ""
      assert @stack.save
      assert_equal 'production', @stack.environment
    end

    test "environment can contain a `:`" do
      @stack.environment = 'foo:bar'
      assert @stack.save
      assert_equal 'foo:bar', @stack.environment
    end

    test "repo_http_url" do
      assert_equal "https://github.com/#{@stack.repo_owner}/#{@stack.repo_name}", @stack.repo_http_url
    end

    test "repo_git_url" do
      assert_equal "https://github.com/#{@stack.repo_owner}/#{@stack.repo_name}.git", @stack.repo_git_url
    end

    test "base_path" do
      assert_equal @expected_base_path, @stack.base_path.to_s
    end

    test "deploys_path" do
      assert_equal File.join(@expected_base_path, "deploys"), @stack.deploys_path.to_s
    end

    test "git_path" do
      assert_equal File.join(@expected_base_path, "git"), @stack.git_path.to_s
    end

    test "#trigger_deploy persist a new deploy" do
      last_commit = shipit_commits(:third)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert deploy.persisted?
      assert_equal last_commit.id, deploy.until_commit_id
      assert_equal shipit_deploys(:shipit_complete).until_commit_id, deploy.since_commit_id
    end

    test "#trigger_deploy emits a hook" do
      original_receivers = Shipit.internal_hook_receivers

      FakeReceiver = Module.new do
        mattr_accessor :hooks
        self.hooks = []

        def self.deliver(event, stack, payload)
          hooks << [event, stack, payload]
        end
      end
      Shipit.internal_hook_receivers = [FakeReceiver]

      last_commit = shipit_commits(:third)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert_includes(FakeReceiver.hooks, [
        :deploy,
        @stack,
        { deploy: deploy, status: "pending", stack: @stack },
      ])
    ensure
      Shipit.internal_hook_receivers = original_receivers
    end

    test "#trigger_deploy deploy until the commit passed in argument" do
      last_commit = shipit_commits(:third)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert_equal last_commit.id, deploy.until_commit_id
    end

    test "#trigger_deploy since_commit is the last completed deploy until_commit if there is a previous deploy" do
      last_commit = shipit_commits(:fifth)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert_equal shipit_deploys(:shipit_complete).until_commit_id, deploy.since_commit_id
    end

    test "#trigger_deploy since_commit is the first stack commit if there is no previous deploy" do
      @stack.deploys.destroy_all

      last_commit = shipit_commits(:third)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert_equal @stack.commits.first.id, deploy.since_commit_id
    end

    test "#trigger_deploy enqueues a deploy job" do
      @stack.deploys.destroy_all
      Deploy.any_instance.expects(:enqueue).once

      last_commit = shipit_commits(:third)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert_instance_of Deploy, deploy
    end

    test "#trigger_deploy doesn't enqueues a deploy job when run_now is provided" do
      @stack.deploys.destroy_all
      Deploy.any_instance.expects(:run_now!).once

      last_commit = shipit_commits(:third)
      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new, run_now: true)
      assert_instance_of Deploy, deploy
    end

    test "#trigger_deploy marks the deploy as `ignored_safeties` if the commit wasn't deployable" do
      last_commit = shipit_commits(:fifth)
      refute_predicate last_commit, :deployable?

      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      assert_predicate deploy, :ignored_safeties?
    end

    test "#trigger_deploy doesn't mark the deploy as `ignored_safeties` if the commit was deployable" do
      last_commit = shipit_commits(:third)
      assert_predicate last_commit, :deployable?

      deploy = @stack.trigger_deploy(last_commit, AnonymousUser.new)
      refute_predicate deploy, :ignored_safeties?
    end

    test "#update_deployed_revision bail out if there is an active deploy" do
      @stack.deploys_and_rollbacks.last.update_columns(status: 'running')
      assert_no_difference 'Deploy.count' do
        @stack.update_deployed_revision(shipit_commits(:fifth).sha)
      end
    end

    test "#update_deployed_revision bail out if sha is unknown" do
      assert_no_difference 'Deploy.count' do
        @stack.update_deployed_revision('skldjaslkdjas')
      end
    end

    test "#update_deployed_revision create a new completed deploy" do
      assert_equal shipit_commits(:fourth), @stack.last_deployed_commit
      assert_difference 'Deploy.count', 1 do
        deploy = @stack.update_deployed_revision(shipit_commits(:fifth).sha)
        assert_not_nil deploy
        assert_equal shipit_commits(:fourth), deploy.since_commit
        assert_equal shipit_commits(:fifth), deploy.until_commit
      end
      assert_equal shipit_commits(:fifth), @stack.last_deployed_commit
    end

    test "#update_deployed_revision creates a new completed deploy without previous deploys" do
      stack = shipit_stacks(:undeployed_stack)
      assert_empty stack.deploys_and_rollbacks
      assert_difference 'Deploy.count', 1 do
        deploy = stack.update_deployed_revision(shipit_commits(:undeployed_stack_first).sha)
        assert_not_nil deploy
        assert_equal shipit_commits(:undeployed_stack_first), deploy.since_commit
        assert_equal shipit_commits(:undeployed_stack_first), deploy.until_commit
      end
      assert_equal shipit_commits(:undeployed_stack_first), stack.last_deployed_commit
    end

    test "#update_deployed_revision works with short shas" do
      Deploy.active.update_all(status: 'error')

      assert_equal shipit_commits(:fourth), @stack.last_deployed_commit
      assert_difference 'Deploy.count', 1 do
        deploy = @stack.update_deployed_revision(shipit_commits(:fifth).sha[0..5])
        assert_not_nil deploy
        assert_equal shipit_commits(:fourth), deploy.since_commit
        assert_equal shipit_commits(:fifth), deploy.until_commit
      end
      assert_equal shipit_commits(:fifth), @stack.last_deployed_commit
    end

    test "#update_deployed_revision accepts the deploy if the reported revision is consistent" do
      Deploy.active.update_all(status: 'error')

      Deploy.any_instance.expects(:accept!).once
      last_deploy = @stack.deploys_and_rollbacks.completed.last
      @stack.update_deployed_revision(last_deploy.until_commit.sha)
    end

    test "#update_deployed_revision reject the deploy if the reported revision is inconsistent" do
      Deploy.active.update_all(status: 'error')

      Deploy.any_instance.expects(:reject!).once
      last_deploy = @stack.deploys_and_rollbacks.completed.last
      @stack.update_deployed_revision(last_deploy.since_commit.sha)
    end

    test "#create queues a GithubSyncJob" do
      assert_enqueued_with(job: GithubSyncJob) do
        Stack.create!(repository: shipit_repositories(:rails), branch: 'main')
      end
    end

    test "#destroy also destroy associated GithubHooks" do
      assert_difference -> { GithubHook.count }, -2 do
        shipit_stacks(:shipit).destroy
      end
    end

    test "#destroy also destroy associated Commits" do
      assert_difference -> { Commit.count }, -shipit_stacks(:shipit).commits.count do
        shipit_stacks(:shipit).destroy
      end
    end

    test "#destroy also destroy associated CommitDeployments" do
      assert_difference -> { CommitDeployment.count }, -8 do
        shipit_stacks(:shipit).destroy
      end
    end

    test "#destroy delete all local files (git mirror and deploy clones)" do
      FileUtils.expects(:rm_rf).with(Rails.root.join('data', 'stacks', 'shopify', 'shipit-engine', 'production').to_s)
      shipit_stacks(:shipit).destroy
    end

    test "#active_task? is false if stack has no deploy in either pending or running state" do
      @stack.deploys.active.destroy_all
      refute @stack.active_task?
    end

    test "#active_task? is false if stack has no deploy at all" do
      @stack.deploys.destroy_all
      refute @stack.active_task?
    end

    test "#active_task? is true if stack has a deploy in either pending or running state" do
      @stack.trigger_deploy(shipit_commits(:third), AnonymousUser.new)
      assert @stack.active_task?
    end

    test "#active_task? is true if a rollback is ongoing" do
      shipit_deploys(:shipit_complete).trigger_rollback(AnonymousUser.new)
      assert @stack.active_task?
    end

    test ".run_deploy_in_foreground triggers a deploy" do
      stack = Stack.create!(
        repository: Repository.new(owner: "foo", name: "bar"),
        environment: 'production',
        branch: 'main',
      )
      commit = shipit_commits(:first)
      stack.commits << commit

      Stack.any_instance.expects(:trigger_deploy).with(anything, anything, env: {}, force: true, run_now: true)

      Stack.run_deploy_in_foreground(stack: stack.to_param, revision: commit.sha)
    end

    test "#active_task? is memoized" do
      assert_queries(1) do
        10.times { @stack.active_task? }
      end
    end

    test "#active_task? is false if stack has a concurrent deploy in active state" do
      @stack.trigger_deploy(shipit_commits(:third), AnonymousUser.new, force: true)
      refute @stack.active_task?
    end

    test "#occupied? is false if stack has no deploy in either pending or running state" do
      @stack.deploys.active.destroy_all
      refute @stack.occupied?
    end

    test "#occupied? is false if stack has no deploy at all" do
      @stack.deploys.destroy_all
      refute @stack.occupied?
    end

    test "occupied? is true if stack has a concurrent deploy in active state" do
      @stack.trigger_deploy(shipit_commits(:third), AnonymousUser.new, force: true)
      assert @stack.occupied?
    end

    test "occupied? is true if stack has a deploy in pending state" do
      @stack.trigger_deploy(shipit_commits(:third), AnonymousUser.new)
      assert @stack.occupied?
    end

    test "#occupied? is true if a rollback is ongoing" do
      shipit_deploys(:shipit_complete).trigger_rollback(AnonymousUser.new)
      assert @stack.occupied?
    end

    test "#deployable? returns true if the stack is not locked, not awaiting provision, and is not deploying" do
      @stack.deploys.destroy_all
      @stack.update!(lock_reason: nil, awaiting_provision: false)
      assert_predicate @stack, :deployable?
    end

    test "#deployable? returns false if the stack is locked" do
      @stack.update!(lock_reason: 'Maintenance operation')
      refute_predicate @stack, :deployable?
    end

    test "#deployable? returns false if the stack is deploying" do
      @stack.trigger_deploy(shipit_commits(:third), AnonymousUser.new)
      refute_predicate @stack, :deployable?
    end

    test "#deployable? returns false if the stack is awaiting provisioning" do
      @stack.update!(lock_reason: nil, awaiting_provision: true)
      refute_predicate @stack, :deployable?
    end

    test "#allows_merges? returns true if the stack is not locked and the branch is green" do
      assert_predicate @stack, :allows_merges?
    end

    test "#allows_merges? returns false if the stack is locked" do
      @stack.update!(lock_reason: 'Maintenance operation')
      refute_predicate @stack, :allows_merges?
    end

    test "#allows_merges? returns false if the merge queue is disabled" do
      @stack.update!(merge_queue_enabled: false)
      refute_predicate @stack, :allows_merges?
    end

    test "#allows_merges? returns false if the branch is failing" do
      @stack.undeployed_commits.last.statuses.create!(context: 'ci/travis', state: 'failure', stack: @stack)
      refute_predicate @stack, :allows_merges?
    end

    test "#monitoring is empty if cached_deploy_spec is blank" do
      @stack.cached_deploy_spec = nil
      assert_equal [], @stack.monitoring
    end

    test "#monitoring returns deploy_spec's content" do
      assert_equal [{ 'image' => 'https://example.com/monitor.png', 'width' => 200, 'height' => 300 }], @stack.monitoring
    end

    test "#destroy deletes the related commits" do
      assert_difference -> { @stack.commits.count }, -@stack.commits.count do
        @stack.destroy
      end
    end

    test "#destroy deletes the related tasks" do
      assert_difference -> { @stack.tasks.count }, -@stack.tasks.count do
        @stack.destroy
      end
    end

    test "#destroy deletes the related webhooks" do
      assert_difference -> { @stack.github_hooks.count }, -@stack.github_hooks.count do
        @stack.destroy
      end
    end

    test "locking the stack triggers a webhook" do
      expect_hook(:lock, @stack, locked: true, lock_details: nil, stack: @stack) do
        @stack.update(lock_reason: "Just for fun", lock_author: shipit_users(:walrus))
      end
    end

    test "unlocking the stack triggers a webhook" do
      freeze_time do
        time = Time.current
        @stack.update(lock_reason: "Just for fun", lock_author: shipit_users(:walrus))
        travel 1.day
        expect_hook(:lock, @stack, locked: false, lock_details: { from: time, until: Time.current }, stack: @stack) do
          @stack.update(lock_reason: nil)
        end
      end
    end

    test "unlocking the stack triggers a MergeMergeRequests job" do
      assert_no_enqueued_jobs(only: ProcessMergeRequestsJob) do
        @stack.update(lock_reason: "Just for fun", lock_author: shipit_users(:walrus))
      end

      assert_enqueued_with(job: ProcessMergeRequestsJob, args: [@stack]) do
        @stack.update(lock_reason: nil)
      end
    end

    test "the git cache lock prevent concurrent access to the git cache" do
      second_stack = Shipit::Stack.find(@stack.id)
      second_stack.acquire_git_cache_lock do
        assert_raises Flock::TimeoutError do
          @stack.acquire_git_cache_lock(timeout: 0.1) {}
        end
      end
    end

    test "the git cache lock is reentrant if called on the same Stack instance" do
      called = false
      @stack.acquire_git_cache_lock(timeout: 0.01) do
        @stack.acquire_git_cache_lock(timeout: 0.01) do
          called = true
        end
      end
      assert called
    end

    test "the git cache lock is scoped to the stack" do
      called = false
      shipit_stacks(:cyclimse).acquire_git_cache_lock do
        @stack.acquire_git_cache_lock do
          called = true
        end
      end
      assert called
    end

    test "#clear_git_cache! deletes the stack git directory" do
      FileUtils.mkdir_p(@stack.git_path)
      path = File.join(@stack.git_path, 'foo')
      File.write(path, 'bar')
      @stack.clear_git_cache!
      refute File.exist?(path)
    end

    test "#clear_git_cache! does nothing if the git directory is not present" do
      FileUtils.rm_rf(@stack.git_path)
      assert_nothing_raised do
        @stack.clear_git_cache!
      end
    end

    test "updating the stack emit a hook" do
      expect_hook(:stack, @stack, action: :updated, stack: @stack) do
        @stack.update(environment: 'foo')
      end
    end

    test "updating the stack doesn't emit a hook if only `updated_at` is changed" do
      # force a save to make sure `cached_deploy_spec` serialization is consistent with how Active Record would
      # serialize it.
      @stack.update(updated_at: 2.days.ago)

      expect_no_hook(:stack) do
        @stack.update(updated_at: Time.zone.now)
      end
    end

    test "#merge_status returns locked if stack is locked" do
      @stack.update!(lock_reason: 'Maintenance operation')
      assert_equal 'locked', @stack.merge_status
    end

    test "#merge_status returns state of last finalized undeployed commit" do
      @stack.deploys_and_rollbacks.destroy_all
      shipit_commits(:fifth).statuses.destroy_all
      shipit_commits(:fourth).statuses.update_all(state: 'pending')
      shipit_commits(:third).statuses.update_all(state: 'success')
      shipit_commits(:second).statuses.update_all(state: 'failure')

      assert_equal 'success', @stack.merge_status
    end

    test "#merge_status returns success if all undeployed commits and last deployed commit are in pending or unknown state" do
      shipit_commits(:fifth).statuses.destroy_all
      shipit_commits(:fourth).statuses.update_all(state: 'pending')
      shipit_commits(:third).statuses.update_all(state: 'pending')
      @stack.deploys_and_rollbacks.last.update!(status: 'success', until_commit: shipit_commits(:third))

      assert_equal 'success', @stack.merge_status
    end

    test "#merge_status returns success if there are no undeployed commits and no deployed commits" do
      @stack.deploys_and_rollbacks.last.update(status: 'success', until_commit: shipit_commits(:fifth))

      assert_equal 'success', @stack.merge_status
    end

    test "#merge_status returns backlogged if there are too many undeployed commits" do
      @stack.deploys_and_rollbacks.destroy_all
      @stack.update_undeployed_commits_count
      @stack.reload
      assert_equal 'backlogged', @stack.merge_status(backlog_leniency_factor: 1.5)
    end

    test "#merge_status returns success with a higher leniency factor" do
      @stack.deploys_and_rollbacks.destroy_all
      @stack.update_undeployed_commits_count
      @stack.reload
      assert_equal 'success', @stack.merge_status(backlog_leniency_factor: 3.0)
    end

    test "#handle_github_redirections update the stack if the repository was renamed" do
      repo_permalink = 'https://api.github.com/repositories/42'

      commits_redirection = stub(message: 'Moved Permanently', url: File.join(repo_permalink, '/commits'))
      Shipit.github.api.expects(:commits).with(@stack.github_repo_name, sha: 'master').returns(commits_redirection)

      repo_redirection = stub(message: 'Moved Permanently', url: repo_permalink)
      Shipit.github.api.expects(:repo).with('shopify/shipit-engine').returns(repo_redirection)

      repo_resource = stub(name: 'shipster', owner: stub(login: 'george'))
      Shipit.github.api.expects(:get).with(repo_permalink).returns(repo_resource)

      commits_resource = stub
      Shipit.github.api.expects(:commits).with('george/shipster', sha: 'master').returns(commits_resource)

      assert_equal 'shopify/shipit-engine', @stack.github_repo_name
      assert_equal commits_resource, @stack.github_commits
      @stack.reload
      assert_equal 'george/shipster', @stack.github_repo_name
    end

    test "#update_estimated_deploy_duration! records the 90th percentile duration among the last 100 deploys" do
      assert_equal 1, @stack.estimated_deploy_duration
      @stack.update_estimated_deploy_duration!
      assert_equal 120, @stack.estimated_deploy_duration
    end

    test "#trigger_continuous_delivery bails out if the stack isn't deployable" do
      Hook.stubs(:emit) # TODO: Once on rails 5, use assert_no_enqueued_jobs(only: Shipit::PerformTaskJob)

      @stack.lock('yada yada yada', AnonymousUser.new)
      refute_predicate @stack, :deployable?
      refute_predicate @stack, :deployed_too_recently?

      assert_no_enqueued_jobs do
        assert_no_difference -> { Deploy.count } do
          value = @stack.trigger_continuous_delivery

          assert_nil value
        end
      end
    end

    test "#trigger_continuous_delivery bails out if the stack is deployable but was deployed too recently" do
      Hook.stubs(:emit) # TODO: Once on rails 5, use assert_no_enqueued_jobs(only: Shipit::PerformTaskJob)

      @stack.tasks.delete_all
      deploy = @stack.trigger_deploy(shipit_commits(:first), AnonymousUser.new)
      deploy.run!
      deploy.complete!
      @stack.reload

      assert_predicate @stack, :deployable?
      assert_predicate @stack, :deployed_too_recently?

      assert_no_enqueued_jobs do
        assert_no_difference -> { Deploy.count } do
          @stack.trigger_continuous_delivery
        end
      end
    end

    test "#trigger_continuous_delivery bails out if the commit was already unsuccessfully deployed" do
      Hook.stubs(:emit) # TODO: Once on rails 5, use assert_no_enqueued_jobs(only: Shipit::PerformTaskJob)

      @stack.tasks.delete_all

      assert_predicate @stack, :deployable?
      refute_predicate @stack, :deployed_too_recently?

      commit = @stack.next_commit_to_deploy
      deploy = @stack.trigger_deploy(commit, AnonymousUser.new)
      deploy.error!
      assert_predicate commit, :deploy_failed?

      assert_no_enqueued_jobs do
        assert_no_difference -> { Deploy.count } do
          @stack.trigger_continuous_delivery
        end
      end
    end

    test "#trigger_continuous_delivery bails out if the previous deploy is still validating" do
      @stack = shipit_stacks(:shipit_canaries)
      shipit_tasks(:canaries_running).delete

      assert_predicate @stack, :deployable?
      assert_predicate @stack, :deployed_too_recently?
      assert_predicate @stack.last_active_task, :validating?

      assert_no_enqueued_jobs(only: Shipit::PerformTaskJob) do
        assert_no_difference -> { Deploy.count } do
          @stack.trigger_continuous_delivery
        end
      end

      @stack.last_active_task.complete!
      refute_predicate @stack, :deployed_too_recently?

      assert_enqueued_with(job: Shipit::PerformTaskJob) do
        assert_difference -> { Deploy.count }, +1 do
          @stack.trigger_continuous_delivery
        end
      end
    end

    test "#trigger_continuous_delivery bails out if no DeploySpec has been cached" do
      @stack = shipit_stacks(:check_deploy_spec)
      deploy_spec = @stack.cached_deploy_spec

      assert_predicate @stack, :deployable?
      refute_predicate @stack, :deployed_too_recently?
      assert(deploy_spec.blank?, "DeploySpec blank? returned false")

      assert_no_enqueued_jobs(only: Shipit::PerformTaskJob) do
        assert_no_difference -> { Deploy.count } do
          @stack.trigger_continuous_delivery
        end
      end
    end

    test "#trigger_continuous_delivery enqueues deployment ref update job" do
      Shipit.stubs(:update_latest_deployed_ref).returns(true)

      @stack = shipit_stacks(:shipit_canaries)
      shipit_tasks(:canaries_running).delete

      assert_no_enqueued_jobs(only: Shipit::UpdateGithubLastDeployedRefJob) do
        assert_no_difference -> { Deploy.count } do
          @stack.trigger_continuous_delivery
        end
      end

      assert_enqueued_with(job: Shipit::UpdateGithubLastDeployedRefJob, args: [@stack]) do
        @stack.last_active_task.complete!
      end
    end

    test "#trigger_continuous_delivery executes ref update job with correct sha" do
      Shipit.stubs(:update_latest_deployed_ref).returns(true)

      @stack = shipit_stacks(:shipit_canaries)
      shipit_tasks(:canaries_running).delete

      assert_no_enqueued_jobs(only: Shipit::UpdateGithubLastDeployedRefJob) do
        assert_no_difference -> { Deploy.count } do
          @stack.trigger_continuous_delivery
        end
      end

      desired_last_commit_sha = @stack.last_active_task.until_commit.sha
      Shipit.github.api.expects(:update_ref).with(anything, anything, desired_last_commit_sha).returns("test")

      perform_enqueued_jobs(only: Shipit::UpdateGithubLastDeployedRefJob) do
        @stack.last_active_task.complete!
      end
    end

    test "#trigger_continuous_delivery trigger a deploy if all conditions are met" do
      @stack.tasks.delete_all
      assert_predicate @stack, :deployable?
      refute_predicate @stack, :deployed_too_recently?

      assert_difference -> { Deploy.count }, +1 do
        @stack.trigger_continuous_delivery
      end
    end

    test "#trigger_continuous_delivery use default env vars" do
      @stack.tasks.delete_all

      deploy = @stack.trigger_continuous_delivery
      assert_equal({ 'SAFETY_DISABLED' => '0' }, deploy.env)
    end

    test "#continuous_delivery_delayed! bumps updated_at" do
      old_updated_at = @stack.updated_at - 3.minutes
      @stack.update_column(:updated_at, old_updated_at)
      @stack.reload

      @stack.continuous_delivery_delayed!

      assert_not_equal old_updated_at, @stack.updated_at
    end

    test "#next_commit_to_deploy returns the last deployable commit" do
      @stack.tasks.where.not(until_commit_id: shipit_commits(:second).id).destroy_all
      assert_equal shipit_commits(:second), @stack.last_deployed_commit

      assert_equal shipit_commits(:third), @stack.next_commit_to_deploy

      fifth_commit = shipit_commits(:fifth)
      fifth_commit.statuses.create!(stack_id: @stack.id, state: 'success', context: 'ci/travis')
      assert_predicate fifth_commit, :deployable?

      assert_equal shipit_commits(:fifth), @stack.next_commit_to_deploy
    end

    test "#next_commit_to_deploy respects the deploy.max_commits directive" do
      @stack.tasks.destroy_all

      fifth_commit = shipit_commits(:third)
      fifth_commit.statuses.create!(stack_id: @stack.id, state: 'success', context: 'ci/travis')
      assert_predicate fifth_commit, :deployable?

      assert_equal shipit_commits(:third), @stack.next_commit_to_deploy

      @stack.expects(:maximum_commits_per_deploy).returns(3).at_least_once
      assert_equal shipit_commits(:third), @stack.next_commit_to_deploy
    end

    test "setting #lock_reason also sets #locked_since" do
      assert_predicate @stack.locked_since, :nil?

      @stack.update!(lock_reason: "Here comes the walrus")
      refute_predicate @stack.locked_since, :nil?

      @stack.update!(lock_reason: nil)
      assert_predicate @stack.locked_since, :nil?
    end

    test "updating #lock_reason preserves #locked_since" do
      @stack.update!(lock_reason: "Here comes the walrus")
      expected = @stack.locked_since

      @stack.update!(lock_reason: "The walrus strikes back")
      assert_equal expected, @stack.locked_since
    end

    test "#lock sets reason and user" do
      reason = "Here comes the walrus"
      user = shipit_users(:walrus)
      @stack.lock(reason, user)
      assert @stack.locked?
      assert_equal reason, @stack.lock_reason
      assert_equal user, @stack.lock_author
    end

    test "#unlock deletes reason and user" do
      user = shipit_users(:walrus)
      @stack.lock("Here comes the walrus", user)
      @stack.unlock
      refute @stack.locked?
      assert_nil @stack.lock_reason
      assert_not_equal user, @stack.lock_author
    end

    test "stack contains valid deploy_url" do
      @stack.deploy_url = "Javascript:alert(0);//"
      assert_not_predicate @stack, :valid?
      @stack.deploy_url = "https://shopify.com"
      assert_predicate @stack, :valid?
      @stack.deploy_url = "ssh://abc"
      assert_predicate @stack, :valid?
    end

    test "#ci_enabled? is true if there are any commits with a status" do
      Shipit::CheckRun.where(stack_id: @stack.id).delete_all
      Rails.cache.clear

      assert_predicate Shipit::Status.where(stack_id: @stack.id), :any?
      assert @stack.ci_enabled?
    end

    test "#ci_enabled? is true if there are any check runs" do
      Shipit::Status.where(stack_id: @stack.id).delete_all
      Rails.cache.clear

      assert_predicate Shipit::CheckRun.where(stack_id: @stack.id), :any?
      assert_predicate @stack, :ci_enabled?
    end

    test "#ci_enabled? is false if there are no check_runs or statuses" do
      Shipit::Status.where(stack_id: @stack.id).delete_all
      Shipit::CheckRun.where(stack_id: @stack.id).delete_all

      Rails.cache.clear
      refute_predicate @stack, :ci_enabled?
    end

    test "#undeployed_commits returns list of commits newer than last deployed commit" do
      @stack = shipit_stacks(:shipit_undeployed)
      last_deployed_commit = @stack.last_deployed_commit
      commits = @stack.undeployed_commits

      assert_equal @stack.undeployed_commits_count, commits.size

      commits.each { |c| assert c.id > last_deployed_commit.id }
    end

    test "#next_expected_commit_to_deploy returns nil if there is no deployable commit" do
      commits = @stack.undeployed_commits

      assert !commits.empty?
      commits.each { |c| refute_predicate c, :deployable? }

      assert_nil @stack.next_expected_commit_to_deploy(commits: commits)
    end

    test "#next_expected_commit_to_deploy returns nil if all deployable commits are active" do
      @stack = shipit_stacks(:shipit_undeployed)
      commits = @stack.undeployed_commits.select(&:active?)

      assert !commits.empty?
      commits.each { |c| assert_predicate c, :active? }

      assert_nil @stack.next_expected_commit_to_deploy(commits: commits)
    end

    test "#next_expected_commit_to_deploy returns nil if there are no commits" do
      assert_nil @stack.next_expected_commit_to_deploy(commits: [])
    end

    test "#next_expected_commit_to_deploy returns the most recent non-active deployable commit limited by maximum commits per deploy" do
      @stack = shipit_stacks(:shipit_undeployed)
      commits = @stack.undeployed_commits

      assert !commits.empty?

      most_recent_limited = @stack.next_expected_commit_to_deploy(commits: commits)
      most_recent = commits.find { |c| !c.active? && c.deployable? }

      assert most_recent.id > most_recent_limited.id
      assert_equal @stack.maximum_commits_per_deploy, commits.find_index(most_recent_limited) + 1
    end

    test "#async_refresh_deployed_revision suppresses and logs raised exception" do
      error_message = "Error message"

      Rails.logger.expects(:warn).with("Failed to dispatch FetchDeployedRevisionJob: [StandardError] #{error_message}")
      @stack.expects(:async_refresh_deployed_revision!).raises(StandardError.new(error_message))

      @stack.async_refresh_deployed_revision
    end

    test "#lock_reverted_commits! locks all commits between the original and reverted commits" do
      reverted_commit = @stack.undeployed_commits.first
      revert_author = shipit_users(:bob)
      generate_revert_commit(stack: @stack, reverted_commit: reverted_commit, author: revert_author)
      @stack.reload

      assert_equal(
        [
          ['Revert "whoami"', false, nil],
          ["whoami", false, nil],
          ["fix all the things", false, nil],
        ],
        @stack.undeployed_commits.map { |c| [c.message, c.locked, c.lock_author_id] },
      )

      @stack.lock_reverted_commits!
      @stack.reload

      assert_equal(
        [
          ['Revert "whoami"', false, nil],
          ["whoami", true, revert_author.id],
          ["fix all the things", false, nil],
        ],
        @stack.undeployed_commits.map { |c| [c.message, c.locked, c.lock_author_id] },
      )
    end

    test "#lock_reverted_commits! is a no-op if the reverted commit has already shipped" do
      reverted_commit = shipit_commits(:first)
      revert_author = shipit_users(:bob)
      generate_revert_commit(stack: @stack, reverted_commit: reverted_commit, author: revert_author)
      @stack.reload

      initial_state = [
        ['Revert "lets go"', false, nil],
        ["whoami", false, nil],
        ["fix all the things", false, nil],
      ]

      assert_equal(
        initial_state,
        @stack.undeployed_commits.map { |c| [c.message, c.locked, c.lock_author_id] },
      )

      @stack.lock_reverted_commits!
      @stack.reload

      assert_equal(
        initial_state,
        @stack.undeployed_commits.map { |c| [c.message, c.locked, c.lock_author_id] },
      )
    end

    test "#lock_reverted_commits! handles multiple reverts" do
      first_reverted_commit = @stack.undeployed_commits.last
      second_reverted_commit = @stack.undeployed_commits.first
      first_revert_author = shipit_users(:bob)
      second_revert_author = shipit_users(:walrus)
      generate_revert_commit(stack: @stack, reverted_commit: first_reverted_commit, author: first_revert_author)
      generate_revert_commit(stack: @stack, reverted_commit: second_reverted_commit, author: second_revert_author)
      @stack.reload

      assert_equal(
        [
          ['Revert "whoami"', false, nil],
          ['Revert "fix all the things"', false, nil],
          ["whoami", false, nil],
          ["fix all the things", false, nil],
        ],
        @stack.undeployed_commits.map { |c| [c.message, c.locked, c.lock_author_id] },
      )

      @stack.lock_reverted_commits!
      @stack.reload

      assert_equal(
        [
          ['Revert "whoami"', false, nil],
          ['Revert "fix all the things"', true, second_revert_author.id],
          ["whoami", true, first_revert_author.id],
          ["fix all the things", true, first_revert_author.id],
        ],
        @stack.undeployed_commits.map { |c| [c.message, c.locked, c.lock_author_id] },
      )
    end

    test "#trigger_continuous_delivery sets delay if commit was pushed recently" do
      freeze_time do
        @stack.tasks.delete_all

        commit = @stack.next_commit_to_deploy
        commit.touch(:created_at)

        assert_no_enqueued_jobs(only: Shipit::PerformTaskJob) do
          assert_no_difference -> { Deploy.count } do
            @stack.trigger_continuous_delivery
          end
        end
      end
    end

    test "#links performs template substitutions" do
      @stack.repo_name = "expected-repository-name"
      @stack.environment = "expected-environment"
      @stack.cached_deploy_spec = create_deploy_spec(
        "links" => {
          "logs" => "http://logs.$GITHUB_REPO_NAME.$ENVIRONMENT.domain.com",
          "monitoring" => "https://graphs.$GITHUB_REPO_NAME.$ENVIRONMENT.domain.com",
        },
      )

      assert_equal(
        {
          "logs" => "http://logs.expected-repository-name.expected-environment.domain.com",
          "monitoring" => "https://graphs.expected-repository-name.expected-environment.domain.com",
        },
        @stack.links,
      )
    end

    test "#env includes the stack's environment" do
      expected_environment = {
        'ENVIRONMENT' => @stack.environment,
        'LAST_DEPLOYED_SHA' => @stack.last_deployed_commit.sha,
        'GITHUB_REPO_OWNER' => @stack.repository.owner,
        'GITHUB_REPO_NAME' => @stack.repository.name,
        'DEPLOY_URL' => @stack.deploy_url,
        'BRANCH' => @stack.branch,
      }

      assert_equal(
        @stack.env,
        expected_environment,
      )
    end

    test "#unarchive! triggers a GithubSync job" do
      assert_no_enqueued_jobs(only: GithubSyncJob) do
        @stack.archive!(shipit_users(:codertocat))
      end

      assert_enqueued_with(job: GithubSyncJob, args: [stack_id: @stack.id]) do
        @stack.unarchive!
      end
    end

    test "#update that changes the branch name triggers a GithubSync job" do
      assert_enqueued_with(job: GithubSyncJob, args: [stack_id: @stack.id]) do
        @stack.update!(branch: 'test')
      end
    end

    private

    def generate_revert_commit(stack:, reverted_commit:, author: reverted_commit.author)
      stack.commits.create(
        sha: SecureRandom.hex(20),
        message: "Revert \"#{reverted_commit.message_header}\"",
        author: author,
        committer: author,
        authored_at: Time.zone.now,
        committed_at: Time.zone.now,
      )
    end

    def create_deploy_spec(spec)
      Shipit::DeploySpec.new(spec.stringify_keys)
    end
  end
end