# encoding: utf-8
#
# This file is part of the devdnsd gem. Copyright (C) 2013 and above Shogun <shogun_panda@me.com>.
# Licensed under the MIT license, which can be found at http://www.opensource.org/licenses/mit-license.php.
#

require "spec_helper"

describe DevDNSd::Application do
  before(:each) do
    Bovem::Logger.stub(:default_file).and_return("/dev/null")
    DevDNSd::Application.stub(:instance).and_return(application)
  end

  def create_application(overrides = {})
    mamertes_app = Mamertes::App(run: false) do
      option :configuration, [], {default: overrides["configuration"] || "/dev/null"}
      option :tld, [], {default: overrides["tld"] || "dev"}
      option :port, [], {type: Integer, default: overrides["port"] || 7771}
      option :pid_file, [:P, "pid-file"], {type: String, default: "/var/run/devdnsd.pid"}
      option :log_file, [:l, "log-file"], {default: overrides["log_file"] || "/dev/null"}
      option :log_level, [:L, "log-level"], {type: Integer, default: overrides["log_level"] || 1}
    end

    DevDNSd::Application.new(mamertes_app, :en)
  end

  let(:log_file) { "/tmp/devdnsd-test-log-#{Time.now.strftime("%Y%m%d-%H%M%S")}" }
  let(:application){ create_application({"log_file" => log_file}) }
  let(:executable) { ::Pathname.new(::File.dirname((__FILE__))) + "../../bin/devdnsd" }
  let(:sample_config) { ::Pathname.new(::File.dirname((__FILE__))) + "../../config/devdnsd_config.sample" }
  let(:resolver_path) { "/tmp/devdnsd-test-resolver-#{Time.now.strftime("%Y%m%d-%H%M%S")}" }
  let(:launch_agent_path) { "/tmp/devdnsd-test-agent-#{Time.now.strftime("%Y%m%d-%H%M%S")}" }

  describe "#initialize" do
    it("should setup the logger") do
      expect(application.logger).not_to be_nil
    end

    it("should setup the configuration") do
      expect(application.config).not_to be_nil
    end

    it("should abort with an invalid configuration") do
      path = "/tmp/devdnsd-test-#{Time.now.strftime("%Y%m%d-%H:%M:%S")}"
      file = ::File.new(path, "w")
      file.write("config.port = ")
      file.close

      expect { create_application({"configuration" => file.path, "log_file" => log_file}) }.to raise_error(::SystemExit)
      ::File.unlink(path)
    end
  end

  describe ".run" do
    it "should run the server" do
      application.should_receive(:perform_server)
      DevDNSd::Application.run
    end
  end

  describe ".quit" do
    it "should quit the application" do
      ::EventMachine.should_receive(:stop)
      DevDNSd::Application.quit
    end
  end

  describe ".check_ruby_implementation" do
    it "won't run on Rubinius" do
      stub_const("Rubinius", true)
      Kernel.should_receive(:exit).with(0)
      Kernel.should_receive(:puts)
      DevDNSd::Application.check_ruby_implementation
    end

    it "won't run on JRuby" do
      stub_const("JRuby", true)
      Kernel.should_receive(:exit).with(0)
      Kernel.should_receive(:puts)
      DevDNSd::Application.check_ruby_implementation
    end
  end

  describe ".instance" do
    before(:each) do
      DevDNSd::Application.unstub(:instance)
    end

    let(:mamertes) {
      mamertes_app = Mamertes::App(run: false) do
        option :configuration, [], {default: "/dev/null"}
        option :tld, [], {default: "dev"}
        option :port, [], {type: Integer, default: 7771}
        option :pid_file, [:P], {type: String, default: "/var/run/devdnsd.pid"}
        option :log_file, [], {default: "/dev/null"}
        option :log_level, [:L], {type: Integer, default: 1}
      end
    }

    it "should create a new instance" do
      expect(DevDNSd::Application.instance(mamertes)).to be_a(DevDNSd::Application)
    end

    it "should always return the same instance" do
      other = DevDNSd::Application.instance(mamertes)
      DevDNSd::Application.should_not_receive(:new)
      expect(DevDNSd::Application.instance(mamertes)).to eq(other)
      expect(DevDNSd::Application.instance).to eq(other)
    end

    it "should recreate an instance" do
      other = DevDNSd::Application.instance(mamertes)
      expect(DevDNSd::Application.instance(mamertes, :en, true)).not_to eq(other)
    end
  end

  describe ".pid_fn" do
    let(:application){ create_application({"log_file" => log_file, "configuration" => sample_config}) }

    it "returns the default file" do
      expect(DevDNSd::Application.pid_fn).to eq("/var/run/devdnsd.pid")
    end

    it "return the set file" do
      DevDNSd::Application.instance.config.pid_file = "/this/is/a/daemon.pid"
      expect(DevDNSd::Application.pid_fn).to eq("/this/is/a/daemon.pid")
    end
  end

  describe ".pid_directory" do
    let(:application){ create_application({"log_file" => log_file, "configuration" => sample_config}) }

    it "returns the default path" do
      expect(DevDNSd::Application.pid_directory).to eq("/var/run")
    end

    it "return the set path basing on the PID file" do
      DevDNSd::Application.instance.config.pid_file = "/this/is/a/daemon.pid"
      expect(DevDNSd::Application.pid_directory).to eq("/this/is/a")
    end
  end

  describe ".daemon_name" do
    let(:application){ create_application({"log_file" => log_file, "configuration" => sample_config}) }

    it "returns the default name" do
      expect(DevDNSd::Application.daemon_name).to eq("devdnsd")
    end

    it "return the set name basing on the PID file" do
      DevDNSd::Application.instance.config.pid_file = "/this/is/a/daemon.pid"
      expect(DevDNSd::Application.daemon_name).to eq("daemon")
    end
  end

  describe "#perform_server" do
    let(:application){ create_application({"log_file" => log_file, "configuration" => sample_config}) }

    def test_resolve(host = "match_1.dev", type = "ANY", nameserver = "127.0.0.1", port = 7771, logger = nil)
      application.stub(:on_start) do Thread.main[:resolver].run if Thread.main[:resolver].try(:alive?) end

      Thread.current[:resolver] = Thread.start {
        Thread.stop
        Thread.main[:result] = devdnsd_resolv(host, type, nameserver, port, logger)
      }

      Thread.current[:server] = Thread.start {
        sleep(0.1)
        if block_given? then
          yield
        else
          application.perform_server
        end
      }

      Thread.current[:resolver].join
      Thread.kill(Thread.current[:server])
      Thread.main[:running] = false
      Thread.main[:result]
    end

    it "should run the server" do
      RubyDNS.should_receive(:run_server)
      application.perform_server
    end

    it "should setup callbacks" do
      RubyDNS::Server.any_instance.should_receive(:on).with(:start)
      RubyDNS::Server.any_instance.should_receive(:on).with(:stop)

      Thread.new {
        sleep(0.1)
        application.class.quit
      }

      application.perform_server
    end

    it "should iterate the rules" do
      test_resolve do
        application.config.rules.should_receive(:each).at_least(1)
        application.perform_server
      end
    end

    it "should call process_rule" do
      test_resolve do
        application.should_receive(:process_rule).at_least(1)
        application.perform_server
      end
    end

    it "should complain about wrong rules" do
      test_resolve do
        application.stub(:process_rule).and_raise(::Exception)
        expect { application.perform_server }.to raise_exception
      end
    end

    describe "should correctly resolve hostnames" do
      it "basing on a exact pattern" do
        expect(test_resolve("match_1.dev")).to eq(["10.0.1.1", :A])
        expect(test_resolve("match_2.dev")).to eq(["10.0.2.1", :MX])
        expect(test_resolve("match_3.dev")).to eq(["10.0.3.1", :A])
        expect(test_resolve("match_4.dev")).to eq(["10.0.4.1", :CNAME])
      end

      it "basing on a regexp pattern" do
        expect(test_resolve("match_5_11.dev")).to eq(["10.0.5.11", :A])
        expect(test_resolve("match_5_22.dev")).to eq(["10.0.5.22", :A])
        expect(test_resolve("match_6_33.dev")).to eq(["10.0.6.33", :PTR])
        expect(test_resolve("match_6_44.dev")).to eq(["10.0.6.44", :PTR])
        expect(test_resolve("match_7_55.dev")).to eq(["10.0.7.55", :A])
        expect(test_resolve("match_7_66.dev")).to eq(["10.0.7.66", :A])
        expect(test_resolve("match_8_77.dev")).to eq(["10.0.8.77", :PTR])
        expect(test_resolve("match_8_88.dev")).to eq(["10.0.8.88", :PTR])
      end

      it "and return multiple or only relevant answsers" do
        expect(test_resolve("match_10.dev")).to eq([["10.0.10.1", :A], ["10.0.10.2", :MX]])
        expect(test_resolve("match_10.dev", "MX")).to eq(["10.0.10.2", :MX])
      end

      it "and reject invalid matches (with or without rules)" do
        expect(test_resolve("match_9.dev")).to be_nil
        expect(test_resolve("invalid.dev")).to be_nil
      end
    end
  end

  describe "#process_rule" do
    class FakeTransaction
      attr_reader :resource_class

      def initialize(cls = Resolv::DNS::Resource::IN::ANY)
        @resource_class = cls
      end

      def respond!(*args)
        true
      end
    end

    let(:application){ create_application({"log_file" => log_file, "configuration" => sample_config}) }
    let(:transaction){ FakeTransaction.new }

    it "should match a valid string request" do
      rule = application.config.rules[0]
      expect(application.process_rule(rule, rule.resource_class, nil, transaction)).to be_true
    end

    it "should match a valid string request with specific type" do
      rule = application.config.rules[1]
      expect(application.process_rule(rule, rule.resource_class, nil, transaction)).to be_true
    end

    it "should match a valid string request with a block" do
      rule = application.config.rules[2]
      expect(application.process_rule(rule, rule.resource_class, nil, transaction)).to be_true
    end

    it "should match a valid string request with a block" do
      rule = application.config.rules[3]
      expect(application.process_rule(rule, rule.resource_class, nil, transaction)).to be_true
    end

    it "should match a valid regexp request" do
      rule = application.config.rules[4]
      mo = rule.match_host("match_5_12.dev")
      expect(application.process_rule(rule, rule.resource_class, mo, transaction)).to be_true
    end

    it "should match a valid regexp request with specific type" do
      rule = application.config.rules[5]
      mo = rule.match_host("match_6_34.dev")
      expect(application.process_rule(rule, rule.resource_class, mo, transaction)).to be_true
    end

    it "should match a valid regexp request with a block" do
      rule = application.config.rules[6]
      mo = rule.match_host("match_7_56.dev")
      expect(application.process_rule(rule, rule.resource_class, mo, transaction)).to be_true
    end

    it "should match a valid regexp request with a block and specific type" do
      rule = application.config.rules[7]
      mo = rule.match_host("match_8_78.dev")
      expect(application.process_rule(rule, rule.resource_class, mo, transaction)).to be_true
    end

    it "should return false for a false block" do
      rule = application.config.rules[8]
      expect(application.process_rule(rule, rule.resource_class, nil, transaction)).to be_false
    end

    it "should return nil for a nil reply" do
      rule = application.config.rules[0]
      rule.reply = nil
      expect(application.process_rule(rule, rule.resource_class, nil, transaction)).to be_nil
    end
  end

  describe "#dns_update" do
    it "should update the DNS cache" do
      application.stub(:execute_command).and_return("EXECUTED")
      expect(application.dns_update).to eq("EXECUTED")
    end
  end

  describe "#resolver_path" do
    it "should return the resolver file basing on the configuration" do
      expect(application.resolver_path).to eq("/etc/resolver/#{application.config.tld}")
    end

    it "should return the resolver file basing on the argument" do
      expect(application.resolver_path("foo")).to eq("/etc/resolver/foo")
    end
  end

  describe "#launch_agent_path" do
    it "should return the agent file with a default name" do
      expect(application.launch_agent_path).to eq(ENV["HOME"] + "/Library/LaunchAgents/it.cowtech.devdnsd.plist")
    end

    it "should return the agent file with a specified name" do
      expect(application.launch_agent_path("foo")).to eq(ENV["HOME"] + "/Library/LaunchAgents/foo.plist")
    end
  end

  describe "#action_start" do
    it "should call perform_server in foreground" do
      application = create_application({"log_file" => log_file})
      application.config.foreground = true
      application.should_receive(:perform_server)
      application.action_start
    end

    it "should start the daemon" do
      application = create_application({"log_file" => log_file})
      ::RExec::Daemon::Controller.should_receive(:start)
      application.action_start
    end

    it "should check for availability of fork" do
      application.config.foreground = false

      Process.stub(:respond_to?).and_return(false)
      application.should_receive(:perform_server)
      application.logger.should_receive(:warn)

      application.action_start
      expect(application.config.foreground).to be_true
    end
  end

  describe "#action_stop" do
    it "should stop the daemon" do
      ::RExec::Daemon::Controller.should_receive(:stop)
      application.action_stop
    end
  end

  describe "#action_install" do
    if ::RbConfig::CONFIG['host_os'] =~ /^darwin/ then
      it "should create the resolver" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.action_install
        expect(::File.exists?(resolver_path)).to be_true

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should create the agent" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.stub(:resolver_path).and_return(resolver_path)
        application.action_install
        expect(::File.exists?(application.launch_agent_path)).to be_true

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should update the DNS cache" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.should_receive(:dns_update)
        application.action_install

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should not create an invalid resolver" do
        application.stub(:resolver_path).and_return("/invalid/resolver")
        application.stub(:launch_agent_path).and_return("/invalid/agent")
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.logger.should_receive(:error).with("Cannot create the resolver file.")
        application.action_install

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should not create an invalid agent" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return("/invalid/agent")
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.logger.should_receive(:error).with("Cannot create the launch agent.")
        application.action_install

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should not load an invalid agent" do
        application.stub(:execute_command) do |command|
          command =~ /^launchctl/ ? raise(StandardError) : system(command)
        end

        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.logger.should_receive(:error).with("Cannot load the launch agent.")
        application.action_install

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end
    end

    it "should raise an exception if not running on OSX" do
      application.stub(:is_osx?).and_return(false)
      application.logger.should_receive(:fatal).with("Install DevDNSd as a local resolver is only available on MacOSX.")
      expect(application.action_install).to be_false
    end
  end

  describe "#action_uninstall" do
    if ::RbConfig::CONFIG['host_os'] =~ /^darwin/ then
      it "should remove the resolver" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.action_install
        application.action_uninstall
        expect(::File.exists?(resolver_path)).to be_false

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should remove the agent" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        Bovem::Logger.stub(:default_file).and_return($stdout)
        application.action_install
        application.action_uninstall
        expect(::File.exists?(application.launch_agent_path)).to be_false

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should not delete an invalid resolver" do
        application.stub(:resolver_path).and_return("/invalid/resolver")
        application.stub(:launch_agent_path).and_return("/invalid/agent")

        application.action_install
        application.logger.should_receive(:warn).at_least(1)
        application.action_uninstall

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should not delete an invalid agent" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return("/invalid/agent")

        application.action_install
        application.logger.should_receive(:warn).at_least(1)
        application.action_uninstall

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should not unload invalid agent" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return("/invalid/agent")

        application.action_install
        application.stub(:execute_command).and_raise(StandardError)
        application.stub(:dns_update)
        application.logger.should_receive(:warn).at_least(1)
        application.action_uninstall

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end

      it "should update the DNS cache" do
        application.stub(:resolver_path).and_return(resolver_path)
        application.stub(:launch_agent_path).and_return(launch_agent_path)
        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)

        application.action_install
        application.should_receive(:dns_update)
        application.action_uninstall

        ::File.unlink(application.resolver_path) if ::File.exists?(application.resolver_path)
        ::File.unlink(application.launch_agent_path) if ::File.exists?(application.launch_agent_path)
      end
    end

    it "should raise an exception if not running on OSX" do
      application.stub(:is_osx?).and_return(false)
      application.logger.should_receive(:fatal).with("Install DevDNSd as a local resolver is only available on MacOSX.")
      expect(application.action_uninstall).to be_false
    end
  end
end