diff --git a/.credo.exs b/.credo.exs index 80d748f8..be74a8ce 100644 --- a/.credo.exs +++ b/.credo.exs @@ -119,7 +119,7 @@ {Credo.Check.Refactor.IoPuts, []}, {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MatchInCondition, []}, - {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, {Credo.Check.Refactor.PassAsyncInTestCases, []}, {Credo.Check.Refactor.FilterFilter, []}, {Credo.Check.Refactor.RejectReject, []}, diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 00000000..20b8622e --- /dev/null +++ b/config/config.exs @@ -0,0 +1,19 @@ +import Config + +if config_env() == :dev do + config :mix_test_interactive, clear: true +end + +if config_env() == :test do + config :logger, level: :warning + + if Version.compare(System.version(), "1.15.0") == :lt do + config :logger, :console, + colors: [enabled: false], + format: "$level $message\n" + else + config :logger, :default_formatter, + colors: [enabled: false], + format: "$level $message\n" + end +end diff --git a/lib/config_cat/cache_policy/auto.ex b/lib/config_cat/cache_policy/auto.ex index 128b8fa2..0f36c6e4 100644 --- a/lib/config_cat/cache_policy/auto.ex +++ b/lib/config_cat/cache_policy/auto.ex @@ -140,7 +140,7 @@ defmodule ConfigCat.CachePolicy.Auto do @impl GenServer def handle_call(:get, _from, %State{} = state) do - {:reply, Helpers.cached_settings(state), state} + {:reply, Helpers.cached_config(state), state} end @impl GenServer @@ -204,10 +204,10 @@ defmodule ConfigCat.CachePolicy.Auto do defp be_initialized(%State{} = state) when initialized?(state), do: state defp be_initialized(%State{} = state) do - settings = Helpers.cached_settings(state) + config = Helpers.cached_config(state) for caller <- state.policy_state.callers do - GenServer.reply(caller, settings) + GenServer.reply(caller, config) end Helpers.on_client_ready(state) diff --git a/lib/config_cat/cache_policy/behaviour.ex b/lib/config_cat/cache_policy/behaviour.ex index b1633e9c..a8590b56 100644 --- a/lib/config_cat/cache_policy/behaviour.ex +++ b/lib/config_cat/cache_policy/behaviour.ex @@ -7,7 +7,7 @@ defmodule ConfigCat.CachePolicy.Behaviour do alias ConfigCat.FetchTime @callback get(ConfigCat.instance_id()) :: - {:ok, Config.settings(), FetchTime.t()} | {:error, :not_found} + {:ok, Config.t(), FetchTime.t()} | {:error, :not_found} @callback offline?(ConfigCat.instance_id()) :: boolean() @callback set_offline(ConfigCat.instance_id()) :: :ok @callback set_online(ConfigCat.instance_id()) :: :ok diff --git a/lib/config_cat/cache_policy/helpers.ex b/lib/config_cat/cache_policy/helpers.ex index 97679fa8..7908f557 100644 --- a/lib/config_cat/cache_policy/helpers.ex +++ b/lib/config_cat/cache_policy/helpers.ex @@ -4,7 +4,6 @@ defmodule ConfigCat.CachePolicy.Helpers do alias ConfigCat.Cache alias ConfigCat.CachePolicy alias ConfigCat.Config - alias ConfigCat.ConfigCache alias ConfigCat.ConfigEntry alias ConfigCat.ConfigFetcher.FetchError alias ConfigCat.FetchTime @@ -66,25 +65,11 @@ defmodule ConfigCat.CachePolicy.Helpers do Hooks.invoke_on_client_ready(state.instance_id) end - @spec cached_settings(State.t()) :: - {:ok, Config.settings(), FetchTime.t()} | {:error, :not_found} - def cached_settings(%State{} = state) do - with {:ok, %ConfigEntry{} = entry} <- cached_entry(state), - {:ok, settings} <- Config.fetch_settings(entry.config) do - {:ok, settings, entry.fetch_time_ms} - else - :error -> - {:error, :not_found} - - error -> - error - end - end - - @spec cached_config(State.t()) :: ConfigCache.result() + @spec cached_config(State.t()) :: + {:ok, Config.t(), FetchTime.t()} | {:error, :not_found} def cached_config(%State{} = state) do with {:ok, %ConfigEntry{} = entry} <- cached_entry(state) do - {:ok, entry.config} + {:ok, entry.config, entry.fetch_time_ms} end end diff --git a/lib/config_cat/cache_policy/lazy.ex b/lib/config_cat/cache_policy/lazy.ex index a0476a41..4cbb1181 100644 --- a/lib/config_cat/cache_policy/lazy.ex +++ b/lib/config_cat/cache_policy/lazy.ex @@ -41,7 +41,7 @@ defmodule ConfigCat.CachePolicy.Lazy do @impl GenServer def handle_call(:get, _from, %State{} = state) do with {:ok, new_state} <- maybe_refresh(state) do - {:reply, Helpers.cached_settings(new_state), new_state} + {:reply, Helpers.cached_config(new_state), new_state} end end diff --git a/lib/config_cat/cache_policy/manual.ex b/lib/config_cat/cache_policy/manual.ex index 84a93eb7..0bec1af6 100644 --- a/lib/config_cat/cache_policy/manual.ex +++ b/lib/config_cat/cache_policy/manual.ex @@ -33,7 +33,7 @@ defmodule ConfigCat.CachePolicy.Manual do @impl GenServer def handle_call(:get, _from, %State{} = state) do - {:reply, Helpers.cached_settings(state), state} + {:reply, Helpers.cached_config(state), state} end @impl GenServer diff --git a/lib/config_cat/client.ex b/lib/config_cat/client.ex index 2b938920..feaf75f6 100644 --- a/lib/config_cat/client.ex +++ b/lib/config_cat/client.ex @@ -4,15 +4,18 @@ defmodule ConfigCat.Client do use GenServer alias ConfigCat.CachePolicy + alias ConfigCat.Config + alias ConfigCat.Config.Setting alias ConfigCat.EvaluationDetails + alias ConfigCat.EvaluationLogger alias ConfigCat.FetchTime alias ConfigCat.Hooks alias ConfigCat.OverrideDataSource alias ConfigCat.Rollout alias ConfigCat.User + require ConfigCat.Config.SettingType, as: SettingType require ConfigCat.ConfigCatLogger, as: ConfigCatLogger - require ConfigCat.Constants, as: Constants defmodule State do @moduledoc false @@ -96,9 +99,12 @@ defmodule ConfigCat.Client do @impl GenServer def handle_call({:get_key_and_value, variation_id}, _from, %State{} = state) do - case cached_settings(state) do - {:ok, settings, _fetch_time_ms} -> - result = Enum.find_value(settings, nil, &entry_matching(&1, variation_id)) + case cached_config(state) do + {:ok, config, _fetch_time_ms} -> + result = + config + |> Config.settings() + |> Enum.find_value(nil, &entry_matching(&1, variation_id)) if is_nil(result) do ConfigCatLogger.error( @@ -183,9 +189,9 @@ defmodule ConfigCat.Client do end defp do_get_all_keys(%State{} = state) do - case cached_settings(state) do - {:ok, settings, _fetch_time_ms} -> - Map.keys(settings) + case cached_config(state) do + {:ok, config, _fetch_time_ms} -> + config |> Config.settings() |> Map.keys() _ -> ConfigCatLogger.error("Config JSON is not present. Returning empty result.", @@ -197,29 +203,26 @@ defmodule ConfigCat.Client do end defp entry_matching({key, setting}, variation_id) do - value_matching(key, setting, variation_id) || - value_matching(key, Map.get(setting, Constants.rollout_rules()), variation_id) || - value_matching(key, Map.get(setting, Constants.percentage_rules()), variation_id) - end - - defp value_matching(key, value, variation_id) when is_list(value) do - Enum.find_value(value, nil, &value_matching(key, &1, variation_id)) - end - - defp value_matching(key, value, variation_id) do - if Map.get(value, Constants.variation_id(), nil) == variation_id do - {key, Map.get(value, Constants.value())} + case Setting.variation_value(setting, variation_id) do + nil -> nil + value -> {key, value} end end defp evaluate(key, user, default_value, default_variation_id, %State{} = state) do user = if user != nil, do: user, else: state.default_user - details = - case cached_settings(state) do - {:ok, settings, fetch_time_ms} -> + %EvaluationDetails{} = + details = + with {:ok, config, fetch_time_ms} <- cached_config(state), + {:ok, _settings} <- Config.fetch_settings(config), + {:ok, logger} <- EvaluationLogger.start() do + try do %EvaluationDetails{} = - details = Rollout.evaluate(key, user, default_value, default_variation_id, settings) + details = + Rollout.evaluate(key, user, default_value, default_variation_id, config, logger) + + check_type_mismatch(details.value, default_value) fetch_time = case FetchTime.to_datetime(fetch_time_ms) do @@ -228,7 +231,14 @@ defmodule ConfigCat.Client do end %{details | fetch_time: fetch_time} + after + logger + |> EvaluationLogger.result() + |> ConfigCatLogger.debug(event_id: 5000) + EvaluationLogger.stop(logger) + end + else _ -> message = "Config JSON is not present when evaluating setting '#{key}'. Returning the `default_value` parameter that you specified in your application: '#{default_value}'." @@ -249,23 +259,48 @@ defmodule ConfigCat.Client do details end - defp cached_settings(%State{} = state) do + defp cached_config(%State{} = state) do %{cache_policy: policy, flag_overrides: flag_overrides, instance_id: instance_id} = state - local_settings = OverrideDataSource.overrides(flag_overrides) + local_config = OverrideDataSource.overrides(flag_overrides) case OverrideDataSource.behaviour(flag_overrides) do :local_only -> - {:ok, local_settings, 0} + {:ok, local_config, 0} :local_over_remote -> - with {:ok, remote_settings, fetch_time_ms} <- policy.get(instance_id) do - {:ok, Map.merge(remote_settings, local_settings), fetch_time_ms} + with {:ok, remote_config, fetch_time_ms} <- policy.get(instance_id) do + {:ok, Config.merge(remote_config, local_config), fetch_time_ms} end :remote_over_local -> - with {:ok, remote_settings, fetch_time_ms} <- policy.get(instance_id) do - {:ok, Map.merge(local_settings, remote_settings), fetch_time_ms} + with {:ok, remote_config, fetch_time_ms} <- policy.get(instance_id) do + merged = Config.merge(local_config, remote_config) + {:ok, merged, fetch_time_ms} end end end + + defp check_type_mismatch(_value, nil), do: :ok + + defp check_type_mismatch(value, default_value) do + value_type = SettingType.from_value(value) + default_type = SettingType.from_value(default_value) + number_types = [SettingType.double(), SettingType.int()] + + cond do + value_type == default_type -> + :ok + + value_type in number_types and default_type in number_types -> + :ok + + true -> + ConfigCatLogger.warning( + "The type of a setting does not match the type of the specified default value (#{default_value}). " <> + "Setting's type was #{value_type} but the default value's type was #{default_type}. " <> + "Please make sure that using a default value not matching the setting's type was intended.", + event_id: 4002 + ) + end + end end diff --git a/lib/config_cat/config.ex b/lib/config_cat/config.ex index 475e0c4c..b0383928 100644 --- a/lib/config_cat/config.ex +++ b/lib/config_cat/config.ex @@ -2,7 +2,9 @@ defmodule ConfigCat.Config do @moduledoc """ Defines configuration-related types used in the rest of the library. """ - alias ConfigCat.RedirectMode + alias ConfigCat.Config.Preferences + alias ConfigCat.Config.Segment + alias ConfigCat.Config.Setting @typedoc false @type comparator :: non_neg_integer() @@ -10,11 +12,17 @@ defmodule ConfigCat.Config do @typedoc "The name of a configuration setting." @type key :: String.t() - @typedoc "The configuration settings within a Config." - @type settings :: map() + @typedoc false + @type opt :: {:preferences, Preferences.t()} | {:settings, settings()} + + @typedoc false + @type salt :: String.t() + + @typedoc false + @type settings :: %{String.t() => Setting.t()} @typedoc "A collection of configuration settings and preferences." - @type t :: map() + @type t :: %{String.t() => map()} @typedoc false @type url :: String.t() @@ -25,43 +33,66 @@ defmodule ConfigCat.Config do @typedoc "The name of a variation being tested." @type variation_id :: String.t() - @feature_flags "f" + @settings "f" @preferences "p" - @preferences_base_url "u" - @redirect_mode "r" + @segments "s" + + @doc false + @spec new([opt]) :: t() + def new(opts \\ []) do + settings = Keyword.get(opts, :settings, %{}) + preferences = Keyword.get_lazy(opts, :preferences, &Preferences.new/0) + + %{@settings => settings, @preferences => preferences} + end + + @doc false + @spec preferences(t()) :: Preferences.t() + def preferences(config) do + Map.get_lazy(config, @preferences, &Preferences.new/0) + end @doc false - @spec new_with_preferences(url(), RedirectMode.t()) :: t() - def new_with_preferences(base_url, redirect_mode) do - %{ - @preferences => %{ - @preferences_base_url => base_url, - @redirect_mode => redirect_mode - } - } + @spec segments(t()) :: [Segment.t()] + def segments(config) do + Map.get(config, @segments, []) end @doc false - @spec new_with_settings(settings()) :: t() - def new_with_settings(settings) do - %{@feature_flags => settings} + @spec settings(t()) :: settings() + def settings(config) do + Map.get(config, @settings, %{}) end @doc false @spec fetch_settings(t()) :: {:ok, settings()} | {:error, :not_found} def fetch_settings(config) do - case Map.fetch(config, @feature_flags) do + case Map.fetch(config, @settings) do {:ok, settings} -> {:ok, settings} :error -> {:error, :not_found} end end @doc false - @spec preferences(t()) :: {url() | nil, RedirectMode.t() | nil} - def preferences(config) do - case config[@preferences] do - nil -> {nil, nil} - preferences -> {preferences[@preferences_base_url], preferences[@redirect_mode]} - end + @spec merge(left :: t(), right :: t()) :: t() + def merge(left, right) do + left_flags = settings(left) + right_flags = settings(right) + + Map.put(left, @settings, Map.merge(left_flags, right_flags)) + end + + @doc false + @spec inline_salt_and_segments(t()) :: t() + def inline_salt_and_segments(config) do + salt = config |> preferences() |> Preferences.salt() + segments = segments(config) + + Map.update( + config, + @settings, + %{}, + &Map.new(&1, fn {key, setting} -> {key, Setting.inline_salt_and_segments(setting, salt, segments)} end) + ) end end diff --git a/lib/config_cat/config/condition.ex b/lib/config_cat/config/condition.ex new file mode 100644 index 00000000..0fb5ee86 --- /dev/null +++ b/lib/config_cat/config/condition.ex @@ -0,0 +1,33 @@ +defmodule ConfigCat.Config.Condition do + @moduledoc false + alias ConfigCat.Config.PrerequisiteFlagCondition + alias ConfigCat.Config.Segment + alias ConfigCat.Config.SegmentCondition + alias ConfigCat.Config.UserCondition + + @type t :: %{String.t() => term()} + + @prerequisite_flag_condition "p" + @segment_condition "s" + @user_condition "u" + + @spec prerequisite_flag_condition(t()) :: PrerequisiteFlagCondition.t() + def prerequisite_flag_condition(condition) do + Map.get(condition, @prerequisite_flag_condition) + end + + @spec segment_condition(t()) :: SegmentCondition.t() | nil + def segment_condition(condition) do + Map.get(condition, @segment_condition) + end + + @spec user_condition(t()) :: UserCondition.t() | nil + def user_condition(condition) do + Map.get(condition, @user_condition) + end + + @spec inline_segments(t(), [Segment.t()]) :: t() + def inline_segments(condition, segments) do + Map.update(condition, @segment_condition, nil, &SegmentCondition.inline_segment(&1, segments)) + end +end diff --git a/lib/config_cat/config/percentage_option.ex b/lib/config_cat/config/percentage_option.ex new file mode 100644 index 00000000..e964ccdb --- /dev/null +++ b/lib/config_cat/config/percentage_option.ex @@ -0,0 +1,17 @@ +defmodule ConfigCat.Config.PercentageOption do + @moduledoc false + alias ConfigCat.Config.SettingValueContainer + + @type t :: %{String.t() => term()} + + @percentage "p" + + @spec percentage(t()) :: non_neg_integer() + def percentage(option) do + Map.get(option, @percentage, 0) + end + + defdelegate value(option, setting_type), to: SettingValueContainer + defdelegate variation_id(option, default \\ nil), to: SettingValueContainer + defdelegate variation_value(option, setting_type, variation_id), to: SettingValueContainer +end diff --git a/lib/config_cat/config/preferences.ex b/lib/config_cat/config/preferences.ex new file mode 100644 index 00000000..ed177c1a --- /dev/null +++ b/lib/config_cat/config/preferences.ex @@ -0,0 +1,36 @@ +defmodule ConfigCat.Config.Preferences do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.RedirectMode + + @type opt :: {:base_url, url()} | {:redirect_mode, RedirectMode.t()} + @type t :: %{String.t() => term()} + @type url :: String.t() + + @base_url "u" + @redirect_mode "r" + @salt "s" + + @spec new([opt]) :: t() + def new(opts \\ []) do + %{ + @base_url => opts[:base_url], + @redirect_mode => opts[:redirect_mode] + } + end + + @spec base_url(t()) :: url() | nil + def base_url(preferences) do + Map.get(preferences, @base_url) + end + + @spec redirect_mode(t()) :: RedirectMode.t() | nil + def redirect_mode(preferences) do + Map.get(preferences, @redirect_mode) + end + + @spec salt(t()) :: Config.salt() + def salt(preferences) do + Map.get(preferences, @salt, "") + end +end diff --git a/lib/config_cat/config/prerequisite_flag_comparator.ex b/lib/config_cat/config/prerequisite_flag_comparator.ex new file mode 100644 index 00000000..67d305a1 --- /dev/null +++ b/lib/config_cat/config/prerequisite_flag_comparator.ex @@ -0,0 +1,28 @@ +defmodule ConfigCat.Config.PrerequisiteFlagComparator do + @moduledoc false + alias ConfigCat.Config + + @type t :: non_neg_integer() + + @equals 0 + @not_equals 1 + + @descriptions %{ + @equals => "EQUALS", + @not_equals => "NOT EQUALS" + } + + @spec compare(t(), Config.value(), Config.value()) :: boolean() + def compare(@equals, prerequisite_value, comparison_value) do + prerequisite_value == comparison_value + end + + def compare(@not_equals, prerequisite_value, comparison_value) do + prerequisite_value != comparison_value + end + + @spec description(t()) :: String.t() + def description(comparator) do + Map.get(@descriptions, comparator, "Unsupported comparator") + end +end diff --git a/lib/config_cat/config/prerequisite_flag_condition.ex b/lib/config_cat/config/prerequisite_flag_condition.ex new file mode 100644 index 00000000..1468ae18 --- /dev/null +++ b/lib/config_cat/config/prerequisite_flag_condition.ex @@ -0,0 +1,52 @@ +defmodule ConfigCat.Config.PrerequisiteFlagCondition do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.PrerequisiteFlagComparator + alias ConfigCat.Config.SettingType + alias ConfigCat.Config.SettingValue + + @type t :: %{String.t() => term()} + + @comparator "c" + @comparison_value "v" + @prerequisite_flag_key "f" + + @spec comparator(t()) :: PrerequisiteFlagComparator.t() + def comparator(condition) do + Map.fetch!(condition, @comparator) + end + + @spec comparison_value(t(), SettingType.t()) :: Config.value() | nil + def comparison_value(condition, setting_type) do + case raw_value(condition) do + nil -> nil + value -> SettingValue.get(value, setting_type) + end + end + + @spec prerequisite_flag_key(t()) :: String.t() + def prerequisite_flag_key(condition) do + Map.fetch!(condition, @prerequisite_flag_key) + end + + @spec description(t(), SettingType.t()) :: String.t() + def description(condition, setting_type) do + key = prerequisite_flag_key(condition) + comparator = condition |> comparator() |> PrerequisiteFlagComparator.description() + comparison_value = comparison_value(condition, setting_type) + + "Flag '#{key}' #{comparator} '#{comparison_value}'" + end + + @spec inferred_setting_type(t()) :: SettingType.t() | nil + def inferred_setting_type(condition) do + case raw_value(condition) do + nil -> nil + value -> SettingValue.inferred_setting_type(value) + end + end + + defp raw_value(condition) do + Map.get(condition, @comparison_value) + end +end diff --git a/lib/config_cat/config/segment.ex b/lib/config_cat/config/segment.ex new file mode 100644 index 00000000..095e5875 --- /dev/null +++ b/lib/config_cat/config/segment.ex @@ -0,0 +1,19 @@ +defmodule ConfigCat.Config.Segment do + @moduledoc false + alias ConfigCat.Config.UserCondition + + @type t :: %{String.t() => term()} + + @conditions "r" + @name "n" + + @spec conditions(t()) :: [UserCondition.t()] + def conditions(segment) do + Map.get(segment, @conditions, []) + end + + @spec name(t()) :: String.t() + def name(segment) do + Map.get(segment, @name, "") + end +end diff --git a/lib/config_cat/config/segment_comparator.ex b/lib/config_cat/config/segment_comparator.ex new file mode 100644 index 00000000..f7f4ec33 --- /dev/null +++ b/lib/config_cat/config/segment_comparator.ex @@ -0,0 +1,23 @@ +defmodule ConfigCat.Config.SegmentComparator do + @moduledoc false + + @type t :: non_neg_integer() + + @is_in 0 + @is_not_in 1 + + @descriptions %{ + @is_in => "IS IN SEGMENT", + @is_not_in => "IS NOT IN SEGMENT" + } + + @spec compare(t(), boolean()) :: boolean() + def compare(@is_in, in_segment?), do: in_segment? + def compare(@is_not_in, in_segment?), do: not in_segment? + def compare(_invalid_comparator, _in_segment?), do: false + + @spec description(t()) :: String.t() + def description(comparator) do + Map.get(@descriptions, comparator, "Unsupported comparator") + end +end diff --git a/lib/config_cat/config/segment_condition.ex b/lib/config_cat/config/segment_condition.ex new file mode 100644 index 00000000..adcb6aa1 --- /dev/null +++ b/lib/config_cat/config/segment_condition.ex @@ -0,0 +1,49 @@ +defmodule ConfigCat.Config.SegmentCondition do + @moduledoc false + alias ConfigCat.Config.Segment + alias ConfigCat.Config.SegmentComparator + + @type t :: %{String.t() => any} + + @inline_segment "inline_segment" + @segment_comparator "c" + @segment_index "s" + + @spec segment(t()) :: Segment.t() + def segment(condition) do + Map.get(condition, @inline_segment, %{}) + end + + @spec fetch_segment(t()) :: {:ok, Segment.t()} | {:error, :not_found} + def fetch_segment(condition) do + case Map.fetch(condition, @inline_segment) do + {:ok, segment} -> {:ok, segment} + :error -> {:error, :not_found} + end + end + + @spec segment_comparator(t()) :: SegmentComparator.t() | nil + def segment_comparator(condition) do + Map.get(condition, @segment_comparator) + end + + @spec segment_index(t()) :: non_neg_integer() | nil + def segment_index(condition) do + Map.get(condition, @segment_index) + end + + @spec inline_segment(t(), [Segment.t()]) :: t() + def inline_segment(condition, segments) do + index = segment_index(condition) + segment = Enum.at(segments, index) + Map.put(condition, @inline_segment, segment) + end + + @spec description(t()) :: String.t() + def description(condition) do + comparator = segment_comparator(condition) + segment_name = condition |> segment() |> Segment.name() + + "User #{SegmentComparator.description(comparator)} '#{segment_name}'" + end +end diff --git a/lib/config_cat/config/setting.ex b/lib/config_cat/config/setting.ex new file mode 100644 index 00000000..30b64ccb --- /dev/null +++ b/lib/config_cat/config/setting.ex @@ -0,0 +1,104 @@ +defmodule ConfigCat.Config.Setting do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.PercentageOption + alias ConfigCat.Config.Segment + alias ConfigCat.Config.SettingType + alias ConfigCat.Config.SettingValue + alias ConfigCat.Config.SettingValueContainer + alias ConfigCat.Config.TargetingRule + + @type opt :: {:setting_type, SettingType.t()} | {:value, Config.value()} + @type t :: %{String.t() => term()} + + @inline_salt "inline_salt" + @percentage_option_attribute "a" + @percentage_options "p" + @setting_type "t" + @targeting_rules "r" + @value "v" + + @spec new([opt]) :: t() + def new(opts \\ []) do + case opts[:value] do + nil -> + %{@value => nil} + + value -> + setting_type = SettingType.from_value(value) + + %{ + @setting_type => setting_type, + @value => SettingValue.new(value, setting_type) + } + end + end + + @spec percentage_option_attribute(t()) :: String.t() | nil + def percentage_option_attribute(setting) do + Map.get(setting, @percentage_option_attribute) + end + + @spec percentage_options(t()) :: [PercentageOption.t()] + def percentage_options(setting) do + Map.get(setting, @percentage_options, []) + end + + @spec salt(t()) :: Config.salt() + def salt(setting) do + Map.get(setting, @inline_salt, "") + end + + @spec setting_type(t()) :: SettingType.t() | nil + def setting_type(setting) do + Map.get(setting, @setting_type) + end + + @spec targeting_rules(t()) :: [TargetingRule.t()] + def targeting_rules(setting) do + Map.get(setting, @targeting_rules, []) + end + + @spec value(t()) :: Config.value() + def value(setting) do + SettingValueContainer.value(setting, setting_type(setting)) + end + + defdelegate variation_id(setting, default \\ nil), to: SettingValueContainer + + @spec inline_salt_and_segments(t(), Config.salt(), [Segment.t()]) :: t() + def inline_salt_and_segments(setting, salt, segments) do + setting + |> Map.put(@inline_salt, salt) + |> Map.update(@targeting_rules, [], &Enum.map(&1, fn rule -> TargetingRule.inline_segments(rule, segments) end)) + end + + @spec variation_value(t(), Config.variation_id()) :: Config.value() | nil + def variation_value(setting, variation_id) do + if variation_id(setting) == variation_id do + value(setting) + else + setting_type = setting_type(setting) + + case targeting_rule_variation_value(setting, setting_type, variation_id) do + nil -> + percentage_rule_variation_value(setting, setting_type, variation_id) + + targeting_value -> + targeting_value + end + end + end + + defp targeting_rule_variation_value(setting, setting_type, variation_id) do + setting + |> targeting_rules() + |> Enum.find_value(nil, &TargetingRule.variation_value(&1, setting_type, variation_id)) + end + + defp percentage_rule_variation_value(setting, setting_type, variation_id) do + setting + |> percentage_options() + |> Enum.find_value(nil, &PercentageOption.variation_value(&1, setting_type, variation_id)) + end +end diff --git a/lib/config_cat/config/setting_type.ex b/lib/config_cat/config/setting_type.ex index 84806624..88314f44 100644 --- a/lib/config_cat/config/setting_type.ex +++ b/lib/config_cat/config/setting_type.ex @@ -1,5 +1,6 @@ defmodule ConfigCat.Config.SettingType do @moduledoc false + alias ConfigCat.Config @type t :: non_neg_integer() @@ -7,4 +8,18 @@ defmodule ConfigCat.Config.SettingType do defmacro string, do: 1 defmacro int, do: 2 defmacro double, do: 3 + + @spec from_value(Config.value()) :: t() | nil + def from_value(value) when is_boolean(value), do: bool() + def from_value(value) when is_binary(value), do: string() + def from_value(value) when is_integer(value), do: int() + def from_value(value) when is_number(value), do: double() + def from_value(_value), do: nil + + @spec to_elixir_type(t()) :: String.t() | nil + def to_elixir_type(bool()), do: "boolean()" + def to_elixir_type(string()), do: "String.t()" + def to_elixir_type(int()), do: "integer()" + def to_elixir_type(double()), do: "float()" + def to_elixir_type(_value), do: nil end diff --git a/lib/config_cat/config/setting_value.ex b/lib/config_cat/config/setting_value.ex new file mode 100644 index 00000000..2ce32c6e --- /dev/null +++ b/lib/config_cat/config/setting_value.ex @@ -0,0 +1,69 @@ +defmodule ConfigCat.Config.SettingValue do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.ValueError + + require ConfigCat.Config.SettingType, as: SettingType + + @type t :: %{String.t() => Config.value()} + + @bool "b" + @double "d" + @int "i" + @string "s" + @unsupported_value "unsupported_value" + + @spec new(Config.value(), SettingType.t()) :: t() + def new(value, setting_type) do + %{type_key(setting_type) => value} + end + + @spec get(t(), SettingType.t()) :: Config.value() | nil + def get(value, setting_type) do + case type_key(setting_type) do + @unsupported_value -> + raise ValueError, "Unsupported setting type" + + type_key -> + case Map.get(value, type_key) do + nil -> + expected_type = SettingType.to_elixir_type(setting_type) + raise ValueError, "Setting value is not of the expected type #{expected_type}" + + value -> + :ok = ensure_value_matches_type_key(type_key, value) + value + end + end + end + + defp ensure_value_matches_type_key(@bool, value) when is_boolean(value), do: :ok + defp ensure_value_matches_type_key(@string, value) when is_binary(value), do: :ok + defp ensure_value_matches_type_key(@int, value) when is_integer(value), do: :ok + # It's OK to have integer values for @double settings + defp ensure_value_matches_type_key(@double, value) when is_number(value), do: :ok + + defp ensure_value_matches_type_key(type_key, value) do + raise ValueError, "Setting value '#{value}' is not of the specified type #{type_key}" + end + + @spec inferred_setting_type(t()) :: SettingType.t() | nil + def inferred_setting_type(value) do + Enum.find( + [SettingType.bool(), SettingType.double(), SettingType.int(), SettingType.string()], + fn setting_type -> + !is_nil(Map.get(value, type_key(setting_type))) + end + ) + end + + defp type_key(setting_type) do + case setting_type do + SettingType.bool() -> @bool + SettingType.double() -> @double + SettingType.int() -> @int + SettingType.string() -> @string + _ -> @unsupported_value + end + end +end diff --git a/lib/config_cat/config/setting_value_container.ex b/lib/config_cat/config/setting_value_container.ex new file mode 100644 index 00000000..9cb6e84a --- /dev/null +++ b/lib/config_cat/config/setting_value_container.ex @@ -0,0 +1,46 @@ +defmodule ConfigCat.Config.SettingValueContainer do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.SettingType + alias ConfigCat.Config.SettingValue + alias ConfigCat.Config.ValueError + + @type t :: %{String.t() => term()} + + @value "v" + @variation_id "i" + + @spec value(t(), SettingType.t()) :: Config.value() | nil + def value(v, setting_type) do + case raw_value(v) do + nil -> + raise ValueError, "Value is missing" + + value -> + SettingValue.get(value, setting_type) + end + end + + @spec variation_id(t()) :: Config.variation_id() | nil + @spec variation_id(t(), Config.variation_id() | nil) :: Config.variation_id() | nil + def variation_id(v, default \\ nil) do + Map.get(v, @variation_id, default) + end + + @spec variation_value(t(), SettingType.t(), Config.variation_id()) :: Config.value() | nil + def variation_value(v, setting_type, variation_id) do + if variation_id(v) == variation_id do + v |> value(setting_type) |> ensure_allowed_type() + end + end + + defp ensure_allowed_type(value) do + unless SettingType.from_value(value) do + raise ValueError, "Setting value '#{value}' is of an unsupported type." + end + end + + defp raw_value(v) do + Map.get(v, @value) + end +end diff --git a/lib/config_cat/config/targeting_rule.ex b/lib/config_cat/config/targeting_rule.ex new file mode 100644 index 00000000..bbc6da7c --- /dev/null +++ b/lib/config_cat/config/targeting_rule.ex @@ -0,0 +1,76 @@ +defmodule ConfigCat.Config.TargetingRule do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.Condition + alias ConfigCat.Config.PercentageOption + alias ConfigCat.Config.Segment + alias ConfigCat.Config.SettingType + alias ConfigCat.Config.SettingValueContainer + + @type t :: %{String.t() => term()} + + @conditions "c" + @percentage_options "p" + @simple_value "s" + + @spec conditions(t()) :: [Condition.t()] + def conditions(rule) do + Map.get(rule, @conditions, []) + end + + @spec percentage_options(t()) :: [PercentageOption.t()] + def percentage_options(rule) do + Map.get(rule, @percentage_options, []) + end + + @spec simple_value(t()) :: SettingValueContainer.t() | nil + def simple_value(rule) do + Map.get(rule, @simple_value) + end + + @spec value(t(), SettingType.t()) :: Config.value() | nil + def value(rule, setting_type) do + case simple_value(rule) do + nil -> + nil + + value -> + SettingValueContainer.value(value, setting_type) + end + end + + @spec variation_id(t()) :: Config.variation_id() | nil + @spec variation_id(t(), Config.variation_id() | nil) :: Config.variation_id() | nil + def variation_id(rule, default \\ nil) do + case simple_value(rule) do + nil -> default + value -> SettingValueContainer.variation_id(value, default) + end + end + + @spec inline_segments(t(), [Segment.t()]) :: t() + def inline_segments(rule, segments) do + Map.update(rule, @conditions, [], &Enum.map(&1, fn condition -> Condition.inline_segments(condition, segments) end)) + end + + @spec variation_value(t(), SettingType.t(), Config.variation_id()) :: Config.value() | nil + def variation_value(rule, setting_type, variation_id) do + case percentage_rule_variation_value(rule, setting_type, variation_id) do + nil -> simple_variation_value(rule, setting_type, variation_id) + value -> value + end + end + + defp percentage_rule_variation_value(rule, setting_type, variation_id) do + rule + |> percentage_options() + |> Enum.find_value(nil, &PercentageOption.variation_value(&1, setting_type, variation_id)) + end + + defp simple_variation_value(rule, setting_type, variation_id) do + case simple_value(rule) do + nil -> nil + value -> SettingValueContainer.variation_value(value, setting_type, variation_id) + end + end +end diff --git a/lib/config_cat/config/user_comparator.ex b/lib/config_cat/config/user_comparator.ex new file mode 100644 index 00000000..5661526b --- /dev/null +++ b/lib/config_cat/config/user_comparator.ex @@ -0,0 +1,571 @@ +defmodule ConfigCat.Config.ComparatorMetadata do + @moduledoc false + use TypedStruct + + @type value_type :: :double | :string | :string_list + + typedstruct enforce: true do + field :description, String.t() + field :value_type, value_type() + end +end + +defmodule ConfigCat.Config.ComparisonContext do + @moduledoc false + use TypedStruct + + alias ConfigCat.Config + alias ConfigCat.Config.UserCondition + + typedstruct enforce: true do + field :condition, UserCondition.t() + field :context_salt, Config.salt() + field :key, Config.key() + field :salt, Config.salt() + end +end + +defmodule ConfigCat.Config.UserComparator do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.ComparatorMetadata, as: Metadata + alias ConfigCat.Config.ComparisonContext + alias ConfigCat.Config.UserCondition + + require ConfigCat.ConfigCatLogger, as: ConfigCatLogger + + @is_one_of 0 + @is_not_one_of 1 + @contains_any_of 2 + @not_contains_any_of 3 + @is_one_of_semver 4 + @is_not_one_of_semver 5 + @less_than_semver 6 + @less_than_equal_semver 7 + @greater_than_semver 8 + @greater_than_equal_semver 9 + @equals_number 10 + @not_equals_number 11 + @less_than_number 12 + @less_than_equal_number 13 + @greater_than_number 14 + @greater_than_equal_number 15 + @is_one_of_hashed 16 + @is_not_one_of_hashed 17 + @before_datetime 18 + @after_datetime 19 + @equals_hashed 20 + @not_equals_hashed 21 + @starts_with_any_of_hashed 22 + @not_starts_with_any_of_hashed 23 + @ends_with_any_of_hashed 24 + @not_ends_with_any_of_hashed 25 + @array_contains_any_of_hashed 26 + @array_not_contains_any_of_hashed 27 + @equals 28 + @not_equals 29 + @starts_with_any_of 30 + @not_starts_with_any_of 31 + @ends_with_any_of 32 + @not_ends_with_any_of 33 + @array_contains_any_of 34 + @array_not_contains_any_of 35 + + @metadata %{ + @is_one_of => %Metadata{description: "IS ONE OF", value_type: :string_list}, + @is_not_one_of => %Metadata{description: "IS NOT ONE OF", value_type: :string_list}, + @contains_any_of => %Metadata{description: "CONTAINS ANY OF", value_type: :string_list}, + @not_contains_any_of => %Metadata{description: "NOT CONTAINS ANY OF", value_type: :string_list}, + @is_one_of_semver => %Metadata{description: "IS ONE OF", value_type: :string_list}, + @is_not_one_of_semver => %Metadata{description: "IS NOT ONE OF", value_type: :string_list}, + @less_than_semver => %Metadata{description: "<", value_type: :string}, + @less_than_equal_semver => %Metadata{description: "<=", value_type: :string}, + @greater_than_semver => %Metadata{description: ">", value_type: :string}, + @greater_than_equal_semver => %Metadata{description: ">=", value_type: :string}, + @equals_number => %Metadata{description: "=", value_type: :double}, + @not_equals_number => %Metadata{description: "!=", value_type: :double}, + @less_than_number => %Metadata{description: "<", value_type: :double}, + @less_than_equal_number => %Metadata{description: "<=", value_type: :double}, + @greater_than_number => %Metadata{description: ">", value_type: :double}, + @greater_than_equal_number => %Metadata{description: ">=", value_type: :double}, + @is_one_of_hashed => %Metadata{description: "IS ONE OF", value_type: :string_list}, + @is_not_one_of_hashed => %Metadata{description: "IS NOT ONE OF", value_type: :string_list}, + @before_datetime => %Metadata{description: "BEFORE", value_type: :double}, + @after_datetime => %Metadata{description: "AFTER", value_type: :double}, + @equals_hashed => %Metadata{description: "EQUALS", value_type: :string}, + @not_equals_hashed => %Metadata{description: "NOT EQUALS", value_type: :string}, + @starts_with_any_of_hashed => %Metadata{description: "STARTS WITH ANY OF", value_type: :string_list}, + @not_starts_with_any_of_hashed => %Metadata{description: "NOT STARTS WITH ANY OF", value_type: :string_list}, + @ends_with_any_of_hashed => %Metadata{description: "ENDS WITH ANY OF", value_type: :string_list}, + @not_ends_with_any_of_hashed => %Metadata{description: "NOT ENDS WITH ANY OF", value_type: :string_list}, + @array_contains_any_of_hashed => %Metadata{description: "ARRAY CONTAINS ANY OF", value_type: :string_list}, + @array_not_contains_any_of_hashed => %Metadata{description: "ARRAY NOT CONTAINS ANY OF", value_type: :string_list}, + @equals => %Metadata{description: "EQUALS", value_type: :string}, + @not_equals => %Metadata{description: "NOT EQUALS", value_type: :string}, + @starts_with_any_of => %Metadata{description: "STARTS WITH ANY OF", value_type: :string_list}, + @not_starts_with_any_of => %Metadata{description: "NOT STARTS WITH ANY OF", value_type: :string_list}, + @ends_with_any_of => %Metadata{description: "ENDS WITH ANY OF", value_type: :string_list}, + @not_ends_with_any_of => %Metadata{description: "NOT ENDS WITH ANY OF", value_type: :string_list}, + @array_contains_any_of => %Metadata{description: "ARRAY CONTAINS ANY OF", value_type: :string_list}, + @array_not_contains_any_of => %Metadata{description: "ARRAY NOT CONTAINS ANY OF", value_type: :string_list} + } + + @type result :: + {:ok, boolean()} | {:error, :invalid_datetime | :invalid_float | :invalid_string_list | :invalid_version} + @type t :: non_neg_integer() + @type value_type :: Metadata.value_type() + + defguard is_for_datetime(comparator) when comparator in [@before_datetime, @after_datetime] + + defguard is_for_hashed(comparator) + when comparator in [ + @is_one_of_hashed, + @is_not_one_of_hashed, + @equals_hashed, + @not_equals_hashed, + @starts_with_any_of_hashed, + @not_starts_with_any_of_hashed, + @ends_with_any_of_hashed, + @not_ends_with_any_of_hashed, + @array_contains_any_of_hashed, + @array_not_contains_any_of_hashed + ] + + @spec description(t()) :: String.t() + def description(comparator) do + case Map.fetch(@metadata, comparator) do + {:ok, %Metadata{} = metadata} -> metadata.description + :error -> "Unsupported comparator" + end + end + + @spec value_type(t()) :: value_type() + def value_type(comparator) do + case Map.fetch(@metadata, comparator) do + {:ok, %Metadata{} = metadata} -> + metadata.value_type + + :error -> + :string + end + end + + @spec compare( + t(), + Config.value(), + UserCondition.comparison_value(), + ComparisonContext.t() + ) :: result() + + def compare(@is_one_of, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + {:ok, text in comparison_values} + end + end + + def compare(@is_not_one_of, user_value, comparison_values, %ComparisonContext{} = context) do + @is_one_of |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@contains_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = Enum.any?(comparison_values, &String.contains?(text, &1)) + {:ok, result} + end + end + + def compare(@not_contains_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + @contains_any_of |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@is_one_of_semver, user_value, comparison_values, %ComparisonContext{} = _context) do + with {:ok, user_version} <- to_version(user_value), + {:ok, comparison_versions} <- to_versions(comparison_values) do + result = Enum.any?(comparison_versions, &(Version.compare(user_version, &1) == :eq)) + {:ok, result} + end + end + + def compare(@is_not_one_of_semver, user_value, comparison_values, %ComparisonContext{} = context) do + @is_one_of_semver |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@less_than_semver, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_semver(user_value, comparison_value, [:lt]) + end + + def compare(@less_than_equal_semver, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_semver(user_value, comparison_value, [:lt, :eq]) + end + + def compare(@greater_than_semver, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_semver(user_value, comparison_value, [:gt]) + end + + def compare(@greater_than_equal_semver, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_semver(user_value, comparison_value, [:gt, :eq]) + end + + def compare(@equals_number, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_numbers(user_value, comparison_value, &==/2) + end + + def compare(@not_equals_number, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_numbers(user_value, comparison_value, &!==/2) + end + + def compare(@less_than_number, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_numbers(user_value, comparison_value, &/2) + end + + def compare(@greater_than_equal_number, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_numbers(user_value, comparison_value, &>=/2) + end + + def compare(@is_one_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = hash_value(text, context) in comparison_values + {:ok, result} + end + end + + def compare(@is_not_one_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + @is_one_of_hashed |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@before_datetime, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_datetimes(user_value, comparison_value, [:lt]) + end + + def compare(@after_datetime, user_value, comparison_value, %ComparisonContext{} = _context) do + compare_datetimes(user_value, comparison_value, [:gt]) + end + + def compare(@equals_hashed, user_value, comparison_value, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = hash_value(text, context) == comparison_value + {:ok, result} + end + end + + def compare(@not_equals_hashed, user_value, comparison_value, %ComparisonContext{} = context) do + @equals_hashed |> compare(user_value, comparison_value, context) |> negate() + end + + def compare(@starts_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = + Enum.any?( + comparison_values, + fn comparison -> + {length, comparison_string} = parse_comparison(comparison) + + if byte_size(text) >= length do + hashed = text |> binary_part(0, length) |> hash_value(context) + hashed == comparison_string + else + false + end + end + ) + + {:ok, result} + end + end + + def compare(@not_starts_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + @starts_with_any_of_hashed |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@ends_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = + Enum.any?( + comparison_values, + fn comparison -> + {length, comparison_string} = parse_comparison(comparison) + + if byte_size(text) >= length do + hashed = text |> binary_part(byte_size(text), -length) |> hash_value(context) + hashed == comparison_string + else + false + end + end + ) + + {:ok, result} + end + end + + def compare(@not_ends_with_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + @ends_with_any_of_hashed |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@array_contains_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, user_values} <- to_string_list(user_value) do + hashed_user_values = Enum.map(user_values, &hash_value(&1, context)) + result = Enum.any?(comparison_values, &(&1 in hashed_user_values)) + + {:ok, result} + end + end + + def compare(@array_not_contains_any_of_hashed, user_value, comparison_values, %ComparisonContext{} = context) do + @array_contains_any_of_hashed |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@equals, user_value, comparison_value, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = text == comparison_value + {:ok, result} + end + end + + def compare(@not_equals, user_value, comparison_value, %ComparisonContext{} = context) do + @equals |> compare(user_value, comparison_value, context) |> negate() + end + + def compare(@starts_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = Enum.any?(comparison_values, &String.starts_with?(text, &1)) + {:ok, result} + end + end + + def compare(@not_starts_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + @starts_with_any_of |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@ends_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + with {:ok, text} <- as_text(user_value, context) do + result = Enum.any?(comparison_values, &String.ends_with?(text, &1)) + {:ok, result} + end + end + + def compare(@not_ends_with_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + @ends_with_any_of |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(@array_contains_any_of, user_value, comparison_values, %ComparisonContext{} = _context) do + with {:ok, user_values} <- to_string_list(user_value) do + result = Enum.any?(comparison_values, &(&1 in user_values)) + {:ok, result} + end + end + + def compare(@array_not_contains_any_of, user_value, comparison_values, %ComparisonContext{} = context) do + @array_contains_any_of |> compare(user_value, comparison_values, context) |> negate() + end + + def compare(_comparator, _user_value, _comparison_value, %ComparisonContext{} = _context) do + {:ok, false} + end + + defp compare_semver(user_value, comparison_value, valid_comparisons) do + with {:ok, user_version} <- to_version(user_value), + {:ok, comparison_version} <- to_version(comparison_value) do + result = Version.compare(user_version, comparison_version) + {:ok, result in valid_comparisons} + end + end + + defp compare_numbers(user_value, comparison_value, operator) do + with {:ok, user_float} <- to_float(user_value), + {:ok, comparison_float} <- to_float(comparison_value) do + {:ok, operator.(user_float, comparison_float)} + end + end + + defp compare_datetimes(user_value, comparison_value, valid_comparisons) do + with {:ok, user_seconds} <- to_unix_seconds(user_value), + {:ok, comparison_seconds} <- to_float(comparison_value) do + result = + cond do + user_seconds < comparison_seconds -> :lt + user_seconds > comparison_seconds -> :gt + true -> :eq + end + + {:ok, result in valid_comparisons} + else + {:error, :invalid_float} -> {:error, :invalid_datetime} + error -> error + end + end + + defp hash_value(value, %ComparisonContext{} = context) do + salted = value <> context.salt <> context.context_salt + + :sha256 + |> :crypto.hash(salted) + |> Base.encode16() + |> String.downcase() + end + + defp parse_comparison(value) do + [length_string, comparison_string] = String.split(value, "_", parts: 2) + length = String.trim(length_string) + {String.to_integer(length), comparison_string} + end + + defp as_text(value, _context) when is_binary(value), do: {:ok, value} + + defp as_text(value, %ComparisonContext{} = context) do + %ComparisonContext{condition: condition, key: key} = context + attribute_name = UserCondition.comparison_attribute(condition) + condition_text = UserCondition.description(condition) + + ConfigCatLogger.warning( + "Evaluation of condition (#{condition_text}) for setting '#{key}' may not produce the expected result " <> + "(the User.#{attribute_name} attribute is not a string value, thus it was automatically converted to " <> + "the string value '#{value}'). Please make sure that using a non-string value was intended.", + event_id: 3005 + ) + + user_value_to_string(value) + end + + @spec user_value_to_string(Config.value() | DateTime.t() | NaiveDateTime.t() | [String.t()]) :: + {:ok, String.t() | nil} | {:error, :invalid_datetime | :invalid_float | :invalid_string_list} + def user_value_to_string(nil), do: {:ok, nil} + + def user_value_to_string(%DateTime{} = dt) do + with {:ok, seconds} <- to_unix_seconds(dt) do + {:ok, to_string(seconds)} + end + end + + def user_value_to_string(%NaiveDateTime{} = naive) do + naive |> DateTime.from_naive!("Etc/UTC") |> user_value_to_string() + end + + def user_value_to_string(value) when is_list(value) do + with {:ok, list} <- to_string_list(value) do + {:ok, Jason.encode!(list)} + end + end + + # Per the spec, we need to match JavaScript formatting of floats, which is + # different from the way Elixir does it. + # - Float.to_string/1 doesn't include a `+` for a positive exponent. + # :erlang.float_to_binary() does, but we'd have to specify a number of + # decimal places which we don't know. Instead, we detect an `e` followed by + # digits and replace it with `e+` and the digits. + # - Float.to_string/1 preserves the `.0` for values that would otherwise be + # integers; JavaScript does not, converting e.g. 125.0 -> "125". We need to + # handle that case specially. Note that we have to perform this check AFTER + # checking for an exponent, because for very large floating point values, + # `trunc(value) == value` will be true. + def user_value_to_string(value) when is_float(value) do + result = to_string(value) + + cond do + String.contains?(result, "e") -> + {:ok, String.replace(result, ~r/e([\d+])/, "e+\\1")} + + trunc(value) == value -> + {:ok, value |> trunc() |> to_string()} + + true -> + {:ok, to_string(value)} + end + end + + def user_value_to_string(value), do: {:ok, to_string(value)} + + defp to_float(value) when is_float(value), do: {:ok, value} + defp to_float(value) when is_integer(value), do: {:ok, value * 1.0} + + defp to_float(value) when is_binary(value) do + value + |> String.trim() + |> String.replace(",", ".") + |> Float.parse() + |> case do + {float, ""} -> {:ok, float} + _ -> {:error, :invalid_float} + end + end + + defp to_float(_value), do: {:error, :invalid_float} + + defp to_string_list(value) when is_list(value) do + ensure_all_strings(value) + end + + defp to_string_list(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} when is_list(decoded) -> + ensure_all_strings(decoded) + + _ -> + {:error, :invalid_string_list} + end + end + + defp to_string_list(_value), do: {:error, :invalid_string_list} + + defp ensure_all_strings(list) do + if Enum.all?(list, &is_binary/1) do + {:ok, list} + else + {:error, :invalid_string_list} + end + end + + @spec to_unix_seconds(DateTime.t() | NaiveDateTime.t() | number() | String.t()) :: + {:ok, float()} | {:error, :invalid_float} + def to_unix_seconds(%DateTime{} = value) do + {:ok, DateTime.to_unix(value, :millisecond) / 1000.0} + end + + def to_unix_seconds(%NaiveDateTime{} = value) do + value |> DateTime.from_naive!("Etc/UTC") |> to_unix_seconds() + end + + def to_unix_seconds(value) do + case to_float(value) do + {:ok, float} -> {:ok, float} + {:error, :invalid_float} -> {:error, :invalid_datetime} + end + end + + defp to_versions(values) do + values + |> Enum.reject(&(&1 == "")) + |> Enum.reduce_while({:ok, []}, fn value, {:ok, versions} -> + case to_version(value) do + {:ok, version} -> {:cont, {:ok, [version | versions]}} + error -> {:halt, error} + end + end) + |> case do + {:ok, versions} -> {:ok, Enum.reverse(versions)} + error -> error + end + end + + defp to_version(value) do + value + |> to_string() + |> String.trim() + |> Version.parse() + |> case do + {:ok, version} -> {:ok, version} + :error -> {:error, :invalid_version} + end + end + + defp negate({:ok, result}), do: {:ok, !result} + defp negate(error), do: error +end diff --git a/lib/config_cat/config/user_condition.ex b/lib/config_cat/config/user_condition.ex new file mode 100644 index 00000000..69451777 --- /dev/null +++ b/lib/config_cat/config/user_condition.ex @@ -0,0 +1,136 @@ +defmodule ConfigCat.Config.UserCondition do + @moduledoc false + import ConfigCat.Config.UserComparator, only: [is_for_datetime: 1, is_for_hashed: 1] + + alias ConfigCat.Config.UserComparator + + @type comparison_value :: number() | String.t() | [String.t()] + @type option :: + {:comparator, UserComparator.t()} + | {:comparison_attribute, String.t()} + | {:comparison_value, comparison_value()} + @type t :: %{String.t() => term()} + + @comparator "c" + @comparison_attribute "a" + @double_value "d" + @string_list_value "l" + @string_value "s" + + @spec new([option]) :: t() + def new(options \\ []) do + comparator = options[:comparator] + attribute = options[:comparison_attribute] + comparison_value = options[:comparison_value] + + value_key = + case UserComparator.value_type(comparator) do + :double -> @double_value + :string -> @string_value + :string_list -> @string_list_value + end + + %{ + @comparator => comparator, + @comparison_attribute => attribute, + value_key => comparison_value + } + end + + @spec comparator(t()) :: UserComparator.t() + def comparator(rule) do + Map.fetch!(rule, @comparator) + end + + @spec comparison_attribute(t()) :: String.t() | nil + def comparison_attribute(rule) do + Map.get(rule, @comparison_attribute) + end + + @spec fetch_comparison_attribute(t()) :: {:ok, String.t()} | {:error, :not_found} + def fetch_comparison_attribute(rule) do + case Map.fetch(rule, @comparison_attribute) do + {:ok, attribute} -> {:ok, attribute} + :error -> {:error, :not_found} + end + end + + @spec comparison_value(t()) :: comparison_value() + def comparison_value(rule) do + rule + |> comparator() + |> UserComparator.value_type() + |> case do + :double -> double_value(rule) + :string -> string_value(rule) + :string_list -> string_list_value(rule) + end + end + + @spec description(t()) :: String.t() + def description(condition) do + attribute = comparison_attribute(condition) + comparator = comparator(condition) + comparator_text = UserComparator.description(comparator) + comparison_value = comparison_value(condition) + + "User.#{attribute} #{comparator_text} #{format_comparison_value(comparison_value, comparator)}" + end + + defp double_value(rule) do + Map.get(rule, @double_value) + end + + defp string_list_value(rule) do + Map.get(rule, @string_list_value, []) + end + + defp string_value(rule) do + Map.get(rule, @string_value) + end + + defp format_comparison_value(values, comparator) when length(values) > 1 and is_for_hashed(comparator) do + "[<#{length(values)} hashed values>]" + end + + defp format_comparison_value(values, comparator) when is_list(values) and is_for_hashed(comparator) do + "[<#{length(values)} hashed value>]" + end + + defp format_comparison_value(_value, comparator) when is_for_hashed(comparator) do + "''" + end + + @length_limit 10 + defp format_comparison_value(values, _comparator) when is_list(values) do + length = length(values) + + if length > @length_limit do + remaining = length - @length_limit + more_text = if remaining == 1, do: "<1 more value>", else: "<#{remaining} more values>" + entries = values |> Enum.take(@length_limit) |> format_list_entries() + "[#{entries}, ... #{more_text}]" + else + "[#{format_list_entries(values)}]" + end + end + + defp format_comparison_value(value, comparator) when is_for_datetime(comparator) do + formatted = + (value * 1000) + |> round() + |> DateTime.from_unix!(:millisecond) + |> DateTime.truncate(:millisecond) + |> DateTime.to_iso8601() + + "'#{value}' (#{formatted} UTC)" + end + + defp format_comparison_value(value, _comparator) do + "'#{value}'" + end + + defp format_list_entries(values) do + Enum.map_join(values, ", ", &"'#{&1}'") + end +end diff --git a/lib/config_cat/config/value_error.ex b/lib/config_cat/config/value_error.ex new file mode 100644 index 00000000..c8d39041 --- /dev/null +++ b/lib/config_cat/config/value_error.ex @@ -0,0 +1,9 @@ +defmodule ConfigCat.Config.ValueError do + @moduledoc false + @enforce_keys [:message] + defexception [:message] + + @type t :: %__MODULE__{ + message: String.t() + } +end diff --git a/lib/config_cat/config_cat_logger.ex b/lib/config_cat/config_cat_logger.ex index 24f6e9ac..70ec9ce1 100644 --- a/lib/config_cat/config_cat_logger.ex +++ b/lib/config_cat/config_cat_logger.ex @@ -59,10 +59,7 @@ defmodule ConfigCat.ConfigCatLogger do def formatted_message(message, metadata) do logger_metadata = Logger.metadata() event_id = metadata[:event_id] || logger_metadata[:event_id] || 0 - instance_id = metadata[:instance_id] || logger_metadata[:instance_id] - prefix = if instance_id, do: "[#{instance_id}] ", else: "" - - {prefix <> "[#{event_id}] " <> message, metadata} + {"[#{event_id}] " <> message, metadata} end end diff --git a/lib/config_cat/config_entry.ex b/lib/config_cat/config_entry.ex index fbbd31a2..f4093117 100644 --- a/lib/config_cat/config_entry.ex +++ b/lib/config_cat/config_entry.ex @@ -74,7 +74,7 @@ defmodule ConfigCat.ConfigEntry do defp parse_config(config_json) do case Jason.decode(config_json) do {:ok, config} -> - {:ok, config} + {:ok, Config.inline_salt_and_segments(config)} {:error, error} -> {:error, "Invalid config JSON: #{config_json}. #{Exception.message(error)}"} diff --git a/lib/config_cat/config_fetcher.ex b/lib/config_cat/config_fetcher.ex index 90941a44..1180de6a 100644 --- a/lib/config_cat/config_fetcher.ex +++ b/lib/config_cat/config_fetcher.ex @@ -39,6 +39,7 @@ defmodule ConfigCat.CacheControlConfigFetcher do use GenServer alias ConfigCat.Config + alias ConfigCat.Config.Preferences alias ConfigCat.ConfigEntry alias ConfigCat.ConfigFetcher alias ConfigCat.ConfigFetcher.FetchError @@ -225,12 +226,15 @@ defmodule ConfigCat.CacheControlConfigFetcher do when code >= 200 and code < 300 do ConfigCatLogger.debug("ConfigCat configuration json fetch response code: #{code} Cached: #{extract_etag(headers)}") - with {:ok, config} <- Jason.decode(raw_config), + with {:ok, decoded_config} <- Jason.decode(raw_config), + config = Config.inline_salt_and_segments(decoded_config), new_etag = extract_etag(headers), %{base_url: new_base_url, custom_endpoint?: custom_endpoint?, redirects: redirects} <- - state, - {base_url, redirect_mode} <- Config.preferences(config) do + state do + preferences = Config.preferences(config) followed? = Map.has_key?(redirects, new_base_url) + base_url = Preferences.base_url(preferences) + redirect_mode = Preferences.redirect_mode(preferences) new_state = cond do @@ -315,7 +319,10 @@ defmodule ConfigCat.CacheControlConfigFetcher do defp handle_error({:error, error}, _state) do ConfigCatLogger.error( - "Unexpected error occurred while trying to fetch config JSON: #{inspect(error)}", + "Unexpected error occurred while trying to fetch config JSON. " <> + "It is most likely due to a local network issue. " <> + "Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) over HTTP. " <> + "#{inspect(error)}", event_id: 1103 ) diff --git a/lib/config_cat/constants.ex b/lib/config_cat/constants.ex index 45f95e3a..6fd196be 100644 --- a/lib/config_cat/constants.ex +++ b/lib/config_cat/constants.ex @@ -5,16 +5,8 @@ defmodule ConfigCat.Constants do defmacro base_url_eu_only, do: "https://cdn-eu.configcat.com" defmacro base_path, do: "configuration-files" - defmacro config_filename, do: "config_v5.json" + defmacro config_filename, do: "config_v6.json" defmacro serialization_format_version, do: "v2" - defmacro comparator, do: "t" - defmacro comparison_attribute, do: "a" - defmacro comparison_value, do: "c" - defmacro rollout_rules, do: "r" - defmacro percentage_rules, do: "p" - defmacro percentage, do: "p" - defmacro value, do: "v" - defmacro variation_id, do: "i" defmacro fetch_timeout, do: 10_000 end diff --git a/lib/config_cat/evaluation_details.ex b/lib/config_cat/evaluation_details.ex index 9322b408..dc6202f7 100644 --- a/lib/config_cat/evaluation_details.ex +++ b/lib/config_cat/evaluation_details.ex @@ -7,13 +7,32 @@ defmodule ConfigCat.EvaluationDetails do alias ConfigCat.Config alias ConfigCat.User + @typedoc """ + The results of evaluating a feature flag. + + Fields: + - `:default_value?`: Indicates whether the default value passed to the setting + evaluation functions like `ConfigCat.get_value/3`, + `ConfigCat.get_value_details/3`, etc. is used as the result of the + evaluation. + - `:error`: Error message in case evaluation failed. + - `:fetch_time`: Time of the last successful config download. + - `:key`: The key of the feature flag or setting. + - `:matched_targeting_rule`: The targeting rule (if any) that matched during + the evaluation and was used to return the evaluated value. + - `:matched_percentage_option`: The percentage option (if any) that was used + to select the evaluated value. + - `:user`: The `ConfigCat.User` struct used for the evaluation (if available). + - `:value`: Evaluated value of the feature flag or setting. + - `:variation_id`: Variation ID of the feature flag or setting (if available). + """ typedstruct do field :default_value?, boolean(), default: false field :error, String.t() field :fetch_time, DateTime.t() field :key, Config.key(), enforce: true - field :matched_evaluation_rule, map() - field :matched_evaluation_percentage_rule, map() + field :matched_targeting_rule, map() + field :matched_percentage_option, map() field :user, User.t() field :value, Config.value(), enforce: true field :variation_id, Config.variation_id() diff --git a/lib/config_cat/evaluation_logger.ex b/lib/config_cat/evaluation_logger.ex new file mode 100644 index 00000000..1af8ad7f --- /dev/null +++ b/lib/config_cat/evaluation_logger.ex @@ -0,0 +1,303 @@ +defmodule ConfigCat.EvaluationLogger do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.PrerequisiteFlagCondition + alias ConfigCat.Config.SegmentCondition + alias ConfigCat.Config.SettingType + alias ConfigCat.Config.UserCondition + alias ConfigCat.User + + @type t :: Agent.agent() + @typep condition_result :: {:ok, boolean()} | {:error, String.t()} + + defmodule State do + @moduledoc false + use TypedStruct + + typedstruct enforce: true do + field :indent_level, non_neg_integer(), default: 0 + field :lines, [String.t()], default: [] + end + + @spec append(t(), String.t()) :: t() + def append(%__MODULE__{} = state, text) do + [first | rest] = state.lines + %{state | lines: [first <> text | rest]} + end + + @spec decrease_indent(t()) :: t() + def decrease_indent(%__MODULE__{} = state) do + %{state | indent_level: max(0, state.indent_level - 1)} + end + + @spec increase_indent(t()) :: t() + def increase_indent(%__MODULE__{} = state) do + %{state | indent_level: state.indent_level + 1} + end + + @spec new_line(t(), String.t()) :: t() + def new_line(%__MODULE__{} = state, text) do + line = String.duplicate(" ", state.indent_level) <> text + %{state | lines: [line | state.lines]} + end + + @spec result(t()) :: String.t() + def result(%__MODULE__{} = state) do + state.lines + |> Enum.reverse() + |> Enum.join("\n") + end + end + + @spec start :: Agent.on_start() + def start do + Agent.start(fn -> %State{} end) + end + + @spec stop(t() | nil) :: :ok + def stop(nil), do: :ok + + def stop(logger) do + Agent.stop(logger) + end + + @spec decrease_indent(t() | nil) :: t() | nil + def decrease_indent(nil), do: nil + + def decrease_indent(logger) do + Agent.update(logger, &State.decrease_indent/1) + logger + end + + @spec increase_indent(t() | nil) :: t() | nil + def increase_indent(nil), do: nil + + def increase_indent(logger) do + Agent.update(logger, &State.increase_indent/1) + logger + end + + @spec log_evaluating(t() | nil, Config.key(), User.t() | nil) :: t() | nil + def log_evaluating(nil, _key, _user), do: nil + + def log_evaluating(logger, key, user) do + new_line(logger, "Evaluating '#{key}'") + + if user do + append(logger, " for User '#{user}'") + end + + logger + end + + @spec log_evaluating_condition_final_result(t() | nil, condition_result(), boolean(), Config.value() | nil) :: + t() | nil + def log_evaluating_condition_final_result(nil, _result, _newline?, _value), do: nil + + def log_evaluating_condition_final_result(logger, result, newline?, value) do + increase_indent(logger) + if newline?, do: new_line(logger), else: append(logger, " ") + + formatted_value = if value, do: "'#{value}'", else: "% options" + append(logger, "THEN #{formatted_value} => ") + + case result do + {:ok, condition_result} -> + formatted_result = if condition_result, do: "MATCH, applying rule", else: "no match" + append(logger, formatted_result) + + {:error, error} -> + logger + |> append(error) + |> new_line("The current targeting rule is ignored and the evaluation continues with the next rule.") + end + + decrease_indent(logger) + end + + @spec log_evaluating_condition_result(t() | nil, condition_result()) :: t() | nil + def log_evaluating_condition_result(nil, _result), do: nil + + def log_evaluating_condition_result(logger, result) do + case result do + {:ok, true} -> append(logger, " => true") + _ -> append(logger, " => false, skipping the remaining AND conditions") + end + end + + @spec log_evaluating_condition_start(t() | nil, non_neg_integer()) :: t() | nil + def log_evaluating_condition_start(nil, _index), do: nil + + def log_evaluating_condition_start(logger, index) do + if index == 0 do + logger + |> new_line("- IF ") + |> increase_indent() + else + logger + |> increase_indent() + |> new_line("AND ") + end + end + + @spec log_evaluating_prerequisite_condition_result( + t() | nil, + PrerequisiteFlagCondition.t(), + SettingType.t(), + Config.value(), + boolean() + ) :: + t() | nil + def log_evaluating_prerequisite_condition_result(nil, _condition, _setting_type, _value, _result), do: nil + + def log_evaluating_prerequisite_condition_result(logger, condition, setting_type, value, result) do + logger + |> new_line("Prerequisite flag evaluation result: '#{value}'.") + |> new_line("Condition (#{PrerequisiteFlagCondition.description(condition, setting_type)}) evaluates to #{result}.") + |> decrease_indent() + |> new_line(")") + end + + @spec log_evaluating_prerequisite_condition_start(t() | nil, PrerequisiteFlagCondition.t(), SettingType.t()) :: + t() | nil + def log_evaluating_prerequisite_condition_start(nil, _condition, _setting_type), do: nil + + def log_evaluating_prerequisite_condition_start(logger, condition, setting_type) do + key = PrerequisiteFlagCondition.prerequisite_flag_key(condition) + + logger + |> append(PrerequisiteFlagCondition.description(condition, setting_type)) + |> new_line("(") + |> increase_indent() + |> new_line("Evaluating prerequisite flag '#{key}':") + end + + @spec log_evaluating_segment_condition_result(t() | nil, SegmentCondition.t(), boolean(), condition_result()) :: + t() | nil + def log_evaluating_segment_condition_result(nil, _condition, _in_segment?, _result), do: nil + + def log_evaluating_segment_condition_result(logger, condition, in_segment?, result) do + description = SegmentCondition.description(condition) + + logger + |> decrease_indent() + |> new_line("Segment evaluation result: ") + + case result do + {:ok, match?} -> + maybe_not = if in_segment?, do: " ", else: " NOT " + + logger + |> append("User IS#{maybe_not}IN SEGMENT.") + |> new_line("Condition (#{description}) ") + |> append("evaluates to #{match?}.") + |> decrease_indent() + |> new_line(")") + + {:error, error} -> + logger + |> append("#{error}.") + |> new_line("Condition (#{description}) ") + |> append("failed to evaluate.") + |> decrease_indent() + |> new_line(")") + end + end + + @spec log_evaluating_segment_condition_start(t() | nil, SegmentCondition.t(), String.t()) :: t() | nil + def log_evaluating_segment_condition_start(nil, _condition, _segment_name), do: nil + + def log_evaluating_segment_condition_start(logger, condition, segment_name) do + logger + |> append("#{SegmentCondition.description(condition)}") + |> new_line("(") + |> increase_indent() + |> new_line("Evaluating segment '#{segment_name}':") + end + + @spec log_evaluating_targeting_rules(t() | nil) :: t() | nil + def log_evaluating_targeting_rules(nil), do: nil + + def log_evaluating_targeting_rules(logger) do + new_line(logger, "Evaluating targeting rules and applying the first match if any:") + end + + @spec log_evaluating_user_condition_start(t() | nil, UserCondition.t()) :: t() | nil + def log_evaluating_user_condition_start(nil, _condition), do: nil + + def log_evaluating_user_condition_start(logger, condition) do + append(logger, "#{UserCondition.description(condition)}") + end + + @spec log_ignored_targeting_rule(t() | nil) :: t() | nil + def log_ignored_targeting_rule(nil), do: nil + + def log_ignored_targeting_rule(logger) do + new_line(logger, "The current targeting rule is ignored and the evaluation continues with the next rule.") + end + + @spec log_matching_percentage_option( + t() | nil, + String.t(), + non_neg_integer(), + non_neg_integer(), + non_neg_integer(), + Config.value() + ) :: t() + def log_matching_percentage_option(nil, _attribute_name, _hash_value, _index, _percentage, _value), do: nil + + def log_matching_percentage_option(logger, attribute_name, hash_value, index, percentage, value) do + logger + |> new_line("Evaluating % options based on the User.#{attribute_name} attribute:") + |> new_line( + "- Computing hash in the [0..99] range from User.#{attribute_name} => #{hash_value} " <> + "(this value is sticky and consistent across all SDKs)" + ) + |> new_line("- Hash value #{hash_value} selects % option #{index} (#{percentage}%), '#{value}'.") + end + + @spec log_return_value(t() | nil, Config.value()) :: t() | nil + def log_return_value(nil, _value), do: nil + + def log_return_value(logger, value) do + new_line(logger, "Returning '#{value}'.") + end + + @spec log_skipping_percentage_options_missing_user(t() | nil) :: t() | nil + def log_skipping_percentage_options_missing_user(nil), do: nil + + def log_skipping_percentage_options_missing_user(logger) do + new_line(logger, "Skipping % options because the User Object is missing.") + end + + @spec log_skipping_percentage_options_missing_user_attribute(t() | nil, String.t()) :: t() | nil + def log_skipping_percentage_options_missing_user_attribute(nil, _attribute_name), do: nil + + def log_skipping_percentage_options_missing_user_attribute(logger, attribute_name) do + new_line(logger, "Skipping % options because the User.#{attribute_name} attribute is missing.") + end + + @spec log_skipping_segment_condition_missing_user(t() | nil, SegmentCondition.t()) :: t() | nil + def log_skipping_segment_condition_missing_user(nil, _condition), do: nil + + def log_skipping_segment_condition_missing_user(logger, condition) do + append(logger, "#{SegmentCondition.description(condition)}") + end + + @spec result(t() | nil) :: String.t() + def result(nil), do: "" + + def result(logger) do + Agent.get(logger, &State.result/1) + end + + defp append(logger, text) do + Agent.update(logger, &State.append(&1, text)) + logger + end + + defp new_line(logger, text \\ "") do + Agent.update(logger, &State.new_line(&1, text)) + logger + end +end diff --git a/lib/config_cat/evaluation_warnings.ex b/lib/config_cat/evaluation_warnings.ex new file mode 100644 index 00000000..1714d944 --- /dev/null +++ b/lib/config_cat/evaluation_warnings.ex @@ -0,0 +1,110 @@ +defmodule ConfigCat.EvaluationWarnings do + @moduledoc false + alias ConfigCat.Config + alias ConfigCat.Config.UserCondition + + require ConfigCat.ConfigCatLogger, as: ConfigCatLogger + + @type t :: Agent.agent() + + defmodule State do + @moduledoc false + use TypedStruct + + typedstruct enforce: true do + field :warned_missing_or_invalid_user?, boolean(), default: false + end + + @spec note_warned_missing_or_invalid_user(t()) :: t() + def note_warned_missing_or_invalid_user(%__MODULE__{} = state) do + %{state | warned_missing_or_invalid_user?: true} + end + end + + @spec start :: Agent.on_start() + def start do + Agent.start(fn -> %State{} end) + end + + @spec stop(t()) :: :ok + def stop(warnings) do + Agent.stop(warnings) + end + + @spec warn_invalid_user(t(), Config.key()) :: :ok + def warn_invalid_user(warnings, key) do + if warned_missing_or_invalid_user?(warnings) do + :ok + else + ConfigCatLogger.warning( + "Cannot evaluate targeting rules and % options for setting '#{key}' " <> + "(User Object is not an instance of `ConfigCat.User` struct)." <> + "You should pass a User Object to the evaluation functions like `get_value()` " <> + "in order to make targeting work properly. " <> + "Read more: https://configcat.com/docs/advanced/user-object/", + event_id: 4001 + ) + + note_warned_missing_or_invalid_user(warnings) + end + end + + @spec warn_missing_user(t(), Config.key()) :: :ok + def warn_missing_user(warnings, key) do + if warned_missing_or_invalid_user?(warnings) do + :ok + else + ConfigCatLogger.warning( + "Cannot evaluate targeting rules and % options for setting '#{key}' " <> + "(User Object is missing). " <> + "You should pass a User Object to the evaluation functions like `get_value()` " <> + "in order to make targeting work properly. " <> + "Read more: https://configcat.com/docs/advanced/user-object/", + event_id: 3001 + ) + + note_warned_missing_or_invalid_user(warnings) + end + end + + @spec warn_missing_user_attribute(t(), Config.key(), String.t()) :: :ok + def warn_missing_user_attribute(_warnings, key, attribute_name) do + ConfigCatLogger.warning( + "Cannot evaluate % options for setting '#{key}' " <> + "(the User.#{attribute_name} attribute is missing). You should set the User.#{attribute_name} attribute in order to make " <> + "targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/", + event_id: 3003 + ) + end + + @spec warn_missing_user_attribute(t(), Config.key(), UserCondition.t(), String.t()) :: :ok + def warn_missing_user_attribute(_warnings, key, user_condition, attribute_name) do + ConfigCatLogger.warning( + "Cannot evaluate condition (#{UserCondition.description(user_condition)}) for setting '#{key}' " <> + "(the User.#{attribute_name} attribute is missing). You should set the User.#{attribute_name} attribute in order to make " <> + "targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/", + event_id: 3003 + ) + end + + @spec warn_type_mismatch(t(), Config.key(), UserCondition.t(), String.t()) :: :ok + def warn_type_mismatch(_warnings, key, condition, message) do + attribute = UserCondition.comparison_attribute(condition) + condition_text = UserCondition.description(condition) + + ConfigCatLogger.warning( + "Cannot evaluate condition (#{condition_text}) for setting '#{key}' " <> + "(#{message}). Please check the User.#{attribute} attribute and make sure that its value corresponds to the " <> + "comparison operator.", + event_id: 3004 + ) + end + + defp note_warned_missing_or_invalid_user(warnings) do + Agent.update(warnings, &State.note_warned_missing_or_invalid_user/1) + end + + defp warned_missing_or_invalid_user?(warnings) do + Agent.get(warnings, fn %State{} = state -> state.warned_missing_or_invalid_user? end) + end +end diff --git a/lib/config_cat/local_file_data_source.ex b/lib/config_cat/local_file_data_source.ex index 468cd5b8..d3e0e7f9 100644 --- a/lib/config_cat/local_file_data_source.ex +++ b/lib/config_cat/local_file_data_source.ex @@ -19,7 +19,7 @@ defmodule ConfigCat.LocalFileDataSource do typedstruct do field :cached_timestamp, non_neg_integer(), default: 0 - field :settings, Config.settings() + field :config, Config.t() end @spec start_link(GenServer.options()) :: Agent.on_start() @@ -27,11 +27,11 @@ defmodule ConfigCat.LocalFileDataSource do Agent.start_link(fn -> %__MODULE__{} end) end - @spec cached_settings(Agent.agent()) :: {:ok, Config.t()} | {:error, :not_found} - def cached_settings(cache) do - case Agent.get(cache, fn %__MODULE__{settings: settings} -> settings end) do + @spec cached_config(Agent.agent()) :: {:ok, Config.t()} | {:error, :not_found} + def cached_config(cache) do + case Agent.get(cache, fn %__MODULE__{config: config} -> config end) do nil -> {:error, :not_found} - settings -> {:ok, settings} + config -> {:ok, config} end end @@ -41,9 +41,9 @@ defmodule ConfigCat.LocalFileDataSource do end @spec update(Agent.agent(), Config.t(), integer()) :: :ok - def update(cache, settings, timestamp) do + def update(cache, config, timestamp) do Agent.update(cache, fn %__MODULE__{} = state -> - %{state | cached_timestamp: timestamp, settings: settings} + %{state | cached_timestamp: timestamp, config: config} end) end end @@ -72,19 +72,18 @@ defmodule ConfigCat.LocalFileDataSource do end defimpl OverrideDataSource do + alias ConfigCat.Config.Setting alias ConfigCat.LocalFileDataSource - require ConfigCat.Constants, as: Constants - @spec behaviour(LocalFileDataSource.t()) :: OverrideDataSource.behaviour() def behaviour(data_source), do: data_source.override_behaviour - @spec overrides(LocalFileDataSource.t()) :: Config.settings() + @spec overrides(LocalFileDataSource.t()) :: Config.t() def overrides(%{cache: cache} = data_source) do refresh_cache(cache, data_source.filename) - case FileCache.cached_settings(cache) do - {:ok, settings} -> settings + case FileCache.cached_config(cache) do + {:ok, config} -> config _ -> %{} end end @@ -94,8 +93,8 @@ defmodule ConfigCat.LocalFileDataSource do unless FileCache.cached_timestamp(cache) == timestamp do with {:ok, contents} <- File.read(filename), {:ok, data} <- Jason.decode(contents) do - settings = normalize(data) - FileCache.update(cache, settings, timestamp) + config = normalize(data) + FileCache.update(cache, config, timestamp) else error -> log_error(error, filename) @@ -120,16 +119,16 @@ defmodule ConfigCat.LocalFileDataSource do end defp normalize(%{"flags" => source} = _data) do - source - |> Enum.map(fn {key, value} -> {key, %{Constants.value() => value}} end) - |> Map.new() + settings = + source + |> Enum.map(fn {key, value} -> {key, Setting.new(value: value)} end) + |> Map.new() + + Config.new(settings: settings) end defp normalize(source) do - case Config.fetch_settings(source) do - {:ok, settings} -> settings - _ -> %{} - end + Config.inline_salt_and_segments(source) end end end diff --git a/lib/config_cat/local_map_data_source.ex b/lib/config_cat/local_map_data_source.ex index 53650624..a90eb099 100644 --- a/lib/config_cat/local_map_data_source.ex +++ b/lib/config_cat/local_map_data_source.ex @@ -7,13 +7,12 @@ defmodule ConfigCat.LocalMapDataSource do use TypedStruct alias ConfigCat.Config + alias ConfigCat.Config.Setting alias ConfigCat.OverrideDataSource - require ConfigCat.Constants, as: Constants - typedstruct enforce: true do + field :config, Config.t() field :override_behaviour, OverrideDataSource.behaviour() - field :settings, Config.settings() end @doc """ @@ -23,12 +22,12 @@ defmodule ConfigCat.LocalMapDataSource do def new(overrides, override_behaviour) do settings = overrides - |> Enum.map(fn {key, value} -> {key, %{Constants.value() => value}} end) + |> Enum.map(fn {key, value} -> {key, Setting.new(value: value)} end) |> Map.new() %__MODULE__{ - override_behaviour: override_behaviour, - settings: settings + config: Config.new(settings: settings), + override_behaviour: override_behaviour } end @@ -38,7 +37,7 @@ defmodule ConfigCat.LocalMapDataSource do @spec behaviour(LocalMapDataSource.t()) :: OverrideDataSource.behaviour() def behaviour(%{override_behaviour: behaviour}), do: behaviour - @spec overrides(LocalMapDataSource.t()) :: Config.settings() - def overrides(%{settings: settings}), do: settings + @spec overrides(LocalMapDataSource.t()) :: Config.t() + def overrides(%{config: config}), do: config end end diff --git a/lib/config_cat/null_data_source.ex b/lib/config_cat/null_data_source.ex index 7a25b9db..28012fe7 100644 --- a/lib/config_cat/null_data_source.ex +++ b/lib/config_cat/null_data_source.ex @@ -29,7 +29,7 @@ defmodule ConfigCat.NullDataSource do @spec behaviour(NullDataSource.t()) :: OverrideDataSource.behaviour() def behaviour(_data_source), do: :local_over_remote - @spec overrides(NullDataSource.t()) :: Config.settings() - def overrides(_data_source), do: %{} + @spec overrides(NullDataSource.t()) :: Config.t() + def overrides(_data_source), do: Config.new() end end diff --git a/lib/config_cat/override_data_source.ex b/lib/config_cat/override_data_source.ex index 2826cc58..6b0923c8 100644 --- a/lib/config_cat/override_data_source.ex +++ b/lib/config_cat/override_data_source.ex @@ -43,6 +43,6 @@ defprotocol ConfigCat.OverrideDataSource do @doc """ Return the local flag overrides from the data source. """ - @spec overrides(data_source :: t) :: Config.settings() + @spec overrides(data_source :: t) :: Config.t() def overrides(data_source) end diff --git a/lib/config_cat/rollout.ex b/lib/config_cat/rollout.ex index 816da33f..ea52b478 100644 --- a/lib/config_cat/rollout.ex +++ b/lib/config_cat/rollout.ex @@ -2,58 +2,208 @@ defmodule ConfigCat.Rollout do @moduledoc false alias ConfigCat.Config + alias ConfigCat.Config.ComparisonContext + alias ConfigCat.Config.Condition + alias ConfigCat.Config.PercentageOption + alias ConfigCat.Config.PrerequisiteFlagComparator + alias ConfigCat.Config.PrerequisiteFlagCondition + alias ConfigCat.Config.Segment + alias ConfigCat.Config.SegmentComparator + alias ConfigCat.Config.SegmentCondition + alias ConfigCat.Config.Setting + alias ConfigCat.Config.SettingType + alias ConfigCat.Config.TargetingRule + alias ConfigCat.Config.UserComparator + alias ConfigCat.Config.UserCondition alias ConfigCat.EvaluationDetails - alias ConfigCat.Rollout.Comparator + alias ConfigCat.EvaluationLogger + alias ConfigCat.EvaluationWarnings alias ConfigCat.User require ConfigCat.ConfigCatLogger, as: ConfigCatLogger - require ConfigCat.Constants, as: Constants + + defmodule CircularDependencyError do + @moduledoc false + @enforce_keys [:prerequisite_key, :visited_keys] + defexception [:prerequisite_key, :visited_keys] + + @type option :: {:prerequisite_key, String.t()} | {:visited_keys, [String.t()]} + @type t :: %__MODULE__{ + prerequisite_key: String.t(), + visited_keys: [String.t()] + } + + @impl Exception + def exception(options) do + struct!(__MODULE__, options) + end + + @impl Exception + def message(%__MODULE__{} = error) do + depending_flags = + [error.prerequisite_key | error.visited_keys] + |> Enum.reverse() + |> Enum.map_join(" -> ", &"'#{&1}'") + + "Circular dependency detected between the following depending flags: #{depending_flags}" + end + end + + defmodule EvaluationError do + @moduledoc false + @enforce_keys [:message] + defexception [:message] + + @type t :: %__MODULE__{ + message: String.t() + } + end + + defmodule Context do + @moduledoc false + use TypedStruct + + alias ConfigCat.Config.SettingType + + typedstruct enforce: true do + field :config, Config.t() + field :default_value, Config.value() | nil + field :default_variation_id, Config.variation_id() | nil + field :key, Config.key() + field :logger, pid() | nil + field :percentage_option_attribute, String.t() + field :salt, Config.salt() + field :setting_type, SettingType.t() + field :user, User.t(), enforce: false + field :visited_keys, [String.t()] + field :warnings, pid() + end + end + + @default_percentage_option_attribute "Identifier" + @missing_user_error "cannot evaluate, User Object is missing" @spec evaluate( Config.key(), User.t() | nil, - Config.value(), + Config.value() | nil, Config.variation_id() | nil, - Config.settings() + Config.t(), + pid() | nil, + [String.t()] ) :: EvaluationDetails.t() - def evaluate(key, user, default_value, default_variation_id, settings) do - {:ok, logs} = Agent.start(fn -> [] end) + def evaluate(key, user, default_value, default_variation_id, config, logger \\ nil, visited_keys \\ []) do + settings = Config.settings(config) + + case setting(settings, key, default_value) do + {:ok, setting} -> + {:ok, warnings} = EvaluationWarnings.start() + + try do + validated_user = + case user do + nil -> + nil + + %User{} = user -> + user + + _ -> + EvaluationWarnings.warn_invalid_user(warnings, key) + nil + end + + context = %Context{ + config: config, + default_value: default_value, + default_variation_id: default_variation_id, + key: key, + logger: logger, + percentage_option_attribute: + Setting.percentage_option_attribute(setting) || @default_percentage_option_attribute, + salt: Setting.salt(setting), + setting_type: Setting.setting_type(setting), + user: validated_user, + visited_keys: visited_keys, + warnings: warnings + } + + evaluate_setting(setting, context) + after + EvaluationWarnings.stop(warnings) + end - try do - log_evaluating(logs, key, user) - - with {:ok, valid_user} <- validate_user(user), - {:ok, setting_descriptor} <- setting_descriptor(settings, key, default_value), - setting_variation = - Map.get(setting_descriptor, Constants.variation_id(), default_variation_id), - rollout_rules = Map.get(setting_descriptor, Constants.rollout_rules(), []), - percentage_rules = Map.get(setting_descriptor, Constants.percentage_rules(), []), - {value, variation, rule, percentage_rule} <- - evaluate_rules(rollout_rules, percentage_rules, valid_user, key, logs) do - variation = variation || setting_variation - - value = - if value == :none do - base_value(setting_descriptor, default_value, logs) - else - value - end + {:error, message} -> + ConfigCatLogger.error(message, event_id: 1001) EvaluationDetails.new( + default_value?: true, + error: message, key: key, - matched_evaluation_rule: rule, - matched_evaluation_percentage_rule: percentage_rule, - user: user, - value: value, - variation_id: variation + value: default_value, + variation_id: default_variation_id ) - else - {:error, :invalid_user} -> - log_invalid_user(key) - evaluate(key, nil, default_value, default_variation_id, settings) + end + end - {:error, message} -> - ConfigCatLogger.error(message, event_id: 1001) + defp evaluate_setting(setting, %Context{} = context) do + %Context{ + default_value: default_value, + default_variation_id: default_variation_id, + key: key, + logger: logger, + user: user, + visited_keys: visited_keys + } = context + + root_flag_evaluation? = visited_keys == [] + percentage_options = Setting.percentage_options(setting) + targeting_rules = Setting.targeting_rules(setting) + + try do + if root_flag_evaluation? do + logger + |> EvaluationLogger.log_evaluating(key, user) + |> EvaluationLogger.increase_indent() + end + + case evaluate_rules(targeting_rules, percentage_options, context) do + {:none, _variation_id, _matching_rule, _matching_option} -> + value = Setting.value(setting) + + if root_flag_evaluation? do + EvaluationLogger.log_return_value(logger, value) + end + + EvaluationDetails.new( + key: key, + user: user, + value: value, + variation_id: Setting.variation_id(setting, default_variation_id) + ) + + {value, variation_id, rule, percentage_option} -> + if root_flag_evaluation? do + EvaluationLogger.log_return_value(logger, value) + end + + EvaluationDetails.new( + key: key, + matched_targeting_rule: rule, + matched_percentage_option: percentage_option, + user: user, + value: value, + variation_id: variation_id + ) + end + rescue + error -> + if root_flag_evaluation? do + message = + "Failed to evaluate setting '#{key}'. (#{Exception.message(error)}). " <> + "Returning the default_value parameter that you specified in your application: '#{default_value}'." + + ConfigCatLogger.error(message, event_id: 2001) EvaluationDetails.new( default_value?: true, @@ -62,26 +212,16 @@ defmodule ConfigCat.Rollout do value: default_value, variation_id: default_variation_id ) - end - after - logs - |> Agent.get(& &1) - |> Enum.reverse() - |> Enum.join("\n") - |> ConfigCatLogger.debug(event_id: 5000) - - Agent.stop(logs) + else + reraise(error, __STACKTRACE__) + end end end - defp validate_user(nil), do: {:ok, nil} - defp validate_user(%User{} = user), do: {:ok, user} - defp validate_user(_), do: {:error, :invalid_user} - - defp setting_descriptor(settings, key, default_value) do + defp setting(settings, key, default_value) do case Map.fetch(settings, key) do - {:ok, descriptor} -> - {:ok, descriptor} + {:ok, setting} -> + {:ok, setting} :error -> available_keys = @@ -98,165 +238,357 @@ defmodule ConfigCat.Rollout do end end - defp evaluate_rules([], [], _user, _key, _logs), do: {:none, nil, nil, nil} + defp evaluate_rules([], [], _context), do: {:none, nil, nil, nil} + + defp evaluate_rules(targeting_rules, percentage_options, context) do + case evaluate_targeting_rules(targeting_rules, context) do + {:none, _, _, _} -> + {value, variation, option} = evaluate_percentage_options(percentage_options, context) + {value, variation, nil, option} - defp evaluate_rules(_rollout_rules, _percentage_rules, nil, key, _logs) do - log_nil_user(key) - {:none, nil, nil, nil} + {value, variation, rule, option} -> + {value, variation, rule, option} + end end - defp evaluate_rules(rollout_rules, percentage_rules, user, key, logs) do - case evaluate_rollout_rules(rollout_rules, user, key, logs) do - {:none, _, _} -> - {value, variation, rule} = evaluate_percentage_rules(percentage_rules, user, key) - {value, variation, nil, rule} + defp evaluate_targeting_rules([], _context), do: {:none, nil, nil, nil} - {value, variation, rule} -> - {value, variation, rule, nil} - end + defp evaluate_targeting_rules(rules, %Context{} = context) do + EvaluationLogger.log_evaluating_targeting_rules(context.logger) + + Enum.reduce_while(rules, {:none, nil, nil, nil}, fn rule, acc -> + case evaluate_targeting_rule(rule, context) do + {:none, _, _, _} -> {:cont, acc} + result -> {:halt, result} + end + end) end - defp evaluate_rollout_rules(rules, user, _key, logs) do - Enum.reduce_while(rules, {:none, nil, nil}, &evaluate_rollout_rule(&1, &2, user, logs)) + defp evaluate_targeting_rule(rule, %Context{} = context) do + %Context{logger: logger} = context + conditions = TargetingRule.conditions(rule) + value = TargetingRule.value(rule, context.setting_type) + + if evaluate_conditions(conditions, value, context) do + case TargetingRule.simple_value(rule) do + nil -> + EvaluationLogger.increase_indent(logger) + percentage_options = TargetingRule.percentage_options(rule) + {value, variation_id, option} = evaluate_percentage_options(percentage_options, context) + + if value == :none do + EvaluationLogger.log_ignored_targeting_rule(logger) + end + + EvaluationLogger.decrease_indent(logger) + {value, variation_id, rule, option} + + _ -> + variation_id = TargetingRule.variation_id(rule, context.default_variation_id) + {value, variation_id, rule, nil} + end + else + {:none, nil, nil, nil} + end end - defp evaluate_rollout_rule(rule, default, user, logs) do - comparison_attribute = Map.get(rule, Constants.comparison_attribute()) - comparison_value = Map.get(rule, Constants.comparison_value()) - comparator = Map.get(rule, Constants.comparator()) - value = Map.get(rule, Constants.value()) - variation = Map.get(rule, Constants.variation_id()) + defp evaluate_conditions(conditions, value, context) do + condition_count = length(conditions) - case User.get_attribute(user, comparison_attribute) do - nil -> - log_no_match(logs, comparison_attribute, nil, comparator, comparison_value) - {:cont, default} - - user_value -> - case Comparator.compare(comparator, to_string(user_value), to_string(comparison_value)) do - {:ok, true} -> - log_match( - logs, - comparison_attribute, - user_value, - comparator, - comparison_value, - value - ) - - {:halt, {value, variation, rule}} - - {:ok, false} -> - log_no_match(logs, comparison_attribute, user_value, comparator, comparison_value) - {:cont, default} - - {:error, error} -> - log_validation_error( - logs, - comparison_attribute, - user_value, - comparator, - comparison_value, - error - ) - - {:cont, default} + {result, newline_before_then?} = + conditions + |> Enum.with_index() + |> Enum.reduce_while({:ok, true}, fn {condition, index}, _acc -> + EvaluationLogger.log_evaluating_condition_start(context.logger, index) + + case evaluate_condition(condition, condition_count, context) do + {{:ok, true}, _newline?} = result -> {:cont, result} + result -> {:halt, result} end + end) + + EvaluationLogger.log_evaluating_condition_final_result(context.logger, result, newline_before_then?, value) + + case result do + {:ok, result} -> result + {:error, _error} -> false end end - defp evaluate_percentage_rules([] = _percentage_rules, _user, _key), do: {:none, nil, nil} + defp evaluate_condition(condition, condition_count, %Context{} = context) do + %Context{logger: logger} = context + prerequisite_flag_condition = Condition.prerequisite_flag_condition(condition) + segment_condition = Condition.segment_condition(condition) + user_condition = Condition.user_condition(condition) + + {result, newline?} = + cond do + user_condition -> + { + evaluate_user_condition(user_condition, context.key, context), + condition_count > 1 + } + + segment_condition -> + case evaluate_segment_condition(segment_condition, context) do + {:ok, _value} = result -> + {result, true} + + {:error, @missing_user_error} = result -> + {result, condition_count > 1} + + result -> + {result, true} + end + + prerequisite_flag_condition -> + { + evaluate_prerequisite_flag_condition(prerequisite_flag_condition, context), + true + } + end + + if condition_count > 1 do + EvaluationLogger.log_evaluating_condition_result(logger, result) + end - defp evaluate_percentage_rules(percentage_rules, user, key) do - hash_val = hash_user(user, key) + EvaluationLogger.decrease_indent(logger) + {result, newline?} + end - Enum.reduce_while( - percentage_rules, - {0, nil, nil}, - &evaluate_percentage_rule(&1, &2, hash_val) - ) + defp evaluate_user_condition(condition, _context_salt, %Context{user: nil} = context) do + EvaluationLogger.log_evaluating_user_condition_start(context.logger, condition) + EvaluationWarnings.warn_missing_user(context.warnings, context.key) + {:error, @missing_user_error} end - defp evaluate_percentage_rule(rule, increment, hash_val) do - {bucket, _v, _r} = increment - bucket = increment_bucket(bucket, rule) + defp evaluate_user_condition(condition, context_salt, %Context{} = context) do + %Context{logger: logger, user: user} = context - if hash_val < bucket do - percentage_value = Map.get(rule, Constants.value()) - variation_value = Map.get(rule, Constants.variation_id()) + EvaluationLogger.log_evaluating_user_condition_start(logger, condition) - {:halt, {percentage_value, variation_value, rule}} - else - {:cont, {bucket, nil, nil}} + case UserCondition.fetch_comparison_attribute(condition) do + {:error, :not_found} -> + raise EvaluationError, "Comparison attribute name missing" + + {:ok, comparison_attribute} -> + case User.get_attribute(user, comparison_attribute) do + missing when is_nil(missing) or missing == "" -> + EvaluationWarnings.warn_missing_user_attribute(context.warnings, context.key, condition, comparison_attribute) + {:error, "cannot evaluate, the User.#{comparison_attribute} attribute is missing"} + + user_value -> + compare(condition, user_value, context_salt, context) + end end end - defp increment_bucket(bucket, rule), do: bucket + Map.get(rule, Constants.percentage(), 0) + defp compare(condition, user_value, context_salt, %Context{} = context) do + %Context{key: key, salt: salt} = context - defp hash_user(user, key) do - user_key = User.get_attribute(user, "Identifier") - hash_candidate = "#{key}#{user_key}" + comparison_context = %ComparisonContext{ + condition: condition, + context_salt: context_salt, + key: key, + salt: salt + } - {hash_value, _} = - :sha - |> :crypto.hash(hash_candidate) - |> Base.encode16() - |> String.slice(0, 7) - |> Integer.parse(16) + comparator = UserCondition.comparator(condition) + comparison_value = UserCondition.comparison_value(condition) - rem(hash_value, 100) + case UserComparator.compare(comparator, user_value, comparison_value, comparison_context) do + {:ok, result} -> + {:ok, result} + + {:error, :invalid_datetime} -> + message = "'#{user_value}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)" + handle_invalid_user_attribute(condition, message, context) + + {:error, :invalid_float} -> + message = "'#{user_value}' is not a valid decimal number" + handle_invalid_user_attribute(condition, message, context) + + {:error, :invalid_string_list} -> + message = "'#{user_value}' is not a valid string array" + handle_invalid_user_attribute(condition, message, context) + + {:error, :invalid_version} -> + trimmed = user_value |> to_string() |> String.trim() + message = "'#{trimmed}' is not a valid semantic version" + handle_invalid_user_attribute(condition, message, context) + end end - defp base_value(setting_descriptor, default_value, logs) do - result = Map.get(setting_descriptor, Constants.value(), default_value) - log(logs, "Returning #{result}") + defp evaluate_segment_condition(condition, %Context{user: nil} = context) do + EvaluationWarnings.warn_missing_user(context.warnings, context.key) + EvaluationLogger.log_skipping_segment_condition_missing_user(context.logger, condition) + {:error, "cannot evaluate, User Object is missing"} + end + + defp evaluate_segment_condition(condition, %Context{} = context) do + %Context{logger: logger} = context + + case SegmentCondition.fetch_segment(condition) do + {:error, :not_found} -> + raise EvaluationError, "Segment reference is invalid." + + {:ok, segment} -> + comparator = SegmentCondition.segment_comparator(condition) + name = Segment.name(segment) + + EvaluationLogger.log_evaluating_segment_condition_start(logger, condition, name) + + segment + |> Segment.conditions() + |> Enum.with_index() + |> Enum.reduce_while({:ok, true}, fn {condition, index}, acc -> + EvaluationLogger.log_evaluating_condition_start(logger, index) + + result = evaluate_user_condition(condition, name, context) + EvaluationLogger.log_evaluating_condition_result(logger, result) - result + case result do + {:ok, true} -> {:cont, acc} + {:ok, false} -> {:halt, {:ok, false}} + {:error, error} -> {:halt, {:error, error}} + end + end) + |> case do + {:ok, in_segment?} -> + result = {:ok, SegmentComparator.compare(comparator, in_segment?)} + EvaluationLogger.log_evaluating_segment_condition_result(logger, condition, in_segment?, result) + result + + {:error, _error} = result -> + EvaluationLogger.log_evaluating_segment_condition_result(logger, condition, false, result) + result + end + end end - defp log_evaluating(logs, key, user) do - log(logs, "Evaluating get_value('#{key}). User object:\n#{inspect(user)}") + defp evaluate_prerequisite_flag_condition(condition, %Context{} = context) do + %Context{config: config, logger: logger, user: user, visited_keys: visited_keys} = context + settings = Config.settings(config) + prerequisite_key = PrerequisiteFlagCondition.prerequisite_flag_key(condition) + comparator = PrerequisiteFlagCondition.comparator(condition) + + case Map.get(settings, prerequisite_key) do + nil -> + raise EvaluationError, "Prerequisite flag key is missing or invalid." + + setting -> + setting_type = Setting.setting_type(setting) + comparison_value_type = PrerequisiteFlagCondition.inferred_setting_type(condition) + + unless setting_type == comparison_value_type do + value = + unless is_nil(comparison_value_type) do + PrerequisiteFlagCondition.comparison_value(condition, comparison_value_type) + end + + raise EvaluationError, + "Type mismatch between comparison value '#{value}' and prerequisite flag '#{prerequisite_key}'" + end + + comparison_value = PrerequisiteFlagCondition.comparison_value(condition, setting_type) + next_visited_keys = [context.key | visited_keys] + + if prerequisite_key in visited_keys do + raise CircularDependencyError, prerequisite_key: prerequisite_key, visited_keys: next_visited_keys + else + EvaluationLogger.log_evaluating_prerequisite_condition_start(logger, condition, setting_type) + + %EvaluationDetails{value: prerequisite_value} = + evaluate(prerequisite_key, user, nil, nil, config, logger, next_visited_keys) + + result = PrerequisiteFlagComparator.compare(comparator, prerequisite_value, comparison_value) + + EvaluationLogger.log_evaluating_prerequisite_condition_result( + logger, + condition, + setting_type, + prerequisite_value, + result + ) + + {:ok, result} + end + end end - defp log_match(logs, comparison_attribute, user_value, comparator, comparison_value, value) do - log( - logs, - "Evaluating rule: [#{comparison_attribute}:#{user_value}] [#{Comparator.description(comparator)}] [#{comparison_value}] => match, returning: #{value}" - ) + defp evaluate_percentage_options([] = _percentage_options, _context), do: {:none, nil, nil} + + defp evaluate_percentage_options(_percentage_options, %Context{user: nil} = context) do + EvaluationWarnings.warn_missing_user(context.warnings, context.key) + EvaluationLogger.log_skipping_percentage_options_missing_user(context.logger) + {:none, nil, nil} end - defp log_no_match(logs, comparison_attribute, user_value, comparator, comparison_value) do - log( - logs, - "Evaluating rule: [#{comparison_attribute}:#{user_value}] [#{Comparator.description(comparator)}] [#{comparison_value}] => no match" - ) + defp evaluate_percentage_options(percentage_options, %Context{} = context) do + case extract_user_key(context) do + {:ok, user_key} -> + hash_val = hash_user(user_key, context.key) + Enum.reduce_while(percentage_options, {0, 1}, &evaluate_percentage_option(&1, &2, hash_val, context)) + + {:error, :missing_user_key} -> + attribute_name = context.percentage_option_attribute + EvaluationWarnings.warn_missing_user_attribute(context.warnings, context.key, attribute_name) + EvaluationLogger.log_skipping_percentage_options_missing_user_attribute(context.logger, attribute_name) + {:none, nil, nil} + end end - defp log_validation_error(logs, comparison_attribute, user_value, comparator, comparison_value, error) do - message = - "Evaluating rule: [#{comparison_attribute}:#{user_value}] [#{Comparator.description(comparator)}] [#{comparison_value}] => SKIP rule. Validation error: #{inspect(error)}" + defp evaluate_percentage_option(option, increment, hash_val, %Context{} = context) do + percentage = PercentageOption.percentage(option) + {last_bucket, index} = increment + bucket = last_bucket + percentage + + if hash_val < bucket do + value = PercentageOption.value(option, context.setting_type) + variation_id = PercentageOption.variation_id(option, context.default_variation_id) + attribute_name = context.percentage_option_attribute + + EvaluationLogger.log_matching_percentage_option(context.logger, attribute_name, hash_val, index, percentage, value) - ConfigCatLogger.warning(message) - log(logs, message) + {:halt, {value, variation_id, option}} + else + {:cont, {bucket, index + 1}} + end end - defp log_nil_user(key) do - ConfigCatLogger.warning( - "Cannot evaluate targeting rules and % options for setting '#{key}' (User Object is missing). " <> - "You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. " <> - "Read more: https://configcat.com/docs/advanced/user-object/", - event_id: 3001 - ) + defp extract_user_key(%Context{} = context) do + attribute = context.percentage_option_attribute + + case User.get_attribute(context.user, attribute) do + nil -> + if attribute == @default_percentage_option_attribute do + {:ok, nil} + else + {:error, :missing_user_key} + end + + value -> + UserComparator.user_value_to_string(value) + end end - defp log_invalid_user(key) do - ConfigCatLogger.warning( - "Cannot evaluate targeting rules and % options for setting '#{key}' (User Object is not an instance of User struct).", - event_id: 4001 - ) + defp hash_user(user_key, key) do + hash_candidate = "#{key}#{user_key}" + + {hash_value, _} = + :sha + |> :crypto.hash(hash_candidate) + |> Base.encode16() + |> String.slice(0, 7) + |> Integer.parse(16) + + rem(hash_value, 100) end - defp log(logs, message) do - Agent.update(logs, &[message | &1]) + defp handle_invalid_user_attribute(condition, message, %Context{} = context) do + EvaluationWarnings.warn_type_mismatch(context.warnings, context.key, condition, message) + + attribute = UserCondition.comparison_attribute(condition) + {:error, "cannot evaluate, the User.#{attribute} attribute is invalid (#{message})"} end end diff --git a/lib/config_cat/rollout/comparator.ex b/lib/config_cat/rollout/comparator.ex deleted file mode 100644 index 0e6b2654..00000000 --- a/lib/config_cat/rollout/comparator.ex +++ /dev/null @@ -1,186 +0,0 @@ -defmodule ConfigCat.Rollout.Comparator do - @moduledoc false - - alias ConfigCat.Config - alias Version.InvalidVersionError - - @type comparator :: Config.comparator() - @type description :: String.t() - @type result :: {:ok, boolean()} | {:error, Exception.t()} - - @is_one_of 0 - @is_not_one_of 1 - @contains 2 - @does_not_contain 3 - @is_one_of_semver 4 - @is_not_one_of_semver 5 - @less_than_semver 6 - @less_than_equal_semver 7 - @greater_than_semver 8 - @greater_than_equal_semver 9 - @equals_number 10 - @not_equals_number 11 - @less_than_number 12 - @less_than_equal_number 13 - @greater_than_number 14 - @greater_than_equal_number 15 - @is_one_of_sensitive 16 - @is_not_one_of_sensitive 17 - - @descriptions %{ - @is_one_of => "IS ONE OF", - @is_not_one_of => "IS NOT ONE OF", - @contains => "CONTAINS", - @does_not_contain => "DOES NOT CONTAIN", - @is_one_of_semver => "IS ONE OF (SemVer)", - @is_not_one_of_semver => "IS NOT ONE OF (SemVer)", - @less_than_semver => "< (SemVer)", - @less_than_equal_semver => "<= (SemVer)", - @greater_than_semver => "> (SemVer)", - @greater_than_equal_semver => ">= (SemVer)", - @equals_number => "= (Number)", - @not_equals_number => "<> (Number)", - @less_than_number => "< (Number)", - @less_than_equal_number => "<= (Number)", - @greater_than_number => "> (Number)", - @greater_than_equal_number => ">= (Number)", - @is_one_of_sensitive => "IS ONE OF (Sensitive)", - @is_not_one_of_sensitive => "IS NOT ONE OF (Sensitive)" - } - - @spec description(comparator()) :: description() - def description(comparator) do - Map.get(@descriptions, comparator, "Unsupported comparator") - end - - @spec compare(comparator(), String.t(), String.t()) :: result() - - def compare(@is_one_of, user_value, comparison_value), do: is_one_of(user_value, comparison_value) - - def compare(@is_not_one_of, user_value, comparison_value), do: user_value |> is_one_of(comparison_value) |> negate() - - def compare(@contains, user_value, comparison_value), do: contains(user_value, comparison_value) - - def compare(@does_not_contain, user_value, comparison_value), do: user_value |> contains(comparison_value) |> negate() - - def compare(@is_one_of_semver, user_value, comparison_value), do: is_one_of_semver(user_value, comparison_value) - - def compare(@is_not_one_of_semver, user_value, comparison_value), - do: user_value |> is_one_of_semver(comparison_value) |> negate() - - def compare(@less_than_semver, user_value, comparison_value), do: compare_semver(user_value, comparison_value, [:lt]) - - def compare(@less_than_equal_semver, user_value, comparison_value), - do: compare_semver(user_value, comparison_value, [:lt, :eq]) - - def compare(@greater_than_semver, user_value, comparison_value), do: compare_semver(user_value, comparison_value, [:gt]) - - def compare(@greater_than_equal_semver, user_value, comparison_value), - do: compare_semver(user_value, comparison_value, [:gt, :eq]) - - def compare(@equals_number, user_value, comparison_value), do: compare_numbers(user_value, comparison_value, &==/2) - - def compare(@not_equals_number, user_value, comparison_value), do: compare_numbers(user_value, comparison_value, &!==/2) - - def compare(@less_than_number, user_value, comparison_value), do: compare_numbers(user_value, comparison_value, &/2) - - def compare(@greater_than_equal_number, user_value, comparison_value), - do: compare_numbers(user_value, comparison_value, &>=/2) - - def compare(@is_one_of_sensitive, user_value, comparison_value), do: is_one_of_sensitive(user_value, comparison_value) - - def compare(@is_not_one_of_sensitive, user_value, comparison_value), - do: user_value |> is_one_of_sensitive(comparison_value) |> negate() - - def compare(_comparator, _user_value, _comparison_value) do - {:ok, false} - end - - # These aren't actually predicates, but are inlined in the `config-v6` branch - # so will go away soon. - # credo:disable-for-next-line Credo.Check.Readability.PredicateFunctionNames - defp is_one_of(user_value, comparison_value) do - result = - comparison_value - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.member?(user_value) - - {:ok, result} - end - - defp contains(user_value, comparison_value) do - result = String.contains?(user_value, comparison_value) - {:ok, result} - end - - # These aren't actually predicates, but are inlined in the `config-v6` branch - # so will go away soon. - # credo:disable-for-next-line Credo.Check.Readability.PredicateFunctionNames - defp is_one_of_semver(user_value, comparison_value) do - user_version = Version.parse!(user_value) - - result = - comparison_value - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - |> Enum.map(&Version.parse!/1) - |> Enum.any?(fn version -> Version.compare(user_version, version) == :eq end) - - {:ok, result} - rescue - error in Version.InvalidVersionError -> - {:error, error} - end - - # These aren't actually predicates, but are inlined in the `config-v6` branch - # so will go away soon. - # credo:disable-for-next-line Credo.Check.Readability.PredicateFunctionNames - defp is_one_of_sensitive(user_value, comparison_value) do - user_value - |> hash_value() - |> is_one_of(comparison_value) - end - - defp hash_value(value) do - :sha - |> :crypto.hash(value) - |> Base.encode16() - |> String.downcase() - end - - defp compare_semver(user_value, comparison_value, valid_comparisons) do - user_version = to_version(user_value) - comparison_version = to_version(comparison_value) - result = Version.compare(user_version, comparison_version) in valid_comparisons - {:ok, result} - rescue - error in InvalidVersionError -> {:error, error} - end - - defp to_version(value) do - value |> String.trim() |> Version.parse!() - end - - defp compare_numbers(user_value, comparison_value, operator) do - with {user_float, _} <- to_float(user_value), - {comparison_float, _} <- to_float(comparison_value) do - {:ok, operator.(user_float, comparison_float)} - else - :error -> {:error, :invalid_float} - end - end - - defp to_float(value) do - value |> to_string() |> String.replace(",", ".") |> Float.parse() - end - - defp negate({:ok, result}), do: {:ok, !result} - defp negate(error), do: error -end diff --git a/lib/config_cat/supervisor.ex b/lib/config_cat/supervisor.ex index 41f00b2e..097287bf 100644 --- a/lib/config_cat/supervisor.ex +++ b/lib/config_cat/supervisor.ex @@ -18,14 +18,12 @@ defmodule ConfigCat.Supervisor do @spec start_link(keyword()) :: Supervisor.on_start() def start_link(options) when is_list(options) do + options = Keyword.merge(default_options(), options) sdk_key = options[:sdk_key] - validate_sdk_key(sdk_key) + validate_sdk_key(sdk_key, options) ensure_unique_sdk_key(sdk_key) - options = - default_options() - |> Keyword.merge(options) - |> put_cache_key(sdk_key) + options = put_cache_key(options, sdk_key) # Rename name -> instance_id for everything downstream {instance_id, options} = Keyword.pop!(options, :name) @@ -34,9 +32,34 @@ defmodule ConfigCat.Supervisor do Supervisor.start_link(__MODULE__, options, name: via_tuple(instance_id, sdk_key)) end - defp validate_sdk_key(nil), do: raise(ArgumentError, "SDK Key is required") - defp validate_sdk_key(""), do: raise(ArgumentError, "SDK Key is required") - defp validate_sdk_key(sdk_key) when is_binary(sdk_key), do: :ok + defp validate_sdk_key(nil, _options), do: raise(ArgumentError, "SDK Key is required") + defp validate_sdk_key("", _options), do: raise(ArgumentError, "SDK Key is required") + + defp validate_sdk_key(sdk_key, options) when is_binary(sdk_key) do + has_base_url? = !is_nil(options[:base_url]) + overrides = options[:flag_overrides] + + cond do + OverrideDataSource.behaviour(overrides) == :local_only -> + :ok + + sdk_key =~ ~r[^.{22}/.{22}$] -> + :ok + + sdk_key =~ ~r[^configcat-sdk-1/.{22}/.{22}$] -> + :ok + + has_base_url? and sdk_key =~ ~r[^configcat-proxy/.+$] -> + :ok + + true -> + raise ArgumentError, "SDK Key `#{sdk_key}` is invalid." + end + end + + defp validate_sdk_key(sdk_key, _options) do + raise ArgumentError, "SDK Key `#{inspect(sdk_key)}` is invalid." + end defp ensure_unique_sdk_key(sdk_key) do ConfigCat.Registry diff --git a/lib/config_cat/user.ex b/lib/config_cat/user.ex index 4f0866a7..943d3432 100644 --- a/lib/config_cat/user.ex +++ b/lib/config_cat/user.ex @@ -8,34 +8,77 @@ defmodule ConfigCat.User do ConfigCat SDK. Has the following properties: - - `identifier`: **REQUIRED** We recommend using a UserID, Email address, - or SessionID. Enables ConfigCat to differentiate your users from each - other and to evaluate the setting values for percentage-based targeting. - - - `country`: **OPTIONAL** Fill this for location or country-based - targeting. e.g: Turn on a feature for users in Canada only. - - - `email`: **OPTIONAL** By adding this parameter you will be able to - create Email address-based targeting. e.g: Only turn on a feature - for users with @example.com addresses. - - - `custom`: **OPTIONAL** This parameter will let you create targeting - based on any user data you like. e.g: Age, Subscription type, - User role, Device type, App version number, etc. `custom` is a map - containing string or atom keys and string values. When evaluating - targeting rules, keys are case-sensitive, so make sure you specify - your keys with the same capitalization as you use when defining - your targeting rules. - - While `ConfigCat.User` is a struct, we also provide the `new/2` function - to make it easier to create a new user object. Pass it the `identifier` - and then either a keyword list or map containing the other properties - you want to specify. + - `identifier`: **REQUIRED** We recommend using a primary key, email address, + or session ID. Enables ConfigCat to differentiate your users from each other + and to evaluate the setting values for percentage-based targeting. + + - `country`: **OPTIONAL** Fill this for location or country-based targeting. + e.g: Turn on a feature for users in Canada only. + + - `email`: **OPTIONAL** By adding this parameter you will be able to create + Email address-based targeting. e.g: Only turn on a feature for users with + @example.com addresses. + + - `custom`: **OPTIONAL** This parameter will let you create targeting based on + any user data you like. e.g: age, subscription type, user role, device type, + app version number, etc. `custom` is a map containing string or atom keys. + When evaluating targeting rules, keys are case-sensitive, so make sure you + specify your keys with the same capitalization as you use when defining your + targeting rules. + + All comparators support string values as User Object attributes (in some cases + they need to be provided in a specific format though, see below), but some of + them also support other types of values. It depends on the comparator how the + values will be handled. The following rules apply: + + Text-based comparators (EQUALS, IS_ONE_OF, etc.) + - accept string values, + - all other values are automatically converted to string (a warning will be + logged but evaluation will continue as normal). + + SemVer-based comparators (IS_ONE_OF_SEMVER, LESS_THAN_SEMVER, + GREATER_THAN_SEMVER, etc.) + - accept string values containing a properly formatted, valid semver value + - all other values are considered invalid (a warning will be logged and the + currently evaluated targeting rule will be skipped). + + Number-based comparators (EQUALS_NUMBER, LESS_THAN_NUMBER, + GREATER_THAN_OR_EQUAL_NUMBER, etc.) + - accept float values and all other numeric values which can safely be + converted to float, + - accept string values containing a properly formatted, valid float value, + - all other values are considered invalid (a warning will be logged and the + currently evaluated targeting rule will be skipped). + + Date time-based comparators (BEFORE_DATETIME / AFTER_DATETIME) + - accept `DateTime` and `NaiveDateTime` values, which are automatically + converted to a second-based fractional Unix timestamp (`NaiveDateTime` + values are considered to be in UTC), + - accept float values representing a fractional second-based Unix timestamp + and all other numeric values which can safely be converted to float, + - accept string values containing a properly formatted, valid float value, + - all other values are considered invalid (a warning will be logged and the + currently evaluated targeting rule will be skipped). + + String array-based comparators (ARRAY_CONTAINS_ANY_OF / + ARRAY_NOT_CONTAINS_ANY_OF) + - accept arrays of strings, + - accept string values containing a valid JSON string which can be + deserialized to an array of strings, + - all other values are considered invalid (a warning will be logged and the + currently evaluated targeting rule will be skipped). + + While `ConfigCat.User` is a struct, we also provide the `new/2` function to + make it easier to create a new user object. Pass it the `identifier` and then + either a keyword list or map containing the other properties you want to + specify. e.g. `ConfigCat.User.new("IDENTIFIER", email: "user@example.com")` """ use TypedStruct + alias ConfigCat.Config + typedstruct do @typedoc "The ConfigCat user object." @@ -62,7 +105,7 @@ defmodule ConfigCat.User do @type options :: keyword() | map() @doc """ - Creates a new ConfigCat.User struct. + Creates a new ConfigCat.User Object. This is provided as a convenience to make it easier to create a new user object. @@ -78,15 +121,11 @@ defmodule ConfigCat.User do end @doc false - @spec get_attribute(t(), String.t()) :: String.t() | nil - def get_attribute(user, attribute) do - do_get_attribute(user, attribute) - end - - defp do_get_attribute(user, "Identifier"), do: user.identifier - defp do_get_attribute(user, "Country"), do: user.country - defp do_get_attribute(user, "Email"), do: user.email - defp do_get_attribute(user, attribute), do: custom_attribute(user.custom, attribute) + @spec get_attribute(t(), String.t()) :: Config.value() | nil + def get_attribute(user, "Identifier"), do: user.identifier + def get_attribute(user, "Country"), do: user.country + def get_attribute(user, "Email"), do: user.email + def get_attribute(user, attribute), do: custom_attribute(user.custom, attribute) defp custom_attribute(custom, attribute) do case Enum.find(custom, fn {key, _value} -> @@ -96,4 +135,22 @@ defmodule ConfigCat.User do _ -> nil end end + + defimpl String.Chars do + @moduledoc false + alias ConfigCat.User + + @spec to_string(User.t()) :: String.t() + def to_string(%User{} = user) do + %{ + "Identifier" => user.identifier, + "Email" => user.email, + "Country" => user.country + } + |> Map.merge(user.custom) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + |> Jason.encode!() + end + end end diff --git a/mix.exs b/mix.exs index 7c10ea73..74ed6d5d 100644 --- a/mix.exs +++ b/mix.exs @@ -82,7 +82,8 @@ defmodule ConfigCat.MixProject do {:mix_test_interactive, "~> 1.2", only: :dev, runtime: false}, {:mox, "~> 1.1", only: :test}, {:styler, "~> 0.11", only: [:dev, :test], runtime: false}, - {:typed_struct, "~> 0.3.0"} + {:typed_struct, "~> 0.3.0"}, + {:tz, "~> 0.26.5", only: :test} ] end diff --git a/mix.lock b/mix.lock index ee930d23..9547fefc 100644 --- a/mix.lock +++ b/mix.lock @@ -26,6 +26,8 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "styler": {:hex, :styler, "0.11.1", "1bf6a13f49c00c4e77855cd2dbd6fe5518b6bfb977e76fdb2fa088d64f839194", [:mix], [], "hexpm", "8d995b3e7a787645a09a9a7382e17fb99d4bb63f4cf8390a2aaf0afa8d4b2f7d"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, + "tz": {:hex, :tz, "0.26.5", "bfe8efa345670f90351c5c31d22455d0307c5d9895fbdede7deeb215a7b60dbe", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "c4f9392d710582c7108b6b8c635f4981120ec4b2072adbd242290fc842338183"}, + "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, } diff --git a/samples/multi/lib/multi.ex b/samples/multi/lib/multi.ex index 1fbb0f80..e2412422 100644 --- a/samples/multi/lib/multi.ex +++ b/samples/multi/lib/multi.ex @@ -19,12 +19,12 @@ defmodule Multi do defmodule First do @moduledoc false - use ConfigCat, sdk_key: "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A" + use ConfigCat, sdk_key: "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ" end defmodule Second do @moduledoc false - use ConfigCat, sdk_key: "PKDVCLf-Hq-h-kCzMp-L7Q/HhOWfwVtZ0mb30i9wi17GQ" + use ConfigCat, sdk_key: "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/tiOvFw5gkky9LFu1Duuvzw" end def start_link(_options) do diff --git a/samples/multi/mix.lock b/samples/multi/mix.lock index 013be644..5c64cf5a 100644 --- a/samples/multi/mix.lock +++ b/samples/multi/mix.lock @@ -1,14 +1,14 @@ %{ - "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, - "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, + "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/samples/simple/lib/simple/application.ex b/samples/simple/lib/simple/application.ex index 21119200..a4fdd756 100644 --- a/samples/simple/lib/simple/application.ex +++ b/samples/simple/lib/simple/application.ex @@ -5,7 +5,7 @@ defmodule Simple.Application do require Logger - @sdk_key "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A" + @sdk_key "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ" @impl Application def start(_type, _args) do diff --git a/samples/simple/mix.lock b/samples/simple/mix.lock index 013be644..5c64cf5a 100644 --- a/samples/simple/mix.lock +++ b/samples/simple/mix.lock @@ -1,14 +1,14 @@ %{ - "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, - "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, - "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, + "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/config_cat/cache_policy/auto_test.exs b/test/config_cat/cache_policy/auto_test.exs index d9b1b162..75e75104 100644 --- a/test/config_cat/cache_policy/auto_test.exs +++ b/test/config_cat/cache_policy/auto_test.exs @@ -43,30 +43,33 @@ defmodule ConfigCat.CachePolicy.AutoTest do end describe "getting the config" do - test "refreshes automatically after initializing", %{entry: entry, settings: settings} do + test "refreshes automatically after initializing", %{ + config: config, + entry: entry + } do expect_refresh(entry) {:ok, instance_id} = start_cache_policy(@policy) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "skips initial fetch if cache is already populated with a recent entry", - %{entry: entry, settings: settings} do + %{config: config, entry: entry} do expect_not_refreshed() {:ok, instance_id} = start_cache_policy(@policy, initial_entry: entry) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "performs initial fetch if cache is already populated with an older entry", - %{entry: entry, settings: settings} do + %{config: config, entry: entry} do %{entry: old_entry} = make_old_entry(@policy.poll_interval_ms + 1) expect_refresh(entry) {:ok, instance_id} = start_cache_policy(@policy, initial_entry: old_entry) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end @tag capture_log: true @@ -75,7 +78,7 @@ defmodule ConfigCat.CachePolicy.AutoTest do wait_time_ms = 100 policy = CachePolicy.auto(max_init_wait_time_seconds: wait_time_ms / 1000.0) - %{entry: old_entry, settings: old_settings} = make_old_entry() + %{config: old_config, entry: old_entry} = make_old_entry() old_entry = Map.update!(old_entry, :fetch_time_ms, &(&1 - policy.poll_interval_ms - 1)) expect(MockFetcher, :fetch, fn _id, _etag -> @@ -86,7 +89,7 @@ defmodule ConfigCat.CachePolicy.AutoTest do {:ok, instance_id} = start_cache_policy(policy, initial_entry: old_entry) before = FetchTime.now_ms() - assert {:ok, old_settings, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, old_config, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) elapsed_ms = FetchTime.now_ms() - before assert wait_time_ms <= elapsed_ms && elapsed_ms <= wait_time_ms * 2 @@ -101,7 +104,10 @@ defmodule ConfigCat.CachePolicy.AutoTest do CachePolicy.get(instance_id) end - test "refreshes automatically after poll interval", %{entry: entry, settings: settings} do + test "refreshes automatically after poll interval", %{ + config: config, + entry: entry + } do %{entry: old_entry} = make_old_entry() expect_refresh(old_entry) @@ -113,27 +119,27 @@ defmodule ConfigCat.CachePolicy.AutoTest do expect_refresh(entry) wait_for_poll(policy) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end end describe "refreshing the config" do - test "stores new config in the cache", %{entry: entry, settings: settings} do - %{entry: old_entry, settings: old_settings} = make_old_entry() + test "stores new config in the cache", %{config: config, entry: entry} do + %{config: old_config, entry: old_entry} = make_old_entry() expect_refresh(old_entry) {:ok, instance_id} = start_cache_policy(@policy) - assert {:ok, old_settings, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, old_config, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) expect_refresh(entry) assert :ok = CachePolicy.force_refresh(instance_id) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "updates fetch time when server responds that the config hasn't changed", %{ - entry: entry, - settings: settings + config: config, + entry: entry } do entry = Map.update!(entry, :fetch_time_ms, &(&1 - 200)) @@ -147,7 +153,7 @@ defmodule ConfigCat.CachePolicy.AutoTest do assert :ok = CachePolicy.force_refresh(instance_id) - assert {:ok, ^settings, new_fetch_time_ms} = CachePolicy.get(instance_id) + assert {:ok, ^config, new_fetch_time_ms} = CachePolicy.get(instance_id) assert before <= new_fetch_time_ms && new_fetch_time_ms <= FetchTime.now_ms() end @@ -217,16 +223,20 @@ defmodule ConfigCat.CachePolicy.AutoTest do end describe "offline" do - test "does not fetch config when offline mode is set", %{entry: entry, settings: settings} do + test "does not fetch config when offline mode is set", %{ + config: config, + entry: entry + } do policy = CachePolicy.auto(poll_interval_seconds: 1) - %{entry: old_entry, settings: old_settings} = make_old_entry(policy.poll_interval_ms + 1) + %{config: old_config, entry: old_entry} = + make_old_entry(policy.poll_interval_ms + 1) expect_refresh(old_entry) {:ok, instance_id} = start_cache_policy(policy) refute CachePolicy.offline?(instance_id) - assert {:ok, old_settings, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, old_config, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) assert :ok = CachePolicy.set_offline(instance_id) assert CachePolicy.offline?(instance_id) @@ -234,7 +244,7 @@ defmodule ConfigCat.CachePolicy.AutoTest do expect_not_refreshed() wait_for_poll(policy) - assert {:ok, old_settings, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, old_config, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) expect_refresh(entry, self()) @@ -243,7 +253,7 @@ defmodule ConfigCat.CachePolicy.AutoTest do assert_receive :fetch_complete - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end end diff --git a/test/config_cat/cache_policy/lazy_test.exs b/test/config_cat/cache_policy/lazy_test.exs index c3b212dc..d7be470a 100644 --- a/test/config_cat/cache_policy/lazy_test.exs +++ b/test/config_cat/cache_policy/lazy_test.exs @@ -3,8 +3,10 @@ defmodule ConfigCat.CachePolicy.LazyTest do import Mox + alias ConfigCat.Cache alias ConfigCat.CachePolicy alias ConfigCat.CachePolicy.Lazy + alias ConfigCat.ConfigEntry alias ConfigCat.FetchTime @policy CachePolicy.lazy(cache_refresh_interval_seconds: 300) @@ -21,30 +23,30 @@ defmodule ConfigCat.CachePolicy.LazyTest do end describe "getting the config" do - test "fetches config when first requested", %{entry: entry, settings: settings} do + test "fetches config when first requested", %{config: config, entry: entry} do {:ok, instance_id} = start_cache_policy(@policy) expect_refresh(entry) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "skips initial fetch if cache is already populated with a recent entry", - %{entry: entry, settings: settings} do + %{config: config, entry: entry} do {:ok, instance_id} = start_cache_policy(@policy, initial_entry: entry) expect_not_refreshed() - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "performs initial fetch if cache is already populated with an older entry", - %{entry: entry, settings: settings} do + %{config: config, entry: entry} do %{entry: old_entry} = make_old_entry(@policy.cache_refresh_interval_ms + 1) {:ok, instance_id} = start_cache_policy(@policy, initial_entry: old_entry) expect_refresh(entry) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "doesn't re-fetch if cache has not expired", %{entry: entry} do @@ -57,7 +59,7 @@ defmodule ConfigCat.CachePolicy.LazyTest do CachePolicy.get(instance_id) end - test "re-fetches if cache has expired", %{entry: entry, settings: settings} do + test "re-fetches if cache has expired", %{config: config, entry: entry} do policy = CachePolicy.lazy(cache_refresh_interval_seconds: 0) {:ok, instance_id} = start_cache_policy(policy) %{entry: old_entry} = make_old_entry() @@ -66,18 +68,35 @@ defmodule ConfigCat.CachePolicy.LazyTest do :ok = CachePolicy.force_refresh(instance_id) expect_refresh(entry) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) + end + + test "doesn't re-fetch after expiry if cache has been updated", %{config: config, entry: entry} do + policy = CachePolicy.lazy(cache_refresh_interval_seconds: 1) + %{config: old_config, entry: old_entry} = make_old_entry() + {:ok, instance_id} = start_cache_policy(policy, initial_entry: old_entry) + + expect_not_refreshed() + assert {:ok, old_config, old_entry.fetch_time_ms} == CachePolicy.get(instance_id) + + Process.sleep(policy.cache_refresh_interval_ms) + + new_entry = ConfigEntry.refresh(entry) + Cache.set(instance_id, new_entry) + + expect_not_refreshed() + assert {:ok, config, new_entry.fetch_time_ms} == CachePolicy.get(instance_id) end end describe "refreshing the config" do - test "stores new config in the cache", %{entry: entry, settings: settings} do + test "stores new config in the cache", %{config: config, entry: entry} do {:ok, instance_id} = start_cache_policy(@policy) expect_refresh(entry) assert :ok = CachePolicy.force_refresh(instance_id) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "fetches new config even if cache is not expired", %{entry: entry} do @@ -91,8 +110,8 @@ defmodule ConfigCat.CachePolicy.LazyTest do end test "updates fetch time when server responds that the config hasn't changed", %{ - entry: entry, - settings: settings + config: config, + entry: entry } do entry = Map.update!(entry, :fetch_time_ms, &(&1 - 200)) {:ok, instance_id} = start_cache_policy(@policy, initial_entry: entry) @@ -103,7 +122,7 @@ defmodule ConfigCat.CachePolicy.LazyTest do assert :ok = CachePolicy.force_refresh(instance_id) - assert {:ok, ^settings, new_fetch_time_ms} = CachePolicy.get(instance_id) + assert {:ok, ^config, new_fetch_time_ms} = CachePolicy.get(instance_id) assert before <= new_fetch_time_ms && new_fetch_time_ms <= FetchTime.now_ms() end diff --git a/test/config_cat/cache_policy/manual_test.exs b/test/config_cat/cache_policy/manual_test.exs index 18332757..d2de6c02 100644 --- a/test/config_cat/cache_policy/manual_test.exs +++ b/test/config_cat/cache_policy/manual_test.exs @@ -30,18 +30,18 @@ defmodule ConfigCat.CachePolicy.ManualTest do end describe "refreshing the config" do - test "stores new config in the cache", %{entry: entry, settings: settings} do + test "stores new config in the cache", %{config: config, entry: entry} do {:ok, instance_id} = start_cache_policy(@policy) expect_refresh(entry) assert :ok = CachePolicy.force_refresh(instance_id) - assert {:ok, settings, entry.fetch_time_ms} == CachePolicy.get(instance_id) + assert {:ok, config, entry.fetch_time_ms} == CachePolicy.get(instance_id) end test "updates fetch time when server responds that the config hasn't changed", %{ - entry: entry, - settings: settings + config: config, + entry: entry } do entry = Map.update!(entry, :fetch_time_ms, &(&1 - 200)) {:ok, instance_id} = start_cache_policy(@policy, initial_entry: entry) @@ -52,7 +52,7 @@ defmodule ConfigCat.CachePolicy.ManualTest do assert :ok = CachePolicy.force_refresh(instance_id) - assert {:ok, ^settings, new_fetch_time_ms} = CachePolicy.get(instance_id) + assert {:ok, ^config, new_fetch_time_ms} = CachePolicy.get(instance_id) assert before <= new_fetch_time_ms && new_fetch_time_ms <= FetchTime.now_ms() end diff --git a/test/config_cat/cache_test.exs b/test/config_cat/cache_test.exs index bf10da1e..ee6770a2 100644 --- a/test/config_cat/cache_test.exs +++ b/test/config_cat/cache_test.exs @@ -9,14 +9,20 @@ defmodule ConfigCat.CacheTest do alias ConfigCat.Hooks alias ConfigCat.MockConfigCache - @config Config.new_with_settings(%{}) + @config Config.new() @entry ConfigEntry.new(@config, "ETAG") @serialized ConfigEntry.serialize(@entry) describe "generating a cache key" do - test "generates platform-independent cache keys" do - assert Cache.generate_key("test1") == "147c5b4c2b2d7c77e1605b1a4309f0ea6684a0c6" - assert Cache.generate_key("test2") == "c09513b1756de9e4bc48815ec7a142b2441ed4d5" + for {sdk_key, expected_cache_key} <- [ + {"configcat-sdk-1/TEST_KEY-0123456789012/1234567890123456789012", "f83ba5d45bceb4bb704410f51b704fb6dfa19942"}, + {"configcat-sdk-1/TEST_KEY2-123456789012/1234567890123456789012", "da7bfd8662209c8ed3f9db96daed4f8d91ba5876"} + ] do + test "generates platform-independent cache keys - #{sdk_key}" do + sdk_key = unquote(sdk_key) + expected_cache_key = unquote(expected_cache_key) + assert Cache.generate_key(sdk_key) == expected_cache_key + end end end diff --git a/test/config_cat/config/user_comparator_test.exs b/test/config_cat/config/user_comparator_test.exs new file mode 100644 index 00000000..dcb46f76 --- /dev/null +++ b/test/config_cat/config/user_comparator_test.exs @@ -0,0 +1,508 @@ +defmodule ConfigCat.Config.UserComparatorTest do + @moduledoc """ + All evaluators are tested exhaustively in ConfigCat.RolloutTest. + These are basic tests to ensure that we're using the correct + comparator type for the given comparator value. + """ + + use ExUnit.Case, async: true + + alias ConfigCat.Config.ComparisonContext + alias ConfigCat.Config.UserComparator + alias ConfigCat.Config.UserCondition + + @context_salt "CONTEXT_SALT" + @salt "SALT" + + test "returns false if given an unknown comparator" do + assert {:ok, false} = compare(-1, "b", ["a", "b", "c"]) + end + + describe "basic comparators" do + test "is_one_of" do + is_one_of = 0 + + assert {:ok, true} = compare(is_one_of, "b", ["a", "b", "c"]) + assert {:ok, false} = compare(is_one_of, "x", ["a", "b", "c"]) + end + + test "is_not_one_of" do + is_not_one_of = 1 + + assert {:ok, false} = compare(is_not_one_of, "b", ["a", "b", "c"]) + assert {:ok, true} = compare(is_not_one_of, "x", ["a", "b", "c"]) + end + + test "contains_any_of" do + contains_any_of = 2 + + assert {:ok, true} = compare(contains_any_of, "jane@configcat.com", ["configcat.com", "example.com"]) + assert {:ok, true} = compare(contains_any_of, "jane@example.com", ["configcat.com", "example.com"]) + assert {:ok, false} = compare(contains_any_of, "jane@email.com", ["configcat.com"]) + end + + test "not_contains_any_of" do + not_contains_any_of = 3 + + assert {:ok, false} = + compare(not_contains_any_of, "jane@configcat.com", ["configcat.com", "example.com"]) + + assert {:ok, false} = + compare(not_contains_any_of, "jane@example.com", ["configcat.com", "example.com"]) + + assert {:ok, true} = + compare(not_contains_any_of, "jane@email.com", ["configcat.com"]) + end + + test "equals" do + equals = 28 + + assert {:ok, true} = compare(equals, "abc", "abc") + assert {:ok, false} = compare(equals, "abc", "def") + end + + test "not equals" do + not_equals = 29 + + assert {:ok, true} = compare(not_equals, "abc", "def") + assert {:ok, false} = compare(not_equals, "abc", "abc") + end + + test "starts_with_any_of" do + starts_with_any_of = 30 + comparison = ["a", "b", "c"] + + assert {:ok, true} = compare(starts_with_any_of, "apple", comparison) + assert {:ok, true} = compare(starts_with_any_of, "banana", comparison) + assert {:ok, true} = compare(starts_with_any_of, "cherry", comparison) + assert {:ok, true} = compare(starts_with_any_of, "a", comparison) + assert {:ok, false} = compare(starts_with_any_of, "pear", comparison) + assert {:ok, false} = compare(starts_with_any_of, "", comparison) + end + + test "not starts_with_any_of" do + not_starts_with_any_of = 31 + comparison = ["a", "b", "c"] + + assert {:ok, true} = compare(not_starts_with_any_of, "pear", comparison) + assert {:ok, true} = compare(not_starts_with_any_of, "", comparison) + assert {:ok, false} = compare(not_starts_with_any_of, "apple", comparison) + assert {:ok, false} = compare(not_starts_with_any_of, "banana", comparison) + assert {:ok, false} = compare(not_starts_with_any_of, "cherry", comparison) + assert {:ok, false} = compare(not_starts_with_any_of, "a", comparison) + end + + test "ends_with_any_of" do + ends_with_any_of = 32 + comparison = ["a", "b", "c"] + + assert {:ok, true} = compare(ends_with_any_of, "banana", comparison) + assert {:ok, true} = compare(ends_with_any_of, "thumb", comparison) + assert {:ok, true} = compare(ends_with_any_of, "sonic", comparison) + assert {:ok, true} = compare(ends_with_any_of, "a", comparison) + assert {:ok, false} = compare(ends_with_any_of, "pear", comparison) + assert {:ok, false} = compare(ends_with_any_of, "", comparison) + end + + test "not ends_with_any_of" do + not_ends_with_any_of = 33 + comparison = ["a", "b", "c"] + + assert {:ok, true} = compare(not_ends_with_any_of, "pear", comparison) + assert {:ok, true} = compare(not_ends_with_any_of, "", comparison) + assert {:ok, false} = compare(not_ends_with_any_of, "banana", comparison) + assert {:ok, false} = compare(not_ends_with_any_of, "thumb", comparison) + assert {:ok, false} = compare(not_ends_with_any_of, "sonic", comparison) + assert {:ok, false} = compare(not_ends_with_any_of, "a", comparison) + end + + test "array_contains_any_of" do + array_contains_any_of = 34 + comparison = ["a", "b", "c"] + + assert {:ok, true} = compare(array_contains_any_of, ["a", "x"], comparison) + assert {:ok, true} = compare(array_contains_any_of, ["x", "b"], comparison) + assert {:ok, true} = compare(array_contains_any_of, ["c"], comparison) + assert {:ok, true} = compare(array_contains_any_of, Jason.encode!(["c"]), comparison) + assert {:ok, false} = compare(array_contains_any_of, ["x"], comparison) + assert {:error, :invalid_string_list} = compare(array_contains_any_of, "a", comparison) + assert {:error, :invalid_string_list} = compare(array_contains_any_of, :not_a_list, comparison) + end + + test "array_not_contains_any_of" do + array_not_contains_any_of = 35 + comparison = ["a", "b", "c"] + + assert {:ok, true} = compare(array_not_contains_any_of, ["x"], comparison) + assert {:ok, false} = compare(array_not_contains_any_of, ["a", "x"], comparison) + assert {:ok, false} = compare(array_not_contains_any_of, ["x", "b"], comparison) + assert {:ok, false} = compare(array_not_contains_any_of, ["c"], comparison) + assert {:ok, false} = compare(array_not_contains_any_of, Jason.encode!(["c"]), comparison) + assert {:error, :invalid_string_list} = compare(array_not_contains_any_of, "a", comparison) + assert {:error, :invalid_string_list} = compare(array_not_contains_any_of, :not_a_list, comparison) + end + end + + describe "semantic version comparators" do + test "is_one_of (semver)" do + is_one_of_semver = 4 + + assert {:ok, true} = compare(is_one_of_semver, "1.2.0", ["1.2.0", "1.3.4"]) + assert {:ok, false} = compare(is_one_of_semver, "2.0.0", ["1.2.0", "1.3.4"]) + + assert {:error, :invalid_version} = + compare(is_one_of_semver, "invalid", ["1.2.0", "1.3.4"]) + + assert {:error, :invalid_version} = + compare(is_one_of_semver, "1.2.0", ["invalid", "1.2.0"]) + + assert {:error, :invalid_version} = + compare(is_one_of_semver, "1.2.0", ["1.2.0", "invalid"]) + end + + test "is_not_one_of (semver)" do + is_not_one_of_semver = 5 + + assert {:ok, true} = compare(is_not_one_of_semver, "2.0.0", ["1.2.0", "1.3.4"]) + assert {:ok, false} = compare(is_not_one_of_semver, "1.2.0", ["1.2.0", "1.3.4"]) + + assert {:error, :invalid_version} = + compare(is_not_one_of_semver, "invalid", ["1.2.0", "1.3.4"]) + + assert {:error, :invalid_version} = + compare(is_not_one_of_semver, "1.2.0", ["invalid", "1.3.4"]) + + assert {:error, :invalid_version} = + compare(is_not_one_of_semver, "1.2.0", ["1.2.0", "invalid"]) + end + + test "< (SemVer)" do + less_than_semver = 6 + + assert {:ok, true} = compare(less_than_semver, "1.2.0", "1.3.0") + assert {:ok, false} = compare(less_than_semver, "1.3.0", "1.2.0") + assert {:ok, false} = compare(less_than_semver, "1.2.0", "1.2.0") + + assert {:error, :invalid_version} = + compare(less_than_semver, "invalid", "1.2.0") + + assert {:error, :invalid_version} = + compare(less_than_semver, "1.3.0", "invalid") + end + + test "<= (SemVer)" do + less_than_equal_semver = 7 + + assert {:ok, true} = compare(less_than_equal_semver, "1.2.0", "1.3.0") + assert {:ok, false} = compare(less_than_equal_semver, "1.3.0", "1.2.0") + assert {:ok, true} = compare(less_than_equal_semver, "1.2.0", "1.2.0") + + assert {:error, :invalid_version} = + compare(less_than_equal_semver, "invalid", "1.2.0") + + assert {:error, :invalid_version} = + compare(less_than_equal_semver, "1.3.0", "invalid") + end + + test "> (SemVer)" do + greater_than_semver = 8 + + assert {:ok, true} = compare(greater_than_semver, "1.3.0", "1.2.0") + assert {:ok, false} = compare(greater_than_semver, "1.2.0", "1.3.0") + assert {:ok, false} = compare(greater_than_semver, "1.2.0", "1.2.0") + + assert {:error, :invalid_version} = + compare(greater_than_semver, "invalid", "1.2.0") + + assert {:error, :invalid_version} = + compare(greater_than_semver, "1.3.0", "invalid") + end + + test ">= (SemVer)" do + greater_than_equal_semver = 9 + + assert {:ok, true} = compare(greater_than_equal_semver, "1.3.0", "1.2.0") + assert {:ok, false} = compare(greater_than_equal_semver, "1.2.0", "1.3.0") + assert {:ok, true} = compare(greater_than_equal_semver, "1.2.0", "1.2.0") + + assert {:error, :invalid_version} = + compare(greater_than_equal_semver, "invalid", "1.2.0") + + assert {:error, :invalid_version} = + compare(greater_than_equal_semver, "1.3.0", "invalid") + end + end + + describe "numeric comparators" do + test "= (Number)" do + equals_number = 10 + + assert {:ok, true} = compare(equals_number, "3.5", "3.5000") + assert {:ok, true} = compare(equals_number, "3,5", "3.5000") + assert {:ok, true} = compare(equals_number, 3.5, 3.5000) + assert {:ok, false} = compare(equals_number, "3,5", "4.752") + assert {:error, :invalid_float} = compare(equals_number, "not a float", "3.5000") + assert {:error, :invalid_float} = compare(equals_number, "3,5", "not a float") + end + + test "<> (Number)" do + not_equals_number = 11 + + assert {:ok, false} = compare(not_equals_number, "3.5", "3.5000") + assert {:ok, false} = compare(not_equals_number, "3,5", "3.5000") + assert {:ok, false} = compare(not_equals_number, 3.5, 3.5000) + assert {:ok, true} = compare(not_equals_number, "3,5", "4.752") + + assert {:error, :invalid_float} = + compare(not_equals_number, "not a float", "3.5000") + + assert {:error, :invalid_float} = + compare(not_equals_number, "3,5", "not a float") + end + + test "< (Number)" do + less_than_number = 12 + + assert {:ok, true} = compare(less_than_number, "3.5", "3.6000") + assert {:ok, true} = compare(less_than_number, "3,5", "3.6000") + assert {:ok, true} = compare(less_than_number, 3.5, 3.6000) + assert {:ok, false} = compare(less_than_number, "3,5", "1.752") + assert {:ok, false} = compare(less_than_number, "3,5", "3.5") + + assert {:error, :invalid_float} = + compare(less_than_number, "not a float", "3.5000") + + assert {:error, :invalid_float} = compare(less_than_number, "3,5", "not a float") + end + + test "<= (Number)" do + less_than_equal_number = 13 + + assert {:ok, true} = compare(less_than_equal_number, "3.5", "3.6000") + assert {:ok, true} = compare(less_than_equal_number, "3,5", "3.6000") + assert {:ok, true} = compare(less_than_equal_number, 3.5, 3.6000) + assert {:ok, true} = compare(less_than_equal_number, "3,5", "3.5") + assert {:ok, false} = compare(less_than_equal_number, "3,5", "1.752") + + assert {:error, :invalid_float} = + compare(less_than_equal_number, "not a float", "3.5000") + + assert {:error, :invalid_float} = + compare(less_than_equal_number, "3,5", "not a float") + end + + test "> (Number)" do + greater_than_number = 14 + + assert {:ok, false} = compare(greater_than_number, "3.5", "3.6000") + assert {:ok, false} = compare(greater_than_number, "3,5", "3.6000") + assert {:ok, false} = compare(greater_than_number, 3.5, 3.6000) + assert {:ok, false} = compare(greater_than_number, "3,5", "3.5") + assert {:ok, true} = compare(greater_than_number, "3,5", "1.752") + + assert {:error, :invalid_float} = + compare(greater_than_number, "not a float", "3.5000") + + assert {:error, :invalid_float} = + compare(greater_than_number, "3,5", "not a float") + end + + test ">= (Number)" do + greater_than_equal_number = 15 + + assert {:ok, false} = compare(greater_than_equal_number, "3.5", "3.6000") + assert {:ok, false} = compare(greater_than_equal_number, "3,5", "3.6000") + assert {:ok, false} = compare(greater_than_equal_number, 3.5, 3.6000) + assert {:ok, true} = compare(greater_than_equal_number, "3,5", "1.752") + assert {:ok, true} = compare(greater_than_equal_number, "3,5", "3.5") + + assert {:error, :invalid_float} = + compare(greater_than_equal_number, "not a float", "3.5000") + + assert {:error, :invalid_float} = + compare(greater_than_equal_number, "3,5", "not a float") + end + end + + describe "hashed comparators" do + setup do + hashed = %{ + a: "d21ec0ebb63930d047bb48e94674ea2ae07b8c0e7fec9de888f31bb22444be85", + b: "6f816cafc2729b3f031874ba92e13f05f0b1d9dad3496cecfa2331940aca43be", + c: "a5dbca52195f5eb637f760d9553f3e45088a447566c4067c9f73746a67b93429" + } + + {:ok, hashed: hashed} + end + + test "is_one_of (hashed)", %{hashed: hashed} do + is_one_of_hashed = 16 + %{a: a, b: b, c: c} = hashed + + assert {:ok, true} = compare(is_one_of_hashed, "a", [a, b, c]) + assert {:ok, false} = compare(is_one_of_hashed, "x", [a, b, c]) + end + + test "is_not_one_of (hashed)", %{hashed: hashed} do + is_not_one_of_hashed = 17 + %{a: a, b: b, c: c} = hashed + + assert {:ok, true} = compare(is_not_one_of_hashed, "x", [a, b, c]) + assert {:ok, false} = compare(is_not_one_of_hashed, "a", [a, b, c]) + end + + test "equals (hashed)", %{hashed: hashed} do + equals_hashed = 20 + %{a: a} = hashed + + assert {:ok, true} = compare(equals_hashed, "a", a) + assert {:ok, false} = compare(equals_hashed, "x", a) + end + + test "not equals (hashed)", %{hashed: hashed} do + not_equals_hashed = 21 + %{a: a} = hashed + + assert {:ok, true} = compare(not_equals_hashed, "x", a) + assert {:ok, false} = compare(not_equals_hashed, "a", a) + end + + test "starts_with_any_of (hashed)", %{hashed: hashed} do + starts_with_any_of_hashed = 22 + %{a: a, b: b, c: c} = hashed + comparison = ["1_#{a}", "1_#{b}", "1_#{c}"] + + assert {:ok, true} = compare(starts_with_any_of_hashed, "apple", comparison) + assert {:ok, true} = compare(starts_with_any_of_hashed, "banana", comparison) + assert {:ok, true} = compare(starts_with_any_of_hashed, "cherry", comparison) + assert {:ok, true} = compare(starts_with_any_of_hashed, "a", comparison) + assert {:ok, false} = compare(starts_with_any_of_hashed, "pear", comparison) + assert {:ok, false} = compare(starts_with_any_of_hashed, "", comparison) + end + + test "not starts_with_any_of (hashed)", %{hashed: hashed} do + not_starts_with_any_of_hashed = 23 + %{a: a, b: b, c: c} = hashed + comparison = ["1_#{a}", "1_#{b}", "1_#{c}"] + + assert {:ok, true} = compare(not_starts_with_any_of_hashed, "pear", comparison) + assert {:ok, true} = compare(not_starts_with_any_of_hashed, "", comparison) + assert {:ok, false} = compare(not_starts_with_any_of_hashed, "apple", comparison) + assert {:ok, false} = compare(not_starts_with_any_of_hashed, "banana", comparison) + assert {:ok, false} = compare(not_starts_with_any_of_hashed, "cherry", comparison) + assert {:ok, false} = compare(not_starts_with_any_of_hashed, "a", comparison) + end + + test "ends_with_any_of (hashed)", %{hashed: hashed} do + ends_with_any_of_hashed = 24 + %{a: a, b: b, c: c} = hashed + comparison = ["1_#{a}", "1_#{b}", "1_#{c}"] + + assert {:ok, true} = compare(ends_with_any_of_hashed, "banana", comparison) + assert {:ok, true} = compare(ends_with_any_of_hashed, "thumb", comparison) + assert {:ok, true} = compare(ends_with_any_of_hashed, "sonic", comparison) + assert {:ok, true} = compare(ends_with_any_of_hashed, "a", comparison) + assert {:ok, false} = compare(ends_with_any_of_hashed, "pear", comparison) + assert {:ok, false} = compare(ends_with_any_of_hashed, "", comparison) + end + + test "not ends_with_any_of (hashed)", %{hashed: hashed} do + not_ends_with_any_of_hashed = 25 + %{a: a, b: b, c: c} = hashed + comparison = ["1_#{a}", "1_#{b}", "1_#{c}"] + + assert {:ok, true} = compare(not_ends_with_any_of_hashed, "pear", comparison) + assert {:ok, true} = compare(not_ends_with_any_of_hashed, "", comparison) + assert {:ok, false} = compare(not_ends_with_any_of_hashed, "banana", comparison) + assert {:ok, false} = compare(not_ends_with_any_of_hashed, "thumb", comparison) + assert {:ok, false} = compare(not_ends_with_any_of_hashed, "sonic", comparison) + assert {:ok, false} = compare(not_ends_with_any_of_hashed, "a", comparison) + end + + test "array_contains_any_of (hashed)", %{hashed: hashed} do + array_contains_any_of_hashed = 26 + %{a: a, b: b, c: c} = hashed + + assert {:ok, true} = compare(array_contains_any_of_hashed, ["a", "x"], [a, b, c]) + assert {:ok, true} = compare(array_contains_any_of_hashed, ["x", "b"], [a, b, c]) + assert {:ok, true} = compare(array_contains_any_of_hashed, ["c"], [a, b, c]) + assert {:ok, true} = compare(array_contains_any_of_hashed, Jason.encode!(["c"]), [a, b, c]) + assert {:ok, false} = compare(array_contains_any_of_hashed, ["x"], [a, b, c]) + assert {:error, :invalid_string_list} = compare(array_contains_any_of_hashed, "a", [a, b, c]) + assert {:error, :invalid_string_list} = compare(array_contains_any_of_hashed, :not_a_list, [a, b, c]) + end + + test "array_not_contains_any_of (hashed)", %{hashed: hashed} do + array_not_contains_any_of_hashed = 27 + %{a: a, b: b, c: c} = hashed + + assert {:ok, true} = compare(array_not_contains_any_of_hashed, ["x"], [a, b, c]) + assert {:ok, false} = compare(array_not_contains_any_of_hashed, ["a", "x"], [a, b, c]) + assert {:ok, false} = compare(array_not_contains_any_of_hashed, ["x", "b"], [a, b, c]) + assert {:ok, false} = compare(array_not_contains_any_of_hashed, ["c"], [a, b, c]) + assert {:ok, false} = compare(array_not_contains_any_of_hashed, Jason.encode!(["c"]), [a, b, c]) + assert {:error, :invalid_string_list} = compare(array_not_contains_any_of_hashed, "a", [a, b, c]) + assert {:error, :invalid_string_list} = compare(array_not_contains_any_of_hashed, :not_a_list, [a, b, c]) + end + end + + describe "datetime comparators" do + test "before" do + before_datetime = 18 + now = DateTime.utc_now() + earlier = DateTime.add(now, -1, :second) + later = DateTime.add(now, 1, :second) + {:ok, now_unix} = UserComparator.to_unix_seconds(now) + {:ok, earlier_unix} = UserComparator.to_unix_seconds(earlier) + + assert {:ok, true} = compare(before_datetime, earlier, now_unix) + assert {:ok, true} = compare(before_datetime, earlier_unix, now_unix) + assert {:ok, true} = compare(before_datetime, DateTime.to_naive(earlier), now_unix) + assert {:ok, true} = compare(before_datetime, to_string(earlier_unix), now_unix) + assert {:ok, true} = compare(before_datetime, earlier, to_string(now_unix)) + assert {:ok, false} = compare(before_datetime, now, now_unix) + assert {:ok, false} = compare(before_datetime, later, now_unix) + + assert {:error, :invalid_datetime} = + compare(before_datetime, "not a datetime", now_unix) + + assert {:error, :invalid_datetime} = compare(before_datetime, earlier, "not a datetime") + end + + test "after" do + after_datetime = 19 + now = DateTime.utc_now() + earlier = DateTime.add(now, -1, :second) + later = DateTime.add(now, 1, :second) + {:ok, now_unix} = UserComparator.to_unix_seconds(now) + {:ok, later_unix} = UserComparator.to_unix_seconds(later) + + assert {:ok, true} = compare(after_datetime, later, now_unix) + assert {:ok, true} = compare(after_datetime, later_unix, now_unix) + assert {:ok, true} = compare(after_datetime, to_string(later_unix), now_unix) + assert {:ok, true} = compare(after_datetime, later, to_string(now_unix)) + assert {:ok, false} = compare(after_datetime, now, now_unix) + assert {:ok, false} = compare(after_datetime, earlier, now_unix) + assert {:ok, false} = compare(after_datetime, DateTime.to_naive(earlier), now_unix) + + assert {:error, :invalid_datetime} = + compare(after_datetime, "not a datetime", now_unix) + + assert {:error, :invalid_datetime} = compare(after_datetime, later, "not a datetime") + end + end + + defp compare(comparator, user_value, comparison_value) do + condition = + UserCondition.new(comparator: comparator, comparison_attribute: "SomeAttribute", comparison_value: comparison_value) + + context = %ComparisonContext{ + condition: condition, + context_salt: @context_salt, + key: "someKey", + salt: @salt + } + + UserComparator.compare(comparator, user_value, comparison_value, context) + end +end diff --git a/test/config_cat/config_entry_test.exs b/test/config_cat/config_entry_test.exs index 82404c07..7794d539 100644 --- a/test/config_cat/config_entry_test.exs +++ b/test/config_cat/config_entry_test.exs @@ -1,6 +1,7 @@ defmodule ConfigCat.ConfigEntryTest do use ExUnit.Case, async: true + alias ConfigCat.Config alias ConfigCat.ConfigEntry @config_json """ @@ -10,7 +11,7 @@ defmodule ConfigCat.ConfigEntryTest do "r": 0 }, "f": { - "testKey": { "v": "testValue", "t": 1, "p": [], "r": []} + "testKey": { "v": {"s": "testValue"}, "t": 1} } } """ @@ -44,7 +45,10 @@ defmodule ConfigCat.ConfigEntryTest do end test "round trips" do - entry = ConfigEntry.new(@config, "ETAG", @config_json) + entry = + @config + |> Config.inline_salt_and_segments() + |> ConfigEntry.new("ETAG", @config_json) assert {:ok, ^entry} = entry diff --git a/test/config_cat/config_test.exs b/test/config_cat/config_test.exs new file mode 100644 index 00000000..20467633 --- /dev/null +++ b/test/config_cat/config_test.exs @@ -0,0 +1,48 @@ +defmodule ConfigCat.ConfigTest do + use ExUnit.Case, async: true + + alias ConfigCat.Config + alias ConfigCat.Config.Preferences + + describe "merging configs" do + test "copies right settings when left settings are missing" do + settings = %{"right" => "settings"} + left = Config.new() + right = Config.new(settings: settings) + merged = Config.merge(left, right) + + assert Config.settings(merged) == settings + end + + test "keeps left settings when right settings are missing" do + settings = %{"left" => "settings"} + left = Config.new(settings: settings) + right = Config.new() + merged = Config.merge(left, right) + + assert Config.settings(merged) == settings + end + + test "merges settings when both are present; right wins when both have the same key" do + left = Config.new(settings: %{"a" => "left_a", "b" => "left_b"}) + right = Config.new(settings: %{"b" => "right_b", "c" => "right_c"}) + merged = Config.merge(left, right) + + assert %{ + "a" => "left_a", + "b" => "right_b", + "c" => "right_c" + } == Config.settings(merged) + end + + test "always keeps left preferences" do + left_preferences = Preferences.new(base_url: "https://left.example.com") + right_preferences = Preferences.new(base_url: "https://right.example.com") + left = Config.new(preferences: left_preferences) + right = Config.new(preferences: right_preferences) + merged = Config.merge(left, right) + + assert Config.preferences(merged) == left_preferences + end + end +end diff --git a/test/config_cat/data_governance_test.exs b/test/config_cat/data_governance_test.exs index 03bd8c6d..8e7372d9 100644 --- a/test/config_cat/data_governance_test.exs +++ b/test/config_cat/data_governance_test.exs @@ -5,6 +5,7 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do alias ConfigCat.CacheControlConfigFetcher, as: ConfigFetcher alias ConfigCat.Config + alias ConfigCat.Config.Preferences alias ConfigCat.ConfigEntry alias ConfigCat.Hooks alias ConfigCat.MockAPI @@ -272,8 +273,10 @@ defmodule ConfigCat.ConfigFetcher.DataGovernanceTest do end defp stub_response(response_uri, redirect_mode) do - response_uri - |> Config.new_with_preferences(redirect_mode) + preferences = Preferences.new(base_url: response_uri, redirect_mode: redirect_mode) + + [preferences: preferences] + |> Config.new() |> Jason.encode!() end diff --git a/test/config_cat/default_user_test.exs b/test/config_cat/default_user_test.exs index b851ef45..f1c9158e 100644 --- a/test/config_cat/default_user_test.exs +++ b/test/config_cat/default_user_test.exs @@ -1,27 +1,16 @@ defmodule ConfigCat.DefaultUserTest do use ConfigCat.ClientCase, async: true - import Jason.Sigil - + alias ConfigCat.Config + alias ConfigCat.Factory alias ConfigCat.FetchTime alias ConfigCat.User @moduletag capture_log: true setup do - settings = ~J""" - { - "testBoolKey": {"v": true,"t": 0, "p": [],"r": []}, - "testStringKey": { - "v": "testValue", "i": "id", "t": 1, "p": [], "r": [ - {"i":"id1","v":"fake1","a":"Identifier","t":2,"c":"@test1.com"}, - {"i":"id2","v":"fake2","a":"Identifier","t":2,"c":"@test2.com"} - ] - } - } - """ - - stub_cached_settings({:ok, settings, FetchTime.now_ms()}) + config = Config.inline_salt_and_segments(Factory.config()) + stub_cached_config({:ok, config, FetchTime.now_ms()}) :ok end @@ -48,18 +37,31 @@ defmodule ConfigCat.DefaultUserTest do test "get_all_values/1 uses the default user if no user is passed", %{client: client} do expected = - Enum.sort(%{"testBoolKey" => true, "testStringKey" => "fake1"}) + %{ + "key1" => true, + "key2" => false, + "testBoolKey" => true, + "testIntKey" => 1, + "testDoubleKey" => 1.1, + "testStringKey" => "fake1" + } - actual = nil |> ConfigCat.get_all_values(client: client) |> Enum.sort() + actual = ConfigCat.get_all_values(nil, client: client) assert actual == expected end test "get_all_values/1 uses the passed user", %{client: client} do - expected = - Enum.sort(%{"testBoolKey" => true, "testStringKey" => "fake2"}) + expected = %{ + "key1" => true, + "key2" => false, + "testBoolKey" => true, + "testIntKey" => 1, + "testDoubleKey" => 1.1, + "testStringKey" => "fake2" + } user = User.new("test@test2.com") - actual = user |> ConfigCat.get_all_values(client: client) |> Enum.sort() + actual = ConfigCat.get_all_values(user, client: client) assert actual == expected end end @@ -86,18 +88,32 @@ defmodule ConfigCat.DefaultUserTest do test "get_all_values/1 uses the undefined user case if no user is passed", %{client: client} do expected = - Enum.sort(%{"testBoolKey" => true, "testStringKey" => "testValue"}) + %{ + "key1" => true, + "key2" => false, + "testBoolKey" => true, + "testIntKey" => 1, + "testDoubleKey" => 1.1, + "testStringKey" => "testValue" + } - actual = nil |> ConfigCat.get_all_values(client: client) |> Enum.sort() + actual = ConfigCat.get_all_values(nil, client: client) assert actual == expected end test "get_all_values/1 uses the passed user", %{client: client} do expected = - Enum.sort(%{"testBoolKey" => true, "testStringKey" => "fake2"}) + %{ + "key1" => true, + "key2" => false, + "testBoolKey" => true, + "testIntKey" => 1, + "testDoubleKey" => 1.1, + "testStringKey" => "fake2" + } user = User.new("test@test2.com") - actual = user |> ConfigCat.get_all_values(client: client) |> Enum.sort() + actual = ConfigCat.get_all_values(user, client: client) assert actual == expected end end diff --git a/test/config_cat/hooks_test.exs b/test/config_cat/hooks_test.exs index 62a1f24c..12003c69 100644 --- a/test/config_cat/hooks_test.exs +++ b/test/config_cat/hooks_test.exs @@ -1,42 +1,25 @@ defmodule ConfigCat.HooksTest do use ConfigCat.CachePolicyCase, async: true - import Jason.Sigil import Mox alias ConfigCat.CachePolicy alias ConfigCat.Client alias ConfigCat.Config + alias ConfigCat.Config.TargetingRule alias ConfigCat.ConfigEntry alias ConfigCat.EvaluationDetails + alias ConfigCat.Factory alias ConfigCat.Hooks alias ConfigCat.MockFetcher alias ConfigCat.NullDataSource alias ConfigCat.User - require ConfigCat.Constants, as: Constants + require ConfigCat.Config.SettingType, as: SettingType @moduletag capture_log: true - @config ~J""" - { - "p": { - "u": "https://cdn-global.configcat.com", - "r": 0 - }, - "f": { - "testBoolKey": {"v": true,"t": 0, "p": [],"r": []}, - "testStringKey": {"v": "testValue", "i": "id", "t": 1, "p": [],"r": [ - {"i":"id1","v":"fake1","a":"Identifier","t":2,"c":"@test1.com"}, - {"i":"id2","v":"fake2","a":"Identifier","t":2,"c":"@test2.com"} - ]}, - "testIntKey": {"v": 1,"t": 2, "p": [],"r": []}, - "testDoubleKey": {"v": 1.1,"t": 3,"p": [],"r": []}, - "key1": {"v": true, "i": "fakeId1","p": [], "r": []}, - "key2": {"v": false, "i": "fakeId2","p": [], "r": []} - } - } - """ + @config Config.inline_salt_and_segments(Factory.config()) @policy CachePolicy.manual() defmodule TestHooks do @@ -84,7 +67,7 @@ defmodule ConfigCat.HooksTest do value = ConfigCat.get_value("testStringKey", "", client: instance_id) - {:ok, settings} = Config.fetch_settings(@config) + settings = Config.settings(@config) assert value == "testValue" assert_received :on_client_ready @@ -112,7 +95,7 @@ defmodule ConfigCat.HooksTest do value = ConfigCat.get_value("testStringKey", "", client: instance_id) - {:ok, settings} = Config.fetch_settings(@config) + settings = Config.settings(@config) assert value == "testValue" assert_received :on_client_ready @@ -144,17 +127,15 @@ defmodule ConfigCat.HooksTest do default_value?: false, error: nil, key: "testStringKey", - matched_evaluation_rule: %{ - Constants.comparator() => 2, - Constants.comparison_attribute() => "Identifier", - Constants.comparison_value() => "@test1.com", - Constants.value() => "fake1" - }, - matched_evaluation_percentage_rule: nil, + matched_targeting_rule: rule, + matched_percentage_option: nil, user: ^user, value: "fake1", variation_id: "id1" } = details + + assert TargetingRule.value(rule, SettingType.string()) == "fake1" + assert TargetingRule.variation_id(rule) == "id1" end test "doesn't fail when callbacks raise errors" do diff --git a/test/config_cat/user_test.exs b/test/config_cat/user_test.exs index 95622fef..b5bd450b 100644 --- a/test/config_cat/user_test.exs +++ b/test/config_cat/user_test.exs @@ -88,5 +88,39 @@ defmodule ConfigCat.UserTest do assert User.get_attribute(user, "Country") == nil assert User.get_attribute(user, "AnyCustom") == nil end + + test "attribute names are case-sensitive", %{user: user} do + assert User.get_attribute(user, "identifier") == nil + assert User.get_attribute(user, "EMAIL") == nil + assert User.get_attribute(user, "country") == nil + assert User.get_attribute(user, "UPPERSTRINGPROPERTY") == nil + end + end + + describe "converting to string" do + test "produces a JSON-encoded string" do + user_id = "id" + email = "test@test.com" + country = "country" + + custom = %{ + "datetime" => ~U[2023-09-19T11:01:35.999Z], + "float" => 3.14, + "int" => 42, + "string" => "test" + } + + user = User.new(user_id, country: country, custom: custom, email: email) + + assert %{ + "Identifier" => user_id, + "Email" => email, + "Country" => country, + "string" => "test", + "int" => 42, + "float" => 3.14, + "datetime" => "2023-09-19T11:01:35.999Z" + } == Jason.decode!(to_string(user)) + end end end diff --git a/test/config_cat_test.exs b/test/config_cat_test.exs index ea802520..9253e826 100644 --- a/test/config_cat_test.exs +++ b/test/config_cat_test.exs @@ -1,37 +1,32 @@ defmodule ConfigCatTest do use ConfigCat.ClientCase, async: true + import ExUnit.CaptureLog import Jason.Sigil import Mox + alias ConfigCat.Config + alias ConfigCat.Config.TargetingRule alias ConfigCat.EvaluationDetails + alias ConfigCat.Factory alias ConfigCat.FetchTime + alias ConfigCat.Hooks alias ConfigCat.User - require ConfigCat.Constants, as: Constants + require ConfigCat.Config.SettingType, as: SettingType + + @moduletag capture_log: true setup :verify_on_exit! describe "when the configuration has been fetched" do setup do - settings = ~J""" - { - "testBoolKey": {"v": true,"t": 0, "p": [],"r": []}, - "testStringKey": {"v": "testValue", "i": "id", "t": 1, "p": [],"r": [ - {"i":"id1","v":"fake1","a":"Identifier","t":2,"c":"@test1.com"}, - {"i":"id2","v":"fake2","a":"Identifier","t":2,"c":"@test2.com"} - ]}, - "testIntKey": {"v": 1,"t": 2, "p": [],"r": []}, - "testDoubleKey": {"v": 1.1,"t": 3,"p": [],"r": []}, - "key1": {"v": true, "i": "fakeId1","p": [], "r": []}, - "key2": {"v": false, "i": "fakeId2","p": [], "r": []} - } - """ + config = Config.inline_salt_and_segments(Factory.config()) {:ok, client} = start_client() fetch_time_ms = FetchTime.now_ms() - stub_cached_settings({:ok, settings, fetch_time_ms}) + stub_cached_config({:ok, config, fetch_time_ms}) {:ok, client: client, fetch_time_ms: fetch_time_ms} end @@ -46,7 +41,6 @@ defmodule ConfigCatTest do assert ConfigCat.get_value("testBoolKey", false, client: client) == true end - @tag capture_log: true test "get_value/4 returns a string value", %{client: client} do assert ConfigCat.get_value("testStringKey", "default", client: client) == "testValue" end @@ -59,7 +53,6 @@ defmodule ConfigCatTest do assert ConfigCat.get_value("testDoubleKey", 0.0, client: client) == 1.1 end - @tag capture_log: true test "get_value/4 returns default value if key not found", %{client: client} do assert ConfigCat.get_value("testUnknownKey", "default", client: client) == "default" end @@ -71,7 +64,6 @@ defmodule ConfigCatTest do assert {"key2", false} = ConfigCat.get_key_and_value("fakeId2", client: client) end - @tag capture_log: true test "get_all_values/2 returns all key/value pairs", %{client: client} do expected = Enum.sort(%{ @@ -100,20 +92,17 @@ defmodule ConfigCatTest do error: nil, fetch_time: ^fetch_time, key: "testStringKey", - matched_evaluation_rule: %{ - Constants.comparator() => 2, - Constants.comparison_attribute() => "Identifier", - Constants.comparison_value() => "@test1.com", - Constants.value() => "fake1" - }, - matched_evaluation_percentage_rule: nil, + matched_targeting_rule: rule, + matched_percentage_option: nil, user: ^user, value: "fake1", variation_id: "id1" } = ConfigCat.get_value_details("testStringKey", "", user, client: client) + + assert TargetingRule.value(rule, SettingType.string()) == "fake1" + assert TargetingRule.variation_id(rule) == "id1" end - @tag capture_log: true test "get_all_value_details/2 returns evaluation details for all keys", %{client: client} do all_details = ConfigCat.get_all_value_details(client: client) details_by_key = fn key -> Enum.find(all_details, &(&1.key == key)) end @@ -130,15 +119,91 @@ defmodule ConfigCatTest do assert %{key: "key1", value: true, variation_id: "fakeId1"} = details_by_key.("key1") assert %{key: "key2", value: false, variation_id: "fakeId2"} = details_by_key.("key2") end + + test "reports error for incorrect config json", %{client: client, fetch_time_ms: fetch_time_ms} do + config = + Config.inline_salt_and_segments(~J""" + { + "f": { + "testKey": { + "t": 0, + "r": [ { + "c": [ { "u": { "a": "Custom1", "c": 19, "d": "wrong_utc_timestamp" } } ], + "s": { "v": { "b": true } } + } ], + "v": { "b": false } + } + } + } + """) + + stub_cached_config({:ok, config, fetch_time_ms}) + user = User.new("1234", custom: %{"Custom1" => 1_681_118_000.56}) + test_pid = self() + + [client: client] + |> ConfigCat.hooks() + |> Hooks.add_on_error(fn error -> send(test_pid, {:on_error, error}) end) + + refute ConfigCat.get_value("testKey", false, user, client: client) + assert_received {:on_error, error} + assert error =~ "Failed to evaluate setting 'testKey'." + end + + for {key, user_id, default_value, expected_value, warning?} <- [ + # no type mismatch warning + {"testStringKey", "test@example.com", "default", "testValue", false}, + {"testBoolKey", nil, false, true, false}, + {"testBoolKey", nil, nil, true, false}, + {"testIntKey", nil, 3.14, 1, false}, + {"testIntKey", nil, 42, 1, false}, + {"testDoubleKey", nil, 3.14, 1.1, false}, + {"testDoubleKey", nil, 42, 1.1, false}, + # type mismatch warning + {"testStringKey", "test@example.com", 0, "testValue", true}, + {"testStringKey", "test@example.com", false, "testValue", true}, + {"testBoolKey", nil, 0, true, true}, + {"testBoolKey", nil, 0.1, true, true}, + {"testBoolKey", nil, "default", true, true} + ] do + test "default value and setting type mismatch with key: #{key} user_id: #{user_id} default_value: #{default_value}", + %{client: client} do + key = unquote(key) + user_id = unquote(user_id) + default_value = unquote(default_value) + expected_value = unquote(expected_value) + warning? = unquote(warning?) + user = if user_id, do: User.new(user_id) + + logs = + capture_log(fn -> + assert expected_value == ConfigCat.get_value(key, default_value, user, client: client) + end) + + if warning? do + default_type = SettingType.from_value(default_value) + expected_type = SettingType.from_value(expected_value) + + expected_log = + adjust_log_level( + "warning [4002] The type of a setting does not match the type of the specified default value (#{default_value}). " <> + "Setting's type was #{expected_type} but the default value's type was #{default_type}. " <> + "Please make sure that using a default value not matching the setting's type was intended." + ) + + assert expected_log in String.split(logs, "\n") + else + assert logs == "" + end + end + end end describe "when the configuration has not been fetched" do - @describetag capture_log: true - setup do {:ok, client} = start_client() - stub_cached_settings({:error, :not_found}) + stub_cached_config({:error, :not_found}) {:ok, client: client} end @@ -151,7 +216,6 @@ defmodule ConfigCatTest do assert ConfigCat.get_value("any_feature", "default", client: client) == "default" end - @tag capture_log: true test "get_key_and_value/2 returns nil", %{client: client} do assert ConfigCat.get_key_and_value("any_variation", client: client) == nil end diff --git a/test/evaluation_log_test.exs b/test/evaluation_log_test.exs new file mode 100644 index 00000000..091f541c --- /dev/null +++ b/test/evaluation_log_test.exs @@ -0,0 +1,140 @@ +defmodule ConfigCat.EvaluationLogTest do + # We change the logging level for these tests, so we run them synchronously. + use ConfigCat.Case, async: false + + import ExUnit.CaptureLog + + alias ConfigCat.CachePolicy + alias ConfigCat.LocalFileDataSource + alias ConfigCat.NullDataSource + alias ConfigCat.User + + @moduletag capture_log: true + + setup do + original_level = Logger.level() + Logger.configure(level: :debug) + on_exit(fn -> Logger.configure(level: original_level) end) + end + + test "simple value" do + test_evaluation_log("simple_value.json") + end + + test "1 targeting rule" do + test_evaluation_log("1_targeting_rule.json") + end + + test "2 targeting rules" do + test_evaluation_log("2_targeting_rules.json") + end + + test "options based on user id" do + test_evaluation_log("options_based_on_user_id.json") + end + + test "options based on custom attr" do + test_evaluation_log("options_based_on_custom_attr.json") + end + + test "options after targeting rule" do + test_evaluation_log("options_after_targeting_rule.json") + end + + test "options within targeting rule" do + test_evaluation_log("options_within_targeting_rule.json") + end + + test "and rules" do + test_evaluation_log("and_rules.json") + end + + test "segment" do + test_evaluation_log("segment.json") + end + + test "prerequisite flag" do + test_evaluation_log("prerequisite_flag.json") + end + + test "semver validation" do + test_evaluation_log("semver_validation.json") + end + + test "epoch date validation" do + test_evaluation_log("epoch_date_validation.json") + end + + test "number validation" do + test_evaluation_log("number_validation.json") + end + + test "comparators validation" do + test_evaluation_log("comparators.json") + end + + test "list truncation validation" do + test_evaluation_log("list_truncation.json") + end + + defp test_evaluation_log(filename) do + file_path = "evaluation" |> Path.join(filename) |> fixture_file() + suite_name = Path.basename(file_path, ".json") + suite_sub_dir = file_path |> Path.dirname() |> Path.join(suite_name) + data = file_path |> File.read!() |> Jason.decode!() + sdk_key = Map.get(data, "sdkKey", "configcat-sdk-test-key/0000000000000000000000") + json_override = data["jsonOverride"] + + overrides = + if json_override do + LocalFileDataSource.new(Path.join(suite_sub_dir, json_override), :local_only) + else + NullDataSource.new() + end + + {:ok, client} = start_config_cat(sdk_key, fetch_policy: CachePolicy.manual(), flag_overrides: overrides) + ConfigCat.force_refresh(client: client) + + Enum.each(data["tests"], &run_test(&1, client, suite_sub_dir)) + end + + defp run_test(test, client, suite_sub_dir) do + %{"key" => key, "defaultValue" => default_value, "returnValue" => return_value, "expectedLog" => expected_log_file} = + test + + user = build_user(test["user"]) + + # test_name = Path.basename(expected_log_file, ".txt") + expected_log = File.read!(Path.join(suite_sub_dir, expected_log_file)) + + log = + capture_log(fn -> + assert return_value == ConfigCat.get_value(key, default_value, user, client: client) + end) + + {expected_clean_log, expected_user} = expected_log |> adjust_log_level() |> extract_logged_user() + {clean_log, actual_user} = extract_logged_user(log) + + assert clean_log == expected_clean_log + assert actual_user == expected_user + end + + defp build_user(nil), do: nil + + defp build_user(user_attrs) do + {attrs, custom} = Map.split(user_attrs, ["Country", "Email", "Identifier"]) + + User.new(attrs["Identifier"], country: attrs["Country"], custom: custom, email: attrs["Email"]) + end + + @logged_user_regex ~r/for User '(\{.*?\})'/ + defp extract_logged_user(log) do + case Regex.run(@logged_user_regex, log) do + nil -> + {log, nil} + + [_entire_match, logged_user] -> + {Regex.replace(@logged_user_regex, log, ""), Jason.decode!(logged_user)} + end + end +end diff --git a/test/fixtures/test.json b/test/fixtures/test.json deleted file mode 100644 index c7f89c01..00000000 --- a/test/fixtures/test.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "f": { - "disabledFeature": { - "v": false - }, - "enabledFeature": { - "v": true - }, - "intSetting": { - "v": 5 - }, - "doubleSetting": { - "v": 3.14 - }, - "stringSetting": { - "v": "test" - } - } -} diff --git a/test/fixtures/testmatrix_input_semantic_2.csv b/test/fixtures/testmatrix_input_semantic_2.csv deleted file mode 100644 index eea1bbb2..00000000 --- a/test/fixtures/testmatrix_input_semantic_2.csv +++ /dev/null @@ -1,95 +0,0 @@ -Identifier;Email;Country;AppVersion;precedenceTests -dontcare;;;1.9.1-1 -dontcare;;;1.9.1-2 -dontcare;;;1.9.1-10 -dontcare;;;1.9.1-10a -dontcare;;;1.9.1-1a -dontcare;;;1.9.1-alpha -dontcare;;;1.9.99-alpha -dontcare;;;1.9.99-alpha+build1 -dontcare;;;1.9.99-alpha+build2 -dontcare;;;1.9.99-alpha2 -dontcare;;;1.9.99-beta -dontcare;;;1.9.99-rc -dontcare;;;1.9.99-rc.1 -dontcare;;;1.9.99-rc.2 -dontcare;;;1.9.99-rc.9 -dontcare;;;1.9.99-rc.20 -dontcare;;;1.9.99-rc.20a -dontcare;;;1.9.99-rc.2a -dontcare;;;1.9.99 -dontcare;;;1.9.100 -dontcare;;;1.10.0-alpha -dontcare;;;1.10.0 -dontcare;;;1.10.1 -dontcare;;;1.10.2 -dontcare;;;2.0.0 -dontcare;;;2.0.0+build3 -dontcare;;;2.0.0+001 -dontcare;;;2.0.0+20130313144700 -dontcare;;;2.0.0+exp.sha.5114f85 -dontcare;;;3.0.0 -dontcare;;;4.0.0 -dontcare;;;5.0.0 -dontcare;;;6.0.0 -dontcare;;;7.0.0-patch+metadata -dontcare;;;8.0.0-patch+metadata -dontcare;;;9.0.0-patch -dontcare;;;10.0.0 -dontcare;;;104.0.0 -dontcare;;;103.0.0 -dontcare;;;102.0.0 -dontcare;;;101.0.0 -dontcare;;;90.104.0 -dontcare;;;90.103.0 -dontcare;;;90.102.0 -dontcare;;;90.101.0 -dontcare;;;80.0.104 -dontcare;;;80.0.103 -dontcare;;;80.0.102 -dontcare;;;80.0.101 -dontcare;;;73.0.0 -dontcare;;;72.0.0 -dontcare;;;72.0.0-beta.2 -dontcare;;;72.0.0-beta.1 -dontcare;;;72.0.0-beta -dontcare;;;72.0.0-alpha -dontcare;;;72.0.0-1a -dontcare;;;72.0.0-10aa -dontcare;;;72.0.0-10a -dontcare;;;72.0.0-2 -dontcare;;;71.0.0+metadata -dontcare;;;71.0.0-patch3+metadata -dontcare;;;71.0.0-patch2+metadata -dontcare;;;71.0.0-patch1 -dontcare;;;60.73.0 -dontcare;;;60.72.0 -dontcare;;;60.72.0-beta.2 -dontcare;;;60.72.0-beta.1 -dontcare;;;60.72.0-beta -dontcare;;;60.72.0-alpha -dontcare;;;60.72.0-1a -dontcare;;;60.72.0-10aa -dontcare;;;60.72.0-10a -dontcare;;;60.72.0-2 -dontcare;;;60.71.0+metadata -dontcare;;;60.71.0-patch3+metadata -dontcare;;;60.71.0-patch2+metadata -dontcare;;;60.71.0-patch1 -dontcare;;;50.60.73 -dontcare;;;50.60.72 -dontcare;;;50.60.72-beta.2 -dontcare;;;50.60.72-beta.1 -dontcare;;;50.60.72-beta -dontcare;;;50.60.72-alpha -dontcare;;;50.60.72-1a -dontcare;;;50.60.72-10aa -dontcare;;;50.60.72-10a -dontcare;;;50.60.72-2 -dontcare;;;50.60.71+metadata -dontcare;;;50.60.71-patch3+metadata -dontcare;;;50.60.71-patch2+metadata -dontcare;;;50.60.71-patch1 -dontcare;;;50.60.71-patch1+anothermetadata -dontcare;;;40.0.0-patch -dontcare;;;30.0.0-beta \ No newline at end of file diff --git a/test/flag_override_test.exs b/test/flag_override_test.exs index 463df69f..c93affa8 100644 --- a/test/flag_override_test.exs +++ b/test/flag_override_test.exs @@ -1,22 +1,29 @@ defmodule ConfigCat.FlagOverrideTest do - use ConfigCat.ClientCase, async: true + # Must be async: false to avoid a collision with other tests. + # Now that we only allow a single ConfigCat instance to use the same SDK key, + # one of the async tests would fail due to the existing running instance. + use ConfigCat.ClientCase, async: false import Jason.Sigil + alias ConfigCat.Config alias ConfigCat.FetchTime alias ConfigCat.LocalFileDataSource alias ConfigCat.LocalMapDataSource + alias ConfigCat.NullDataSource + alias ConfigCat.User @moduletag capture_log: true setup do settings = ~J""" { - "fakeKey": {"v": false, "t": 0, "p": [],"r": []} + "fakeKey": {"v": {"b": false}, "t": 0} } """ - stub_cached_settings({:ok, settings, FetchTime.now_ms()}) + config = Config.new(settings: settings) + stub_cached_config({:ok, config, FetchTime.now_ms()}) :ok end @@ -151,10 +158,87 @@ defmodule ConfigCat.FlagOverrideTest do end end - defp fixture_file(name) do - __ENV__.file - |> Path.dirname() - |> Path.join("fixtures/" <> name) + for {key, user_id, email, override_behaviour, expected_value} <- [ + {"stringDependsOnString", "1", "john@sensitivecompany.com", nil, "Dog"}, + {"stringDependsOnString", "1", "john@sensitivecompany.com", :remote_over_local, "Dog"}, + {"stringDependsOnString", "1", "john@sensitivecompany.com", :local_over_remote, "Dog"}, + {"stringDependsOnString", "1", "john@sensitivecompany.com", :local_only, nil}, + {"stringDependsOnString", "2", "john@notsensitivecompany.com", nil, "Cat"}, + {"stringDependsOnString", "2", "john@notsensitivecompany.com", :remote_over_local, "Cat"}, + {"stringDependsOnString", "2", "john@notsensitivecompany.com", :local_over_remote, "Dog"}, + {"stringDependsOnString", "2", "john@notsensitivecompany.com", :local_only, nil}, + {"stringDependsOnInt", "1", "john@sensitivecompany.com", nil, "Dog"}, + {"stringDependsOnInt", "1", "john@sensitivecompany.com", :remote_over_local, "Dog"}, + {"stringDependsOnInt", "1", "john@sensitivecompany.com", :local_over_remote, "Cat"}, + {"stringDependsOnInt", "1", "john@sensitivecompany.com", :local_only, nil}, + {"stringDependsOnInt", "2", "john@notsensitivecompany.com", nil, "Cat"}, + {"stringDependsOnInt", "2", "john@notsensitivecompany.com", :remote_over_local, "Cat"}, + {"stringDependsOnInt", "2", "john@notsensitivecompany.com", :local_over_remote, "Dog"}, + {"stringDependsOnInt", "2", "john@notsensitivecompany.com", :local_only, nil} + ] do + test "prerequisite flag override with key: #{key} user_id: #{user_id} email: #{email} override behaviour: #{inspect(override_behaviour)}" do + # The flag override alters the definition of the following flags: + # * 'mainStringFlag': to check the case where a prerequisite flag is + # overridden (dependent flag: 'stringDependsOnString') + # * 'stringDependsOnInt': to check the case where a dependent flag is + # overridden (prerequisite flag: 'mainIntFlag') + key = unquote(key) + user_id = unquote(user_id) + email = unquote(email) + override_behaviour = unquote(override_behaviour) + expected_value = unquote(expected_value) + + user = User.new(user_id, email: email) + + overrides = + if override_behaviour do + LocalFileDataSource.new(fixture_file("test_override_flagdependency_v6.json"), override_behaviour) + else + NullDataSource.new() + end + + {:ok, client} = + start_config_cat("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg", flag_overrides: overrides) + + assert expected_value == ConfigCat.get_value(key, nil, user, client: client) + end + end + + for {key, user_id, email, override_behaviour, expected_value} <- [ + {"developerAndBetaUserSegment", "1", "john@example.com", nil, false}, + {"developerAndBetaUserSegment", "1", "john@example.com", :remote_over_local, false}, + {"developerAndBetaUserSegment", "1", "john@example.com", :local_over_remote, true}, + {"developerAndBetaUserSegment", "1", "john@example.com", :local_only, true}, + {"notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", nil, true}, + {"notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", :remote_over_local, true}, + {"notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", :local_over_remote, true}, + {"notDeveloperAndNotBetaUserSegment", "2", "kate@example.com", :local_only, nil} + ] do + test "salt/segment override with key: #{key} user_id: #{user_id} email: #{email} override behaviour: #{inspect(override_behaviour)}" do + # The flag override uses a different config json salt than the downloaded one and + # overrides the following segments: + # * "Beta Users": User.Email IS ONE OF ["jane@example.com"] + # * "Developers": User.Email IS ONE OF ["john@example.com"] + key = unquote(key) + user_id = unquote(user_id) + email = unquote(email) + override_behaviour = unquote(override_behaviour) + expected_value = unquote(expected_value) + + user = User.new(user_id, email: email) + + overrides = + if override_behaviour do + LocalFileDataSource.new(fixture_file("test_override_segments_v6.json"), override_behaviour) + else + NullDataSource.new() + end + + {:ok, client} = + start_config_cat("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/h99HYXWWNE2bH8eWyLAVMA", flag_overrides: overrides) + + assert expected_value == ConfigCat.get_value(key, nil, user, client: client) + end end defp temporary_file(name) do diff --git a/test/integration_test.exs b/test/integration_test.exs index 09323522..78b687b1 100644 --- a/test/integration_test.exs +++ b/test/integration_test.exs @@ -2,39 +2,84 @@ defmodule ConfigCat.IntegrationTest do # Must be async: false to avoid a collision with other tests. # Now that we only allow a single ConfigCat instance to use the same SDK key, # one of the async tests would fail due to the existing running instance. - use ExUnit.Case, async: false + use ConfigCat.Case, async: false alias ConfigCat.Cache alias ConfigCat.CachePolicy alias ConfigCat.InMemoryCache + alias ConfigCat.LocalMapDataSource - @sdk_key "PKDVCLf-Hq-h-kCzMp-L7Q/PaDVCFk9EpmD6sLpGLltTA" + @sdk_key "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/1cGEJXUwYUGZCBOL-E2sOw" - test "raises error if SDK key is missing" do - nil - |> start_config_cat() - |> assert_sdk_key_required() - end + describe "SDK key validation" do + test "raises error if SDK key is missing" do + nil + |> start() + |> assert_sdk_key_required() + end - test "raises error if SDK key is an empty string" do - "" - |> start_config_cat() - |> assert_sdk_key_required() - end + test "raises error if SDK key is an empty string" do + "" + |> start() + |> assert_sdk_key_required() + end - @tag capture_log: true - test "raises error when starting another instance with the same SDK key" do - {:ok, _} = start_config_cat(@sdk_key, name: :original) + for {sdk_key, custom_base_url?, valid?} <- [ + {"sdk-key-90123456789012", false, false}, + {"sdk-key-9012345678901/1234567890123456789012", false, false}, + {"sdk-key-90123456789012/123456789012345678901", false, false}, + {"sdk-key-90123456789012/12345678901234567890123", false, false}, + {"sdk-key-901234567890123/1234567890123456789012", false, false}, + {"sdk-key-90123456789012/1234567890123456789012", false, true}, + {"configcat-sdk-1/sdk-key-90123456789012", false, false}, + {"configcat-sdk-1/sdk-key-9012345678901/1234567890123456789012", false, false}, + {"configcat-sdk-1/sdk-key-90123456789012/123456789012345678901", false, false}, + {"configcat-sdk-1/sdk-key-90123456789012/12345678901234567890123", false, false}, + {"configcat-sdk-1/sdk-key-901234567890123/1234567890123456789012", false, false}, + {"configcat-sdk-1/sdk-key-90123456789012/1234567890123456789012", false, true}, + {"configcat-sdk-2/sdk-key-90123456789012/1234567890123456789012", false, false}, + {"configcat-proxy/", false, false}, + {"configcat-proxy/", true, false}, + {"configcat-proxy/sdk-key-90123456789012", false, false}, + {"configcat-proxy/sdk-key-90123456789012", true, true} + ] do + test "validates SDK key format - sdk_key: #{sdk_key} | custom_base_url: #{custom_base_url?}" do + sdk_key = unquote(sdk_key) + custom_base_url? = unquote(custom_base_url?) + valid? = unquote(valid?) + options = if custom_base_url?, do: [base_url: "https://my-configcat-proxy"], else: [] + + if valid? do + assert {:ok, _} = start(sdk_key, options) + else + sdk_key |> start(options) |> assert_sdk_key_invalid(sdk_key) + end + end + end + + test "allows older format SDK keys" do + assert {:ok, _} = start("1234567890abcdefghijkl/1234567890abcdefghijkl") + end - assert {:error, {{:EXIT, {error, _stacktrace}}, _spec}} = - start_config_cat(@sdk_key, name: :duplicate) + test "does not validate SDK key format in local-only mode" do + overrides = LocalMapDataSource.new(%{}, :local_only) + assert {:ok, _} = start("invalid-sdk-key-format", flag_overrides: overrides) + end + + @tag capture_log: true + test "raises error when starting another instance with the same SDK key" do + {:ok, _} = start(@sdk_key, name: :original) - assert %ArgumentError{message: message} = error - assert message =~ ~r/existing ConfigCat instance/ + assert {:error, {{:EXIT, {error, _stacktrace}}, _spec}} = + start(@sdk_key, name: :duplicate) + + assert %ArgumentError{message: message} = error + assert message =~ ~r/existing ConfigCat instance/ + end end test "fetches config" do - {:ok, client} = start_config_cat(@sdk_key) + {:ok, client} = start(@sdk_key) :ok = ConfigCat.force_refresh(client: client) @@ -43,7 +88,7 @@ defmodule ConfigCat.IntegrationTest do end test "maintains previous configuration when config has not changed between refreshes" do - {:ok, client} = start_config_cat(@sdk_key) + {:ok, client} = start(@sdk_key) :ok = ConfigCat.force_refresh(client: client) :ok = ConfigCat.force_refresh(client: client) @@ -54,7 +99,7 @@ defmodule ConfigCat.IntegrationTest do test "lazily fetches configuration when using lazy loading" do {:ok, client} = - start_config_cat( + start( @sdk_key, fetch_policy: CachePolicy.lazy(cache_refresh_interval_seconds: 5) ) @@ -65,7 +110,7 @@ defmodule ConfigCat.IntegrationTest do @tag capture_log: true test "does not fetch config when offline mode is set" do - {:ok, client} = start_config_cat(@sdk_key, offline: true) + {:ok, client} = start(@sdk_key, offline: true) assert ConfigCat.offline?(client: client) @@ -85,21 +130,21 @@ defmodule ConfigCat.IntegrationTest do @tag capture_log: true test "handles errors from ConfigCat server" do - {:ok, client} = start_config_cat("invalid_sdk_key") + {:ok, client} = start("configcat-sdk-1/1234567890abcdefghijkl/1234567890abcdefghijkl") assert {:error, _message} = ConfigCat.force_refresh(client: client) end @tag capture_log: true test "handles invalid base_url" do - {:ok, client} = start_config_cat(@sdk_key, base_url: "https://invalidcdn.configcat.com") + {:ok, client} = start(@sdk_key, base_url: "https://invalidcdn.configcat.com") assert {:error, _message} = ConfigCat.force_refresh(client: client) end @tag capture_log: true test "handles data_governance: eu_only" do - {:ok, client} = start_config_cat(@sdk_key, data_governance: :eu_only) + {:ok, client} = start(@sdk_key, data_governance: :eu_only) assert ConfigCat.get_value("keySampleText", "default value", client: client) == "This text came from ConfigCat" @@ -108,24 +153,25 @@ defmodule ConfigCat.IntegrationTest do @tag capture_log: true test "handles timeout" do {:ok, client} = - start_config_cat(@sdk_key, connect_timeout_milliseconds: 0, read_timeout_milliseconds: 0) + start(@sdk_key, connect_timeout_milliseconds: 0, read_timeout_milliseconds: 0) assert ConfigCat.get_value("keySampleText", "default value", client: client) == "default value" end - defp start_config_cat(sdk_key, options \\ []) do + defp start(sdk_key, options \\ []) do sdk_key |> Cache.generate_key() |> InMemoryCache.clear() - name = String.to_atom(UUID.uuid4()) - default_options = [name: name, sdk_key: sdk_key] + start_config_cat(sdk_key, options) + end - with {:ok, _pid} <- - start_supervised({ConfigCat, Keyword.merge(default_options, options)}, id: name) do - {:ok, name} - end + defp assert_sdk_key_invalid({:error, result}, sdk_key) do + assert {{:EXIT, {error, _stacktrace}}, _spec} = result + + expected_message = "SDK Key `#{sdk_key}` is invalid." + assert %ArgumentError{message: ^expected_message} = error end defp assert_sdk_key_required({:error, result}) do diff --git a/test/rollout/comparator_test.exs b/test/rollout/comparator_test.exs deleted file mode 100644 index ccf02b6e..00000000 --- a/test/rollout/comparator_test.exs +++ /dev/null @@ -1,258 +0,0 @@ -defmodule ConfigCat.Rollout.ComparatorTest do - @moduledoc """ - All evaluators are tested exhaustively in ConfigCat.RolloutTest, - these are basic tests to ensure that we're using the correct - comparator type for the given comparator value. - """ - - use ExUnit.Case, async: true - - alias ConfigCat.Rollout.Comparator - alias Version.InvalidVersionError - - test "returns false if given an unknown comparator" do - assert {:ok, false} = Comparator.compare(-1, "b", "a, b, c") - end - - describe "basic comparators" do - test "is_one_of" do - is_one_of = 0 - - assert {:ok, true} = Comparator.compare(is_one_of, "b", "a, b, c") - assert {:ok, false} = Comparator.compare(is_one_of, "x", "a, b, c") - end - - test "is_not_one_of" do - is_not_one_of = 1 - - assert {:ok, false} = Comparator.compare(is_not_one_of, "b", "a, b, c") - assert {:ok, true} = Comparator.compare(is_not_one_of, "x", "a, b, c") - end - - test "contains" do - contains = 2 - - assert {:ok, true} = Comparator.compare(contains, "jane@influxdata.com", "influxdata.com") - assert {:ok, false} = Comparator.compare(contains, "jane@email.com", "influxdata.com") - end - - test "does_not_contain" do - does_not_contain = 3 - - assert {:ok, false} = - Comparator.compare(does_not_contain, "jane@influxdata.com", "influxdata.com") - - assert {:ok, true} = - Comparator.compare(does_not_contain, "jane@email.com", "influxdata.com") - end - end - - describe "semantic version comparators" do - test "is_one_of (semver)" do - is_one_of_semver = 4 - - assert {:ok, true} = Comparator.compare(is_one_of_semver, "1.2.0", "1.2.0, 1.3.4") - assert {:ok, false} = Comparator.compare(is_one_of_semver, "2.0.0", "1.2.0, 1.3.4") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(is_one_of_semver, "invalid", "1.2.0, 1.3.4") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(is_one_of_semver, "1.2.0", "invalid, 1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(is_one_of_semver, "1.2.0", "1.2.0, invalid") - end - - test "is_not_one_of (semver)" do - is_not_one_of_semver = 5 - - assert {:ok, true} = Comparator.compare(is_not_one_of_semver, "2.0.0", "1.2.0, 1.3.4") - assert {:ok, false} = Comparator.compare(is_not_one_of_semver, "1.2.0", "1.2.0, 1.3.4") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(is_not_one_of_semver, "invalid", "1.2.0, 1.3.4") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(is_not_one_of_semver, "1.2.0", "invalid, 1.3.4") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(is_not_one_of_semver, "1.2.0", "1.2.0, invalid") - end - - test "< (SemVer)" do - less_than_semver = 6 - - assert {:ok, true} = Comparator.compare(less_than_semver, "1.2.0", "1.3.0") - assert {:ok, false} = Comparator.compare(less_than_semver, "1.3.0", "1.2.0") - assert {:ok, false} = Comparator.compare(less_than_semver, "1.2.0", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(less_than_semver, "invalid", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(less_than_semver, "1.3.0", "invalid") - end - - test "<= (SemVer)" do - less_than_equal_semver = 7 - - assert {:ok, true} = Comparator.compare(less_than_equal_semver, "1.2.0", "1.3.0") - assert {:ok, false} = Comparator.compare(less_than_equal_semver, "1.3.0", "1.2.0") - assert {:ok, true} = Comparator.compare(less_than_equal_semver, "1.2.0", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(less_than_equal_semver, "invalid", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(less_than_equal_semver, "1.3.0", "invalid") - end - - test "> (SemVer)" do - greater_than_semver = 8 - - assert {:ok, true} = Comparator.compare(greater_than_semver, "1.3.0", "1.2.0") - assert {:ok, false} = Comparator.compare(greater_than_semver, "1.2.0", "1.3.0") - assert {:ok, false} = Comparator.compare(greater_than_semver, "1.2.0", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(greater_than_semver, "invalid", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(greater_than_semver, "1.3.0", "invalid") - end - - test ">= (SemVer)" do - greater_than_equal_semver = 9 - - assert {:ok, true} = Comparator.compare(greater_than_equal_semver, "1.3.0", "1.2.0") - assert {:ok, false} = Comparator.compare(greater_than_equal_semver, "1.2.0", "1.3.0") - assert {:ok, true} = Comparator.compare(greater_than_equal_semver, "1.2.0", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(greater_than_equal_semver, "invalid", "1.2.0") - - assert {:error, %InvalidVersionError{}} = - Comparator.compare(greater_than_equal_semver, "1.3.0", "invalid") - end - end - - describe "numeric comparators" do - test "= (Number)" do - equals_number = 10 - - assert {:ok, true} = Comparator.compare(equals_number, "3.5", "3.5000") - assert {:ok, true} = Comparator.compare(equals_number, "3,5", "3.5000") - assert {:ok, true} = Comparator.compare(equals_number, 3.5, 3.5000) - assert {:ok, false} = Comparator.compare(equals_number, "3,5", "4.752") - assert {:error, :invalid_float} = Comparator.compare(equals_number, "not a float", "3.5000") - assert {:error, :invalid_float} = Comparator.compare(equals_number, "3,5", "not a float") - end - - test "<> (Number)" do - not_equals_number = 11 - - assert {:ok, false} = Comparator.compare(not_equals_number, "3.5", "3.5000") - assert {:ok, false} = Comparator.compare(not_equals_number, "3,5", "3.5000") - assert {:ok, false} = Comparator.compare(not_equals_number, 3.5, 3.5000) - assert {:ok, true} = Comparator.compare(not_equals_number, "3,5", "4.752") - - assert {:error, :invalid_float} = - Comparator.compare(not_equals_number, "not a float", "3.5000") - - assert {:error, :invalid_float} = - Comparator.compare(not_equals_number, "3,5", "not a float") - end - - test "< (Number)" do - less_than_number = 12 - - assert {:ok, true} = Comparator.compare(less_than_number, "3.5", "3.6000") - assert {:ok, true} = Comparator.compare(less_than_number, "3,5", "3.6000") - assert {:ok, true} = Comparator.compare(less_than_number, 3.5, 3.6000) - assert {:ok, false} = Comparator.compare(less_than_number, "3,5", "1.752") - assert {:ok, false} = Comparator.compare(less_than_number, "3,5", "3.5") - - assert {:error, :invalid_float} = - Comparator.compare(less_than_number, "not a float", "3.5000") - - assert {:error, :invalid_float} = Comparator.compare(less_than_number, "3,5", "not a float") - end - - test "<= (Number)" do - less_than_equal_number = 13 - - assert {:ok, true} = Comparator.compare(less_than_equal_number, "3.5", "3.6000") - assert {:ok, true} = Comparator.compare(less_than_equal_number, "3,5", "3.6000") - assert {:ok, true} = Comparator.compare(less_than_equal_number, 3.5, 3.6000) - assert {:ok, true} = Comparator.compare(less_than_equal_number, "3,5", "3.5") - assert {:ok, false} = Comparator.compare(less_than_equal_number, "3,5", "1.752") - - assert {:error, :invalid_float} = - Comparator.compare(less_than_equal_number, "not a float", "3.5000") - - assert {:error, :invalid_float} = - Comparator.compare(less_than_equal_number, "3,5", "not a float") - end - - test "> (Number)" do - greater_than_number = 14 - - assert {:ok, false} = Comparator.compare(greater_than_number, "3.5", "3.6000") - assert {:ok, false} = Comparator.compare(greater_than_number, "3,5", "3.6000") - assert {:ok, false} = Comparator.compare(greater_than_number, 3.5, 3.6000) - assert {:ok, false} = Comparator.compare(greater_than_number, "3,5", "3.5") - assert {:ok, true} = Comparator.compare(greater_than_number, "3,5", "1.752") - - assert {:error, :invalid_float} = - Comparator.compare(greater_than_number, "not a float", "3.5000") - - assert {:error, :invalid_float} = - Comparator.compare(greater_than_number, "3,5", "not a float") - end - - test ">= (Number)" do - greater_than_equal_number = 15 - - assert {:ok, false} = Comparator.compare(greater_than_equal_number, "3.5", "3.6000") - assert {:ok, false} = Comparator.compare(greater_than_equal_number, "3,5", "3.6000") - assert {:ok, false} = Comparator.compare(greater_than_equal_number, 3.5, 3.6000) - assert {:ok, true} = Comparator.compare(greater_than_equal_number, "3,5", "1.752") - assert {:ok, true} = Comparator.compare(greater_than_equal_number, "3,5", "3.5") - - assert {:error, :invalid_float} = - Comparator.compare(greater_than_equal_number, "not a float", "3.5000") - - assert {:error, :invalid_float} = - Comparator.compare(greater_than_equal_number, "3,5", "not a float") - end - end - - describe "sensitive comparators" do - setup do - hashed = %{ - a: "86f7e437faa5a7fce15d1ddcb9eaeaea377667b8", - b: "e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98", - c: "84a516841ba77a5b4648de2cd0dfcb30ea46dbb4" - } - - {:ok, hashed: hashed} - end - - test "is_one_of (sensitive)", %{hashed: hashed} do - is_one_of_sensitive = 16 - %{a: a, b: b, c: c} = hashed - - assert {:ok, true} = Comparator.compare(is_one_of_sensitive, "a", "#{a}, #{b}, #{c}") - assert {:ok, false} = Comparator.compare(is_one_of_sensitive, "x", "#{a}, #{b}, #{c}") - end - - test "is_not_one_of (sensitive)", %{hashed: hashed} do - is_not_one_of_sensitive = 17 - %{a: a, b: b, c: c} = hashed - - assert {:ok, true} = Comparator.compare(is_not_one_of_sensitive, "x", "#{a}, #{b}, #{c}") - assert {:ok, false} = Comparator.compare(is_not_one_of_sensitive, "a", "#{a}, #{b}, #{c}") - end - end -end diff --git a/test/rollout_test.exs b/test/rollout_test.exs index f7621059..1b6280aa 100644 --- a/test/rollout_test.exs +++ b/test/rollout_test.exs @@ -1,68 +1,162 @@ defmodule ConfigCat.RolloutTest do - use ExUnit.Case, async: true - - alias ConfigCat.CachePolicy + # Must be async: false to avoid a collision with other tests. + # Now that we only allow a single ConfigCat instance to use the same SDK key, + # one of the async tests would fail due to the existing running instance. + use ConfigCat.Case, async: false + + import ExUnit.CaptureLog + import Jason.Sigil + + alias ConfigCat.Config + alias ConfigCat.Config.SettingType + alias ConfigCat.EvaluationDetails + alias ConfigCat.LocalFileDataSource + alias ConfigCat.LocalMapDataSource + alias ConfigCat.OverrideDataSource + alias ConfigCat.Rollout alias ConfigCat.User + require ConfigCat.Config.SettingType + @moduletag capture_log: true @value_test_type "value_test" @variation_test_type "variation_test" - test "basic rule evaluation" do - test_matrix( - "testmatrix.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", - @value_test_type - ) - end + describe "matrix tests (config v1)" do + test "basic operators" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A") + end - test "semantic version matching" do - test_matrix( - "testmatrix_semantic.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA", - @value_test_type - ) - end + test "numeric operators" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d747f0-5986-c2ef-eef3-ec778e32e10a/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix_number.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw") + end - test "semantic version comparisons" do - test_matrix( - "testmatrix_semantic_2.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/q6jMCFIp-EmuAfnmZhPY7w", - @value_test_type - ) - end + test "segments v1" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d9f207-6883-43e5-868c-cbf677af3fe6/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix_segments_old.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/LcYz135LE0qbcacz2mgXnA") + end - test "semantic version comparisons #2" do - test_matrix( - "testmatrix_input_semantic_2.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/q6jMCFIp-EmuAfnmZhPY7w", - @value_test_type - ) - end + test "semantic version operators" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d745f1-f315-7daf-d163-5541d3786e6f/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix_semantic.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA") + end - test "numeric comparisons" do - test_matrix( - "testmatrix_number.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw", - @value_test_type - ) - end + test "semantic version operators 2" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d77fa1-a796-85f9-df0c-57c448eb9934/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix_semantic_2.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/q6jMCFIp-EmuAfnmZhPY7w") + end + + test "sensitive text operators" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d7b724-9285-f4a7-9fcd-00f64f1e83d5/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix_sensitive.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/qX3TP2dTj06ZpCCT1h_SPA") + end - test "sensitive information comparisons" do - test_matrix( - "testmatrix_sensitive.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/qX3TP2dTj06ZpCCT1h_SPA", - @value_test_type - ) + test "variation ID" do + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d774b9-3d05-0027-d5f4-3e76c3dba752/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix("testmatrix_variationId.csv", "PKDVCLf-Hq-h-kCzMp-L7Q/nQ5qkhRAUEa6beEyyrVLBA", @variation_test_type) + end end - test "variation id" do - test_matrix( - "testmatrix_variationId.csv", - "PKDVCLf-Hq-h-kCzMp-L7Q/nQ5qkhRAUEa6beEyyrVLBA", - @variation_test_type - ) + describe "matrix tests (config v2)" do + test "basic rule evaluation" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-1927-4d6b-8fb9-b1472564e2d3/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ" + ) + end + + test "semantic version matching" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-278c-4f83-8d36-db73ad6e2a3a/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix_semantic.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg" + ) + end + + test "semantic version comparisons" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-2b2b-451e-8359-abdef494c2a2/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix_semantic_2.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/U8nt3zEhDEO5S2ulubCopA" + ) + end + + test "numeric comparisons" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-0fa3-48d0-8de8-9de55b67fb8b/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix_number.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw" + ) + end + + test "sensitive information comparisons" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-2d62-4e1b-884b-6aa237b34764/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix_sensitive.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/-0YmVOUNgEGKkgRF-rU65g" + ) + end + + test "v6 comparators" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb + test_matrix( + "testmatrix_comparators_v6.csv", + "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ" + ) + end + + test "segments" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9cfb-486f-8906-72a57c693615/08dbc325-9ebd-4587-8171-88f76a3004cb + test_matrix( + "testmatrix_segments.csv", + "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/h99HYXWWNE2bH8eWyLAVMA" + ) + end + + test "segments (old)" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbd6ca-a85f-4ed0-888a-2da18def92b5/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix_segments_old.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/y_ZB7o-Xb0Swxth-ZlMSeA" + ) + end + + test "prerequisite flags" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9b74-45cb-86d0-4d61c25af1aa/08dbc325-9ebd-4587-8171-88f76a3004cb + test_matrix( + "testmatrix_prerequisite_flag.csv", + "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg" + ) + end + + test "and/or" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb + test_matrix( + "testmatrix_and_or.csv", + "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A" + ) + end + + test "variation id" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbc4dc-30c6-4969-8e4c-03f6a8764199/244cf8b0-f604-11e8-b543-f23c917f9d8d + test_matrix( + "testmatrix_variationId.csv", + "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/spQnkRTIPEWVivZkWM84lQ", + @variation_test_type + ) + end + + test "unicode support" do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbd63c-9774-49d6-8187-5f2aab7bd606/08dbc325-9ebd-4587-8171-88f76a3004cb + test_matrix( + "testmatrix_unicode.csv", + "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/Da6w8dBbmUeMUBhh0iEeQQ" + ) + end end test "invalid user object" do @@ -73,7 +167,425 @@ defmodule ConfigCat.RolloutTest do assert actual == "Cat" end - defp test_matrix(filename, sdk_key, type) do + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb + for {user_id, email, percentage_base, expected_return_value, expected_matched_targeting_rule, + expected_matched_percentage_option} <- [ + {nil, nil, nil, "Cat", false, false}, + {"12345", nil, nil, "Cat", false, false}, + {"12345", "a@example.com", nil, "Dog", true, false}, + {"12345", "a@configcat.com", nil, "Cat", false, false}, + {"12345", "a@configcat.com", "", "Frog", true, true}, + {"12345", "a@configcat.com", "US", "Fish", true, true}, + {"12345", "b@configcat.com", nil, "Cat", false, false}, + {"12345", "b@configcat.com", "", "Falcon", false, true}, + {"12345", "b@configcat.com", "US", "Spider", false, true} + ] do + test "matched evaluation rule and percentage option with user_id: #{inspect(user_id)} email: #{inspect(email)} percentage_base: #{inspect(percentage_base)}" do + sdk_key = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw" + key = "stringMatchedTargetingRuleAndOrPercentageOption" + user_id = unquote(user_id) + email = unquote(email) + percentage_base = unquote(percentage_base) + expected_return_value = unquote(expected_return_value) + expected_matched_targeting_rule = unquote(expected_matched_targeting_rule) + expected_matched_percentage_option = unquote(expected_matched_percentage_option) + + {:ok, client} = start_config_cat(sdk_key) + + user = User.new(user_id, email: email, custom: %{"PercentageBase" => percentage_base}) + + %EvaluationDetails{} = evaluation_details = ConfigCat.get_value_details(key, nil, user, client: client) + assert evaluation_details.value == expected_return_value + assert !is_nil(evaluation_details.matched_targeting_rule) == expected_matched_targeting_rule + assert !is_nil(evaluation_details.matched_percentage_option) == expected_matched_percentage_option + end + end + + test "user object attribute value conversion text comparison" do + sdk_key = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ" + + {:ok, client} = start_config_cat(sdk_key) + custom_attribute_name = "Custom1" + custom_attribute_value = 42 + user = User.new("12345", custom: %{custom_attribute_name => custom_attribute_value}) + + key = "boolTextEqualsNumber" + + logs = + capture_log(fn -> + assert ConfigCat.get_value(key, nil, user, client: client) + end) + + expected_log = + adjust_log_level( + "warning [3005] Evaluation of condition (User.#{custom_attribute_name} EQUALS '#{custom_attribute_value}') " <> + "for setting '#{key}' may not produce the expected result (the User.#{custom_attribute_name} attribute is not a string value, " <> + "thus it was automatically converted to the string value '#{custom_attribute_value}'). " <> + "Please make sure that using a non-string value was intended." + ) + + assert expected_log in String.split(logs, "\n", trim: true) + end + + test "config json type mismatch" do + config = + Config.inline_salt_and_segments(~j""" + { + "f": { + "test": { + "t": #{SettingType.string()}, + "v": {"b": true} + } + } + } + """) + + logs = + capture_log(fn -> + assert %EvaluationDetails{value: false} = Rollout.evaluate("test", nil, false, "default_variation_id", config) + end) + + expected_log = + "error [2001] Failed to evaluate setting 'test'. " <> + "(Setting value is not of the expected type String.t())" + + assert logs =~ expected_log + end + + for {sdk_key, key, custom_attribute_value, expected_return_value} <- [ + # SemVer-based comparisons + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "0.0", "20%"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "0.9.9", "< 1.0.0"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "1.0.0", "20%"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "1.1", "20%"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", 0, "20%"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", 0.9, "20%"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", 2, "20%"}, + # Number-based comparisons + # {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", float(~c"-inf"), + # "<2.1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", -1, "<2.1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", 2, "<2.1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", 2.1, "<=2,1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", 3, "<>4.2"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", 5, ">=5"}, + # {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", float('inf'), ">5"}, + # {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", + # "numberWithPercentage", float('nan'), "<>4.2"}, + # {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "-inf", "<2.1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "-1", "<2.1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "2", "<2.1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "2.1", "<=2,1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "2,1", "<=2,1"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "3", "<>4.2"}, + {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "5", ">=5"}, + # {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "inf", ">5"}, + # {"configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "nan", "<>4.2"}, + # Date time-based comparisons + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + ~N[2023-03-31T23:59:59.999000], false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + ~U[2023-03-31T23:59:59.999000Z], false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + DateTime.new!(~D[2023-04-01], ~T[01:59:59.999000], "Etc/GMT-2"), false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + ~U[2023-04-01T00:00:00.001000Z], true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + DateTime.new!(~D[2023-04-01], ~T[02:00:00.001000], "Etc/GMT-2"), true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + ~U[2023-04-30T23:59:59.999000Z], true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + DateTime.new!(~D[2023-05-01], ~T[01:59:59.999000], "Etc/GMT-2"), true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + ~U[2023-05-01T00:00:00.001000Z], false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", + DateTime.new!(~D[2023-05-01], ~T[02:00:00.001000], "Etc/GMT-2"), false}, + # {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", float('-inf'), false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_680_307_199.999, false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_680_307_200.001, true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_682_899_199.999, true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_682_899_200.001, false}, + # {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", float('inf'), false}, + # {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", float("nan"), false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_680_307_199, false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_680_307_201, true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_682_899_199, true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", 1_682_899_201, false}, + # {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "-inf", false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "1680307199.999", false}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "1680307200.001", true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "1682899199.999", true}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "1682899200.001", false}, + # {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "+inf", false}, + # {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "NaN", false}, + # String array-based comparisons + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", + ["x", "read"], "Dog"}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", + ["x", "Read"], "Cat"}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", + "[\"x\", \"read\"]", "Dog"}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", + "[\"x\", \"Read\"]", "Cat"}, + {"configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", + "x, read", "Cat"} + ] do + test "attribute value conversion with key: '#{key}' value: '#{inspect(custom_attribute_value)}" do + sdk_key = unquote(sdk_key) + key = unquote(key) + user_id = "12345" + custom_attribute_name = "Custom1" + custom_attribute_value = unquote(Macro.escape(custom_attribute_value)) + expected_return_value = unquote(expected_return_value) + + {:ok, client} = start_config_cat(sdk_key) + + user = User.new(user_id, custom: %{custom_attribute_name => custom_attribute_value}) + actual = ConfigCat.get_value(key, nil, user, client: client) + + assert expected_return_value == actual + end + end + + for {key, dependency_cycle} <- [ + {"key1", "'key1' -> 'key1'"}, + {"key2", "'key2' -> 'key3' -> 'key2'"}, + {"key4", "'key4' -> 'key3' -> 'key2' -> 'key3'"} + ] do + test "prerequisite flag circular dependency for key: #{key}" do + key = unquote(key) + dependency_cycle = unquote(dependency_cycle) + + config = + "test_circulardependency_v6.json" + |> fixture_file() + |> LocalFileDataSource.new(:local_only) + |> OverrideDataSource.overrides() + + logs = + capture_log(fn -> + assert %EvaluationDetails{value: "default_value"} = + Rollout.evaluate(key, nil, "default_value", "default_variation_id", config) + end) + + assert logs =~ "Circular dependency detected" + assert logs =~ dependency_cycle + end + end + + for {key, comparison_value_type, prerequisite_flag_key, prerequisite_flag_value, expected_value} <- [ + {"stringDependsOnBool", "boolean()", "mainBoolFlag", true, "Dog"}, + {"stringDependsOnBool", "boolean()", "mainBoolFlag", false, "Cat"}, + {"stringDependsOnBool", "boolean()", "mainBoolFlag", "1", nil}, + {"stringDependsOnBool", "boolean()", "mainBoolFlag", 1, nil}, + {"stringDependsOnBool", "boolean()", "mainBoolFlag", 1.0, nil}, + {"stringDependsOnBool", "boolean()", "mainBoolFlag", [true], nil}, + {"stringDependsOnBool", "boolean()", "mainBoolFlag", nil, nil}, + {"stringDependsOnString", "String.t()", "mainStringFlag", "private", "Dog"}, + {"stringDependsOnString", "String.t()", "mainStringFlag", "Private", "Cat"}, + {"stringDependsOnString", "String.t()", "mainStringFlag", true, nil}, + {"stringDependsOnString", "String.t()", "mainStringFlag", 1, nil}, + {"stringDependsOnString", "String.t()", "mainStringFlag", 1.0, nil}, + {"stringDependsOnString", "String.t()", "mainStringFlag", ["private"], nil}, + {"stringDependsOnString", "String.t()", "mainStringFlag", nil, nil}, + {"stringDependsOnInt", "integer()", "mainIntFlag", 2, "Dog"}, + {"stringDependsOnInt", "integer()", "mainIntFlag", 1, "Cat"}, + {"stringDependsOnInt", "integer()", "mainIntFlag", "2", nil}, + {"stringDependsOnInt", "integer()", "mainIntFlag", true, nil}, + {"stringDependsOnInt", "integer()", "mainIntFlag", 2.0, nil}, + {"stringDependsOnInt", "integer()", "mainIntFlag", [2], nil}, + {"stringDependsOnInt", "integer()", "mainIntFlag", nil, nil}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", 0.1, "Dog"}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", 0.11, "Cat"}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", "0.1", nil}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", true, nil}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", 1, nil}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", [0.1], nil}, + {"stringDependsOnDouble", "float()", "mainDoubleFlag", nil, nil} + ] do + test "prerequisite flag value type mismatch with key: #{key} type: #{comparison_value_type} flag_key: #{prerequisite_flag_key} value: #{inspect(prerequisite_flag_value)}" do + sdk_key = "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg" + key = unquote(key) + flag_key = unquote(prerequisite_flag_key) + flag_value = unquote(prerequisite_flag_value) + expected_value = unquote(expected_value) + + flag_overrides = LocalMapDataSource.new(%{flag_key => flag_value}, :local_over_remote) + + {:ok, client} = start_config_cat(sdk_key, flag_overrides: flag_overrides) + + logs = + capture_log(fn -> + assert expected_value == ConfigCat.get_value(key, nil, client: client) + end) + + unless expected_value do + expected_message = + ~r/Type mismatch between comparison value '[^']+' and prerequisite flag '#{flag_key}'/ + + assert logs =~ expected_message + end + end + end + + for {key, custom_attribute_value, expected_return_value} <- [ + {"numberToStringConversion", 0.12345, "1"}, + {"numberToStringConversionInt", 125.0, "4"}, + {"numberToStringConversionPositiveExp", -1.23456789e96, "2"}, + {"numberToStringConversionNegativeExp", -12345.6789e-100, "4"}, + # {"numberToStringConversionNaN", NaN, "3"}, + # {"numberToStringConversionPositiveInf", Infinity, "4"}, + # {"numberToStringConversionNegativeInf", -Infinity, "3"}, + {"dateToStringConversion", ~U[2023-03-31T23:59:59.9990000Z], "3"}, + {"dateToStringConversion", 1_680_307_199.999, "3"}, + # {"dateToStringConversionNaN", NaN, "3"}, + # {"dateToStringConversionPositiveInf", Infinity, "1"}, + # {"dateToStringConversionNegativeInf", -Infinity, "5"}, + {"stringArrayToStringConversion", ["read", "Write", " eXecute "], "4"}, + {"stringArrayToStringConversionEmpty", [], "5"}, + {"stringArrayToStringConversionSpecialChars", ["+<>%\"'\\/\t\r\n"], "3"}, + {"stringArrayToStringConversionUnicode", ["盲枚眉脛脰脺莽茅猫帽谋艧臒芒垄鈩⑩湏馃榾"], "2"} + ] do + test "comparison attribute conversion to canonical string representation - key: #{key} | custom_attribute_value: #{custom_attribute_value}" do + key = unquote(key) + custom_attribute_value = unquote(Macro.escape(custom_attribute_value)) + expected_return_value = unquote(expected_return_value) + + config = + "comparison_attribute_conversion.json" + |> fixture_file() + |> LocalFileDataSource.new(:local_only) + |> OverrideDataSource.overrides() + + user = + User.new("12345", custom: %{"Custom1" => custom_attribute_value}) + + assert %EvaluationDetails{value: ^expected_return_value} = Rollout.evaluate(key, user, "default", nil, config) + end + end + + for {key, expected_return_value} <- [ + {"isoneof", "no trim"}, + {"isnotoneof", "no trim"}, + {"isoneofhashed", "no trim"}, + {"isnotoneofhashed", "no trim"}, + {"equalshashed", "no trim"}, + {"notequalshashed", "no trim"}, + {"arraycontainsanyofhashed", "no trim"}, + {"arraynotcontainsanyofhashed", "no trim"}, + {"equals", "no trim"}, + {"notequals", "no trim"}, + {"startwithanyof", "no trim"}, + {"notstartwithanyof", "no trim"}, + {"endswithanyof", "no trim"}, + {"notendswithanyof", "no trim"}, + {"arraycontainsanyof", "no trim"}, + {"arraynotcontainsanyof", "no trim"}, + {"startwithanyofhashed", "no trim"}, + {"notstartwithanyofhashed", "no trim"}, + {"endswithanyofhashed", "no trim"}, + {"notendswithanyofhashed", "no trim"}, + # semver comparators user values trimmed because of backward compatibility + {"semverisoneof", "4 trim"}, + {"semverisnotoneof", "5 trim"}, + {"semverless", "6 trim"}, + {"semverlessequals", "7 trim"}, + {"semvergreater", "8 trim"}, + {"semvergreaterequals", "9 trim"}, + # number and date comparators user values trimmed because of backward compatibility + {"numberequals", "10 trim"}, + {"numbernotequals", "11 trim"}, + {"numberless", "12 trim"}, + {"numberlessequals", "13 trim"}, + {"numbergreater", "14 trim"}, + {"numbergreaterequals", "15 trim"}, + {"datebefore", "18 trim"}, + {"dateafter", "19 trim"}, + # "contains any of" and "not contains any of" is a special case, + # the not trimmed user attribute checked against not trimmed comparator values. + {"containsanyof", "no trim"}, + {"notcontainsanyof", "no trim"} + ] do + test "comparison attribute trimming - key: #{key}" do + key = unquote(key) + expected_return_value = unquote(expected_return_value) + + config = + "comparison_attribute_trimming.json" + |> fixture_file() + |> LocalFileDataSource.new(:local_only) + |> OverrideDataSource.overrides() + + user = + User.new(" 12345 ", + country: ~s([" USA "]), + custom: %{ + "Version" => " 1.0.0 ", + "Number" => " 3 ", + "Date" => " 1705253400 " + } + ) + + assert %EvaluationDetails{value: ^expected_return_value} = Rollout.evaluate(key, user, "default", nil, config) + end + end + + for {key, expected_return_value} <- [ + {"isoneof", "no trim"}, + {"isnotoneof", "no trim"}, + {"containsanyof", "no trim"}, + {"notcontainsanyof", "no trim"}, + {"isoneofhashed", "no trim"}, + {"isnotoneofhashed", "no trim"}, + {"equalshashed", "no trim"}, + {"notequalshashed", "no trim"}, + {"arraycontainsanyofhashed", "no trim"}, + {"arraynotcontainsanyofhashed", "no trim"}, + {"equals", "no trim"}, + {"notequals", "no trim"}, + {"startwithanyof", "no trim"}, + {"notstartwithanyof", "no trim"}, + {"endswithanyof", "no trim"}, + {"notendswithanyof", "no trim"}, + {"arraycontainsanyof", "no trim"}, + {"arraynotcontainsanyof", "no trim"}, + {"startwithanyofhashed", "no trim"}, + {"notstartwithanyofhashed", "no trim"}, + {"endswithanyofhashed", "no trim"}, + {"notendswithanyofhashed", "no trim"}, + # semver comparator values trimmed because of backward compatibility + {"semverisoneof", "4 trim"}, + {"semverisnotoneof", "5 trim"}, + {"semverless", "6 trim"}, + {"semverlessequals", "7 trim"}, + {"semvergreater", "8 trim"}, + {"semvergreaterequals", "9 trim"} + ] do + test "comparison value trimming - key: #{key}" do + key = unquote(key) + expected_return_value = unquote(expected_return_value) + + config = + "comparison_value_trimming.json" + |> fixture_file() + |> LocalFileDataSource.new(:local_only) + |> OverrideDataSource.overrides() + + user = + User.new("12345", + country: ~s(["USA"]), + custom: %{ + "Version" => "1.0.0", + "Number" => "3", + "Date" => "1705253400" + } + ) + + assert %EvaluationDetails{value: ^expected_return_value} = Rollout.evaluate(key, user, "default", nil, config) + end + end + + defp test_matrix(filename, sdk_key, type \\ @value_test_type) do [header | test_lines] = read_test_matrix(filename) {custom_key, settings_keys} = parse_header(header) @@ -85,9 +597,8 @@ defmodule ConfigCat.RolloutTest do end defp read_test_matrix(filename) do - __ENV__.file - |> Path.dirname() - |> Path.join("fixtures/#{filename}") + filename + |> fixture_file() |> File.read!() |> String.split("\n", trim: true) end @@ -148,7 +659,7 @@ defmodule ConfigCat.RolloutTest do ConfigCat.get_value_details(setting_key, nil, user, client: client).variation_id end - if to_string(actual) !== to_string(expected) do + unless equal?(actual, expected) do %{ identifier: user && user.identifier, setting_key: setting_key, @@ -158,6 +669,27 @@ defmodule ConfigCat.RolloutTest do end end + defp equal?(actual, expected) when is_boolean(actual) do + parsed = String.downcase(expected) == "true" + parsed == actual + end + + defp equal?(actual, expected) when is_integer(actual) do + case Integer.parse(expected, 10) do + {parsed, ""} -> parsed == actual + _ -> false + end + end + + defp equal?(actual, expected) when is_float(actual) do + case Float.parse(expected) do + {parsed, ""} -> parsed == actual + _ -> false + end + end + + defp equal?(actual, expected), do: actual == expected + defp build_custom(_custom_key, nil), do: %{} defp build_custom(custom_key, custom_value), do: %{custom_key => custom_value} @@ -165,20 +697,4 @@ defmodule ConfigCat.RolloutTest do defp normalize(""), do: nil defp normalize("##null##"), do: nil defp normalize(value), do: value - - defp start_config_cat(sdk_key) do - name = String.to_atom(UUID.uuid4()) - - with {:ok, _pid} <- - start_supervised( - {ConfigCat, - [ - fetch_policy: CachePolicy.lazy(cache_refresh_interval_seconds: 300), - name: name, - sdk_key: sdk_key - ]} - ) do - {:ok, name} - end - end end diff --git a/test/special_characters_test.exs b/test/special_characters_test.exs new file mode 100644 index 00000000..5a1b628b --- /dev/null +++ b/test/special_characters_test.exs @@ -0,0 +1,23 @@ +defmodule ConfigCat.SpecialCharactersTest do + # Must be async: false to avoid a collision with other tests. + # Now that we only allow a single ConfigCat instance to use the same SDK key, + # one of the async tests would fail due to the existing running instance. + use ConfigCat.Case, async: false + + alias ConfigCat.User + + @sdk_key "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/u28_1qNyZ0Wz-ldYHIU7-g" + @user User.new("盲枚眉脛脰脺莽茅猫帽谋艧臒芒垄鈩⑩湏馃榾") + + test "special characters work in cleartext" do + {:ok, client} = start_config_cat(@sdk_key) + + assert "盲枚眉脛脰脺莽茅猫帽谋艧臒芒垄鈩⑩湏馃榾" == ConfigCat.get_value("specialCharacters", "NOT_CAT", @user, client: client) + end + + test "special characters work when hashed" do + {:ok, client} = start_config_cat(@sdk_key) + + assert "盲枚眉脛脰脺莽茅猫帽谋艧臒芒垄鈩⑩湏馃榾" == ConfigCat.get_value("specialCharactersHashed", "NOT_CAT", @user, client: client) + end +end diff --git a/test/support/cache_policy_case.ex b/test/support/cache_policy_case.ex index ffe848e0..b2345f4b 100644 --- a/test/support/cache_policy_case.ex +++ b/test/support/cache_policy_case.ex @@ -8,6 +8,7 @@ defmodule ConfigCat.CachePolicyCase do alias ConfigCat.Cache alias ConfigCat.CachePolicy alias ConfigCat.Config + alias ConfigCat.Config.Setting alias ConfigCat.ConfigEntry alias ConfigCat.ConfigFetcher.FetchError alias ConfigCat.Hooks @@ -22,31 +23,30 @@ defmodule ConfigCat.CachePolicyCase do end setup do - settings = %{"some" => "settings"} + initial_settings = %{"new" => Setting.new(value: "new")} + config = [settings: initial_settings] |> Config.new() |> Config.inline_salt_and_segments() + settings = Config.settings(config) + entry = ConfigEntry.new(config, "ETag") - entry = - settings - |> Config.new_with_settings() - |> ConfigEntry.new("ETag") - - %{entry: entry, settings: settings} + %{config: config, entry: entry, settings: settings} end - @spec make_old_entry :: %{entry: ConfigEntry.t(), settings: Config.settings()} + @spec make_old_entry :: %{config: Config.t(), entry: ConfigEntry.t(), settings: Config.settings()} @spec make_old_entry(non_neg_integer()) :: %{ entry: ConfigEntry.t(), settings: Config.settings() } def make_old_entry(age_ms \\ 0) do - settings = %{"old" => "settings"} + initial_settings = %{"old" => Setting.new(value: "old")} + config = [settings: initial_settings] |> Config.new() |> Config.inline_salt_and_segments() + settings = Config.settings(config) entry = - settings - |> Config.new_with_settings() + config |> ConfigEntry.new("OldETag") |> Map.update!(:fetch_time_ms, &(&1 - age_ms)) - %{entry: entry, settings: settings} + %{config: config, entry: entry, settings: settings} end @spec start_cache_policy(CachePolicy.t(), keyword()) :: {:ok, atom()} diff --git a/test/support/case.ex b/test/support/case.ex new file mode 100644 index 00000000..41350651 --- /dev/null +++ b/test/support/case.ex @@ -0,0 +1,43 @@ +defmodule ConfigCat.Case do + @moduledoc false + use ExUnit.CaseTemplate + + alias ConfigCat.CachePolicy + + using do + quote do + import unquote(__MODULE__) + end + end + + @spec adjust_log_level(String.t()) :: String.t() + if Version.compare(System.version(), "1.13.0") == :lt do + def adjust_log_level(log) do + Regex.replace(~r/^warning/m, log, "warn") + end + else + def adjust_log_level(log), do: log + end + + @spec fixture_file(String.t()) :: String.t() + def fixture_file(filename) do + __ENV__.file + |> Path.dirname() + |> Path.join("fixtures/" <> filename) + end + + @spec start_config_cat(String.t(), keyword) :: {:ok, GenServer.name()} + def start_config_cat(sdk_key, options \\ []) do + name = String.to_atom(UUID.uuid4()) + + default_options = [ + fetch_policy: CachePolicy.lazy(cache_refresh_interval_seconds: 300), + name: name, + sdk_key: sdk_key + ] + + with {:ok, _pid} <- start_supervised({ConfigCat, Keyword.merge(default_options, options)}, id: name) do + {:ok, name} + end + end +end diff --git a/test/support/client_case.ex b/test/support/client_case.ex index b574f022..205ae488 100644 --- a/test/support/client_case.ex +++ b/test/support/client_case.ex @@ -9,8 +9,10 @@ defmodule ConfigCat.ClientCase do alias ConfigCat.MockCachePolicy alias ConfigCat.NullDataSource - using do + using opts do quote do + use ConfigCat.Case, unquote(opts) + import unquote(__MODULE__) end end @@ -31,9 +33,12 @@ defmodule ConfigCat.ClientCase do {:ok, instance_id} end - @spec stub_cached_settings({:ok, Config.settings(), FetchTime.t()} | {:error, :not_found}) :: + @spec stub_cached_config( + {:ok, Config.t(), FetchTime.t()} + | {:error, :not_found} + ) :: :ok - def stub_cached_settings(response) do + def stub_cached_config(response) do Mox.stub(MockCachePolicy, :get, fn _id -> response end) :ok end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 00000000..328364c0 --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,33 @@ +defmodule ConfigCat.Factory do + @moduledoc false + import Jason.Sigil + + alias ConfigCat.Config + + @spec config :: Config.t() + def config do + ~J""" + { + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "s": [ + {"n": "id1", "r": [{"a": "Identifier", "c": 2, "l": ["@test1.com"]}]}, + {"n": "id2", "r": [{"a": "Identifier", "c": 2, "l": ["@test2.com"]}]} + ], + "f": { + "testBoolKey": {"v": {"b": true}, "t": 0}, + "testStringKey": {"v": {"s": "testValue"}, "i": "id", "t": 1, "r": [ + {"c": [{"s": {"s": 0, "c": 0}}], "s": {"v": {"s": "fake1"}, "i": "id1"}}, + {"c": [{"s": {"s": 1, "c": 0}}], "s": {"v": {"s": "fake2"}, "i": "id2"}} + ]}, + "testIntKey": {"v": {"i": 1}, "t": 2}, + "testDoubleKey": {"v": {"d": 1.1}, "t": 3}, + "key1": {"v": {"b": true}, "t": 0, "i": "fakeId1"}, + "key2": {"v": {"b": false}, "t": 0, "i": "fakeId2"} + } + } + """ + end +end diff --git a/test/support/fixtures/comparison_attribute_conversion.json b/test/support/fixtures/comparison_attribute_conversion.json new file mode 100644 index 00000000..5a900ae6 --- /dev/null +++ b/test/support/fixtures/comparison_attribute_conversion.json @@ -0,0 +1,789 @@ +{ + "p": { + "u": "https://test-cdn-global.configcat.com", + "r": 0, + "s": "uM29sy1rjx71ze3ehr\u002BqCnoIpx8NZgL8V//MN7OL1aM=" + }, + "f": { + "numberToStringConversion": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "0.12345" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionInt": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "125" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionPositiveExp": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-1.23456789e+96" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionNegativeExp": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-1.23456789e-96" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionNaN": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "NaN" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionPositiveInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "numberToStringConversionNegativeInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversion": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "1680307199.999" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversionNaN": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "NaN" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversionPositiveInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "dateToStringConversionNegativeInf": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "-Infinity" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversion": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[\"read\",\"Write\",\" eXecute \"]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversionEmpty": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversionSpecialChars": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[\"+<>%\\\"'\\\\/\\t\\r\\n\"]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + }, + "stringArrayToStringConversionUnicode": { + "t": 1, + "a": "Custom1", + "r": [ + { + "c": [ + { + "u": { + "a": "Custom1", + "c": 28, + "s": "[\"盲枚眉脛脰脺莽茅猫帽谋艧臒芒垄鈩⑩湏馃榾\"]" + } + } + ], + "p": [ + { + "p": 20, + "v": { + "s": "1" + } + }, + { + "p": 20, + "v": { + "s": "2" + } + }, + { + "p": 20, + "v": { + "s": "3" + } + }, + { + "p": 20, + "v": { + "s": "4" + } + }, + { + "p": 20, + "v": { + "s": "5" + } + } + ] + } + ], + "v": { + "s": "0" + } + } + } +} diff --git a/test/support/fixtures/comparison_attribute_trimming.json b/test/support/fixtures/comparison_attribute_trimming.json new file mode 100644 index 00000000..a42df5f2 --- /dev/null +++ b/test/support/fixtures/comparison_attribute_trimming.json @@ -0,0 +1,985 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "VjBfGYcmyHzLBv5EINgSBbX6/rYevYGWQhF3Zk5t8i4=" + }, + "f": { + "arraycontainsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 34, + "l": [ + "USA" + ] + } + } + ], + "s": { + "v": { + "s": "34 trim" + }, + "i": "99c90883" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "9c66d87c" + }, + "arraycontainsanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 26, + "l": [ + "09d5761537a8136eb7fc45a53917b51cb9dcd2bb9b62ffa24ace0e8a7600a3c7" + ] + } + } + ], + "s": { + "v": { + "s": "26 trim" + }, + "i": "706c94b6" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "3b342be3" + }, + "arraynotcontainsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 35, + "l": [ + "USA" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "4eeb2176" + } + } + ], + "v": { + "s": "35 trim" + }, + "i": "98bc8ebb" + }, + "arraynotcontainsanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 27, + "l": [ + "99d06b6b3669b906803c285267f76fe4e2ccc194b00801ab07f2fd49939b6960" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "8f248790" + } + } + ], + "v": { + "s": "27 trim" + }, + "i": "278ddbe9" + }, + "endswithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 32, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "32 trim" + }, + "i": "0ac9e321" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "777456df" + }, + "endswithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 24, + "l": [ + "5_7eb158c29b48b62cec860dffc459171edbfeef458bcc8e8bb62956d823eef3df" + ] + } + } + ], + "s": { + "v": { + "s": "24 trim" + }, + "i": "0364bf98" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "2f6fc77b" + }, + "equals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 28, + "s": "12345" + } + } + ], + "s": { + "v": { + "s": "28 trim" + }, + "i": "f2a682ca" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "0f806923" + }, + "equalshashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 20, + "s": "ea0d05859bb737105eea40bc605f6afd542c8f50f8497cd21ace38e731d7eef0" + } + } + ], + "s": { + "v": { + "s": "20 trim" + }, + "i": "6f1798e9" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "771ecd4d" + }, + "isnotoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 1, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "79d49e05" + } + } + ], + "v": { + "s": "1 trim" + }, + "i": "61d13448" + }, + "isnotoneofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 1, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "1c2df623" + } + } + ], + "v": { + "s": "17 trim" + }, + "i": "0bc3daa1" + }, + "isoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 0, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "0 trim" + }, + "i": "308f0749" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "90984858" + }, + "isoneofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 16, + "l": [ + "1765b470044971bbc19e7bed10112199c5da9c626455f86be109fef96e747911" + ] + } + } + ], + "s": { + "v": { + "s": "16 trim" + }, + "i": "cd78a85d" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "30b9483f" + }, + "notendswithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 33, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "b0d7203e" + } + } + ], + "v": { + "s": "33 trim" + }, + "i": "89740c7e" + }, + "notendswithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 25, + "l": [ + "5_2a338d3beb8ebe2e711d198420d04e2627e39501c2fcc7d5b3b8d93540691097" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "059f59e3" + } + } + ], + "v": { + "s": "25 trim" + }, + "i": "c1e95c48" + }, + "notequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 29, + "s": "12345" + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "af1f1e95" + } + } + ], + "v": { + "s": "29 trim" + }, + "i": "219e6bac" + }, + "notequalshashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 21, + "s": "650fe0e8e86030b5f73ccd77e6532f307adf82506048a22f02d95386206ecea1" + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "9fe2b26b" + } + } + ], + "v": { + "s": "21 trim" + }, + "i": "9211e9f1" + }, + "notstartwithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 31, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "ebe3ed2d" + } + } + ], + "v": { + "s": "31 trim" + }, + "i": "7deb7219" + }, + "notstartwithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 23, + "l": [ + "5_586ab2ec61946cb1457d4af170d88e7f14e655d9debf352b4ab6bf5bf77df3f7" + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "7b606e54" + } + } + ], + "v": { + "s": "23 trim" + }, + "i": "edec740e" + }, + "semvergreater": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 8, + "s": "0.1.1" + } + } + ], + "s": { + "v": { + "s": "8 trim" + }, + "i": "25edfdc1" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "cb0224fd" + }, + "semvergreaterequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 9, + "s": "0.1.1" + } + } + ], + "s": { + "v": { + "s": "9 trim" + }, + "i": "d8960b43" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "530ea45c" + }, + "semverisnotoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 5, + "l": [ + "1.0.1" + ] + } + } + ], + "s": { + "v": { + "s": "5 trim" + }, + "i": "cb1bad57" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "4a7025a4" + }, + "semverisoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 4, + "l": [ + "1.0.0" + ] + } + } + ], + "s": { + "v": { + "s": "4 trim" + }, + "i": "6cc37494" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "842a56b5" + }, + "semverless": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 6, + "s": "1.0.1" + } + } + ], + "s": { + "v": { + "s": "6 trim" + }, + "i": "64c04b67" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "ae58de40" + }, + "semverlessequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 7, + "s": "1.0.1" + } + } + ], + "s": { + "v": { + "s": "7 trim" + }, + "i": "7c62748d" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "631a1888" + }, + "startwithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 30, + "l": [ + "12345" + ] + } + } + ], + "s": { + "v": { + "s": "30 trim" + }, + "i": "475a9c4f" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "5a73105a" + }, + "startwithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 22, + "l": [ + "5_67a323069ee45fef4ccd8365007d4713f7a3bc87764943b1139e8e50d1aee8fd" + ] + } + } + ], + "s": { + "v": { + "s": "22 trim" + }, + "i": "7650175d" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "a38edbee" + }, + "dateafter": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Date", + "c": 19, + "d": 1705251600 + } + } + ], + "s": { + "v": { + "s": "19 trim" + }, + "i": "83e580ce" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "1c12e0cc" + }, + "datebefore": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Date", + "c": 18, + "d": 1705255200 + } + } + ], + "s": { + "v": { + "s": "18 trim" + }, + "i": "34614b07" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "26d4f328" + }, + "numberequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Number", + "c": 10, + "d": 3 + } + } + ], + "s": { + "v": { + "s": "10 trim" + }, + "i": "6a8c0a08" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "7b8e49b9" + }, + "numbergreater": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Number", + "c": 14, + "d": 2 + } + } + ], + "s": { + "v": { + "s": "14 trim" + }, + "i": "2037a7a4" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "902f9bd9" + }, + "numbergreaterequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Number", + "c": 15, + "d": 2 + } + } + ], + "s": { + "v": { + "s": "15 trim" + }, + "i": "527c49d2" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "2280c961" + }, + "numberless": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Number", + "c": 12, + "d": 4 + } + } + ], + "s": { + "v": { + "s": "12 trim" + }, + "i": "c454f775" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "ec935943" + }, + "numberlessequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Number", + "c": 13, + "d": 4 + } + } + ], + "s": { + "v": { + "s": "13 trim" + }, + "i": "1e31aed8" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "1d53c679" + }, + "numbernotequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Number", + "c": 11, + "d": 6 + } + } + ], + "s": { + "v": { + "s": "11 trim" + }, + "i": "e8d7cf05" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "21c749a7" + }, + "containsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "f750380a" + } + } + ], + "v": { + "s": "2 trim" + }, + "i": "c3ab37cf" + }, + "notcontainsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 3, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "3 trim" + }, + "i": "4b8760c4" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "f91ecf16" + } + } +} \ No newline at end of file diff --git a/test/support/fixtures/comparison_value_trimming.json b/test/support/fixtures/comparison_value_trimming.json new file mode 100644 index 00000000..db917032 --- /dev/null +++ b/test/support/fixtures/comparison_value_trimming.json @@ -0,0 +1,777 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "zsVN1DQ9Oa2FjFc96MvPfMM5Vs+KKV00NyybJZipyf4=" + }, + "f": { + "arraycontainsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 34, + "l": [ + " USA " + ] + } + } + ], + "s": { + "v": { + "s": "34 trim" + }, + "i": "99c90883" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "9c66d87c" + }, + "arraycontainsanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 26, + "l": [ + " 028fdb841bf3b2cc27fce407da08f87acd3a58a08c67d819cdb9351857b14237 " + ] + } + } + ], + "s": { + "v": { + "s": "26 trim" + }, + "i": "706c94b6" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "3b342be3" + }, + "arraynotcontainsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 35, + "l": [ + " USA " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "4eeb2176" + } + } + ], + "v": { + "s": "35 trim" + }, + "i": "98bc8ebb" + }, + "arraynotcontainsanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Country", + "c": 27, + "l": [ + " 60b747c290642863f9a6c68773ed309a9fb02c6c1ae65c77037046918f4c1d3c " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "8f248790" + } + } + ], + "v": { + "s": "27 trim" + }, + "i": "278ddbe9" + }, + "containsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "2 trim" + }, + "i": "f750380a" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "c3ab37cf" + }, + "endswithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 32, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "32 trim" + }, + "i": "0ac9e321" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "777456df" + }, + "endswithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 24, + "l": [ + " 5_a6ce5e2838d4e0c27cd705c90f39e60d79056062983c39951668cf947ec406c2 " + ] + } + } + ], + "s": { + "v": { + "s": "24 trim" + }, + "i": "0364bf98" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "2f6fc77b" + }, + "equals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 28, + "s": " 12345 " + } + } + ], + "s": { + "v": { + "s": "28 trim" + }, + "i": "f2a682ca" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "0f806923" + }, + "equalshashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 20, + "s": " a2868640b1fe24c98e50b168756d83fd03779dd4349d6ddab5d7d6ef8dad13bd " + } + } + ], + "s": { + "v": { + "s": "20 trim" + }, + "i": "6f1798e9" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "771ecd4d" + }, + "isnotoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 1, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "79d49e05" + } + } + ], + "v": { + "s": "1 trim" + }, + "i": "61d13448" + }, + "isnotoneofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 1, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "1c2df623" + } + } + ], + "v": { + "s": "17 trim" + }, + "i": "0bc3daa1" + }, + "isoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 0, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "0 trim" + }, + "i": "308f0749" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "90984858" + }, + "isoneofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 16, + "l": [ + " 55ce90920d20fc0bf8078471062a85f82cc5ea2226012a901a5045775bace0f4 " + ] + } + } + ], + "s": { + "v": { + "s": "16 trim" + }, + "i": "cd78a85d" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "30b9483f" + }, + "notcontainsanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 3, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "4b8760c4" + } + } + ], + "v": { + "s": "3 trim" + }, + "i": "f91ecf16" + }, + "notendswithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 33, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "b0d7203e" + } + } + ], + "v": { + "s": "33 trim" + }, + "i": "89740c7e" + }, + "notendswithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 25, + "l": [ + " 5_c517fc957907e30b6a790540a20172a3a5d3a7458a85e340a7b1a1ac982be278 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "059f59e3" + } + } + ], + "v": { + "s": "25 trim" + }, + "i": "c1e95c48" + }, + "notequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 29, + "s": " 12345 " + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "af1f1e95" + } + } + ], + "v": { + "s": "29 trim" + }, + "i": "219e6bac" + }, + "notequalshashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 21, + "s": " 31ceae14b865b0842e93fdc3a42a7e45780ccc41772ca9355db50e09d81e13ef " + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "9fe2b26b" + } + } + ], + "v": { + "s": "21 trim" + }, + "i": "9211e9f1" + }, + "notstartwithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 31, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "ebe3ed2d" + } + } + ], + "v": { + "s": "31 trim" + }, + "i": "7deb7219" + }, + "notstartwithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 23, + "l": [ + " 5_3643bbdd1bce4021fe4dbd55e6cc2f4902e4f50e592597d1a2d0e944fb7dfb42 " + ] + } + } + ], + "s": { + "v": { + "s": "no trim" + }, + "i": "7b606e54" + } + } + ], + "v": { + "s": "23 trim" + }, + "i": "edec740e" + }, + "semvergreater": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 8, + "s": " 0.1.1 " + } + } + ], + "s": { + "v": { + "s": "8 trim" + }, + "i": "25edfdc1" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "cb0224fd" + }, + "semvergreaterequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 9, + "s": " 0.1.1 " + } + } + ], + "s": { + "v": { + "s": "9 trim" + }, + "i": "d8960b43" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "530ea45c" + }, + "semverisnotoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 5, + "l": [ + " 1.0.1 " + ] + } + } + ], + "s": { + "v": { + "s": "5 trim" + }, + "i": "cb1bad57" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "4a7025a4" + }, + "semverisoneof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 4, + "l": [ + " 1.0.0 " + ] + } + } + ], + "s": { + "v": { + "s": "4 trim" + }, + "i": "6cc37494" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "842a56b5" + }, + "semverless": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 6, + "s": " 1.0.1 " + } + } + ], + "s": { + "v": { + "s": "6 trim" + }, + "i": "64c04b67" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "ae58de40" + }, + "semverlessequals": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Version", + "c": 7, + "s": " 1.0.1 " + } + } + ], + "s": { + "v": { + "s": "7 trim" + }, + "i": "7c62748d" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "631a1888" + }, + "startwithanyof": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 30, + "l": [ + " 12345 " + ] + } + } + ], + "s": { + "v": { + "s": "30 trim" + }, + "i": "475a9c4f" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "5a73105a" + }, + "startwithanyofhashed": { + "t": 1, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 22, + "l": [ + " 5_3e052709552ca9d5bd6c459cb7ab0389f3210f6aafc3d006a2481635e9614a7c " + ] + } + } + ], + "s": { + "v": { + "s": "22 trim" + }, + "i": "7650175d" + } + } + ], + "v": { + "s": "no trim" + }, + "i": "a38edbee" + } + } +} \ No newline at end of file diff --git a/test/support/fixtures/evaluation/1_targeting_rule.json b/test/support/fixtures/evaluation/1_targeting_rule.json new file mode 100644 index 00000000..596bd2b4 --- /dev/null +++ b/test/support/fixtures/evaluation/1_targeting_rule.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "1_rule_no_user.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "1_rule_no_targeted_attribute.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": "Cat", + "expectedLog": "1_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": "Dog", + "expectedLog": "1_rule_matching_targeted_attribute.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/1_targeting_rule/1_rule_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_matching_targeted_attribute.txt new file mode 100644 index 00000000..9adc1a09 --- /dev/null +++ b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +debug [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/test/support/fixtures/evaluation/1_targeting_rule/1_rule_no_targeted_attribute.txt b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_no_targeted_attribute.txt new file mode 100644 index 00000000..21937760 --- /dev/null +++ b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_no_targeted_attribute.txt @@ -0,0 +1,6 @@ +warning [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'stringContainsDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/1_targeting_rule/1_rule_no_user.txt b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_no_user.txt new file mode 100644 index 00000000..b9d37b59 --- /dev/null +++ b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_no_user.txt @@ -0,0 +1,6 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'stringContainsDogDefaultCat' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringContainsDogDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..54cd2b41 --- /dev/null +++ b/test/support/fixtures/evaluation/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +debug [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => no match + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/2_targeting_rules.json b/test/support/fixtures/evaluation/2_targeting_rules.json new file mode 100644 index 00000000..5cf8a3c8 --- /dev/null +++ b/test/support/fixtures/evaluation/2_targeting_rules.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "2_rules_no_user.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "2_rules_no_targeted_attribute.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Custom1": "user" + }, + "returnValue": "Cat", + "expectedLog": "2_rules_not_matching_targeted_attribute.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Custom1": "admin" + }, + "returnValue": "Dog", + "expectedLog": "2_rules_matching_targeted_attribute.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/2_targeting_rules/2_rules_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_matching_targeted_attribute.txt new file mode 100644 index 00000000..a1a1742b --- /dev/null +++ b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +warning [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345","Custom1":"admin"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/test/support/fixtures/evaluation/2_targeting_rules/2_rules_no_targeted_attribute.txt b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_no_targeted_attribute.txt new file mode 100644 index 00000000..a42e4b4e --- /dev/null +++ b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_no_targeted_attribute.txt @@ -0,0 +1,9 @@ +warning [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +warning [3003] Cannot evaluate condition (User.Custom1 IS ONE OF ['admin']) for setting 'stringIsInDogDefaultCat' (the User.Custom1 attribute is missing). You should set the User.Custom1 attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => cannot evaluate, the User.Custom1 attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/2_targeting_rules/2_rules_no_user.txt b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_no_user.txt new file mode 100644 index 00000000..6b764597 --- /dev/null +++ b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_no_user.txt @@ -0,0 +1,8 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'stringIsInDogDefaultCat' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringIsInDogDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..3af04b14 --- /dev/null +++ b/test/support/fixtures/evaluation/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +warning [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345","Custom1":"user"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => no match + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/and_rules.json b/test/support/fixtures/evaluation/and_rules.json new file mode 100644 index 00000000..c6ed879f --- /dev/null +++ b/test/support/fixtures/evaluation/and_rules.json @@ -0,0 +1,22 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A", + "tests": [ + { + "key": "emailAnd", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "and_rules_no_user.txt" + }, + { + "key": "emailAnd", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "jane@configcat.com" + }, + "returnValue": "Cat", + "expectedLog": "and_rules_user.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/and_rules/and_rules_no_user.txt b/test/support/fixtures/evaluation/and_rules/and_rules_no_user.txt new file mode 100644 index 00000000..80fa243f --- /dev/null +++ b/test/support/fixtures/evaluation/and_rules/and_rules_no_user.txt @@ -0,0 +1,7 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'emailAnd' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'emailAnd' + Evaluating targeting rules and applying the first match if any: + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/and_rules/and_rules_user.txt b/test/support/fixtures/evaluation/and_rules/and_rules_user.txt new file mode 100644 index 00000000..ca926bf9 --- /dev/null +++ b/test/support/fixtures/evaluation/and_rules/and_rules_user.txt @@ -0,0 +1,7 @@ +debug [5000] Evaluating 'emailAnd' for User '{"Identifier":"12345","Email":"jane@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => true + AND User.Email CONTAINS ANY OF ['@'] => true + AND User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'Dog' => no match + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/comparators.json b/test/support/fixtures/evaluation/comparators.json new file mode 100644 index 00000000..5d5631e5 --- /dev/null +++ b/test/support/fixtures/evaluation/comparators.json @@ -0,0 +1,20 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", + "tests": [ + { + "key": "allinone", + "defaultValue": "", + "user": { + "Identifier": "12345", + "Email": "joe@example.com", + "Country": "[\"USA\"]", + "Version": "1.0.0", + "Number": "1.0", + "Date": "1693497500" + }, + "returnValue": "default", + "expectedLog": "allinone.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/comparators/allinone.txt b/test/support/fixtures/evaluation/comparators/allinone.txt new file mode 100644 index 00000000..b86f1bd8 --- /dev/null +++ b/test/support/fixtures/evaluation/comparators/allinone.txt @@ -0,0 +1,57 @@ +debug [5000] Evaluating 'allinone' for User '{"Identifier":"12345","Email":"joe@example.com","Country":"[\"USA\"]","Version":"1.0.0","Number":"1.0","Date":"1693497500"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email EQUALS '' => true + AND User.Email NOT EQUALS '' => false, skipping the remaining AND conditions + THEN '1h' => no match + - IF User.Email EQUALS 'joe@example.com' => true + AND User.Email NOT EQUALS 'joe@example.com' => false, skipping the remaining AND conditions + THEN '1c' => no match + - IF User.Email IS ONE OF [<1 hashed value>] => true + AND User.Email IS NOT ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '2h' => no match + - IF User.Email IS ONE OF ['joe@example.com'] => true + AND User.Email IS NOT ONE OF ['joe@example.com'] => false, skipping the remaining AND conditions + THEN '2c' => no match + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => true + AND User.Email NOT STARTS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '3h' => no match + - IF User.Email STARTS WITH ANY OF ['joe@'] => true + AND User.Email NOT STARTS WITH ANY OF ['joe@'] => false, skipping the remaining AND conditions + THEN '3c' => no match + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => true + AND User.Email NOT ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '4h' => no match + - IF User.Email ENDS WITH ANY OF ['@example.com'] => true + AND User.Email NOT ENDS WITH ANY OF ['@example.com'] => false, skipping the remaining AND conditions + THEN '4c' => no match + - IF User.Email CONTAINS ANY OF ['e@e'] => true + AND User.Email NOT CONTAINS ANY OF ['e@e'] => false, skipping the remaining AND conditions + THEN '5' => no match + - IF User.Version IS ONE OF ['1.0.0'] => true + AND User.Version IS NOT ONE OF ['1.0.0'] => false, skipping the remaining AND conditions + THEN '6' => no match + - IF User.Version < '1.0.1' => true + AND User.Version >= '1.0.1' => false, skipping the remaining AND conditions + THEN '7' => no match + - IF User.Version > '0.9.9' => true + AND User.Version <= '0.9.9' => false, skipping the remaining AND conditions + THEN '8' => no match + - IF User.Number = '1' => true + AND User.Number != '1' => false, skipping the remaining AND conditions + THEN '9' => no match + - IF User.Number < '1.1' => true + AND User.Number >= '1.1' => false, skipping the remaining AND conditions + THEN '10' => no match + - IF User.Number > '0.9' => true + AND User.Number <= '0.9' => false, skipping the remaining AND conditions + THEN '11' => no match + - IF User.Date BEFORE '1693497600' (2023-08-31T16:00:00.000Z UTC) => true + AND User.Date AFTER '1693497600' (2023-08-31T16:00:00.000Z UTC) => false, skipping the remaining AND conditions + THEN '12' => no match + - IF User.Country ARRAY CONTAINS ANY OF [<1 hashed value>] => true + AND User.Country ARRAY NOT CONTAINS ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '13h' => no match + - IF User.Country ARRAY CONTAINS ANY OF ['USA'] => true + AND User.Country ARRAY NOT CONTAINS ANY OF ['USA'] => false, skipping the remaining AND conditions + THEN '13c' => no match + Returning 'default'. diff --git a/test/support/fixtures/evaluation/epoch_date_validation.json b/test/support/fixtures/evaluation/epoch_date_validation.json new file mode 100644 index 00000000..e916d218 --- /dev/null +++ b/test/support/fixtures/evaluation/epoch_date_validation.json @@ -0,0 +1,16 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", + "tests": [ + { + "key": "boolTrueIn202304", + "defaultValue": true, + "returnValue": false, + "expectedLog": "date_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "2023.04.10" + } + } + ] +} diff --git a/test/support/fixtures/evaluation/epoch_date_validation/date_error.txt b/test/support/fixtures/evaluation/epoch_date_validation/date_error.txt new file mode 100644 index 00000000..4b2e6dee --- /dev/null +++ b/test/support/fixtures/evaluation/epoch_date_validation/date_error.txt @@ -0,0 +1,7 @@ +warning [3004] Cannot evaluate condition (User.Custom1 AFTER '1680307200' (2023-04-01T00:00:00.000Z UTC)) for setting 'boolTrueIn202304' ('2023.04.10' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +debug [5000] Evaluating 'boolTrueIn202304' for User '{"Identifier":"12345","Custom1":"2023.04.10"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 AFTER '1680307200' (2023-04-01T00:00:00.000Z UTC) => false, skipping the remaining AND conditions + THEN 'true' => cannot evaluate, the User.Custom1 attribute is invalid ('2023.04.10' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'false'. diff --git a/test/support/fixtures/evaluation/list_truncation.json b/test/support/fixtures/evaluation/list_truncation.json new file mode 100644 index 00000000..64e94262 --- /dev/null +++ b/test/support/fixtures/evaluation/list_truncation.json @@ -0,0 +1,14 @@ +{ + "jsonOverride": "test_list_truncation.json", + "tests": [ + { + "key": "booleanKey1", + "defaultValue": false, + "user": { + "Identifier": "12" + }, + "returnValue": true, + "expectedLog": "list_truncation.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/list_truncation/list_truncation.txt b/test/support/fixtures/evaluation/list_truncation/list_truncation.txt new file mode 100644 index 00000000..cd2701d1 --- /dev/null +++ b/test/support/fixtures/evaluation/list_truncation/list_truncation.txt @@ -0,0 +1,7 @@ +debug [5000] Evaluating 'booleanKey1' for User '{"Identifier":"12"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] => true + AND User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', ... <1 more value>] => true + AND User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', ... <2 more values>] => true + THEN 'true' => MATCH, applying rule + Returning 'true'. diff --git a/test/support/fixtures/evaluation/list_truncation/test_list_truncation.json b/test/support/fixtures/evaluation/list_truncation/test_list_truncation.json new file mode 100644 index 00000000..6fdde459 --- /dev/null +++ b/test/support/fixtures/evaluation/list_truncation/test_list_truncation.json @@ -0,0 +1,83 @@ +{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0, + "s": "test-salt" + }, + "f": { + "booleanKey1": { + "t": 0, + "v": { + "b": false + }, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + } + }, + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11" + ] + } + }, + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12" + ] + } + } + ], + "s": { + "v": { + "b": true + } + } + } + ] + } + } +} diff --git a/test/support/fixtures/evaluation/number_validation.json b/test/support/fixtures/evaluation/number_validation.json new file mode 100644 index 00000000..640cf3da --- /dev/null +++ b/test/support/fixtures/evaluation/number_validation.json @@ -0,0 +1,16 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d747f0-5986-c2ef-eef3-ec778e32e10a/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw", + "tests": [ + { + "key": "number", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "number_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "not_a_number" + } + } + ] +} diff --git a/test/support/fixtures/evaluation/number_validation/number_error.txt b/test/support/fixtures/evaluation/number_validation/number_error.txt new file mode 100644 index 00000000..b2515b8b --- /dev/null +++ b/test/support/fixtures/evaluation/number_validation/number_error.txt @@ -0,0 +1,6 @@ +warning [3004] Cannot evaluate condition (User.Custom1 != '5') for setting 'number' ('not_a_number' is not a valid decimal number). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +debug [5000] Evaluating 'number' for User '{"Identifier":"12345","Custom1":"not_a_number"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 != '5' THEN '<>5' => cannot evaluate, the User.Custom1 attribute is invalid ('not_a_number' is not a valid decimal number) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/test/support/fixtures/evaluation/options_after_targeting_rule.json b/test/support/fixtures/evaluation/options_after_targeting_rule.json new file mode 100644 index 00000000..803840e2 --- /dev/null +++ b/test/support/fixtures/evaluation/options_after_targeting_rule.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "returnValue": -1, + "expectedLog": "options_after_targeting_rule_no_user.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345" + }, + "returnValue": 2, + "expectedLog": "options_after_targeting_rule_no_targeted_attribute.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": 2, + "expectedLog": "options_after_targeting_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": 5, + "expectedLog": "options_after_targeting_rule_matching_targeted_attribute.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt new file mode 100644 index 00000000..23721293 --- /dev/null +++ b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +debug [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => MATCH, applying rule + Returning '5'. diff --git a/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt new file mode 100644 index 00000000..affd20c2 --- /dev/null +++ b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt @@ -0,0 +1,9 @@ +warning [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'integer25One25Two25Three25FourAdvancedRules' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 25 (this value is sticky and consistent across all SDKs) + - Hash value 25 selects % option 2 (25%), '2'. + Returning '2'. diff --git a/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_user.txt b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_user.txt new file mode 100644 index 00000000..69a7091d --- /dev/null +++ b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_user.txt @@ -0,0 +1,7 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'integer25One25Two25Three25FourAdvancedRules' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Skipping % options because the User Object is missing. + Returning '-1'. diff --git a/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..6c6c7975 --- /dev/null +++ b/test/support/fixtures/evaluation/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +debug [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => no match + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 25 (this value is sticky and consistent across all SDKs) + - Hash value 25 selects % option 2 (25%), '2'. + Returning '2'. diff --git a/test/support/fixtures/evaluation/options_based_on_custom_attr.json b/test/support/fixtures/evaluation/options_based_on_custom_attr.json new file mode 100644 index 00000000..5f8d1c63 --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_custom_attr.json @@ -0,0 +1,31 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", + "tests": [ + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "options_custom_attribute_no_user.txt" + }, + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Chicken", + "expectedLog": "no_options_custom_attribute.txt" + }, + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Country": "US" + }, + "returnValue": "Cat", + "expectedLog": "matching_options_custom_attribute.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/options_based_on_custom_attr/matching_options_custom_attribute.txt b/test/support/fixtures/evaluation/options_based_on_custom_attr/matching_options_custom_attribute.txt new file mode 100644 index 00000000..34be8c36 --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_custom_attr/matching_options_custom_attribute.txt @@ -0,0 +1,5 @@ +debug [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' for User '{"Identifier":"12345","Country":"US"}' + Evaluating % options based on the User.Country attribute: + - Computing hash in the [0..99] range from User.Country => 70 (this value is sticky and consistent across all SDKs) + - Hash value 70 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/options_based_on_custom_attr/no_options_custom_attribute.txt b/test/support/fixtures/evaluation/options_based_on_custom_attr/no_options_custom_attribute.txt new file mode 100644 index 00000000..4e1c4002 --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_custom_attr/no_options_custom_attribute.txt @@ -0,0 +1,4 @@ +warning [3003] Cannot evaluate % options for setting 'string75Cat0Dog25Falcon0HorseCustomAttr' (the User.Country attribute is missing). You should set the User.Country attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' for User '{"Identifier":"12345"}' + Skipping % options because the User.Country attribute is missing. + Returning 'Chicken'. diff --git a/test/support/fixtures/evaluation/options_based_on_custom_attr/options_custom_attribute_no_user.txt b/test/support/fixtures/evaluation/options_based_on_custom_attr/options_custom_attribute_no_user.txt new file mode 100644 index 00000000..6b25bb8d --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_custom_attr/options_custom_attribute_no_user.txt @@ -0,0 +1,4 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'string75Cat0Dog25Falcon0HorseCustomAttr' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' + Skipping % options because the User Object is missing. + Returning 'Chicken'. diff --git a/test/support/fixtures/evaluation/options_based_on_user_id.json b/test/support/fixtures/evaluation/options_based_on_user_id.json new file mode 100644 index 00000000..442f575c --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_user_id.json @@ -0,0 +1,21 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "string75Cat0Dog25Falcon0Horse", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "options_user_attribute_no_user.txt" + }, + { + "key": "string75Cat0Dog25Falcon0Horse", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "options_user_attribute_user.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/options_based_on_user_id/options_user_attribute_no_user.txt b/test/support/fixtures/evaluation/options_based_on_user_id/options_user_attribute_no_user.txt new file mode 100644 index 00000000..5deefe12 --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_user_id/options_user_attribute_no_user.txt @@ -0,0 +1,4 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'string75Cat0Dog25Falcon0Horse' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'string75Cat0Dog25Falcon0Horse' + Skipping % options because the User Object is missing. + Returning 'Chicken'. diff --git a/test/support/fixtures/evaluation/options_based_on_user_id/options_user_attribute_user.txt b/test/support/fixtures/evaluation/options_based_on_user_id/options_user_attribute_user.txt new file mode 100644 index 00000000..ae710af0 --- /dev/null +++ b/test/support/fixtures/evaluation/options_based_on_user_id/options_user_attribute_user.txt @@ -0,0 +1,5 @@ +debug [5000] Evaluating 'string75Cat0Dog25Falcon0Horse' for User '{"Identifier":"12345"}' + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 21 (this value is sticky and consistent across all SDKs) + - Hash value 21 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/options_within_targeting_rule.json b/test/support/fixtures/evaluation/options_within_targeting_rule.json new file mode 100644 index 00000000..4c6c533b --- /dev/null +++ b/test/support/fixtures/evaluation/options_within_targeting_rule.json @@ -0,0 +1,52 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", + "tests": [ + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_no_user.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_no_targeted_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com", + "Country": "US" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt new file mode 100644 index 00000000..035f2e14 --- /dev/null +++ b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt @@ -0,0 +1,7 @@ +warning [3003] Cannot evaluate % options for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (the User.Country attribute is missing). You should set the User.Country attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => MATCH, applying rule + Skipping % options because the User.Country attribute is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt new file mode 100644 index 00000000..c79571d9 --- /dev/null +++ b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt @@ -0,0 +1,7 @@ +debug [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com","Country":"US"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => MATCH, applying rule + Evaluating % options based on the User.Country attribute: + - Computing hash in the [0..99] range from User.Country => 63 (this value is sticky and consistent across all SDKs) + - Hash value 63 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt new file mode 100644 index 00000000..04e9112c --- /dev/null +++ b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt @@ -0,0 +1,6 @@ +warning [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_user.txt b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_user.txt new file mode 100644 index 00000000..964f1c5e --- /dev/null +++ b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_user.txt @@ -0,0 +1,6 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt new file mode 100644 index 00000000..18491acc --- /dev/null +++ b/test/support/fixtures/evaluation/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +debug [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => no match + Returning 'Cat'. diff --git a/test/support/fixtures/evaluation/prerequisite_flag.json b/test/support/fixtures/evaluation/prerequisite_flag.json new file mode 100644 index 00000000..9c35c00e --- /dev/null +++ b/test/support/fixtures/evaluation/prerequisite_flag.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A", + "tests": [ + { + "key": "dependentFeatureWithUserCondition", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "prerequisite_flag_no_user_needed_by_dep.txt" + }, + { + "key": "dependentFeature", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "prerequisite_flag_no_user_needed_by_prereq.txt" + }, + { + "key": "dependentFeatureWithUserCondition2", + "defaultValue": "default", + "returnValue": "Frog", + "expectedLog": "prerequisite_flag_no_user_needed_by_both.txt" + }, + { + "key": "dependentFeature", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "kate@configcat.com", + "Country": "USA" + }, + "returnValue": "Horse", + "expectedLog": "prerequisite_flag.txt" + }, + { + "key": "dependentFeatureMultipleLevels", + "defaultValue": "default", + "returnValue": "Dog", + "expectedLog": "prerequisite_flag_multilevel.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag.txt b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag.txt new file mode 100644 index 00000000..f178a880 --- /dev/null +++ b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag.txt @@ -0,0 +1,32 @@ +debug [5000] Evaluating 'dependentFeature' for User '{"Identifier":"12345","Email":"kate@configcat.com","Country":"USA"}' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeature' EQUALS 'target' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => no match + - IF User.Country IS ONE OF [<1 hashed value>] => true + AND User IS NOT IN SEGMENT 'Beta Users' + ( + Evaluating segment 'Beta Users': + - IF User.Email IS ONE OF [<2 hashed values>] => false, skipping the remaining AND conditions + Segment evaluation result: User IS NOT IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Beta Users') evaluates to true. + ) => true + AND User IS NOT IN SEGMENT 'Developers' + ( + Evaluating segment 'Developers': + - IF User.Email IS ONE OF [<2 hashed values>] => false, skipping the remaining AND conditions + Segment evaluation result: User IS NOT IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Developers') evaluates to true. + ) => true + THEN 'target' => MATCH, applying rule + Prerequisite flag evaluation result: 'target'. + Condition (Flag 'mainFeature' EQUALS 'target') evaluates to true. + ) + THEN % options => MATCH, applying rule + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 78 (this value is sticky and consistent across all SDKs) + - Hash value 78 selects % option 4 (25%), 'Horse'. + Returning 'Horse'. diff --git a/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_multilevel.txt b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_multilevel.txt new file mode 100644 index 00000000..aa8ef6c5 --- /dev/null +++ b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_multilevel.txt @@ -0,0 +1,24 @@ +debug [5000] Evaluating 'dependentFeatureMultipleLevels' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'intermediateFeature' EQUALS 'true' + ( + Evaluating prerequisite flag 'intermediateFeature': + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeatureWithoutUserCondition' EQUALS 'true' + ( + Evaluating prerequisite flag 'mainFeatureWithoutUserCondition': + Prerequisite flag evaluation result: 'true'. + Condition (Flag 'mainFeatureWithoutUserCondition' EQUALS 'true') evaluates to true. + ) => true + AND Flag 'mainFeatureWithoutUserCondition' EQUALS 'true' + ( + Evaluating prerequisite flag 'mainFeatureWithoutUserCondition': + Prerequisite flag evaluation result: 'true'. + Condition (Flag 'mainFeatureWithoutUserCondition' EQUALS 'true') evaluates to true. + ) => true + THEN 'true' => MATCH, applying rule + Prerequisite flag evaluation result: 'true'. + Condition (Flag 'intermediateFeature' EQUALS 'true') evaluates to true. + ) + THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt new file mode 100644 index 00000000..abdd7788 --- /dev/null +++ b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt @@ -0,0 +1,38 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'dependentFeatureWithUserCondition2' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +warning [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +warning [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'dependentFeatureWithUserCondition2' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF [<2 hashed values>] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeature' EQUALS 'public' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'public') evaluates to true. + ) + THEN % options => MATCH, applying rule + Skipping % options because the User Object is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeature' EQUALS 'public' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'public') evaluates to true. + ) + THEN 'Frog' => MATCH, applying rule + Returning 'Frog'. diff --git a/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt new file mode 100644 index 00000000..8c00649e --- /dev/null +++ b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt @@ -0,0 +1,15 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'dependentFeatureWithUserCondition' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'dependentFeatureWithUserCondition' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF [<2 hashed values>] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeatureWithoutUserCondition' EQUALS 'true' + ( + Evaluating prerequisite flag 'mainFeatureWithoutUserCondition': + Prerequisite flag evaluation result: 'true'. + Condition (Flag 'mainFeatureWithoutUserCondition' EQUALS 'true') evaluates to true. + ) + THEN % options => MATCH, applying rule + Skipping % options because the User Object is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Chicken'. diff --git a/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt new file mode 100644 index 00000000..0e16f635 --- /dev/null +++ b/test/support/fixtures/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt @@ -0,0 +1,18 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'dependentFeature' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeature' EQUALS 'target' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'target') evaluates to false. + ) + THEN % options => no match + Returning 'Chicken'. diff --git a/test/support/fixtures/evaluation/segment.json b/test/support/fixtures/evaluation/segment.json new file mode 100644 index 00000000..1bb4df5b --- /dev/null +++ b/test/support/fixtures/evaluation/segment.json @@ -0,0 +1,47 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08dbd6ca-a85f-4ed0-888a-2da18def92b5/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/y_ZB7o-Xb0Swxth-ZlMSeA", + "tests": [ + { + "key": "featureWithSegmentTargeting", + "defaultValue": false, + "returnValue": false, + "expectedLog": "segment_no_user.txt" + }, + { + "key": "featureWithSegmentTargetingMultipleConditions", + "defaultValue": false, + "returnValue": false, + "expectedLog": "segment_no_user_multi_conditions.txt" + }, + { + "key": "featureWithNegatedSegmentTargetingCleartext", + "defaultValue": false, + "user": { + "Identifier": "12345" + }, + "returnValue": false, + "expectedLog": "segment_no_targeted_attribute.txt" + }, + { + "key": "featureWithSegmentTargeting", + "defaultValue": false, + "user": { + "Identifier": "12345", + "Email": "jane@example.com" + }, + "returnValue": true, + "expectedLog": "segment_matching.txt" + }, + { + "key": "featureWithNegatedSegmentTargeting", + "defaultValue": false, + "user": { + "Identifier": "12345", + "Email": "jane@example.com" + }, + "returnValue": false, + "expectedLog": "segment_no_matching.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/segment/segment_matching.txt b/test/support/fixtures/evaluation/segment/segment_matching.txt new file mode 100644 index 00000000..479ab1af --- /dev/null +++ b/test/support/fixtures/evaluation/segment/segment_matching.txt @@ -0,0 +1,11 @@ +debug [5000] Evaluating 'featureWithSegmentTargeting' for User '{"Identifier":"12345","Email":"jane@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users' + ( + Evaluating segment 'Beta users': + - IF User.Email IS ONE OF [<2 hashed values>] => true + Segment evaluation result: User IS IN SEGMENT. + Condition (User IS IN SEGMENT 'Beta users') evaluates to true. + ) + THEN 'true' => MATCH, applying rule + Returning 'true'. diff --git a/test/support/fixtures/evaluation/segment/segment_no_matching.txt b/test/support/fixtures/evaluation/segment/segment_no_matching.txt new file mode 100644 index 00000000..622cc9d8 --- /dev/null +++ b/test/support/fixtures/evaluation/segment/segment_no_matching.txt @@ -0,0 +1,11 @@ +debug [5000] Evaluating 'featureWithNegatedSegmentTargeting' for User '{"Identifier":"12345","Email":"jane@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS NOT IN SEGMENT 'Beta users' + ( + Evaluating segment 'Beta users': + - IF User.Email IS ONE OF [<2 hashed values>] => true + Segment evaluation result: User IS IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Beta users') evaluates to false. + ) + THEN 'true' => no match + Returning 'false'. diff --git a/test/support/fixtures/evaluation/segment/segment_no_targeted_attribute.txt b/test/support/fixtures/evaluation/segment/segment_no_targeted_attribute.txt new file mode 100644 index 00000000..44a1b50e --- /dev/null +++ b/test/support/fixtures/evaluation/segment/segment_no_targeted_attribute.txt @@ -0,0 +1,13 @@ +warning [3003] Cannot evaluate condition (User.Email IS ONE OF ['jane@example.com', 'john@example.com']) for setting 'featureWithNegatedSegmentTargetingCleartext' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'featureWithNegatedSegmentTargetingCleartext' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS NOT IN SEGMENT 'Beta users (cleartext)' + ( + Evaluating segment 'Beta users (cleartext)': + - IF User.Email IS ONE OF ['jane@example.com', 'john@example.com'] => false, skipping the remaining AND conditions + Segment evaluation result: cannot evaluate, the User.Email attribute is missing. + Condition (User IS NOT IN SEGMENT 'Beta users (cleartext)') failed to evaluate. + ) + THEN 'true' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'false'. diff --git a/test/support/fixtures/evaluation/segment/segment_no_user.txt b/test/support/fixtures/evaluation/segment/segment_no_user.txt new file mode 100644 index 00000000..a266dacc --- /dev/null +++ b/test/support/fixtures/evaluation/segment/segment_no_user.txt @@ -0,0 +1,6 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'featureWithSegmentTargeting' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'featureWithSegmentTargeting' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users' THEN 'true' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'false'. diff --git a/test/support/fixtures/evaluation/segment/segment_no_user_multi_conditions.txt b/test/support/fixtures/evaluation/segment/segment_no_user_multi_conditions.txt new file mode 100644 index 00000000..89af549a --- /dev/null +++ b/test/support/fixtures/evaluation/segment/segment_no_user_multi_conditions.txt @@ -0,0 +1,7 @@ +warning [3001] Cannot evaluate targeting rules and % options for setting 'featureWithSegmentTargetingMultipleConditions' (User Object is missing). You should pass a User Object to the evaluation functions like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +debug [5000] Evaluating 'featureWithSegmentTargetingMultipleConditions' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users (cleartext)' => false, skipping the remaining AND conditions + THEN 'true' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'false'. diff --git a/test/support/fixtures/evaluation/semver_validation.json b/test/support/fixtures/evaluation/semver_validation.json new file mode 100644 index 00000000..3a14fc67 --- /dev/null +++ b/test/support/fixtures/evaluation/semver_validation.json @@ -0,0 +1,26 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d745f1-f315-7daf-d163-5541d3786e6f/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA", + "tests": [ + { + "key": "isNotOneOf", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "semver_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "wrong_semver" + } + }, + { + "key": "relations", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "semver_relations_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "wrong_semver" + } + } + ] +} diff --git a/test/support/fixtures/evaluation/semver_validation/semver_error.txt b/test/support/fixtures/evaluation/semver_validation/semver_error.txt new file mode 100644 index 00000000..9c3bfa14 --- /dev/null +++ b/test/support/fixtures/evaluation/semver_validation/semver_error.txt @@ -0,0 +1,9 @@ +warning [3004] Cannot evaluate condition (User.Custom1 IS NOT ONE OF ['1.0.0', '1.0.1', '2.0.0', '2.0.1', '2.0.2', '']) for setting 'isNotOneOf' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +warning [3004] Cannot evaluate condition (User.Custom1 IS NOT ONE OF ['1.0.0', '3.0.1']) for setting 'isNotOneOf' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +debug [5000] Evaluating 'isNotOneOf' for User '{"Identifier":"12345","Custom1":"wrong_semver"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 IS NOT ONE OF ['1.0.0', '1.0.1', '2.0.0', '2.0.1', '2.0.2', ''] THEN 'Is not one of (1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, )' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS NOT ONE OF ['1.0.0', '3.0.1'] THEN 'Is not one of (1.0.0, 3.0.1)' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/test/support/fixtures/evaluation/semver_validation/semver_relations_error.txt b/test/support/fixtures/evaluation/semver_validation/semver_relations_error.txt new file mode 100644 index 00000000..c6273517 --- /dev/null +++ b/test/support/fixtures/evaluation/semver_validation/semver_relations_error.txt @@ -0,0 +1,18 @@ +warning [3004] Cannot evaluate condition (User.Custom1 < '1.0.0,') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +warning [3004] Cannot evaluate condition (User.Custom1 < '1.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +warning [3004] Cannot evaluate condition (User.Custom1 <= '1.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +warning [3004] Cannot evaluate condition (User.Custom1 > '2.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +warning [3004] Cannot evaluate condition (User.Custom1 >= '2.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +debug [5000] Evaluating 'relations' for User '{"Identifier":"12345","Custom1":"wrong_semver"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 < '1.0.0,' THEN '<1.0.0,' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 < '1.0.0' THEN '< 1.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 <= '1.0.0' THEN '<=1.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 > '2.0.0' THEN '>2.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 >= '2.0.0' THEN '>=2.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/test/support/fixtures/evaluation/simple_value.json b/test/support/fixtures/evaluation/simple_value.json new file mode 100644 index 00000000..070d6f59 --- /dev/null +++ b/test/support/fixtures/evaluation/simple_value.json @@ -0,0 +1,37 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "boolDefaultFalse", + "defaultValue": true, + "returnValue": false, + "expectedLog": "off_flag.txt" + }, + { + "key": "boolDefaultTrue", + "defaultValue": false, + "returnValue": true, + "expectedLog": "on_flag.txt" + }, + { + "key": "stringDefaultCat", + "defaultValue": "Default", + "returnValue": "Cat", + "expectedLog": "text_setting.txt" + }, + { + "key": "integerDefaultOne", + "defaultValue": 0, + "returnValue": 1, + "expectedLog": "int_setting.txt" + }, + { + "testName": "double_setting", + "key": "doubleDefaultPi", + "defaultValue": 0.0, + "returnValue": 3.1415, + "expectedLog": "double_setting.txt" + } + ] +} diff --git a/test/support/fixtures/evaluation/simple_value/double_setting.txt b/test/support/fixtures/evaluation/simple_value/double_setting.txt new file mode 100644 index 00000000..07abb88b --- /dev/null +++ b/test/support/fixtures/evaluation/simple_value/double_setting.txt @@ -0,0 +1,2 @@ +debug [5000] Evaluating 'doubleDefaultPi' + Returning '3.1415'. diff --git a/test/support/fixtures/evaluation/simple_value/int_setting.txt b/test/support/fixtures/evaluation/simple_value/int_setting.txt new file mode 100644 index 00000000..a8d424a2 --- /dev/null +++ b/test/support/fixtures/evaluation/simple_value/int_setting.txt @@ -0,0 +1,2 @@ +debug [5000] Evaluating 'integerDefaultOne' + Returning '1'. diff --git a/test/support/fixtures/evaluation/simple_value/off_flag.txt b/test/support/fixtures/evaluation/simple_value/off_flag.txt new file mode 100644 index 00000000..3c95bfa1 --- /dev/null +++ b/test/support/fixtures/evaluation/simple_value/off_flag.txt @@ -0,0 +1,2 @@ +debug [5000] Evaluating 'boolDefaultFalse' + Returning 'false'. diff --git a/test/support/fixtures/evaluation/simple_value/on_flag.txt b/test/support/fixtures/evaluation/simple_value/on_flag.txt new file mode 100644 index 00000000..f67621e3 --- /dev/null +++ b/test/support/fixtures/evaluation/simple_value/on_flag.txt @@ -0,0 +1,2 @@ +debug [5000] Evaluating 'boolDefaultTrue' + Returning 'true'. diff --git a/test/support/fixtures/evaluation/simple_value/text_setting.txt b/test/support/fixtures/evaluation/simple_value/text_setting.txt new file mode 100644 index 00000000..f38cc2f5 --- /dev/null +++ b/test/support/fixtures/evaluation/simple_value/text_setting.txt @@ -0,0 +1,2 @@ +debug [5000] Evaluating 'stringDefaultCat' + Returning 'Cat'. diff --git a/test/support/fixtures/test.json b/test/support/fixtures/test.json new file mode 100644 index 00000000..942eb333 --- /dev/null +++ b/test/support/fixtures/test.json @@ -0,0 +1,24 @@ +{ + "f": { + "disabledFeature": { + "t": 0, + "v": { "b": false } + }, + "enabledFeature": { + "t": 0, + "v": { "b": true } + }, + "intSetting": { + "t": 2, + "v": { "i": 5 } + }, + "doubleSetting": { + "t": 3, + "v": { "d": 3.14 } + }, + "stringSetting": { + "t": 1, + "v": { "s": "test" } + } + } +} diff --git a/test/support/fixtures/test_circulardependency_v6.json b/test/support/fixtures/test_circulardependency_v6.json new file mode 100644 index 00000000..a8a9e176 --- /dev/null +++ b/test/support/fixtures/test_circulardependency_v6.json @@ -0,0 +1,80 @@ +{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "f": { + "key1": { + "t": 1, + "v": { "s": "key1-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key1", + "c": 0, + "v": { "s": "key1-prereq" } + } + } + ], + "s": { "v": { "s": "key1-prereq" } } + } + ] + }, + "key2": { + "t": 1, + "v": { "s": "key2-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key3", + "c": 0, + "v": { "s": "key3-prereq" } + } + } + ], + "s": { "v": { "s": "key2-prereq" } } + } + ] + }, + "key3": { + "t": 1, + "v": { "s": "key3-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key2", + "c": 0, + "v": { "s": "key2-prereq" } + } + } + ], + "s": { "v": { "s": "key3-prereq" } } + } + ] + }, + "key4": { + "t": 1, + "v": { "s": "key4-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key3", + "c": 0, + "v": { "s": "key3-prereq" } + } + } + ], + "s": { "v": { "s": "key4-prereq" } } + } + ] + } + } +} diff --git a/test/support/fixtures/test_override_flagdependency_v6.json b/test/support/fixtures/test_override_flagdependency_v6.json new file mode 100644 index 00000000..62e159e5 --- /dev/null +++ b/test/support/fixtures/test_override_flagdependency_v6.json @@ -0,0 +1,44 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "TsTuRHo\u002BMHs8h8j16HQY83sooJsLg34Ir5KIVOletFU=" + }, + "f": { + "mainStringFlag": { + "t": 1, + "v": { + "s": "private" + }, + "i": "24c96275" + }, + "stringDependsOnInt": { + "t": 1, + "r": [ + { + "c": [ + { + "p": { + "f": "mainIntFlag", + "c": 0, + "v": { + "i": 42 + } + } + } + ], + "s": { + "v": { + "s": "Dog" + }, + "i": "12531eec" + } + } + ], + "v": { + "s": "Cat" + }, + "i": "e227d926" + } + } +} diff --git a/test/support/fixtures/test_override_segments_v6.json b/test/support/fixtures/test_override_segments_v6.json new file mode 100644 index 00000000..47bf15ce --- /dev/null +++ b/test/support/fixtures/test_override_segments_v6.json @@ -0,0 +1,66 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "80xCU/SlDz1lCiWFaxIBjyJeJecWjq46T4eu6GtozkM=" + }, + "s": [ + { + "n": "Beta Users", + "r": [ + { + "a": "Email", + "c": 16, + "l": [ + "9189c42f6035bd1d2df5eda347a4f62926d27c80540a7aa6cc72cc75bc6757ff" + ] + } + ] + }, + { + "n": "Developers", + "r": [ + { + "a": "Email", + "c": 16, + "l": [ + "a7cdf54e74b5527bd2617889ec47f6d29b825ccfc97ff00832886bcb735abded" + ] + } + ] + } + ], + "f": { + "developerAndBetaUserSegment": { + "t": 0, + "r": [ + { + "c": [ + { + "s": { + "s": 1, + "c": 0 + } + }, + { + "s": { + "s": 0, + "c": 1 + } + } + ], + "s": { + "v": { + "b": true + }, + "i": "ddc50638" + } + } + ], + "v": { + "b": false + }, + "i": "6427f4b8" + } + } +} diff --git a/test/fixtures/test_simple.json b/test/support/fixtures/test_simple.json similarity index 100% rename from test/fixtures/test_simple.json rename to test/support/fixtures/test_simple.json diff --git a/test/fixtures/testmatrix.csv b/test/support/fixtures/testmatrix.csv similarity index 100% rename from test/fixtures/testmatrix.csv rename to test/support/fixtures/testmatrix.csv diff --git a/test/support/fixtures/testmatrix_and_or.csv b/test/support/fixtures/testmatrix_and_or.csv new file mode 100644 index 00000000..5a149f4a --- /dev/null +++ b/test/support/fixtures/testmatrix_and_or.csv @@ -0,0 +1,15 @@ +Identifier;Email;Country;Custom1;mainFeature;dependentFeature;emailAnd;emailOr +##null##;;;;public;Chicken;Cat;Cat +;;;;public;Chicken;Cat;Cat +jane@example.com;jane@example.com;##null##;##null##;public;Chicken;Cat;Jane +john@example.com;john@example.com;##null##;##null##;public;Chicken;Cat;John +a@example.com;a@example.com;USA;##null##;target;Cat;Cat;Cat +mark@example.com;mark@example.com;USA;##null##;target;Dog;Cat;Mark +nora@example.com;nora@example.com;USA;##null##;target;Falcon;Cat;Cat +stern@msn.com;stern@msn.com;USA;##null##;target;Horse;Cat;Cat +jane@sensitivecompany.com;jane@sensitivecompany.com;England;##null##;private;Chicken;Dog;Jane +anna@sensitivecompany.com;anna@sensitivecompany.com;France;##null##;private;Chicken;Cat;Cat +jane@sensitivecompany.com;jane@sensitivecompany.com;england;##null##;public;Chicken;Dog;Jane +jane;jane;##null##;##null##;public;Chicken;Cat;Cat +@sensitivecompany.com;@sensitivecompany.com;##null##;##null##;public;Chicken;Cat;Cat +jane.sensitivecompany.com;jane.sensitivecompany.com;##null##;##null##;public;Chicken;Cat;Cat diff --git a/test/support/fixtures/testmatrix_comparators_v6.csv b/test/support/fixtures/testmatrix_comparators_v6.csv new file mode 100644 index 00000000..d53efb54 --- /dev/null +++ b/test/support/fixtures/testmatrix_comparators_v6.csv @@ -0,0 +1,24 @@ +Identifier;Email;Country;Custom1;boolTrueIn202304;stringEqualsDogDefaultCat;stringEqualsCleartextDogDefaultCat;stringDoseNotEqualDogDefaultCat;stringNotEqualsCleartextDogDefaultCat;stringStartsWithDogDefaultCat;stringNotStartsWithDogDefaultCat;stringEndsWithDogDefaultCat;stringNotEndsWithDogDefaultCat;arrayContainsDogDefaultCat;arrayDoesNotContainDogDefaultCat;arrayContainsCaseCheckDogDefaultCat;arrayDoesNotContainCaseCheckDogDefaultCat;customPercentageAttribute;missingPercentageAttribute;countryPercentageAttribute;stringContainsAnyOfDogDefaultCat;stringNotContainsAnyOfDogDefaultCat;stringStartsWithAnyOfDogDefaultCat;stringStartsWithAnyOfCleartextDogDefaultCat;stringNotStartsWithAnyOfDogDefaultCat;stringNotStartsWithAnyOfCleartextDogDefaultCat;stringEndsWithAnyOfDogDefaultCat;stringEndsWithAnyOfCleartextDogDefaultCat;stringNotEndsWithAnyOfDogDefaultCat;stringNotEndsWithAnyOfCleartextDogDefaultCat;stringArrayContainsAnyOfDogDefaultCat;stringArrayContainsAnyOfCleartextDogDefaultCat;stringArrayNotContainsAnyOfDogDefaultCat;stringArrayNotContainsAnyOfCleartextDogDefaultCat +##null##;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat +;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat +a@configcat.com;a@configcat.com;##null##;##null##;False;Dog;Dog;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Chicken;NotFound;Chicken;Cat;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +b@configcat.com;b@configcat.com;Hungary;0;False;Cat;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Falcon;Cat;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +c@configcat.com;c@configcat.com;United Kingdom;1680307199.9;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +anna@configcat.com;anna@configcat.com;Hungary;1681118000.56;True;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +bogjobber@verizon.net;bogjobber@verizon.net;##null##;1682899200.1;False;Cat;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Horse;Chicken;Chicken;Dog;Cat;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Cat +cliffordj@aol.com;cliffordj@aol.com;Austria;1682999200;False;Cat;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Falcon;Chicken;Falcon;Dog;Cat;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Bahamas;read,execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +writer@configcat.com;writer@configcat.com;Belgium;write, execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Canada;execute, Read;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +writer@configcat.com;writer@configcat.com;China;Write;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +admin@configcat.com;admin@configcat.com;France;read, write,execute;False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +user@configcat.com;user@configcat.com;Greece;,execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Bahamas;["read","execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +writer@configcat.com;writer@configcat.com;Belgium;["write", "execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +reader@configcat.com;reader@configcat.com;Canada;["execute", "Read"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +writer@configcat.com;writer@configcat.com;China;["Write"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog +admin@configcat.com;admin@configcat.com;France;["read", "write","execute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Dog;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +admin@configcat.com;admin@configcat.com;France;["Read", "Write", "execute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +admin@configcat.com;admin@configcat.com;France;["Read", "Write", "eXecute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog +user@configcat.com;user@configcat.com;Greece;["","execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +user@configcat.com;user@configcat.com;Monaco;,null, ,,nil, None;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat diff --git a/test/fixtures/testmatrix_number.csv b/test/support/fixtures/testmatrix_number.csv similarity index 100% rename from test/fixtures/testmatrix_number.csv rename to test/support/fixtures/testmatrix_number.csv diff --git a/test/support/fixtures/testmatrix_prerequisite_flag.csv b/test/support/fixtures/testmatrix_prerequisite_flag.csv new file mode 100644 index 00000000..dcf68f4d --- /dev/null +++ b/test/support/fixtures/testmatrix_prerequisite_flag.csv @@ -0,0 +1,5 @@ +Identifier;Email;Country;Custom1;mainBoolFlag;mainStringFlag;mainIntFlag;mainDoubleFlag;stringDependsOnBool;stringDependsOnString;stringDependsOnStringCaseCheck;stringDependsOnInt;stringDependsOnDouble;stringDependsOnDoubleIntValue;boolDependsOnBool;intDependsOnBool;doubleDependsOnBool;boolDependsOnBoolDependsOnBool;mainBoolFlagEmpty;stringDependsOnEmptyBool;stringInverseDependsOnEmptyBool;mainBoolFlagInverse;boolDependsOnBoolInverse +##null##;;;;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True +;;;;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True +john@sensitivecompany.com;john@sensitivecompany.com;##null##;##null##;False;private;2;0.1;Cat;Dog;Cat;Dog;Dog;Cat;False;42;3.14;True;True;EmptyOn;EmptyOn;True;False +jane@example.com;jane@example.com;##null##;##null##;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True diff --git a/test/support/fixtures/testmatrix_segments.csv b/test/support/fixtures/testmatrix_segments.csv new file mode 100644 index 00000000..b59ba3a0 --- /dev/null +++ b/test/support/fixtures/testmatrix_segments.csv @@ -0,0 +1,6 @@ +Identifier;Email;Country;Custom1;developerAndBetaUserSegment;developerAndBetaUserCleartextSegment;notDeveloperAndNotBetaUserSegment;notDeveloperAndNotBetaUserCleartextSegment +##null##;;;;False;False;False;False +;;;;False;False;False;False +john@example.com;john@example.com;##null##;##null##;False;False;False;False +jane@example.com;jane@example.com;##null##;##null##;False;False;False;False +kate@example.com;kate@example.com;##null##;##null##;True;True;True;True diff --git a/test/support/fixtures/testmatrix_segments_old.csv b/test/support/fixtures/testmatrix_segments_old.csv new file mode 100644 index 00000000..9fc605ec --- /dev/null +++ b/test/support/fixtures/testmatrix_segments_old.csv @@ -0,0 +1,6 @@ +Identifier;Email;Country;Custom1;featureWithSegmentTargeting;featureWithSegmentTargetingCleartext;featureWithNegatedSegmentTargeting;featureWithNegatedSegmentTargetingCleartext;featureWithSegmentTargetingInverse;featureWithSegmentTargetingInverseCleartext;featureWithNegatedSegmentTargetingInverse;featureWithNegatedSegmentTargetingInverseCleartext +##null##;;;;False;False;False;False;False;False;False;False +;;;;False;False;False;False;False;False;False;False +john@example.com;john@example.com;##null##;##null##;True;True;False;False;False;False;True;True +jane@example.com;jane@example.com;##null##;##null##;True;True;False;False;False;False;True;True +kate@example.com;kate@example.com;##null##;##null##;False;False;True;True;True;True;False;False diff --git a/test/fixtures/testmatrix_semantic.csv b/test/support/fixtures/testmatrix_semantic.csv similarity index 100% rename from test/fixtures/testmatrix_semantic.csv rename to test/support/fixtures/testmatrix_semantic.csv diff --git a/test/fixtures/testmatrix_semantic_2.csv b/test/support/fixtures/testmatrix_semantic_2.csv similarity index 100% rename from test/fixtures/testmatrix_semantic_2.csv rename to test/support/fixtures/testmatrix_semantic_2.csv diff --git a/test/fixtures/testmatrix_sensitive.csv b/test/support/fixtures/testmatrix_sensitive.csv similarity index 100% rename from test/fixtures/testmatrix_sensitive.csv rename to test/support/fixtures/testmatrix_sensitive.csv diff --git a/test/support/fixtures/testmatrix_unicode.csv b/test/support/fixtures/testmatrix_unicode.csv new file mode 100644 index 00000000..e5b01de0 --- /dev/null +++ b/test/support/fixtures/testmatrix_unicode.csv @@ -0,0 +1,14 @@ +Identifier;Email;Country;馃唭馃叴馃唶馃唭;boolTextEqualsHashed;boolTextEqualsCleartext;boolTextNotEqualsHashed;boolTextNotEqualsCleartext;boolIsOneOfHashed;boolIsOneOfCleartext;boolIsNotOneOfHashed;boolIsNotOneOfCleartext;boolStartsWithHashed;boolStartsWithCleartext;boolNotStartsWithHashed;boolNotStartsWithCleartext;boolEndsWithHashed;boolEndsWithCleartext;boolNotEndsWithHashed;boolNotEndsWithCleartext;boolContainsCleartext;boolNotContainsCleartext;boolArrayContainsHashed;boolArrayContainsCleartext;boolArrayNotContainsHashed;boolArrayNotContainsCleartext +1;;;蕜菬占茍蕪 榷蓻蛹榷;True;True;False;False;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;蕜a占茍蕪 榷蓻蛹榷;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;脕RV脥ZT虐R艕 t眉k枚rf煤r贸g茅p;False;False;True;True;True;True;False;False;True;True;False;False;True;True;False;False;True;False;False;False;False;False +1;;;谩rv铆zt疟r艖 t眉k枚rf煤r贸g茅p;False;False;True;True;False;False;True;True;False;False;True;True;True;True;False;False;True;False;False;False;False;False +1;;;脕RV脥ZT虐R艕 T脺K脰RF脷R脫G脡P;False;False;True;True;False;False;True;True;True;True;False;False;False;False;True;True;True;False;False;False;False;False +1;;;谩rv铆zt疟r艖 T脺K脰RF脷R脫G脡P;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;u饾枔饾枎饾枅饾枖饾枆e;False;False;True;True;True;True;False;False;True;True;False;False;True;True;False;False;True;False;False;False;False;False +;;;饾枤饾枔饾枎饾枅饾枖饾枆e;False;False;True;True;False;False;True;True;False;False;True;True;True;True;False;False;True;False;False;False;False;False +;;;u饾枔饾枎饾枅饾枖饾枆饾枈;False;False;True;True;False;False;True;True;True;True;False;False;False;False;True;True;True;False;False;False;False;False +;;;饾枤饾枔饾枎饾枅饾枖饾枆饾枈;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;["脕RV脥ZT虐R艕 t眉k枚rf煤r贸g茅p", "unicode"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;True;True;False;False +1;;;["脕RV脥ZT虐R艕", "t眉k枚rf煤r贸g茅p", "u饾枔饾枎饾枅饾枖饾枆e"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;True;True;False;False +1;;;["脕RV脥ZT虐R艕", "t眉k枚rf煤r贸g茅p", "unicode"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;False;False;True;True diff --git a/test/fixtures/testmatrix_variationId.csv b/test/support/fixtures/testmatrix_variationId.csv similarity index 100% rename from test/fixtures/testmatrix_variationId.csv rename to test/support/fixtures/testmatrix_variationId.csv diff --git a/test/test_helper.exs b/test/test_helper.exs index 194f2824..197d6491 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,2 @@ -Logger.configure(level: :warning) +Calendar.put_time_zone_database(Tz.TimeZoneDatabase) ExUnit.start() diff --git a/test/using_block_test.exs b/test/using_block_test.exs index 32007050..ab788d4d 100644 --- a/test/using_block_test.exs +++ b/test/using_block_test.exs @@ -6,7 +6,7 @@ defmodule ConfigCat.UsingBlockTest do defmodule CustomModule do @moduledoc false - use ConfigCat, sdk_key: "PKDVCLf-Hq-h-kCzMp-L7Q/PaDVCFk9EpmD6sLpGLltTA" + use ConfigCat, sdk_key: "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/1cGEJXUwYUGZCBOL-E2sOw" end test "can call API through using block" do