# frozen_string_literal: true

require 'spec_helper'

RSpec.describe ApplicationExperiment, :experiment do
  subject { described_class.new('namespaced/stub', **context) }

  let(:context) { {} }
  let(:feature_definition) { { name: 'namespaced_stub', type: 'experiment', default_enabled: false } }

  around do |example|
    Feature::Definition.definitions[:namespaced_stub] = Feature::Definition.new('namespaced_stub.yml', feature_definition)
    example.run
    Feature::Definition.definitions.delete(:namespaced_stub)
  end

  before do
    allow(subject).to receive(:enabled?).and_return(true)
  end

  it "doesn't raise an exception without a defined control" do
    # because we have a default behavior defined

    expect { experiment('namespaced/stub') { } }.not_to raise_error
  end

  describe "#enabled?" do
    before do
      allow(subject).to receive(:enabled?).and_call_original

      allow(Feature::Definition).to receive(:get).and_return('_instance_')
      allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
      allow(Feature).to receive(:get).and_return(double(state: :on))
    end

    it "is enabled when all criteria are met" do
      expect(subject).to be_enabled
    end

    it "isn't enabled if the feature definition doesn't exist" do
      expect(Feature::Definition).to receive(:get).with('namespaced_stub').and_return(nil)

      expect(subject).not_to be_enabled
    end

    it "isn't enabled if we're not in dev or dotcom environments" do
      expect(Gitlab).to receive(:dev_env_or_com?).and_return(false)

      expect(subject).not_to be_enabled
    end

    it "isn't enabled if the feature flag state is :off" do
      expect(Feature).to receive(:get).with('namespaced_stub').and_return(double(state: :off))

      expect(subject).not_to be_enabled
    end
  end

  describe "#publish" do
    let(:should_track) { true }

    before do
      allow(subject).to receive(:should_track?).and_return(should_track)
    end

    it "tracks the assignment", :snowplow do
      subject.publish

      expect_snowplow_event(
        category: 'namespaced/stub',
        action: 'assignment',
        context: [{ schema: anything, data: anything }]
      )
    end

    it "publishes to the client" do
      expect(subject).to receive(:publish_to_client)

      subject.publish
    end

    it "publishes to the database if we've opted for that" do
      subject.record!

      expect(subject).to receive(:publish_to_database)

      subject.publish
    end

    context 'when we should not track' do
      let(:should_track) { false }

      it 'does not track an event to Snowplow', :snowplow do
        subject.publish

        expect_no_snowplow_event
      end
    end

    describe "#publish_to_client" do
      it "adds the data into Gon" do
        signature = { key: '86208ac54ca798e11f127e8b23ec396a', variant: 'control' }
        expect(Gon).to receive(:push).with({ experiment: { 'namespaced/stub' => hash_including(signature) } }, true)

        subject.publish_to_client
      end

      it "handles when Gon raises exceptions (like when it can't be pushed into)" do
        expect(Gon).to receive(:push).and_raise(NoMethodError)

        expect { subject.publish_to_client }.not_to raise_error
      end

      context 'when we should not track' do
        let(:should_track) { false }

        it 'returns early' do
          expect(Gon).not_to receive(:push)

          subject.publish_to_client
        end
      end
    end

    describe '#publish_to_database' do
      using RSpec::Parameterized::TableSyntax

      shared_examples 'does not record to the database' do
        it 'does not create an experiment record' do
          expect { subject.publish_to_database }.not_to change(Experiment, :count)
        end

        it 'does not create an experiment subject record' do
          expect { subject.publish_to_database }.not_to change(ExperimentSubject, :count)
        end
      end

      context 'when there is a usable subject' do
        let(:context) { { context_key => context_value } }

        where(:context_key, :context_value, :object_type) do
          :namespace | build(:namespace) | :namespace
          :group     | build(:namespace) | :namespace
          :project   | build(:project)   | :project
          :user      | build(:user)      | :user
          :actor     | build(:user)      | :user
        end

        with_them do
          it 'creates an experiment and experiment subject record' do
            expect { subject.publish_to_database }.to change(Experiment, :count).by(1)

            expect(Experiment.last.name).to eq('namespaced/stub')
            expect(ExperimentSubject.last.send(object_type)).to eq(context[context_key])
          end
        end
      end

      context 'when there is not a usable subject' do
        let(:context) { { context_key => context_value } }

        where(:context_key, :context_value) do
          :namespace | nil
          :foo       | :bar
        end

        with_them do
          include_examples 'does not record to the database'
        end
      end

      context 'but we should not track' do
        let(:should_track) { false }

        include_examples 'does not record to the database'
      end
    end
  end

  describe "#track", :snowplow do
    let(:fake_context) do
      SnowplowTracker::SelfDescribingJson.new('iglu:com.gitlab/fake/jsonschema/0-0-0', { data: '_data_' })
    end

    it "doesn't track if we shouldn't track" do
      allow(subject).to receive(:should_track?).and_return(false)

      subject.track(:action)

      expect_no_snowplow_event
    end

    it "tracks the event with the expected arguments and merged contexts" do
      subject.track(:action, property: '_property_', context: [fake_context])

      expect_snowplow_event(
        category: 'namespaced/stub',
        action: 'action',
        property: '_property_',
        context: [
          {
            schema: 'iglu:com.gitlab/fake/jsonschema/0-0-0',
            data: { data: '_data_' }
          },
          {
            schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
            data: { experiment: 'namespaced/stub', key: '86208ac54ca798e11f127e8b23ec396a', variant: 'control' }
          }
        ]
      )
    end

    it "tracks the event correctly even when using the base class" do
      subject = Gitlab::Experiment.new(:unnamed)
      subject.track(:action, context: [fake_context])

      expect_snowplow_event(
        category: 'unnamed',
        action: 'action',
        context: [
          {
            schema: 'iglu:com.gitlab/fake/jsonschema/0-0-0',
            data: { data: '_data_' }
          },
          {
            schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
            data: { experiment: 'unnamed', key: subject.context.key, variant: 'control' }
          }
        ]
      )
    end
  end

  describe "#key_for" do
    it "generates MD5 hashes" do
      expect(subject.key_for(foo: :bar)).to eq('6f9ac12afdb9b58c2f19a136d09f9153')
    end
  end

  describe "#process_redirect_url" do
    using RSpec::Parameterized::TableSyntax

    where(:url, :processed_url) do
      'https://about.gitlab.com/'                 | 'https://about.gitlab.com/'
      'https://gitlab.com/'                       | 'https://gitlab.com/'
      'http://docs.gitlab.com'                    | 'http://docs.gitlab.com'
      'https://docs.gitlab.com/some/path?foo=bar' | 'https://docs.gitlab.com/some/path?foo=bar'
      'http://badgitlab.com'                      | nil
      'https://gitlab.com.nefarious.net'          | nil
      'https://unknown.gitlab.com'                | nil
      "https://badplace.com\nhttps://gitlab.com"  | nil
      'https://gitlabbcom'                        | nil
      'https://gitlabbcom/'                       | nil
    end

    with_them do
      it "returns the url or nil if invalid" do
        allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
        expect(subject.process_redirect_url(url)).to eq(processed_url)
      end

      it "considers all urls invalid when not on dev or com" do
        allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
        expect(subject.process_redirect_url(url)).to be_nil
      end
    end

    it "generates the correct urls based on where the engine was mounted" do
      url = Rails.application.routes.url_helpers.experiment_redirect_url(subject, url: 'https://docs.gitlab.com')
      expect(url).to include("/-/experiment/namespaced%2Fstub:#{subject.context.key}?https://docs.gitlab.com")
    end
  end

  context "when resolving variants" do
    it "uses the default value as specified in the yaml" do
      expect(Feature).to receive(:enabled?).with('namespaced_stub', subject, type: :experiment, default_enabled: :yaml)

      expect(subject.variant.name).to eq('control')
    end

    context "when rolled out to 100%" do
      before do
        stub_feature_flags(namespaced_stub: true)
      end

      it "returns the first variant name" do
        subject.try(:variant1) {}
        subject.try(:variant2) {}

        expect(subject.variant.name).to eq('variant1')
      end
    end
  end

  context "when caching" do
    let(:cache) { Gitlab::Experiment::Configuration.cache }

    before do
      allow(Gitlab::Experiment::Configuration).to receive(:cache).and_call_original

      cache.clear(key: subject.name)

      subject.use { } # setup the control
      subject.try { } # setup the candidate
    end

    it "caches the variant determined by the variant resolver" do
      expect(subject.variant.name).to eq('candidate') # we should be in the experiment

      subject.run

      expect(subject.cache.read).to eq('candidate')
    end

    it "doesn't cache a variant if we don't explicitly provide one" do
      # by not caching "empty" variants, we effectively create a mostly
      # optimal combination of caching and rollout flexibility. If we cached
      # every control variant assigned, we'd inflate the cache size and
      # wouldn't be able to roll out to subjects that we'd already assigned to
      # the control.
      stub_feature_flags(namespaced_stub: false) # simulate being not rolled out

      expect(subject.variant.name).to eq('control') # if we ask, it should be control

      subject.run

      expect(subject.cache.read).to be_nil
    end

    it "caches a control variant if we assign it specifically" do
      # by specifically assigning the control variant here, we're guaranteeing
      # that this context will always get the control variant unless we delete
      # the field from the cache (or clear the entire experiment cache) -- or
      # write code that would specify a different variant.
      subject.run(:control)

      expect(subject.cache.read).to eq('control')
    end

    context "arbitrary attributes" do
      before do
        subject.cache.store.clear(key: subject.name + '_attrs')
      end

      it "sets and gets attributes about an experiment" do
        subject.cache.attr_set(:foo, :bar)

        expect(subject.cache.attr_get(:foo)).to eq('bar')
      end

      it "increments a value for an experiment" do
        expect(subject.cache.attr_get(:foo)).to be_nil

        expect(subject.cache.attr_inc(:foo)).to eq(1)
        expect(subject.cache.attr_inc(:foo)).to eq(2)
      end
    end
  end
end