#
# Copyright:: Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "spec_helper"
require "chef-cli/policyfile_compiler"

describe ChefCLI::PolicyfileCompiler, "when expressing the Policyfile graph demands" do

  let(:run_list) { [] }

  let(:default_source) { nil }

  let(:external_cookbook_universe) { {} }

  let(:policyfile) do
    policyfile = ChefCLI::PolicyfileCompiler.new.build do |p|

      p.default_source(*default_source) if default_source
      p.run_list(*run_list)

      allow(p.default_source.first).to receive(:universe_graph).and_return(external_cookbook_universe)
    end

    policyfile
  end

  let(:demands) { policyfile.graph_demands }

  shared_context("community default source") do

    let(:default_source) { [:community] }

    let(:external_cookbook_universe) do
      {
        "nginx" => {
          "1.0.0" => [ [ "apt", "~> 2.0" ], [ "yum", "~> 1.0" ] ],
          "1.2.0" => [ [ "apt", "~> 2.1" ], [ "yum", "~> 1.0" ] ],
          "2.0.0" => [ [ "apt", "~> 3.0" ], [ "yum", "~> 1.0" ], [ "ohai", "~> 2.0" ] ],
        },

        "mysql" => {
          "3.0.0" => [ [ "apt", "~> 2.0" ], [ "yum", "~> 1.0" ] ],
          "4.0.0" => [ [ "apt", "~> 2.4" ], [ "yum", "~> 1.1" ] ],
          "5.0.0" => [ ],
        },

        "local-cookbook" => {
          "9.9.9" => [ ["local-cookbook-on-community-dep", "= 1.0.0"] ],
        },

        "git-sourced-cookbook" => {
          "10.10.10" => [ ["git-sourced-cookbook-dep", "= 1.0.0"] ],
        },

        "remote-cb" => {
          "0.1.0" => [ ],
          "1.1.1" => [ ],
        },

        "remote-cb-two" => {
          "0.1.0" => [ ],
          "1.1.1" => [ ],
        },

        "local-cookbook-dep-one" => {
          "1.5.0" => [ ],
        },

        "git-sourced-cookbook-dep" => {
          "2.8.0" => [ ],
        },

      }
    end
  end

  shared_context("chef server default source") do

    let(:default_source) { [:chef_server, "https://chef.example.com"] }

    let(:external_cookbook_universe) do
      {
        "nginx" => {
          "1.0.0" => [ [ "apt", "~> 2.0" ], [ "yum", "~> 1.0" ] ],
        },

        "mysql" => {
          "5.0.0" => [ ],
        },

        "local-cookbook" => {
          "9.9.9" => [ ["local-cookbook-on-community-dep", "= 1.0.0"] ],
        },

        "remote-cb" => {
          "1.1.1" => [ ],
        },

        "git-sourced-cookbook" => {
          "10.10.10" => [ ["git-sourced-cookbook-dep", "= 1.0.0"] ],
        },

        "private-cookbook" => {
          "0.1.0" => [ ],
        },

        "local-cookbook-dep-one" => {
          "1.6.0" => [ ],
        },

        "git-sourced-cookbook-dep" => {
          "2.9.0" => [ ],
        },

      }
    end
  end

  describe "when normalizing run_list items" do

    it "normalizes a bare cookbook name" do
      policyfile.run_list("local-cookbook")
      expect(policyfile.normalized_run_list).to eq(["recipe[local-cookbook::default]"])
    end

    it "normalizes a bare cookbook::recipe item" do
      policyfile.run_list("local-cookbook::server")
      expect(policyfile.normalized_run_list).to eq(["recipe[local-cookbook::server]"])
    end

    it "normalizes a recipe[] item with implicit default" do
      policyfile.run_list("recipe[local-cookbook]")
      expect(policyfile.normalized_run_list).to eq(["recipe[local-cookbook::default]"])
    end

    it "does not modify a fully qualified recipe" do
      policyfile.run_list("recipe[local-cookbook::jazz_hands]")
      expect(policyfile.normalized_run_list).to eq(["recipe[local-cookbook::jazz_hands]"])
    end

    describe "in an alternate run list" do

      it "normalizes a bare cookbook name" do
        policyfile.named_run_list(:foo, "local-cookbook")
        expect(policyfile.normalized_named_run_lists[:foo]).to eq(["recipe[local-cookbook::default]"])
      end

      it "normalizes a bare cookbook::recipe item" do
        policyfile.named_run_list(:foo, "local-cookbook::server")
        expect(policyfile.normalized_named_run_lists[:foo]).to eq(["recipe[local-cookbook::server]"])
      end

      it "normalizes a recipe[] item with implicit default" do
        policyfile.named_run_list(:foo, "recipe[local-cookbook]")
        expect(policyfile.normalized_named_run_lists[:foo]).to eq(["recipe[local-cookbook::default]"])
      end

      it "does not modify a fully qualified recipe" do
        policyfile.named_run_list(:foo, "recipe[local-cookbook::jazz_hands]")
        expect(policyfile.normalized_named_run_lists[:foo]).to eq(["recipe[local-cookbook::jazz_hands]"])
      end

    end
  end

  before do
    expect(policyfile.errors).to eq([])
  end

  context "Given resolvable cookbook demands" do

    let(:default_source) { [:supermarket] }

    let(:trimmed_cookbook_universe) do
      {
        "remote-cb" => {
          "1.1.1" => [ ],
        },

      }
    end

    let(:remote_cb_source_opts) do
      { artifactserver: "https://supermarket.example/c/remote-cb/1.1.1/download", version: "1.1.1" }
    end

    let(:default_source_obj) do
      instance_double("ChefCLI::Policyfile::CommunityCookbookSource")
    end

    let(:cb_location_spec) do
      s = "Cookbook 'remote-cb'"
      s << " = 1.1.1"
      s << " #{remote_cb_source_opts}"

      instance_double("ChefCLI::Policyfile::CookbookLocationSpecification",
        name: "remote-cb",
        version_constraint: Semverse::Constraint.new("= 1.1.1"),
        ensure_cached: nil,
        to_s: s)
    end

    before do
      allow(policyfile).to receive(:default_source).and_return([default_source_obj])

      allow(default_source_obj).to receive(:universe_graph)
        .and_return(trimmed_cookbook_universe)

      allow(default_source_obj).to receive(:preferred_source_for?)
        .with("remote-cb")
        .and_return(true)

      allow(default_source_obj).to receive(:source_options_for)
        .with("remote-cb", "1.1.1")
        .and_return(remote_cb_source_opts)

      allow(ChefCLI::Policyfile::CookbookLocationSpecification).to receive(:new)
        .with("remote-cb", "= 1.1.1", remote_cb_source_opts, policyfile.storage_config)
        .and_return(cb_location_spec)

      allow(cb_location_spec).to receive(:installed?).and_return(true)
    end

    context "when the resolved cookbooks have the recipes requested by the run list" do

      context "with an implied default recipe" do

        before do
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("default")
            .and_return(true)
        end

        let(:run_list) { ["remote-cb"] }

        it "installs without error" do
          expect { policyfile.install }.to_not raise_error
        end

      end

      context "with an explicit recipe name" do

        before do
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("this_exists")
            .and_return(true)
        end

        let(:run_list) { ["remote-cb::this_exists"] }

        it "installs without error" do
          expect { policyfile.install }.to_not raise_error
        end

      end

      context "with a fully qualified recipe name" do

        before do
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("this_exists")
            .and_return(true)
        end

        let(:run_list) { ["recipe[remote-cb::this_exists]"] }

        it "installs without error" do
          expect { policyfile.install }.to_not raise_error
        end

      end

    end

    context "when the resolved cookbooks do not have the recipes requested by the run list" do

      context "when the cookbook with a missing recipe appears once in the run list" do
        before do
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("this_recipe_doesnt_exist")
            .and_return(false)
        end

        let(:run_list) { ["remote-cb::this_recipe_doesnt_exist"] }

        it "emits an error" do
          message = <<~MESSAGE
            The installed cookbooks do not contain all the recipes required by your run list(s):
            Cookbook 'remote-cb' = 1.1.1 {:artifactserver=>"https://supermarket.example/c/remote-cb/1.1.1/download", :version=>"1.1.1"}
            is missing the following required recipes:
            * this_recipe_doesnt_exist

            You may have specified an incorrect recipe in your run list,
            or this recipe may not be available in that version of the cookbook
          MESSAGE

          expect { policyfile.install }.to raise_error do |e|
            expect(e).to be_a(ChefCLI::CookbookDoesNotContainRequiredRecipe)
            expect(e.message).to eq(message)
          end
        end
      end

      context "when there is one valid item and one invalid item in the run list" do

        before do
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("default")
            .and_return(true)
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("this_recipe_doesnt_exist")
            .and_return(false)
        end

        let(:run_list) { ["remote-cb::default", "remote-cb::this_recipe_doesnt_exist"] }

        it "emits an error" do
          message = <<~MESSAGE
            The installed cookbooks do not contain all the recipes required by your run list(s):
            Cookbook 'remote-cb' = 1.1.1 {:artifactserver=>"https://supermarket.example/c/remote-cb/1.1.1/download", :version=>"1.1.1"}
            is missing the following required recipes:
            * this_recipe_doesnt_exist

            You may have specified an incorrect recipe in your run list,
            or this recipe may not be available in that version of the cookbook
          MESSAGE

          expect { policyfile.install }.to raise_error do |e|
            expect(e).to be_a(ChefCLI::CookbookDoesNotContainRequiredRecipe)
            expect(e.message).to eq(message)
          end
        end

      end

      context "when there are multiple invalid items in the run list" do

        before do
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("this_recipe_doesnt_exist")
            .and_return(false)
          expect(cb_location_spec).to receive(:cookbook_has_recipe?)
            .with("this_also_doesnt_exist")
            .and_return(false)
        end

        let(:run_list) { ["remote-cb::this_recipe_doesnt_exist", "remote-cb::this_also_doesnt_exist"] }

        it "emits an error" do
          message = <<~MESSAGE
            The installed cookbooks do not contain all the recipes required by your run list(s):
            Cookbook 'remote-cb' = 1.1.1 {:artifactserver=>"https://supermarket.example/c/remote-cb/1.1.1/download", :version=>"1.1.1"}
            is missing the following required recipes:
            * this_recipe_doesnt_exist
            * this_also_doesnt_exist

            You may have specified an incorrect recipe in your run list,
            or this recipe may not be available in that version of the cookbook
          MESSAGE

          expect { policyfile.install }.to raise_error do |e|
            expect(e).to be_a(ChefCLI::CookbookDoesNotContainRequiredRecipe)
            expect(e.message).to eq(message)
          end
        end

      end

    end
  end

  context "Given no local or git cookbooks, no default source, and an empty run list" do

    let(:run_list) { [] }

    it "has an empty set of demands" do
      expect(demands).to eq([])
    end

    it "uses an empty universe for dependencies" do
      expect(policyfile.artifacts_graph).to eq({})
    end

    it "has an empty solution" do
      expect(policyfile).to receive(:ensure_cache_dir_exists)
      expect(policyfile.graph_solution).to eq({})
    end

    it "has an empty set of solution_dependencies" do
      expected_solution_deps = {
        "Policyfile" => [],
        "dependencies" => {},
      }
      expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
    end
  end

  context "Given a run list and no local or git cookbooks" do

    let(:run_list) { ["remote-cb"] }

    context "with no default source" do

      it "fails to locate the cookbook" do
        expect { policyfile.graph_solution }.to raise_error(Solve::Errors::NoSolutionError)
      end

      context "when the policyfile also has a `cookbook` entry for the run list item" do

        before do
          policyfile.dsl.cookbook "remote-cb"
        end

        it "fails to locate the cookbook" do
          expect { policyfile.graph_solution }.to raise_error(Solve::Errors::NoSolutionError)
        end

      end

    end

    context "And the default source is the community site" do

      include_context "community default source"

      it "has an unconstrained demand on the required cookbooks" do
        expect(demands).to eq([["remote-cb", ">= 0.0.0"]])
      end

      it "uses the community site universe for dependencies" do
        expect(policyfile.artifacts_graph).to eq(external_cookbook_universe)
      end

      it "uses the community cookbook in the solution" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "remote-cb" => "1.1.1" })
      end

      it "includes the cookbook in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [],
          "dependencies" => { "remote-cb (1.1.1)" => [] },
        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end

    context "And the default source is the chef-server" do

      include_context "chef server default source"

      it "has an unconstrained demand on the required cookbooks" do
        expect(demands).to eq([["remote-cb", ">= 0.0.0"]])
      end

      it "uses the chef-server universe for dependencies" do
        expect(policyfile.artifacts_graph).to eq(external_cookbook_universe)
      end

      it "uses the chef-server cookbook in the solution" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "remote-cb" => "1.1.1" })
      end
    end
  end

  context "Given a local cookbook and only that cookbook in the run list" do

    let(:run_list) { ["local-cookbook"] }

    before do
      policyfile.dsl.cookbook("local-cookbook", path: "/foo")
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:version).and_return("2.3.4")
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:dependencies).and_return([])
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:ensure_cached).and_return(true)
    end

    it "demands a solution using the local cookbook" do
      expect(demands).to eq([["local-cookbook", "= 2.3.4"]])
    end

    it "includes the local cookbook in the artifact universe" do
      expected_artifacts_graph = {
        "local-cookbook" => { "2.3.4" => [] },
      }
      expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
    end

    it "includes the cookbook in the solution dependencies" do
      expected_solution_deps = {
        "Policyfile" => [ [ "local-cookbook", ">= 0.0.0" ] ],
        "dependencies" => { "local-cookbook (2.3.4)" => [] },
      }
      expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
    end

  end

  context "Given a local cookbook with a dependency and only the local cookbook in the run list" do

    let(:run_list) { ["local-cookbook"] }

    context "And the default source is the community site" do

      include_context "community default source"

      before do
        policyfile.dsl.cookbook("local-cookbook", path: "foo/")
        allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:ensure_cached)
        allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:version).and_return("2.3.4")
        allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:dependencies).and_return([ [ "local-cookbook-dep-one", "~> 1.0"] ])
      end

      it "demands a solution using the local cookbook" do
        expect(demands).to eq([["local-cookbook", "= 2.3.4"]])
      end

      it "overrides the community site universe with the local cookbook and its dependencies" do
        expected_artifacts_graph = external_cookbook_universe.dup
        expected_artifacts_graph["local-cookbook"] = {
          "2.3.4" => [ [ "local-cookbook-dep-one", "~> 1.0" ] ],
        }
        expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
      end

      it "uses the local cookbook in the solution and gets dependencies remotely" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "local-cookbook" => "2.3.4", "local-cookbook-dep-one" => "1.5.0" })
      end

      it "includes the cookbook and dependencies in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [ [ "local-cookbook", ">= 0.0.0" ] ],
          "dependencies" => {
            "local-cookbook (2.3.4)" => [[ "local-cookbook-dep-one", "~> 1.0"]],
            "local-cookbook-dep-one (1.5.0)" => [],
          },

        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end
    context "And the default source is the chef server" do

      include_context "chef server default source"

      before do
        policyfile.dsl.cookbook("local-cookbook", path: "foo/")
        allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:ensure_cached)
        allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:version).and_return("2.3.4")
        allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:dependencies).and_return([ [ "local-cookbook-dep-one", "~> 1.0"] ])
      end

      it "demands a solution using the local cookbook" do
        expect(demands).to eq([["local-cookbook", "= 2.3.4"]])
      end

      it "overrides the chef server universe with the local cookbook and its dependencies" do
        # all versions of "local-cookbook" from the cookbook site universe
        # should be removed so we won't run into trouble if there's a community
        # cookbook with the same name and version but different deps.
        expected_artifacts_graph = external_cookbook_universe.dup
        expected_artifacts_graph["local-cookbook"] = {
          "2.3.4" => [ [ "local-cookbook-dep-one", "~> 1.0" ] ],
        }
        expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
      end

      it "uses the local cookbook in the solution and gets dependencies remotely" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "local-cookbook" => "2.3.4", "local-cookbook-dep-one" => "1.6.0" })
      end

      it "includes the cookbook and dependencies in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [ [ "local-cookbook", ">= 0.0.0" ] ],
          "dependencies" => {
            "local-cookbook (2.3.4)" => [[ "local-cookbook-dep-one", "~> 1.0"]],
            "local-cookbook-dep-one (1.6.0)" => [],
          },

        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end
  end

  context "Given a git-sourced cookbook with no dependencies and only the git cookbook in the run list" do

    let(:run_list) { ["git-sourced-cookbook"] }

    before do
      policyfile.dsl.cookbook("git-sourced-cookbook", git: "git://git.example.org:user/a-cookbook.git")
      allow(policyfile.cookbook_location_spec_for("git-sourced-cookbook")).to receive(:ensure_cached)
      allow(policyfile.cookbook_location_spec_for("git-sourced-cookbook")).to receive(:version).and_return("8.6.7")
      allow(policyfile.cookbook_location_spec_for("git-sourced-cookbook")).to receive(:dependencies).and_return([ ])
    end

    it "demands a solution using the git sourced cookbook" do
      expect(demands).to eq([["git-sourced-cookbook", "= 8.6.7"]])
    end

    it "includes the git-sourced cookbook in the universe graph" do
      expected_artifacts_graph = {
        "git-sourced-cookbook" => { "8.6.7" => [ ] },
      }
      expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
    end

    it "uses the git sourced cookbook in the solution" do
      expect(policyfile).to receive(:ensure_cache_dir_exists)
      expect(policyfile.graph_solution).to eq({ "git-sourced-cookbook" => "8.6.7" })
    end

    it "includes the cookbook and dependencies in the solution dependencies" do
      expected_solution_deps = {
        "Policyfile" => [ [ "git-sourced-cookbook", ">= 0.0.0" ] ],
        "dependencies" => {
          "git-sourced-cookbook (8.6.7)" => [],
        },

      }
      expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
    end

  end

  context "Given a git-sourced cookbook with a dependency and only the git cookbook in the run list" do

    let(:run_list) { ["git-sourced-cookbook"] }

    before do
      policyfile.dsl.cookbook("git-sourced-cookbook", git: "git://git.example.org:user/a-cookbook.git")
      allow(policyfile.cookbook_location_spec_for("git-sourced-cookbook")).to receive(:ensure_cached)
      allow(policyfile.cookbook_location_spec_for("git-sourced-cookbook")).to receive(:version).and_return("8.6.7")
      allow(policyfile.cookbook_location_spec_for("git-sourced-cookbook")).to receive(:dependencies).and_return([ ["git-sourced-cookbook-dep", "~> 2.2" ] ])
    end

    context "And the default source is the community site" do

      include_context "community default source"

      it "demands a solution using the git sourced cookbook" do
        expect(demands).to eq([["git-sourced-cookbook", "= 8.6.7"]])
      end

      it "overrides the community site universe with the git-sourced cookbook and deps" do
        expected_artifacts_graph = external_cookbook_universe.dup
        expected_artifacts_graph["git-sourced-cookbook"] = {
          "8.6.7" => [ ["git-sourced-cookbook-dep", "~> 2.2" ] ],
        }
        expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
      end

      it "uses the git sourced cookbook with remote dependencies in the solution" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "git-sourced-cookbook" => "8.6.7", "git-sourced-cookbook-dep" => "2.8.0" })
      end

      it "includes the cookbook and dependencies in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [ [ "git-sourced-cookbook", ">= 0.0.0" ] ],
          "dependencies" => {
            "git-sourced-cookbook (8.6.7)" => [ [ "git-sourced-cookbook-dep", "~> 2.2" ] ],
            "git-sourced-cookbook-dep (2.8.0)" => [],
          },

        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end

    context "And the default source is the chef server" do

      include_context "chef server default source"

      it "demands a solution using the git sourced cookbook" do
        expect(demands).to eq([["git-sourced-cookbook", "= 8.6.7"]])
      end

      it "overrides the chef server universe with the git-sourced cookbook and deps" do
        expected_artifacts_graph = external_cookbook_universe.dup
        expected_artifacts_graph["git-sourced-cookbook"] = {
          "8.6.7" => [ ["git-sourced-cookbook-dep", "~> 2.2" ] ],
        }
        expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
      end

      it "uses the git sourced cookbook with remote dependencies in the solution" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "git-sourced-cookbook" => "8.6.7", "git-sourced-cookbook-dep" => "2.9.0" })
      end

      it "includes the cookbook and dependencies in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [ [ "git-sourced-cookbook", ">= 0.0.0" ] ],
          "dependencies" => {
            "git-sourced-cookbook (8.6.7)" => [ [ "git-sourced-cookbook-dep", "~> 2.2" ] ],
            "git-sourced-cookbook-dep (2.9.0)" => [],
          },

        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end
  end

  context "Given a local cookbook with a run list containing the local cookbook and another cookbook" do

    let(:run_list) { %w{local-cookbook remote-cb} }

    before do
      policyfile.dsl.cookbook("local-cookbook", path: "foo/")
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:ensure_cached)
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:version).and_return("2.3.4")
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:dependencies).and_return([])
    end

    context "And the default source is the community site" do

      include_context "community default source"

      it "demands a solution with the local cookbook and any version of the other cookbook" do
        expect(demands).to eq([["local-cookbook", "= 2.3.4"], ["remote-cb", ">= 0.0.0"]])
      end

      it "overrides the community universe with the local cookbook and deps" do
        expected_artifacts_graph = external_cookbook_universe.dup
        expected_artifacts_graph["local-cookbook"] = { "2.3.4" => [ ] }
        expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
      end

      it "uses the locally specified cookbook and remote cookbooks in the solution" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "local-cookbook" => "2.3.4", "remote-cb" => "1.1.1" })
      end

      it "includes the cookbook and dependencies in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [ [ "local-cookbook", ">= 0.0.0" ] ],
          "dependencies" => {
            "local-cookbook (2.3.4)" => [],
            "remote-cb (1.1.1)" => [],
          },

        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end

    context "And the default source is the chef server" do

      include_context "chef server default source"

      it "demands a solution with the local cookbook and any version of the other cookbook" do
        expect(demands).to eq([["local-cookbook", "= 2.3.4"], ["remote-cb", ">= 0.0.0"]])
      end

      it "overrides the chef-server universe with the local cookbook and deps" do
        expected_artifacts_graph = external_cookbook_universe.dup
        expected_artifacts_graph["local-cookbook"] = { "2.3.4" => [ ] }
        expect(policyfile.artifacts_graph).to eq(expected_artifacts_graph)
      end

      it "uses the locally specified cookbook and remote cookbooks in the solution" do
        expect(policyfile).to receive(:ensure_cache_dir_exists)
        expect(policyfile.graph_solution).to eq({ "local-cookbook" => "2.3.4", "remote-cb" => "1.1.1" })
      end

      it "includes the cookbook and dependencies in the solution dependencies" do
        expected_solution_deps = {
          "Policyfile" => [ [ "local-cookbook", ">= 0.0.0" ] ],
          "dependencies" => {
            "local-cookbook (2.3.4)" => [],
            "remote-cb (1.1.1)" => [],
          },

        }
        expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
      end

    end
  end

  context "given a cookbook with a version constraint in the policyfile" do

    include_context "community default source"

    let(:run_list) { ["remote-cb"] }

    before do
      policyfile.dsl.cookbook("remote-cb", "~> 0.1")
    end

    it "demands a solution that matches the version constraint in the policyfile" do
      expect(demands).to eq([["remote-cb", "~> 0.1"]])
    end

    it "emits a solution that satisfies the policyfile constraint" do
      expect(policyfile).to receive(:ensure_cache_dir_exists)
      expect(policyfile.graph_solution).to eq({ "remote-cb" => "0.1.0" })
    end

    it "includes the policyfile constraint in the solution dependencies" do
      expected_solution_deps = {
        "Policyfile" => [ [ "remote-cb", "~> 0.1" ] ],
        "dependencies" => {
          "remote-cb (0.1.0)" => [],
        },

      }
      expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
    end
  end

  context "given a cookbook that isn't in the run list is specified with a version constraint in the policyfile" do

    include_context "community default source"

    let(:run_list) { ["local-cookbook"] }

    before do
      policyfile.dsl.cookbook("remote-cb", "~> 0.1")

      policyfile.dsl.cookbook("local-cookbook", path: "foo/")

      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:ensure_cached)
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:version).and_return("2.3.4")
      allow(policyfile.cookbook_location_spec_for("local-cookbook")).to receive(:dependencies).and_return([])
    end

    it "demands a solution that matches the version constraint in the policyfile" do
      expect(demands).to eq([["local-cookbook", "= 2.3.4"], ["remote-cb", "~> 0.1"]])
    end

    it "emits a solution that satisfies the policyfile constraint" do
      expect(policyfile).to receive(:ensure_cache_dir_exists)
      expect(policyfile.graph_solution).to eq({ "local-cookbook" => "2.3.4", "remote-cb" => "0.1.0" })
    end

    it "includes the policyfile constraint in the solution dependencies" do
      expected_solution_deps = {
        "Policyfile" => [ [ "local-cookbook", ">= 0.0.0" ], [ "remote-cb", "~> 0.1" ] ],
        "dependencies" => {
          "local-cookbook (2.3.4)" => [],
          "remote-cb (0.1.0)" => [],
        },

      }
      expect(policyfile.solution_dependencies.to_lock).to eq(expected_solution_deps)
    end
  end

  context "Given a run_list and named run_lists" do

    before do
      policyfile.dsl.named_run_list(:foo, "local-cookbook", "nginx")
      policyfile.dsl.named_run_list(:bar, "remote-cb", "nginx")
      policyfile.dsl.run_list("private-cookbook", "nginx")
    end

    it "demands a solution that satisfies all of the run lists, with no duplicates" do
      expect(policyfile.graph_demands).to include(["private-cookbook", ">= 0.0.0"])
      expect(policyfile.graph_demands).to include(["nginx", ">= 0.0.0"])
      expect(policyfile.graph_demands).to include(["remote-cb", ">= 0.0.0"])
      expect(policyfile.graph_demands).to include(["local-cookbook", ">= 0.0.0"])

      # ensure there are no duplicates:
      expected_demands = [["private-cookbook", ">= 0.0.0"],
                          ["nginx", ">= 0.0.0"],
                          ["local-cookbook", ">= 0.0.0"],
                          ["remote-cb", ">= 0.0.0"]]
      expect(policyfile.graph_demands).to eq(expected_demands)
    end

  end

  context "when using multiple default sources" do

    include_context "community default source"

    let(:run_list) { %w{repo-cookbook-one remote-cb remote-cb-two} }

    before do
      policyfile.default_source(:chef_repo, "path/to/repo")
      allow(policyfile.default_source.last).to receive(:universe_graph).and_return(repo_cookbook_universe)
    end

    context "when the graphs don't conflict" do

      before do
        # This is on the community site
        policyfile.dsl.cookbook("remote-cb")
      end

      let(:repo_cookbook_universe) do
        {
          "repo-cookbook-one" => {
            "1.0.0" => [ ],
          },

          "repo-cookbook-two" => {
            "9.9.9" => [ ["repo-cookbook-on-community-dep", "= 1.0.0"] ],
          },

          "private-cookbook" => {
            "0.1.0" => [ ],
          },
        }
      end

      it "merges the graphs" do
        merged = policyfile.remote_artifacts_graph
        expected = external_cookbook_universe.merge(repo_cookbook_universe)

        expect(merged).to eq(expected)
      end

      it "solves the graph demands using cookbooks from both sources" do
        expected = { "repo-cookbook-one" => "1.0.0", "remote-cb" => "1.1.1", "remote-cb-two" => "1.1.1" }
        expect(policyfile.graph_solution).to eq(expected)
      end

      it "finds the location of a cookbook declared via explicit `cookbook` with no source options" do
        community_source = policyfile.default_source.first

        expected_source_options = { artifactserver: "https://chef.example/url", version: "1.1.1" }

        expect(community_source).to be_a(ChefCLI::Policyfile::CommunityCookbookSource)
        expect(community_source).to receive(:source_options_for)
          .with("remote-cb", "1.1.1")
          .and_return(expected_source_options)

        location_spec = policyfile.create_spec_for_cookbook("remote-cb", "1.1.1")
        expect(location_spec.source_options).to eq(expected_source_options)
      end

      it "sources cookbooks from the correct source when the cookbook doesn't have a `cookbook` entry" do
        # these don't have `cookbook` entries in the Policyfile.rb, so they are nil
        expect(policyfile.cookbook_location_spec_for("repo-cookbook-one")).to be_nil
        expect(policyfile.cookbook_location_spec_for("remote-cb-two")).to be_nil

        # We have to stub #source_options_for or else we'd need to stub the
        # source options data inside the source object. That's getting a bit
        # too deep into the source object's internals.

        expected_repo_options = { path: "path/to/cookbook", version: "1.0.0" }
        repo_source = policyfile.default_source.last
        expect(repo_source).to be_a(ChefCLI::Policyfile::ChefRepoCookbookSource)
        expect(repo_source).to receive(:source_options_for)
          .with("repo-cookbook-one", "1.0.0")
          .and_return(expected_repo_options)

        repo_cb_location = policyfile.create_spec_for_cookbook("repo-cookbook-one", "1.0.0")
        expect(repo_cb_location.source_options).to eq(expected_repo_options)

        expected_server_options = { artifactserver: "https://chef.example/url", version: "1.1.1" }
        community_source = policyfile.default_source.first
        expect(community_source).to be_a(ChefCLI::Policyfile::CommunityCookbookSource)
        expect(community_source).to receive(:source_options_for)
          .with("remote-cb-two", "1.1.1")
          .and_return(expected_server_options)

        remote_cb_location = policyfile.create_spec_for_cookbook("remote-cb-two", "1.1.1")
        expect(remote_cb_location.source_options).to eq(expected_server_options)
      end

    end

    context "when the graphs conflict" do

      let(:repo_cookbook_universe) do
        {
          "repo-cookbook-one" => {
            "1.0.0" => [ ],
          },

          "repo-cookbook-two" => {
            "9.9.9" => [ ["repo-cookbook-on-community-dep", "= 1.0.0"] ],
          },

          "private-cookbook" => {
            "0.1.0" => [ ],
          },

          # NOTE: cookbooks are considered to conflict when both sources have
          # cookbooks with the same name, regardless of whether any version
          # numbers overlap.
          #
          # The before block does the equivalent to putting this in the
          # Policyfile.rb:
          #
          #   cookbook "remote-cb"
          #
          # This makes the compiler take a slightly different code path than if
          # the cookbook was just in the dep graphs.
          "remote-cb" => {
            "99.99.99" => [ ],
          },

          # This also conflicts, but only via the graphs
          "remote-cb-two" => {
            "1.2.3" => [ ],
          },

          # This has a dependency on a conflicting cookbook
          "dep_on_conflicting_cookbook" => {

            # Only the older cookbook has the conflicting dep, however we don't
            # know how the dependency solver will solve this without doing more
            # inspection of the graph, so the expected behavior is to error if
            # any possible solution could have a conflict.
            "1.0.0" => [ ["remote-cb-two", ">= 0.0.0" ] ],
            "2.0.0" => [ ],

          },

        }
      end

      context "and the conflicting cookbook is in the run list" do

        let(:run_list) { %w{repo-cookbook-one remote-cb remote-cb-two} }

        context "and no explicit source is given for the conflicting cookbook" do

          before do
            # This is on the community site
            policyfile.dsl.cookbook("remote-cb")
          end

          it "raises an error describing the conflict" do
            repo_path = File.expand_path("path/to/repo")

            expected_err = <<~ERROR
              Source supermarket(https://supermarket.chef.io) and chef_repo(#{repo_path}) contain conflicting cookbooks:
              - remote-cb
              - remote-cb-two

              You can set a preferred source to resolve this issue with code like:

              default_source :supermarket, "https://supermarket.chef.io" do |s|
                s.preferred_for "remote-cb", "remote-cb-two"
              end
            ERROR

            expect { policyfile.remote_artifacts_graph }.to raise_error do |error|
              expect(error).to be_a(ChefCLI::CookbookSourceConflict)
              expect(error.message).to eq(expected_err)
            end
          end
        end

        context "and the conflicting cookbook has an explicit source in the Policyfile" do

          before do
            # This is on the community site
            policyfile.dsl.cookbook("remote-cb", path: "path/to/remote-cb")
            policyfile.dsl.cookbook("remote-cb-two", git: "git://git.example:user/remote-cb-two.git")
            policyfile.error!
          end

          it "solves the graph" do
            expect { policyfile.remote_artifacts_graph }.to_not raise_error
          end

          it "assigns the correct source options to the cookbook" do
            remote_cb_source_opts = policyfile.cookbook_location_spec_for("remote-cb").source_options
            expect(remote_cb_source_opts).to eq(path: "path/to/remote-cb")

            remote_cb_two_source_opts = policyfile.cookbook_location_spec_for("remote-cb-two").source_options
            expect(remote_cb_two_source_opts).to eq(git: "git://git.example:user/remote-cb-two.git")
          end
        end

        context "and the conflicting cookbook has a preferred source" do

          let(:community_source) { policyfile.dsl.default_source.first }

          let(:repo_source) { policyfile.dsl.default_source.last }

          let(:full_universe_graph) do
            {
              "remote-cb" => {
                "1.1.1" => {
                  "download_url" => "https://supermarket.chef.io/api/v1/cookbooks/remote-cb/versions/1.1.1/download",
                },
              },
            }
          end

          let(:cookbook_version_paths) do
            {
              "remote-cb-two" => {
                "1.1.1" => "path/to/repo/remote-cb-two",
              },
            }
          end

          before do
            community_source.preferred_for "remote-cb"
            allow(community_source).to receive(:full_community_graph).and_return(full_universe_graph)
            allow(repo_source).to receive(:cookbook_version_paths).and_return(cookbook_version_paths)
            repo_source.preferred_for "remote-cb-two"
          end

          it "solves the graph" do
            expect { policyfile.remote_artifacts_graph }.to_not raise_error
          end

          it "assigns the correct source options to the cookbook" do
            expected_remote_cb_source_opts = {
              artifactserver: "https://supermarket.chef.io/api/v1/cookbooks/remote-cb/versions/1.1.1/download",
              version: "1.1.1",
            }
            actual_remote_cb_source_opts = policyfile.create_spec_for_cookbook("remote-cb", "1.1.1").source_options
            expect(actual_remote_cb_source_opts).to eq(expected_remote_cb_source_opts)

            expected_remote_cb_two_source_opts = {
              path: "path/to/repo/remote-cb-two",
              version: "1.1.1",
            }
            actual_remote_cb_two_source_opts = policyfile.create_spec_for_cookbook("remote-cb-two", "1.1.1").source_options
            expect(actual_remote_cb_two_source_opts).to eq(expected_remote_cb_two_source_opts)
          end

        end

      end

      context "when top-level cookbooks don't conflict, but dependencies could" do

        let(:run_list) { [ "dep_on_conflicting_cookbook" ] }

        it "raises an error describing the conflict" do
          repo_path = File.expand_path("path/to/repo")

          expected_err = <<~ERROR
            Source supermarket(https://supermarket.chef.io) and chef_repo(#{repo_path}) contain conflicting cookbooks:
            - remote-cb-two

            You can set a preferred source to resolve this issue with code like:

            default_source :supermarket, "https://supermarket.chef.io" do |s|
              s.preferred_for "remote-cb-two"
            end
          ERROR

          expect { policyfile.remote_artifacts_graph }.to raise_error do |error|
            expect(error).to be_a(ChefCLI::CookbookSourceConflict)
            expect(error.message).to eq(expected_err)
          end
        end
      end

      context "when the conflicting cookbook could not be in the solution set" do

        let(:run_list) { [ "local_cookbook" ] }

        it "creates the merged graph without error" do
          expect { policyfile.remote_artifacts_graph }.to_not raise_error
          expect { policyfile.graph }.to_not raise_error
        end

        it "has an empty set of artifacts for the conflicting cookbook" do
          expect(policyfile.remote_artifacts_graph["remote-cb"]).to eq({})
          expect(policyfile.remote_artifacts_graph["remote-cb-two"]).to eq({})
        end

      end

    end

  end

end