diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..135b072 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: mix test + runs-on: ubuntu-latest + + strategy: + matrix: + include: + - elixir: "1.17" + otp: "26" + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Elixir + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Restore deps cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Run tests + run: mix test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..435dfa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +absmartly-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +# Editor and OS files +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +*~ + +.claude/ +.DS_Store +AUDIT_REPORT.md +FIXES_SUMMARY.md +AUDIT_FIXES_COMPLETE.md +BEFORE_AFTER.md +CHANGELOG.md +CONTRIBUTING.md +DOCUMENTATION_CHECKLIST.md +IMPLEMENTATION.md +IMPLEMENTATION_REPORT.md +README_AUDIT_FIXES.md +SDK_COMPARISON.md +test_murmur_libs.exs +verify_murmur_library.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20181fe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ABsmartly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b469c9 --- /dev/null +++ b/README.md @@ -0,0 +1,540 @@ +# ABsmartly Elixir SDK + +The official Elixir SDK for [ABsmartly](https://www.absmartly.com) - A/B testing and feature flagging platform. + +## Compatibility + +The ABsmartly Elixir SDK is compatible with Elixir 1.17+ and Erlang/OTP 25+. It provides both synchronous and asynchronous context creation for variant assignment and goal tracking. The Context is implemented as a GenServer process. + +## Installation + +Add `absmartly` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:absmartly, "~> 1.0"} + ] +end +``` + +Then run: + +```bash +mix deps.get +``` + +## Getting Started + +Please follow the [installation](#installation) instructions before trying the following code. + +### Initialization + +This example assumes an API Key, an Application, and an Environment have been created in the ABsmartly web console. + +```elixir +alias ABSmartly.SDK + +{:ok, sdk} = SDK.new( + endpoint: "https://your-company.absmartly.io/v1", + api_key: System.get_env("ABSMARTLY_API_KEY"), + application: "website", + environment: "production" +) +``` + +#### With Optional Parameters + +```elixir +{:ok, sdk} = SDK.new( + endpoint: "https://your-company.absmartly.io/v1", + api_key: System.get_env("ABSMARTLY_API_KEY"), + application: "website", + environment: "production", + timeout: 5000, + retries: 3 +) +``` + +**SDK Options** + +| Config | Type | Required? | Default | Description | +| :----------- | :-------- | :-------: | :-----: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `String` | ✅ | `nil` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` | +| api_key | `String` | ✅ | `nil` | Your API key which can be found on the Web Console. | +| environment | `String` | ✅ | `nil` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| application | `String` | ✅ | `nil` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| retries | `Integer` | ❌ | `5` | Number of retry attempts for failed HTTP requests | +| timeout | `Integer` | ❌ | `3000` | Connection timeout in milliseconds | + +## Creating a New Context + +### Synchronously + +```elixir +alias ABSmartly.{SDK, Context} + +units = %{"session_id" => "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} + +{:ok, context} = SDK.create_context(sdk, units) + +true = Context.is_ready?(context) +``` + +### Asynchronously + +```elixir +alias ABSmartly.{SDK, Context} + +units = %{"session_id" => "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} + +{:ok, context} = SDK.create_context_async(sdk, units) + +Context.wait_until_ready(context) +``` + +### With Pre-fetched Data + +When doing full-stack experimentation with ABsmartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the ABsmartly event collector. We can avoid repeating the round-trip on the client-side by sending the server-side data embedded in the first document. + +```elixir +alias ABSmartly.{SDK, Context, Types} + +units = %{"session_id" => "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} + +context_data = %Types.ContextData{ + experiments: [...] +} + +{:ok, context} = SDK.create_context_with(sdk, units, context_data) + +true = Context.is_ready?(context) +``` + +### Refreshing the Context with Fresh Experiment Data + +For long-running contexts, the context can be refreshed manually to pull updated experiment data. + +```elixir +new_data = %{"experiments" => [...]} + +:ok = Context.refresh(context, new_data) +``` + +### Setting Extra Units + +You can add additional units to a context by calling the `set_unit/3` function. This may be used, for example, when a user logs in to your application, and you want to use the new unit type in the context. + +**Note:** You cannot override an already set unit type as that would be a change of identity. In this case, you must create a new context instead. The `set_unit/3` and `set_units/2` functions can be called before the context is ready. + +```elixir +Context.set_unit(context, "db_user_id", "1000013") + +Context.set_units(context, %{ + "db_user_id" => "1000013", + "user_type" => "premium" +}) +``` + +## Basic Usage + +### Selecting a Treatment + +```elixir +variant = Context.treatment(context, "exp_test_experiment") + +case variant do + 0 -> + # User is in control group (variant 0) + render_control_experience() + _ -> + # User is in treatment group + render_treatment_experience() +end +``` + +### Treatment Variables + +Variables allow you to change experiment parameters without code deployment. + +```elixir +button_color = Context.variable_value(context, "button_color", "blue") + +timeout = Context.variable_value(context, "api_timeout", 5000) +``` + +### Peek at Treatment Variants + +Although generally not recommended, it is sometimes necessary to peek at a treatment without triggering an exposure. The ABsmartly SDK provides a `peek/2` function for that. + +```elixir +variant = Context.peek(context, "exp_test_experiment") + +if variant == 0 do + # User is in control group +else + # User is in treatment group +end +``` + +#### Peeking at Variables + +```elixir +color = Context.peek_variable_value(context, "button_color", "blue") +``` + +### Overriding Treatment Variants + +During development or testing, you may want to force a specific variant. The `set_override/3` function allows you to override the variant assignment. +The `set_override/3` and `set_overrides/2` functions can be called before the context is ready. + +```elixir +Context.set_override(context, "exp_test_experiment", 1) + +Context.set_overrides(context, %{ + "exp_test_experiment" => 1, + "exp_another_test" => 0 +}) +``` + +## Advanced + +### Context Attributes + +Attributes are used for audience targeting. The `set_attribute/3` function can be called before the context is ready. It accepts native Elixir types directly. + +```elixir +Context.set_attribute(context, "user_agent", "Mozilla/5.0") +Context.set_attribute(context, "customer_age", "new_customer") +Context.set_attribute(context, "age", 25) + +Context.set_attributes(context, %{ + "user_agent" => "Mozilla/5.0", + "customer_age" => "new_customer", + "age" => 25 +}) +``` + +### Custom Assignments + +Custom assignments allow you to implement your own assignment logic while still benefiting from ABsmartly's analytics. + +```elixir +Context.set_custom_assignment(context, "exp_test_experiment", 1) + +Context.set_custom_assignments(context, %{ + "exp_test_experiment" => 1, + "exp_another_experiment" => 0 +}) +``` + +### Tracking Goals + +Goals are created in the ABsmartly web console. + +```elixir +Context.track(context, "signup") + +Context.track(context, "purchase", %{ + "amount" => 99.99, + "items" => 3 +}) +``` + +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the ABsmartly collector before proceeding. You can explicitly call the `publish/1` function. + +```elixir +:ok = Context.publish(context) +``` + +If the publisher fails, `publish/1` returns `{:error, reason}`, emits an `error` event and keeps the pending exposures and goals queued, so a later `publish/1` or `finalize/1` sends them again. + +### Finalizing + +The `finalize/1` function will ensure all events have been published to the ABsmartly collector, like `publish/1`, and will also "seal" the context, preventing any further events from being tracked. + +```elixir +:ok = Context.finalize(context) + +true = Context.is_finalized?(context) +``` + +If publishing fails, `finalize/1` returns `{:error, reason}` and the context stays open with its events queued, so `finalize/1` can be called again. + +`finalize/1` keeps the context process alive so it can still be inspected. For request- or session-scoped contexts, call `Context.dispose/1` instead: it finalizes (retrying a failed final publish up to `:attempts` times, default 3) and then always stops the process, releasing it and its retained state. If events could not be published it returns `{:error, {:disposed_unpublished, reason}}` so you can observe the loss; the documented integrations ignore the result because the process is released either way. Inspect the context (for example `is_finalized?/1`) before disposing, since the pid is dead afterwards. + +### Custom Event Logger + +The ABsmartly SDK supports custom event logging for debugging, analytics, or integrating with other systems. Event loggers can be set at the SDK level or per-context. + +**Event Types** + +| Event | When | Data | +|------------|----------------------------------------------|--------------------------------------| +| `error` | Context data fails to load, or `publish/1` / `finalize/1` fails | Error reason | +| `ready` | Context turns ready | ContextData used to initialize | +| `refresh` | `refresh/2` succeeds | ContextData used to refresh | +| `publish` | `publish/1` succeeds | Published events data | +| `exposure` | `treatment/2` succeeds on first exposure | Exposure enqueued for publishing | +| `goal` | `track/2` or `track/3` succeeds | Goal enqueued for publishing | +| `close` | `finalize/1` succeeds the first time | `nil` | + +## Platform-Specific Examples + +### Using with Phoenix Framework + +Integrate ABsmartly with Phoenix by creating a singleton GenServer and using it in your controllers. + +```elixir +# lib/my_app/absmartly_service.ex +defmodule MyApp.ABSmartlyService do + use GenServer + + alias ABSmartly.SDK + + def start_link(_opts) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def get_sdk do + GenServer.call(__MODULE__, :get_sdk) + end + + @impl true + def init(:ok) do + {:ok, sdk} = SDK.new( + endpoint: Application.get_env(:my_app, :absmartly_endpoint), + api_key: Application.get_env(:my_app, :absmartly_api_key), + environment: Application.get_env(:my_app, :absmartly_environment), + application: "my_app" + ) + + {:ok, %{sdk: sdk}} + end + + @impl true + def handle_call(:get_sdk, _from, state) do + {:reply, {:ok, state.sdk}, state} + end +end + +# Add to application.ex supervision tree +defmodule MyApp.Application do + use Application + + def start(_type, _args) do + children = [ + MyAppWeb.Endpoint, + MyApp.ABSmartlyService + ] + + opts = [strategy: :one_for_one, name: MyApp.Supervisor] + Supervisor.start_link(children, opts) + end +end + +# lib/my_app_web/controllers/product_controller.ex +defmodule MyAppWeb.ProductController do + use MyAppWeb, :controller + + alias ABSmartly.{SDK, Context} + + def show(conn, _params) do + session_id = get_session(conn, :session_id) || generate_session_id() + conn = put_session(conn, :session_id, session_id) + + {:ok, sdk} = MyApp.ABSmartlyService.get_sdk() + + units = %{"session_id" => session_id} + + case SDK.create_context(sdk, units) do + {:ok, context} -> + Context.set_attribute(context, "user_agent", get_req_header(conn, "user-agent")) + + treatment = Context.treatment(context, "exp_product_layout") + + Context.publish(context) + Context.dispose(context) + + case treatment do + 0 -> render(conn, "show_control.html") + _ -> render(conn, "show_treatment.html") + end + + {:error, _reason} -> + render(conn, "show_control.html") + end + end + + defp generate_session_id do + :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + end +end +``` + +### Using with Phoenix LiveView + +Handle experiments in LiveView with proper lifecycle management. + +```elixir +# lib/my_app_web/live/product_live.ex +defmodule MyAppWeb.ProductLive do + use MyAppWeb, :live_view + + alias ABSmartly.{SDK, Context} + + def mount(_params, session, socket) do + session_id = session["session_id"] || generate_session_id() + + {:ok, sdk} = MyApp.ABSmartlyService.get_sdk() + units = %{"session_id" => session_id} + + case SDK.create_context(sdk, units) do + {:ok, context} -> + treatment = Context.treatment(context, "exp_product_layout") + + {:ok, + socket + |> assign(:context, context) + |> assign(:treatment, treatment)} + + {:error, _reason} -> + {:ok, + socket + |> assign(:context, nil) + |> assign(:treatment, 0)} + end + end + + def handle_event("purchase", %{"amount" => amount}, socket) do + if context = socket.assigns.context do + Context.track(context, "purchase", %{"amount" => String.to_float(amount)}) + Context.publish(context) + end + + {:noreply, socket} + end + + def terminate(_reason, socket) do + if context = socket.assigns.context do + Context.dispose(context) + end + + :ok + end + + def render(assigns) do + ~H""" + <%= if @treatment == 0 do %> +
Control Layout
+ <% else %> +
Treatment Layout
+ <% end %> + """ + end + + defp generate_session_id do + :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + end +end +``` + +### Using with Plug + +Create a Plug to automatically manage ABsmartly contexts for requests. + +```elixir +# lib/my_app_web/plugs/absmartly_context.ex +defmodule MyAppWeb.Plugs.ABSmartlyContext do + import Plug.Conn + + alias ABSmartly.{SDK, Context} + + def init(opts), do: opts + + def call(conn, _opts) do + session_id = get_session(conn, :session_id) || generate_session_id() + conn = put_session(conn, :session_id, session_id) + + {:ok, sdk} = MyApp.ABSmartlyService.get_sdk() + units = %{"session_id" => session_id} + + case SDK.create_context(sdk, units) do + {:ok, context} -> + user_agent = + case get_req_header(conn, "user-agent") do + [ua | _] -> ua + [] -> "unknown" + end + + Context.set_attribute(context, "user_agent", user_agent) + + conn + |> assign(:absmartly_context, context) + |> register_before_send(&finalize_context/1) + + {:error, _reason} -> + conn + end + end + + defp finalize_context(conn) do + if context = conn.assigns[:absmartly_context] do + Context.publish(context) + Context.dispose(context) + end + + conn + end + + defp generate_session_id do + :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + end +end + +# Use in router +# lib/my_app_web/router.ex +defmodule MyAppWeb.Router do + use MyAppWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_flash + plug :protect_from_forgery + plug :put_secure_browser_headers + plug MyAppWeb.Plugs.ABSmartlyContext + end + + scope "/", MyAppWeb do + pipe_through :browser + + get "/", PageController, :index + end +end +``` + +## About A/B Smartly + +**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. + +A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. + +### Have a look at our growing list of clients and SDKs: + +- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Swift SDK](https://www.github.com/absmartly/swift-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) +- [Elixir SDK](https://www.github.com/absmartly/elixir-sdk) (this package) diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..a004a9e --- /dev/null +++ b/config/config.exs @@ -0,0 +1,9 @@ +import Config + +# Configure Jason as JSON library +config :absmartly, :json_library, Jason + +# Logger configuration +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] diff --git a/lib/absmartly.ex b/lib/absmartly.ex new file mode 100644 index 0000000..a571b13 --- /dev/null +++ b/lib/absmartly.ex @@ -0,0 +1,51 @@ +defmodule ABSmartly do + @moduledoc """ + ABSmartly SDK for Elixir. + + Official Elixir SDK for the ABSmartly experimentation platform. + + ## Usage + + # Configure SDK with keyword list + {:ok, sdk} = ABSmartly.new( + endpoint: "https://your-company.absmartly.io/v1", + api_key: "YOUR-API-KEY", + application: "website", + environment: "development" + ) + + # Create context + {:ok, context} = ABSmartly.create_context(sdk, %{"session_id" => "abc123"}) + + # Get treatment + variant = ABSmartly.Context.treatment(context, "exp_test") + + # Track goal + ABSmartly.Context.track(context, "purchase", %{amount: 99.99}) + + # Publish events + ABSmartly.Context.publish(context) + + # Finalize and release the context process + ABSmartly.Context.dispose(context) + + ## Pipe-friendly API + + {:ok, sdk} = ABSmartly.new(endpoint: "...", api_key: "...", ...) + |> ABSmartly.with_timeout(5000) + |> ABSmartly.with_retries(3) + """ + + # Re-export main modules for convenience + defdelegate new(opts), to: ABSmartly.SDK + defdelegate with_timeout(sdk, timeout), to: ABSmartly.SDK + defdelegate with_retries(sdk, retries), to: ABSmartly.SDK + defdelegate create_context(sdk, units, options \\ %{}), to: ABSmartly.SDK + defdelegate create_context_async(sdk, units, options \\ %{}), to: ABSmartly.SDK + defdelegate create_context_with(sdk, units, data, options \\ %{}), to: ABSmartly.SDK + + # Version + @version Mix.Project.config()[:version] + + def version, do: @version +end diff --git a/lib/absmartly/application.ex b/lib/absmartly/application.ex new file mode 100644 index 0000000..70af22b --- /dev/null +++ b/lib/absmartly/application.ex @@ -0,0 +1,24 @@ +defmodule ABSmartly.Application do + @moduledoc """ + Application supervisor for ABSmartly SDK. + + Provides supervision tree for fault tolerance and recovery. + Fixes CRITICAL-02: Unsupervised GenServer. + """ + use Application + + require Logger + + @impl true + def start(_type, _args) do + children = [ + # Context supervisor will be started on demand + {DynamicSupervisor, name: ABSmartly.ContextSupervisor, strategy: :one_for_one} + ] + + opts = [strategy: :one_for_one, name: ABSmartly.Supervisor] + + Logger.info("Starting ABSmartly.Application") + Supervisor.start_link(children, opts) + end +end diff --git a/lib/absmartly/assigner.ex b/lib/absmartly/assigner.ex new file mode 100644 index 0000000..34ffcbd --- /dev/null +++ b/lib/absmartly/assigner.ex @@ -0,0 +1,53 @@ +defmodule ABSmartly.VariantAssigner do + @moduledoc """ + Assigns variants to units based on experiment configuration. + + CRITICAL: Unit must be hashed with hash_unit() BEFORE creating VariantAssigner. + """ + + alias ABSmartly.{Murmur3, Utils} + + @doc """ + Assign a variant for a hashed unit. + + ## Parameters + + * `hashed_unit` - Unit identifier already hashed with Utils.hash_unit() + * `split` - Array of cumulative split probabilities (e.g., [0.5, 0.5]) + * `seed_hi` - High 32 bits of seed + * `seed_lo` - Low 32 bits of seed + + ## Examples + + iex> hashed = ABSmartly.Utils.hash_unit("bleh@absmartly.com") + iex> ABSmartly.VariantAssigner.assign(hashed, [0.5, 0.5], 0, 0) + 0 + + iex> hashed = ABSmartly.Utils.hash_unit("bleh@absmartly.com") + iex> ABSmartly.VariantAssigner.assign(hashed, [0.5, 0.5], 0, 1) + 1 + + iex> hashed = ABSmartly.Utils.hash_unit("123456789") + iex> ABSmartly.VariantAssigner.assign(hashed, [0.5, 0.5], 0, 0) + 1 + """ + def assign(hashed_unit, split, seed_hi, seed_lo) + when is_binary(hashed_unit) and is_list(split) and + is_integer(seed_hi) and is_integer(seed_lo) do + # Compute unit hash + unit_hash = Murmur3.hash32(hashed_unit, 0) + + # Build buffer: [seedLo (4 bytes LE), seedHi (4 bytes LE), unitHash (4 bytes LE)] + buffer = + <> + + # Hash the buffer + hash = Murmur3.hash32(buffer, 0) + + # Compute probability (CRITICAL: divide by 0xFFFFFFFF, NOT 0x100000000) + probability = hash / 0xFFFFFFFF + + # Choose variant based on split + Utils.choose_variant(split, probability) + end +end diff --git a/lib/absmartly/context.ex b/lib/absmartly/context.ex new file mode 100644 index 0000000..044c252 --- /dev/null +++ b/lib/absmartly/context.ex @@ -0,0 +1,1821 @@ +defmodule ABSmartly.Context do + use GenServer + + require Logger + + alias ABSmartly.{ + Types, + Utils, + VariantAssigner, + Matcher, + HTTP + } + + @type t :: GenServer.server() + + @max_uid_length 1024 + @max_queue_size 10_000 + + defstruct [ + :sdk_config, + :data, + :ready, + :failed, + :failed_reason, + :finalized, + :finalizing, + :units, + :attributes, + :overrides, + :custom_assignments, + :assignments, + :exposures, + :goals, + :variable_index, + :experiment_index, + :event_handler, + :exposed_experiments, + :audience_cache, + :data_fetcher, + attrs_seq: 0, + pending_waiters: [], + exposure_count: 0, + goal_count: 0 + ] + + def child_spec(opts) do + shutdown = + case opts do + [%{} = sdk_config | _] -> shutdown_timeout(sdk_config) + _ -> 5_000 + end + + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, opts}, + type: :worker, + restart: :temporary, + shutdown: shutdown + } + end + + def start_link(sdk_config, data, context_config, opts \\ []) do + data_fetcher = Keyword.get(opts, :data_fetcher) + GenServer.start_link(__MODULE__, {sdk_config, data, context_config, data_fetcher}) + end + + def start_link_async(sdk_config, context_config, opts \\ []) do + data_fetcher = Keyword.get(opts, :data_fetcher) + GenServer.start_link(__MODULE__, {:async, sdk_config, context_config, data_fetcher}) + end + + def set_data(context, data) do + GenServer.call(context, {:set_data, data}) + end + + def set_failed(context, reason) do + GenServer.call(context, {:set_failed, reason}) + end + + def ready_error(context) do + GenServer.call(context, :ready_error) + end + + def wait_until_ready(context, timeout \\ 5000) do + GenServer.call(context, :wait_until_ready, timeout) + end + + def set_unit(context, unit_type, uid) do + GenServer.call(context, {:set_unit, unit_type, uid}) + end + + def set_units(context, units) when is_map(units) do + GenServer.call(context, {:set_units, units}) + end + + def get_unit(context, unit_type) do + GenServer.call(context, {:get_unit, unit_type}) + end + + def get_units(context) do + GenServer.call(context, :get_units) + end + + def set_attribute(context, name, value) do + GenServer.call(context, {:set_attribute, name, value}) + end + + def set_attributes(context, attributes) when is_map(attributes) or is_list(attributes) do + GenServer.call(context, {:set_attributes, attributes}) + end + + def get_attribute(context, name) do + GenServer.call(context, {:get_attribute, name}) + end + + def get_attributes(context) do + GenServer.call(context, :get_attributes) + end + + def set_override(context, experiment_name, variant) do + GenServer.call(context, {:set_override, experiment_name, variant}) + end + + def set_overrides(context, overrides) when is_map(overrides) do + GenServer.call(context, {:set_overrides, overrides}) + end + + def set_custom_assignment(context, experiment_name, variant) do + GenServer.call(context, {:set_custom_assignment, experiment_name, variant}) + end + + def set_custom_assignments(context, assignments) when is_map(assignments) do + GenServer.call(context, {:set_custom_assignments, assignments}) + end + + def treatment(context, experiment_name) do + GenServer.call(context, {:treatment, experiment_name}) + end + + def peek(context, experiment_name) do + GenServer.call(context, {:peek, experiment_name}) + end + + def variable_value(context, key, default_value \\ nil) do + GenServer.call(context, {:variable_value, key, default_value}) + end + + def peek_variable_value(context, key, default_value \\ nil) do + GenServer.call(context, {:peek_variable_value, key, default_value}) + end + + def variable_keys(context) do + GenServer.call(context, :variable_keys) + end + + def custom_field_value(context, experiment_name, field_name) do + GenServer.call(context, {:custom_field_value, experiment_name, field_name}) + end + + def custom_field_keys(context) do + GenServer.call(context, :custom_field_keys) + end + + def custom_field_value_type(context, experiment_name, field_name) do + GenServer.call(context, {:custom_field_value_type, experiment_name, field_name}) + end + + def track(context, goal_name, properties \\ nil) do + GenServer.call(context, {:track, goal_name, properties}) + end + + def publish(context) do + GenServer.call(context, :publish, :infinity) + end + + def finalize(context) do + GenServer.call(context, :finalize, :infinity) + end + + def close(context) do + finalize(context) + end + + @doc """ + Finalizes the context and then stops its process, releasing the supervised + process and everything it retains (data, attributes, assignment caches). + + Use this at the end of a request/session lifecycle. Disposal always releases + the process: if the final publish keeps failing, it is retried up to + `:attempts` times (default 3, with `:backoff` ms between attempts, default + 100), after which the unpublished events are discarded, the process is + stopped anyway and `{:error, {:disposed_unpublished, reason}}` is + returned so the caller can observe that events were not delivered. Returns + `:ok` when everything was published. + + After `dispose/1` the pid is dead, so inspection helpers such as + `is_finalized?/1` must be called before it. + """ + def dispose(context, opts \\ []) do + attempts = max(Keyword.get(opts, :attempts, 3), 1) + backoff = Keyword.get(opts, :backoff, 100) + + result = finalize_with_retries(context, attempts, backoff) + + # Events that could not be published are dropped (and reported below) so the + # termination hook makes no further, unbounded publisher call. + if result != :ok do + try do + GenServer.call(context, :discard_pending, 5_000) + catch + :exit, _ -> :ok + end + end + + try do + GenServer.stop(context, :normal) + catch + :exit, _ -> :ok + end + + case result do + :ok -> :ok + {:error, reason} -> {:error, {:disposed_unpublished, reason}} + end + end + + defp finalize_with_retries(context, attempts, backoff) do + case finalize(context) do + :ok -> + :ok + + {:error, _reason} = error when attempts <= 1 -> + error + + {:error, _reason} -> + Process.sleep(backoff) + finalize_with_retries(context, attempts - 1, backoff) + end + catch + :exit, reason -> {:error, {:exit, reason}} + end + + def refresh(context) do + GenServer.call(context, :refresh, :infinity) + end + + def refresh(context, new_data) do + GenServer.call(context, {:refresh, new_data}) + end + + def is_ready?(context) do + GenServer.call(context, :is_ready) + end + + def is_failed?(context) do + GenServer.call(context, :is_failed) + end + + def is_finalized?(context) do + GenServer.call(context, :is_finalized) + end + + def is_finalizing?(context) do + GenServer.call(context, :is_finalizing) + end + + def is_closed?(context) do + is_finalized?(context) + end + + def is_closing?(context) do + is_finalizing?(context) + end + + def pending(context) do + GenServer.call(context, :pending) + end + + def data(context) do + GenServer.call(context, :data) + end + + def experiments(context) do + GenServer.call(context, :experiments) + end + + # GenServer callbacks + + @impl true + def init({:async, sdk_config, context_config}) do + init({:async, sdk_config, context_config, nil}) + end + + def init({:async, sdk_config, context_config, data_fetcher}) do + # Trap exits so supervisor-driven shutdown runs terminate/2 (pending-event publish). + Process.flag(:trap_exit, true) + empty_data = %Types.ContextData{experiments: []} + state = %__MODULE__{ + sdk_config: sdk_config, + data: empty_data, + ready: false, + failed: false, + failed_reason: nil, + finalized: false, + finalizing: false, + units: normalize_units(context_config.units), + attributes: config_attributes_to_list(context_config.attributes), + overrides: context_config.overrides, + custom_assignments: context_config.custom_assignments, + assignments: %{}, + exposures: [], + goals: [], + variable_index: %{}, + experiment_index: %{}, + event_handler: context_config.event_handler, + exposed_experiments: MapSet.new(), + audience_cache: %{}, + data_fetcher: data_fetcher, + pending_waiters: [], + exposure_count: 0, + goal_count: 0 + } + + {:ok, state, {:continue, :fetch_async}} + end + + @impl true + def handle_continue(:fetch_async, state) do + # Fetch context data off the GenServer process so the context stays + # responsive (set_unit/set_attribute/set_failed/wait_until_ready) while the + # request is in flight, mirroring the future/callback model of the other SDKs. + server = self() + fetcher = state.data_fetcher + + spawn(fn -> + result = + try do + if fetcher do + fetcher.() + else + ABSmartly.HTTP.Client.fetch_context( + state.sdk_config.endpoint, + state.sdk_config.api_key, + state.sdk_config.application, + state.sdk_config.environment, + state.sdk_config.retries, + state.sdk_config.timeout + ) + end + rescue + exception -> {:error, Exception.message(exception)} + catch + kind, reason -> {:error, {kind, reason}} + end + + send(server, {:fetch_async_complete, result}) + end) + + {:noreply, state} + end + + @impl true + def handle_info({:fetch_async_complete, _result}, %{ready: true} = state), do: {:noreply, state} + def handle_info({:fetch_async_complete, _result}, %{failed: true} = state), do: {:noreply, state} + + def handle_info({:fetch_async_complete, {:ok, data}}, state) do + case safe_context_data(data) do + {:ok, context_data} -> complete_fetch(context_data, state) + {:error, reason} -> handle_info({:fetch_async_complete, {:error, reason}}, state) + end + end + + def handle_info({:fetch_async_complete, {:error, reason}}, state) do + {:noreply, mark_failed(state, reason)} + end + + @doc false + # Converts raw context data and dry-runs the index build so schema-invalid + # nested fields are reported as errors instead of crashing a context later. + def safe_context_data(data) do + context_data = Types.ContextData.from_map(data) + _ = build_indexes(context_data.experiments) + {:ok, context_data} + rescue + exception -> {:error, "Invalid context data: " <> Exception.message(exception)} + end + + defp complete_fetch(context_data, state) do + {var_index, exp_index, aud_cache} = build_indexes(context_data.experiments) + state = %{state | + data: context_data, + ready: true, + variable_index: var_index, + experiment_index: exp_index, + audience_cache: aud_cache + } + + for waiter <- state.pending_waiters do + GenServer.reply(waiter, :ok) + end + state = %{state | pending_waiters: []} + + emit_event(state, :ready, %{experiments: context_data.experiments}) + {:noreply, state} + end + + @impl true + def init({sdk_config, data, context_config}) do + init({sdk_config, data, context_config, nil}) + end + + def init({sdk_config, data, context_config, data_fetcher}) do + Process.flag(:trap_exit, true) + {var_index, exp_index, aud_cache} = build_indexes(data.experiments) + state = %__MODULE__{ + sdk_config: sdk_config, + data: data, + ready: true, + failed: false, + failed_reason: nil, + finalized: false, + finalizing: false, + units: normalize_units(context_config.units), + attributes: config_attributes_to_list(context_config.attributes), + overrides: context_config.overrides, + custom_assignments: context_config.custom_assignments, + assignments: %{}, + exposures: [], + goals: [], + variable_index: var_index, + experiment_index: exp_index, + event_handler: context_config.event_handler, + exposed_experiments: MapSet.new(), + audience_cache: aud_cache, + data_fetcher: data_fetcher, + exposure_count: 0, + goal_count: 0 + } + + Logger.info("Context initialized successfully") + emit_event(state, :ready, %{experiments: data.experiments}) + + {:ok, state} + end + + @impl true + def handle_call({:set_data, data}, _from, state) do + context_data = Types.ContextData.from_map(data) + {var_index, exp_index, aud_cache} = build_indexes(context_data.experiments) + state = %{state | + data: context_data, + ready: true, + variable_index: var_index, + experiment_index: exp_index, + audience_cache: aud_cache + } + for waiter <- state.pending_waiters do + GenServer.reply(waiter, :ok) + end + state = %{state | pending_waiters: []} + emit_event(state, :ready, %{experiments: context_data.experiments}) + {:reply, :ok, state} + end + + @impl true + def handle_call({:set_failed, reason}, _from, state) do + {:reply, :ok, mark_failed(state, reason)} + end + + @impl true + def handle_call(:ready_error, _from, state) do + {:reply, state.failed_reason, state} + end + + @impl true + def handle_call(:wait_until_ready, from, state) do + cond do + state.failed -> + {:reply, {:error, state.failed_reason}, state} + state.ready -> + {:reply, :ok, state} + true -> + {:noreply, %{state | pending_waiters: [from | state.pending_waiters]}} + end + end + + @impl true + def handle_call({:set_unit, unit_type, uid}, _from, state) do + if state.finalized do + {:reply, {:error, "ABsmartly Context is finalized."}, state} + else + key = to_string(unit_type) + + case validate_uid_or_error(uid, key) do + {:error, msg} -> + {:reply, {:error, msg}, state} + {:ok, uid_str} -> + if Map.has_key?(state.units, key) && Map.get(state.units, key) != uid_str do + {:reply, {:error, "Unit '#{key}' UID already set."}, state} + else + {:reply, :ok, %{state | units: Map.put(state.units, key, uid_str)}} + end + end + end + end + + @impl true + def handle_call({:set_units, units}, _from, state) do + if state.finalized do + {:reply, {:error, "ABsmartly Context is finalized."}, state} + else + try do + new_units = Enum.reduce(units, state.units, fn {unit_type, uid}, acc -> + key = to_string(unit_type) + + uid_str = case validate_uid_or_error(uid, key) do + {:error, msg} -> throw({:validation_error, msg}) + {:ok, str} -> str + end + + if Map.has_key?(acc, key) && Map.get(acc, key) != uid_str do + throw({:duplicate_unit, key}) + end + + Map.put(acc, key, uid_str) + end) + + {:reply, :ok, %{state | units: new_units}} + catch + {:duplicate_unit, key} -> + {:reply, {:error, "Unit '#{key}' UID already set."}, state} + {:validation_error, msg} -> + {:reply, {:error, msg}, state} + end + end + end + + @impl true + def handle_call({:get_unit, unit_type}, _from, state) do + value = Map.get(state.units, to_string(unit_type)) + {:reply, value, state} + end + + @impl true + def handle_call(:get_units, _from, state) do + {:reply, state.units, state} + end + + @impl true + def handle_call({:set_attribute, name, value}, _from, state) do + name_str = to_string(name) + entry = %{name: name_str, value: value, set_at: now_millis()} + state = %{state | attributes: state.attributes ++ [entry], attrs_seq: state.attrs_seq + 1} + {:reply, :ok, state} + end + + @impl true + def handle_call({:set_attributes, attributes}, _from, state) do + set_at = now_millis() + new_entries = Enum.map(attributes, fn {name, value} -> + %{name: to_string(name), value: value, set_at: set_at} + end) + state = %{state | attributes: state.attributes ++ new_entries, attrs_seq: state.attrs_seq + 1} + {:reply, :ok, state} + end + + @impl true + def handle_call({:get_attribute, name}, _from, state) do + name_str = to_string(name) + value = state.attributes + |> Enum.filter(fn attr -> attr.name == name_str end) + |> List.last() + |> case do + nil -> nil + attr -> attr.value + end + {:reply, value, state} + end + + @impl true + def handle_call(:get_attributes, _from, state) do + {:reply, state.attributes, state} + end + + @impl true + def handle_call({:set_override, experiment_name, variant}, _from, state) do + name = to_string(experiment_name) + state = %{ + state + | overrides: Map.put(state.overrides, name, variant), + exposed_experiments: MapSet.delete(state.exposed_experiments, name) + } + {:reply, :ok, state} + end + + @impl true + def handle_call({:set_overrides, overrides}, _from, state) do + {new_overrides, new_exposed} = Enum.reduce(overrides, {state.overrides, state.exposed_experiments}, fn {experiment_name, variant}, {ov_acc, exp_acc} -> + name = to_string(experiment_name) + {Map.put(ov_acc, name, variant), MapSet.delete(exp_acc, name)} + end) + + state = %{state | overrides: new_overrides, exposed_experiments: new_exposed} + {:reply, :ok, state} + end + + @impl true + def handle_call({:set_custom_assignment, experiment_name, variant}, _from, state) do + if state.finalized do + {:reply, {:error, "ABsmartly Context is finalized."}, state} + else + name = to_string(experiment_name) + state = %{ + state + | custom_assignments: Map.put(state.custom_assignments, name, variant), + exposed_experiments: MapSet.delete(state.exposed_experiments, name) + } + + {:reply, :ok, state} + end + end + + @impl true + def handle_call({:set_custom_assignments, assignments}, _from, state) do + if state.finalized do + {:reply, {:error, "ABsmartly Context is finalized."}, state} + else + {new_assignments, new_exposed} = Enum.reduce(assignments, {state.custom_assignments, state.exposed_experiments}, fn {experiment_name, variant}, {ca_acc, exp_acc} -> + name = to_string(experiment_name) + {Map.put(ca_acc, name, variant), MapSet.delete(exp_acc, name)} + end) + + state = %{state | custom_assignments: new_assignments, exposed_experiments: new_exposed} + {:reply, :ok, state} + end + end + + @impl true + def handle_call({:treatment, experiment_name}, _from, state) do + with_ready(state, true, fn -> + do_treatment(state, experiment_name, true) + end) + end + + @impl true + def handle_call({:peek, experiment_name}, _from, state) do + with_ready(state, true, fn -> + do_treatment(state, experiment_name, false) + end) + end + + @impl true + def handle_call({:variable_value, key, default_value}, _from, state) do + with_ready(state, true, fn -> + do_variable_value(state, key, default_value, true) + end) + end + + @impl true + def handle_call({:peek_variable_value, key, default_value}, _from, state) do + with_ready(state, true, fn -> + do_variable_value(state, key, default_value, false) + end) + end + + @impl true + def handle_call(:variable_keys, _from, state) do + with_ready(state, true, fn -> + result = Map.new(state.variable_index, fn {key, experiments} -> + {key, Enum.map(experiments, & &1.name)} + end) + {result, state} + end) + end + + @impl true + def handle_call({:custom_field_value, experiment_name, field_name}, _from, state) do + with_ready(state, true, fn -> + {do_custom_field_value(state, experiment_name, field_name), state} + end) + end + + @impl true + def handle_call(:custom_field_keys, _from, state) do + with_ready(state, true, fn -> {do_custom_field_keys(state), state} end) + end + + @impl true + def handle_call({:custom_field_value_type, experiment_name, field_name}, _from, state) do + with_ready(state, true, fn -> + {do_custom_field_value_type(state, experiment_name, field_name), state} + end) + end + + @impl true + def handle_call({:track, goal_name, properties}, _from, state) do + if state.finalized do + {:reply, {:error, "ABsmartly Context is finalized."}, state} + else + state = do_track(state, goal_name, properties) + {:reply, :ok, state} + end + end + + @impl true + def handle_call(:publish, _from, state) do + case check_ready(state, true) do + :ok -> + {result, new_state} = do_publish(state) + {:reply, result, new_state} + + error -> + {:reply, error, state} + end + end + + @impl true + def handle_call(:finalize, _from, state) do + if state.finalized or state.finalizing do + {:reply, :ok, state} + else + case do_publish(%{state | finalizing: true}) do + {:ok, new_state} -> + new_state = %{new_state | finalized: true, finalizing: false} + emit_event(new_state, :finalize, nil) + {:reply, :ok, new_state} + + # The context stays open with its events queued, so finalize/1 can be + # retried. + {{:error, _reason} = error, new_state} -> + {:reply, error, %{new_state | finalizing: false}} + end + end + end + + @impl true + def handle_call(:discard_pending, _from, state) do + {:reply, :ok, %{state | exposures: [], goals: []}} + end + + # Like the JavaScript SDK, refreshing a context whose data failed to load is a + # successful no-op: a failed context is never recovered by a refresh. + @impl true + def handle_call(:refresh, _from, state) do + case check_ready(state, true) do + :ok when state.failed -> + {:reply, :ok, state} + + :ok -> + case run_fetch(state) do + {:ok, new_data} -> + handle_call({:refresh, new_data}, nil, state) + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + + error -> + {:reply, error, state} + end + end + + @impl true + def handle_call({:refresh, new_data}, _from, state) do + case check_ready(state, true) do + :ok when state.failed -> + {:reply, :ok, state} + + :ok -> + apply_refresh(state, new_data) + + error -> + {:reply, error, state} + end + end + + @impl true + def handle_call(:is_ready, _from, state) do + {:reply, state.ready, state} + end + + @impl true + def handle_call(:is_failed, _from, state) do + {:reply, state.failed, state} + end + + @impl true + def handle_call(:is_finalized, _from, state) do + {:reply, state.finalized, state} + end + + @impl true + def handle_call(:is_finalizing, _from, state) do + {:reply, state.finalizing, state} + end + + @impl true + def handle_call(:pending, _from, state) do + {:reply, state.exposure_count + state.goal_count, state} + end + + @impl true + def handle_call(:data, _from, state) do + case check_ready(state, false) do + :ok -> {:reply, state.data, state} + error -> {:reply, error, state} + end + end + + @impl true + def handle_call(:experiments, _from, state) do + case check_ready(state, false) do + :ok -> {:reply, Enum.map(state.data.experiments, & &1.name), state} + error -> {:reply, error, state} + end + end + + defp apply_refresh(state, new_data) do + case convert_refresh_data(new_data) do + {:ok, context_data} -> + try do + {:reply, :ok, do_refresh(state, context_data)} + rescue + exception -> + {:reply, {:error, "Invalid context data: " <> Exception.message(exception)}, state} + end + + {:error, reason} -> + {:reply, {:error, reason}, state} + end + end + + # Mirrors the JavaScript SDK's `_checkReady(expectNotFinalized)`: reads before + # the context data has settled (loaded or failed) return a "not yet ready" + # error, and reads after finalize (or while finalizing) return a finalized + # error. A failed context counts as ready and reads from its empty data. + defp check_ready(state, expect_not_finalized) do + cond do + not (state.ready or state.failed) -> {:error, "ABsmartly Context is not yet ready."} + expect_not_finalized and state.finalized -> {:error, "ABsmartly Context is finalized."} + expect_not_finalized and state.finalizing -> {:error, "ABsmartly Context is finalizing."} + true -> :ok + end + end + + defp with_ready(state, expect_not_finalized, fun) do + case check_ready(state, expect_not_finalized) do + :ok -> + {result, state} = fun.() + {:reply, result, state} + + error -> + {:reply, error, state} + end + end + + @impl true + def terminate(reason, state) do + Logger.info("Context terminating: #{inspect(reason)}") + + if state.exposures != [] or state.goals != [] do + Logger.info("Context terminating with pending data, attempting final publish") + do_publish_sync(state) + end + + :ok + end + + @impl true + def handle_info({:EXIT, _pid, :normal}, state), do: {:noreply, state} + + def handle_info({:EXIT, _pid, reason}, state) do + Logger.warning("Context received EXIT signal: #{inspect(reason)}") + {:noreply, state} + end + + @impl true + def handle_info(msg, state) do + Logger.debug("Context received unexpected message: #{inspect(msg)}") + {:noreply, state} + end + + # Private helper functions + + defp validate_uid(uid, unit_type \\ "unknown") do + uid_str = to_string(uid) + + if String.length(uid_str) > @max_uid_length do + raise ArgumentError, "Unit ID exceeds maximum length of #{@max_uid_length}" + end + + uid_str + end + + # Store units as string => string so later setter comparisons are consistent + # (hash_unit/1 yields the same hash for an integer and its string form). + defp normalize_units(nil), do: %{} + + defp normalize_units(units) when is_map(units) do + # An absent (nil) UID stays absent so the experiment's unit is treated as + # missing rather than becoming a shared empty-string identity. + units + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new(fn {k, v} -> {to_string(k), to_string(v)} end) + end + + defp validate_uid_or_error(uid, unit_type) do + uid_str = to_string(uid) + + cond do + String.trim(uid_str) == "" -> + {:error, "Unit '#{unit_type}' UID must not be blank."} + String.length(uid_str) > @max_uid_length -> + {:error, "Unit ID exceeds maximum length of #{@max_uid_length}"} + true -> + {:ok, uid_str} + end + end + + defp config_attributes_to_list(nil), do: [] + defp config_attributes_to_list(attrs) when is_map(attrs) do + set_at = now_millis() + Enum.map(attrs, fn {name, value} -> + %{name: to_string(name), value: value, set_at: set_at} + end) + end + defp config_attributes_to_list(attrs) when is_list(attrs) do + set_at = now_millis() + Enum.flat_map(attrs, fn + %{"name" => name, "value" => value} -> [%{name: name, value: value, set_at: set_at}] + {name, value} -> [%{name: to_string(name), value: value, set_at: set_at}] + _ -> [] + end) + end + + defp do_treatment(state, experiment_name, queue_exposure) do + override = Map.get(state.overrides, experiment_name) + experiment = Map.get(state.experiment_index, experiment_name) + + case {experiment, override} do + {exp, _} when not is_nil(exp) -> + do_treatment_for_experiment(state, experiment_name, exp, queue_exposure) + + {nil, ov} when not is_nil(ov) -> + do_treatment_for_override(state, experiment_name, ov, queue_exposure) + + {nil, nil} -> + do_treatment_for_unknown(state, experiment_name, queue_exposure) + end + end + + defp do_treatment_for_experiment(state, experiment_name, experiment, queue_exposure) do + {assignment, state} = get_or_assign(state, experiment) + + state = + if queue_exposure do + exposure = create_exposure(experiment, assignment) + queue_exposure(state, experiment_name, exposure) + else + state + end + + {assignment.variant, state} + end + + defp do_treatment_for_override(state, experiment_name, override, queue_exposure) do + state = + if queue_exposure do + exposure = %Types.Exposure{ + id: 0, + name: experiment_name, + unit: nil, + variant: override, + exposed_at: now_millis(), + assigned: false, + eligible: true, + overridden: true, + full_on: false, + custom: false, + audience_mismatch: false + } + + queue_exposure(state, experiment_name, exposure) + else + state + end + + {override, state} + end + + defp do_treatment_for_unknown(state, experiment_name, queue_exposure) do + state = + if queue_exposure do + exposure = %Types.Exposure{ + id: 0, + name: experiment_name, + unit: nil, + variant: 0, + exposed_at: now_millis(), + assigned: false, + eligible: true, + overridden: false, + full_on: false, + custom: false, + audience_mismatch: false + } + + queue_exposure(state, experiment_name, exposure) + else + state + end + + {0, state} + end + + defp queue_exposure(state, experiment_name, exposure) do + unless MapSet.member?(state.exposed_experiments, experiment_name) do + state = + if state.exposure_count >= @max_queue_size do + Logger.error( + "Exposure queue size limit reached (#{@max_queue_size}), dropping oldest" + ) + + %{state | exposures: Enum.drop(state.exposures, -1), exposure_count: state.exposure_count - 1} + else + state + end + + state = %{ + state + | exposures: [exposure | state.exposures], + exposed_experiments: MapSet.put(state.exposed_experiments, experiment_name), + exposure_count: state.exposure_count + 1 + } + + emit_event(state, :exposure, exposure) + state + else + state + end + end + + defp do_variable_value(state, key, default_value, queue_exposure) do + experiments = Map.get(state.variable_index, key, []) + + {result, state} = + Enum.reduce(experiments, {nil, state}, fn + _exp, {found, acc_state} when found != nil -> + {found, acc_state} + + exp, {found, acc_state} -> + {assignment, acc_state} = get_or_assign(acc_state, exp) + + acc_state = + if queue_exposure do + exposure = create_exposure(exp, assignment) + queue_exposure(acc_state, exp.name, exposure) + else + acc_state + end + + if found == nil && (assignment.assigned || assignment.overridden) do + variant_data = Enum.at(exp.variants, assignment.variant) + + if variant_data do + config = parse_variant_config(variant_data["config"]) + + if is_map(config) && Map.has_key?(config, key) do + {{exp, assignment, config[key]}, acc_state} + else + {found, acc_state} + end + else + {found, acc_state} + end + else + {found, acc_state} + end + end) + + case result do + {_experiment, _assignment, value} -> + {value, state} + + nil -> + {default_value, state} + end + end + + defp parse_variant_config(nil), do: nil + defp parse_variant_config(config) when is_map(config), do: config + + defp parse_variant_config(config) when is_binary(config) do + case Jason.decode(config) do + {:ok, decoded} when is_map(decoded) -> + decoded + + {:ok, decoded} -> + Logger.warning("Variant config decoded but not a map: #{inspect(decoded)}") + nil + + {:error, error} -> + Logger.error( + "Failed to parse variant config JSON: #{inspect(error)}, config: #{config}" + ) + + nil + end + end + + defp parse_variant_config(_), do: nil + + defp do_custom_field_value(state, experiment_name, field_name) do + experiment = Map.get(state.experiment_index, experiment_name) + + if experiment && experiment.custom_field_values do + custom_field = + Enum.find(experiment.custom_field_values, fn field -> + field["name"] == field_name + end) + + if custom_field do + parse_custom_field(custom_field["value"], custom_field["type"]) + end + end + end + + defp do_custom_field_keys(state) do + state.experiment_index + |> Map.values() + |> Enum.flat_map(fn experiment -> + if experiment.custom_field_values do + Enum.map(experiment.custom_field_values, & &1["name"]) + else + [] + end + end) + |> Enum.uniq() + end + + defp do_custom_field_value_type(state, experiment_name, field_name) do + experiment = Map.get(state.experiment_index, experiment_name) + + if experiment && experiment.custom_field_values do + custom_field = + Enum.find(experiment.custom_field_values, fn field -> + field["name"] == field_name + end) + + if custom_field do + custom_field["type"] + end + end + end + + defp parse_custom_field(value, "string"), do: value + defp parse_custom_field(value, "text"), do: value + defp parse_custom_field(value, "number"), do: Utils.to_number(value) + defp parse_custom_field(value, "boolean"), do: value == "true" or value == true + + defp parse_custom_field(value, "json") when not is_binary(value), do: nil + + defp parse_custom_field(value, "json") do + case Jason.decode(value) do + {:ok, decoded} -> + decoded + + {:error, error} -> + Logger.error( + "Failed to parse custom field JSON: #{inspect(error)}, value: #{value}" + ) + + nil + end + end + + defp parse_custom_field(value, _type), do: value + + defp do_track(state, goal_name, properties) do + sanitized_properties = + case properties do + map when is_map(map) -> map + nil -> nil + _other -> nil + end + + goal = %Types.Goal{ + name: goal_name, + achieved_at: now_millis(), + properties: sanitized_properties + } + + state = + if state.goal_count >= @max_queue_size do + Logger.error("Goal queue size limit reached (#{@max_queue_size}), dropping oldest") + %{state | goals: Enum.drop(state.goals, -1), goal_count: state.goal_count - 1} + else + state + end + + state = %{state | goals: [goal | state.goals], goal_count: state.goal_count + 1} + emit_event(state, :goal, goal) + state + end + + # Sends the queued exposures and goals to the publisher. The queues are only + # cleared once the publisher accepts them; on any failure they are kept for + # the next publish/finalize, an `:error` event is emitted and + # `{:error, reason}` is returned. The `:publish` event is emitted only after + # the publisher succeeds. + # + # The GenServer is blocked for the duration of the call, so no events can be + # queued in between and the state can be used as-is on either outcome. + defp do_publish(state) do + if state.exposures != [] or state.goals != [] do + event_map = build_publish_event_map(state) + + case run_publisher(state, event_map) do + :ok -> + Logger.info("Successfully published #{state.exposure_count} exposures and #{state.goal_count} goals") + emit_event(state, :publish, event_map) + {:ok, %{state | exposures: [], goals: [], exposure_count: 0, goal_count: 0}} + + {:error, reason} -> + Logger.error("Failed to publish events, keeping them queued: #{inspect(reason)}") + emit_event(state, :error, reason) + {{:error, reason}, state} + end + else + {:ok, state} + end + end + + @doc """ + Supervisor shutdown budget (ms) so a context can finish its bounded final publish. + """ + def shutdown_timeout(sdk_config) do + publisher = Map.get(sdk_config, :publisher) || ABSmartly.DefaultContextPublisher + capped = Map.put(sdk_config, :retries, min(Map.get(sdk_config, :retries) || 0, 1)) + publish_deadline(capped, publisher) + 2_000 + end + + # Worst case for the default HTTP publisher: every attempt may spend the + # connect plus receive timeout, plus the exponential backoff between attempts. + defp publish_deadline(sdk_config) do + retries = min(Map.get(sdk_config, :retries) || 0, 10) + timeout = Map.get(sdk_config, :timeout) || 30_000 + backoff = 50 * (:erlang.bsl(1, retries) - 1) + (retries + 1) * 2 * timeout + backoff + 1_000 + end + + # Custom publishers that only implement publish/6 carry no timeout budget, so + # keep the historical 5s floor for them; the HTTP-derived deadline is a floor too. + defp publish_deadline(sdk_config, publisher) do + Code.ensure_loaded(publisher) + derived = publish_deadline(sdk_config) + + if function_exported?(publisher, :publish, 7), do: derived, else: max(derived, 5_000) + end + + defp call_publisher(publisher, c, event_map) do + Code.ensure_loaded(publisher) + + if function_exported?(publisher, :publish, 7) do + publisher.publish(c.endpoint, c.api_key, c.application, c.environment, event_map, c.retries, c.timeout) + else + publisher.publish(c.endpoint, c.api_key, c.application, c.environment, event_map, c.retries) + end + end + + # Runs the refresh fetch in a task so the context can still react to the + # supervisor's shutdown signal while HTTP retries/backoff are in progress. + defp run_fetch(state) do + task = + Task.async(fn -> + try do + if state.data_fetcher do + state.data_fetcher.() + else + ABSmartly.HTTP.Client.fetch_context( + state.sdk_config.endpoint, + state.sdk_config.api_key, + state.sdk_config.application, + state.sdk_config.environment, + state.sdk_config.retries, + state.sdk_config.timeout + ) + end + rescue + exception -> {:error, Exception.message(exception)} + catch + kind, reason -> {:error, {kind, reason}} + end + end) + + case await_task(task, publish_deadline(state.sdk_config)) do + {:ok, {:ok, _} = ok} -> ok + {:ok, {:error, _} = error} -> error + {:ok, other} -> {:error, {:unexpected_fetch_result, other}} + {:exit, reason} -> {:error, {:exit, reason}} + nil -> {:error, "Context refresh timed out"} + end + end + + defp run_publisher(state, event_map) do + publisher = Map.get(state.sdk_config, :publisher, ABSmartly.DefaultContextPublisher) + + task = + Task.async(fn -> + try do + call_publisher(publisher, state.sdk_config, event_map) + rescue + exception -> {:error, Exception.message(exception)} + catch + kind, reason -> {:error, {kind, reason}} + end + end) + + await_publisher(task, publish_deadline(state.sdk_config, publisher)) + end + + # Waits for the task, but also reacts to the supervisor's shutdown signal (we + # trap exits) so in-flight work cannot outlive the child shutdown budget: the + # task is killed and the shutdown reason is re-raised so terminate/2 runs its + # bounded final publish. + defp await_task(%Task{ref: ref, pid: task_pid} = task, timeout) do + parent = parent_pid() + + result = + receive do + {^ref, result} -> + Process.demonitor(ref, [:flush]) + {:ok, result} + + {:DOWN, ^ref, _, _, reason} -> + {:exit, reason} + + {:EXIT, ^parent, reason} when parent != nil -> + Task.shutdown(task, :brutal_kill) + exit(reason) + after + timeout -> Task.shutdown(task, :brutal_kill) + end + + flush_task_exit(task_pid) + result + end + + defp await_publisher(task, timeout) do + case await_task(task, timeout) do + {:ok, result} -> normalize_publish_result(result) + {:exit, reason} -> {:error, {:exit, reason}} + nil -> {:error, "Publish timed out"} + end + end + + defp normalize_publish_result(:ok), do: :ok + defp normalize_publish_result({:error, _reason} = error), do: error + defp normalize_publish_result(other), do: {:error, {:unexpected_publisher_result, other}} + + defp flush_task_exit(task_pid) do + receive do + {:EXIT, ^task_pid, _} -> :ok + after + 0 -> :ok + end + end + + defp parent_pid do + case Process.get(:"$ancestors") do + [parent | _] when is_pid(parent) -> parent + [name | _] when is_atom(name) -> Process.whereis(name) + _ -> nil + end + end + + defp do_publish_sync(state) do + if state.exposures != [] or state.goals != [] do + event_map = build_publish_event_map(state) + sync_state = %{state | sdk_config: %{state.sdk_config | retries: min(state.sdk_config.retries, 1)}} + + case run_publisher(sync_state, event_map) do + :ok -> + Logger.info("Successfully published pending data on terminate") + + {:error, reason} -> + Logger.error("Failed to publish on terminate: #{inspect(reason)}") + end + end + end + + defp build_publish_event_map(state) do + hashed_units = + Enum.map(state.units, fn {unit_type, uid} -> + %{ + "type" => unit_type, + "uid" => Utils.hash_unit(uid) + } + end) + + attrs_list = Enum.map(state.attributes, fn attr -> + %{"name" => attr.name, "value" => attr.value, "setAt" => attr.set_at} + end) + + publish_event = %Types.PublishEvent{ + hashed: true, + published_at: now_millis(), + units: hashed_units, + exposures: Enum.reverse(state.exposures), + goals: Enum.reverse(state.goals), + attributes: attrs_list + } + + Types.PublishEvent.to_map(publish_event) + end + + defp do_refresh(state, %Types.ContextData{} = new_data) do + do_refresh_with_context_data(state, new_data) + end + + defp convert_refresh_data(%Types.ContextData{} = data), do: {:ok, data} + defp convert_refresh_data(data), do: safe_context_data(data) + + defp do_refresh_with_context_data(state, context_data) do + changed_names = changed_experiment_names(state.data.experiments, context_data.experiments) + + assignments = + invalidate_changed_assignments( + state.assignments, + state.data.experiments, + context_data.experiments + ) + + {var_index, exp_index, aud_cache} = build_indexes(context_data.experiments) + + exposed_experiments = Enum.reduce(changed_names, state.exposed_experiments, fn name, acc -> + if Map.has_key?(state.overrides, name) do + acc + else + MapSet.delete(acc, name) + end + end) + + state = %{ + state + | data: context_data, + assignments: assignments, + variable_index: var_index, + experiment_index: exp_index, + audience_cache: aud_cache, + exposed_experiments: exposed_experiments + } + + Logger.info("Context refreshed with #{length(context_data.experiments)} experiments") + emit_event(state, :refresh, %{experiments: context_data.experiments}) + state + end + + defp get_or_assign(state, experiment) do + name = experiment.name + override = Map.get(state.overrides, name) + + if override != nil do + assignment = %Types.Assignment{ + id: experiment.id, + iteration: experiment.iteration, + full_on_variant: experiment.full_on_variant, + traffic_split: experiment.traffic_split, + variant: override, + assigned: false, + overridden: true, + eligible: true, + full_on: false, + custom: false, + audience_mismatch: false + } + + {assignment, state} + else + cached = Map.get(state.assignments, name) + + {cached, state} = maybe_reassign_for_audience(cached, state, experiment, name) + + if cached != nil do + custom = Map.get(state.custom_assignments, name) + + if custom_applies?(custom, cached) do + custom_assignment = %{cached | variant: custom, custom: true} + {custom_assignment, state} + else + {cached, state} + end + else + assignment = assign_variant(state, experiment) + state = %{state | assignments: Map.put(state.assignments, name, assignment)} + + custom = Map.get(state.custom_assignments, name) + + if custom_applies?(custom, assignment) do + custom_assignment = %Types.Assignment{ + assignment + | variant: custom, + custom: true + } + + {custom_assignment, state} + else + {assignment, state} + end + end + end + end + + # As in the JavaScript SDK, a custom assignment only replaces a randomized + # assignment: not a full-on, traffic-excluded, missing-unit or strict + # audience-excluded one (those are all unassigned or ineligible). + defp custom_applies?(custom, assignment), + do: custom != nil and assignment.assigned and assignment.eligible and not assignment.full_on + + defp maybe_reassign_for_audience(nil, state, _experiment, _name), do: {nil, state} + defp maybe_reassign_for_audience(cached, state, experiment, name) do + has_audience = experiment.audience != nil and experiment.audience != "" + + if has_audience and cached.audience_match_seq < state.attrs_seq do + # An indeterminate (nil) audience result keeps the cached flag, like the + # JavaScript SDK's `audienceMatches`. + new_mismatch = + case audience_result(state, experiment) do + nil -> cached.audience_mismatch + result -> not result + end + + if new_mismatch != cached.audience_mismatch do + new_assignment = assign_variant(state, experiment) + state = %{state | + assignments: Map.put(state.assignments, name, new_assignment), + exposed_experiments: MapSet.delete(state.exposed_experiments, name) + } + {new_assignment, state} + else + updated = %{cached | audience_match_seq: state.attrs_seq} + state = %{state | assignments: Map.put(state.assignments, name, updated)} + {updated, state} + end + else + {cached, state} + end + end + + # Same decision order as the JavaScript SDK's `_assign` (context.ts): the + # audience is evaluated first, a strict mismatch forces variant 0, a full-on + # experiment is assigned its full-on variant even without a unit, and only a + # running experiment needs the unit for the traffic and variant split. + defp assign_variant(state, experiment) do + unit_type = experiment.unit_type || "session_id" + uid = Map.get(state.units, unit_type) + base = %{base_assignment(experiment) | audience_match_seq: state.attrs_seq} + + audience_mismatch = + case audience_result(state, experiment) do + false -> true + _ -> false + end + + base = %{base | audience_mismatch: audience_mismatch} + + cond do + audience_mismatch and experiment.audience_strict == true -> + base + + full_on?(experiment) -> + %{base | variant: experiment.full_on_variant, assigned: true, full_on: true} + + is_nil(uid) -> + base + + true -> + hashed_unit = Utils.hash_unit(uid) + + if traffic_eligible?(hashed_unit, experiment) do + variant = + VariantAssigner.assign( + hashed_unit, + experiment.split, + experiment.seed_hi, + experiment.seed_lo + ) + + %{base | variant: variant, assigned: true} + else + %{base | assigned: true, eligible: false} + end + end + end + + defp base_assignment(experiment) do + %Types.Assignment{ + id: experiment.id, + iteration: experiment.iteration, + full_on_variant: experiment.full_on_variant, + traffic_split: experiment.traffic_split, + variant: 0, + assigned: false, + overridden: false, + eligible: true, + full_on: false, + custom: false, + audience_mismatch: false + } + end + + # true / false, or nil when the audience cannot be evaluated (no filter, + # invalid JSON or an invalid filter), which the JavaScript SDK treats as + # neither a match nor a mismatch. + defp audience_result(state, experiment) do + case experiment.audience do + nil -> nil + "" -> nil + _audience -> + attrs_list = Enum.map(state.attributes, fn attr -> + %{"name" => attr.name, "value" => attr.value} + end) + + Matcher.evaluate(Map.get(state.audience_cache, experiment.name), attrs_list) + end + end + + defp full_on?(experiment) do + experiment.full_on_variant != nil && experiment.full_on_variant > 0 + end + + defp traffic_eligible?(hashed_unit, experiment) do + if experiment.traffic_split && length(experiment.traffic_split) > 1 do + traffic_variant = + VariantAssigner.assign( + hashed_unit, + experiment.traffic_split, + experiment.traffic_seed_hi || 0, + experiment.traffic_seed_lo || 0 + ) + + traffic_variant != 0 + else + true + end + end + + defp parse_audience(nil), do: nil + defp parse_audience(""), do: nil + defp parse_audience("null"), do: nil + defp parse_audience("{}"), do: %{} + + defp parse_audience(audience) when is_binary(audience) do + case Jason.decode(audience) do + {:ok, decoded} -> + decoded + + {:error, error} -> + Logger.error( + "Failed to parse audience JSON: #{inspect(error)}, audience: #{audience}" + ) + + %{"invalid" => true} + end + end + + defp parse_audience(audience) when is_map(audience), do: audience + defp parse_audience(_), do: nil + + defp create_exposure(experiment, assignment) do + unit_type = experiment.unit_type || "session_id" + + %Types.Exposure{ + id: experiment.id, + name: experiment.name, + unit: unit_type, + variant: assignment.variant, + exposed_at: now_millis(), + assigned: assignment.assigned, + eligible: assignment.eligible, + overridden: assignment.overridden, + full_on: assignment.full_on, + custom: assignment.custom, + audience_mismatch: assignment.audience_mismatch + } + end + + defp build_indexes(experiments) do + exp_index = Enum.into(experiments, %{}, fn exp -> {exp.name, exp} end) + + aud_cache = Enum.reduce(experiments, %{}, fn experiment, cache -> + case experiment.audience do + nil -> cache + "" -> cache + audience -> + parsed = parse_audience(audience) + if parsed, do: Map.put(cache, experiment.name, parsed), else: cache + end + end) + + var_index = Enum.reduce(experiments, %{}, fn experiment, index -> + keys_in_experiment = Enum.reduce(experiment.variants || [], MapSet.new(), fn variant, keys -> + config = parse_variant_config(variant["config"]) || %{} + Enum.reduce(Map.keys(config), keys, &MapSet.put(&2, &1)) + end) + + Enum.reduce(keys_in_experiment, index, fn key, idx -> + Map.update(idx, key, [experiment], fn exps -> [experiment | exps] end) + end) + end) + + reversed_var_index = Map.new(var_index, fn {key, exps} -> {key, Enum.reverse(exps)} end) + + {reversed_var_index, exp_index, aud_cache} + end + + # Assignments are sticky across a refresh, as in the JavaScript SDK's + # `experimentMatches` (context.ts): only a change of id, iteration, + # fullOnVariant, trafficSplit or unitType invalidates a cached assignment. + # Seeds, split, variant configs and audience edits deliberately do not, so a + # refresh never re-buckets a user or queues a duplicate exposure for them. + defp changed_experiment_names(old_experiments, new_experiments) do + old_exp_map = Enum.into(old_experiments, %{}, fn exp -> {exp.name, exp} end) + new_exp_map = Enum.into(new_experiments, %{}, fn exp -> {exp.name, exp} end) + all_names = MapSet.union(MapSet.new(Map.keys(old_exp_map)), MapSet.new(Map.keys(new_exp_map))) + + Enum.filter(all_names, fn name -> + old_exp = Map.get(old_exp_map, name) + new_exp = Map.get(new_exp_map, name) + + cond do + is_nil(old_exp) || is_nil(new_exp) -> true + old_exp.id != new_exp.id -> true + old_exp.iteration != new_exp.iteration -> true + old_exp.full_on_variant != new_exp.full_on_variant -> true + old_exp.traffic_split != new_exp.traffic_split -> true + old_exp.unit_type != new_exp.unit_type -> true + true -> false + end + end) + end + + defp invalidate_changed_assignments(assignments, old_experiments, new_experiments) do + old_exp_map = Enum.into(old_experiments, %{}, fn exp -> {exp.name, exp} end) + new_exp_map = Enum.into(new_experiments, %{}, fn exp -> {exp.name, exp} end) + + Enum.reduce(assignments, %{}, fn {name, assignment}, acc -> + old_exp = Map.get(old_exp_map, name) + new_exp = Map.get(new_exp_map, name) + + keep = + cond do + assignment.overridden -> true + is_nil(old_exp) && is_nil(new_exp) -> true + is_nil(old_exp) -> false + is_nil(new_exp) -> false + old_exp.id != new_exp.id -> false + old_exp.iteration != new_exp.iteration -> false + old_exp.full_on_variant != new_exp.full_on_variant -> false + old_exp.traffic_split != new_exp.traffic_split -> false + old_exp.unit_type != new_exp.unit_type -> false + true -> true + end + + if keep do + Map.put(acc, name, assignment) + else + acc + end + end) + end + + # Marks the context as failed to load, wakes up wait_until_ready/2 callers + # with the reason, and reports it through the `:error` event. + defp mark_failed(state, reason) do + for waiter <- state.pending_waiters do + GenServer.reply(waiter, {:error, reason}) + end + + # Like the JS SDK, a failed context is ready with empty data so reads + # return control/default values. + state = %{state | + failed: true, + ready: true, + failed_reason: reason, + pending_waiters: [], + data: %Types.ContextData{experiments: []}, + variable_index: %{}, + experiment_index: %{}, + audience_cache: %{} + } + emit_event(state, :error, reason) + state + end + + defp emit_event(state, event_type, data) do + if state.event_handler do + # Deliver synchronously so events are observable immediately after the + # operation that produced them returns (e.g. track() -> goal event). + try do + state.event_handler.(event_type, data) + rescue + exception -> + Logger.error(""" + Event handler crashed for event #{event_type} + Exception: #{Exception.format(:error, exception, __STACKTRACE__)} + Data: + """) + catch + kind, reason -> + Logger.error("Event handler for #{event_type} exited with #{inspect(kind)}: #{inspect(reason)}") + end + end + + :ok + end + + defp now_millis do + System.system_time(:millisecond) + end +end diff --git a/lib/absmartly/context_publisher.ex b/lib/absmartly/context_publisher.ex new file mode 100644 index 0000000..b962cf0 --- /dev/null +++ b/lib/absmartly/context_publisher.ex @@ -0,0 +1,14 @@ +defmodule ABSmartly.ContextPublisher do + @moduledoc """ + Behaviour for publishing context events to the ABSmartly collector. + """ + + @callback publish( + endpoint :: String.t(), + api_key :: String.t(), + application :: String.t(), + environment :: String.t(), + event_map :: map(), + retries :: non_neg_integer() + ) :: :ok | {:error, term()} +end diff --git a/lib/absmartly/default_context_publisher.ex b/lib/absmartly/default_context_publisher.ex new file mode 100644 index 0000000..059342e --- /dev/null +++ b/lib/absmartly/default_context_publisher.ex @@ -0,0 +1,21 @@ +defmodule ABSmartly.DefaultContextPublisher do + @moduledoc """ + Default implementation of ContextPublisher that sends events via HTTP. + """ + + @behaviour ABSmartly.ContextPublisher + + alias ABSmartly.HTTP + + @impl ABSmartly.ContextPublisher + def publish(endpoint, api_key, application, environment, event_map, retries) do + HTTP.Client.publish_events(endpoint, api_key, application, environment, event_map, retries) + end + + @doc """ + Like `publish/6` but also applies the SDK request timeout (ms) to each HTTP request. + """ + def publish(endpoint, api_key, application, environment, event_map, retries, timeout) do + HTTP.Client.publish_events(endpoint, api_key, application, environment, event_map, retries, timeout) + end +end diff --git a/lib/absmartly/http/client.ex b/lib/absmartly/http/client.ex new file mode 100644 index 0000000..1358d27 --- /dev/null +++ b/lib/absmartly/http/client.ex @@ -0,0 +1,149 @@ +defmodule ABSmartly.HTTP.Client do + @moduledoc """ + HTTP client for ABSmartly API with retry logic. + + Fixes: + - CRITICAL-07: Rescue Jason.encode! errors + - CRITICAL-08: Handle 3xx redirects + - CRITICAL-16: Deduplicate HTTP retry logic + - HIGH-25: Deduplicate header construction + """ + + require Logger + + @default_timeout 30_000 + + @doc """ + Fetch context data from API endpoint. + + Implements retry logic: + - Retry on network errors and 5xx status codes + - Don't retry on 4xx status codes + - Exponential backoff: 50ms × 2^attempt + """ + def fetch_context(endpoint, api_key, application, environment, retries \\ 3, timeout \\ @default_timeout) do + url = "#{endpoint}/context" + headers = build_headers(api_key, application, environment) + + query_params = %{ + "application" => application, + "environment" => environment + } + + request_fn = fn -> + query_string = URI.encode_query(query_params) + full_url = "#{url}?#{query_string}" + HTTPoison.get(full_url, headers, request_options(timeout)) + end + + case with_retry(request_fn, retries) do + {:ok, body} -> + case Jason.decode(body) do + {:ok, data} -> {:ok, data} + {:error, _} -> {:error, "Invalid JSON response"} + end + + error -> + error + end + end + + @doc """ + Publish events to API endpoint. + Fixes CRITICAL-07: Rescue Jason.encode! errors. + """ + def publish_events(endpoint, api_key, application, environment, events, retries \\ 3, timeout \\ @default_timeout) do + url = "#{endpoint}/context" + headers = build_headers(api_key, application, environment) + + case Jason.encode(events) do + {:ok, body} -> + request_fn = fn -> + HTTPoison.put(url, body, headers, request_options(timeout)) + end + + case with_retry(request_fn, retries) do + {:ok, _body} -> :ok + error -> error + end + + {:error, %Jason.EncodeError{} = error} -> + Logger.error("Failed to encode events: #{Exception.message(error)}") + {:error, "Failed to encode events: #{Exception.message(error)}"} + end + end + + # Direct (unpooled) connections: the SDK makes few requests, and tasks that are + # cancelled mid-request (shutdown, publish deadline) must not leak slots in + # Hackney's shared pool / per-host load regulation, which surfaces as spurious + # :checkout_timeout errors. Redirects are deliberately not followed (a + # redirected event PUT must never be acknowledged). + defp request_options(timeout) do + [timeout: timeout, recv_timeout: timeout, hackney: [pool: false]] + end + + # Fixes HIGH-25: Extract common header construction + defp build_headers(api_key, application, environment) do + [ + {"X-API-Key", api_key}, + {"X-Application", application}, + {"X-Environment", environment}, + {"X-Application-Version", "0"}, + {"X-Agent", "absmartly-elixir-sdk"}, + {"Content-Type", "application/json"} + ] + end + + # Fixes CRITICAL-16: Unified retry logic for GET and POST + # Fixes CRITICAL-08: Handle 3xx redirects + # Fixes CRITICAL-05: Sanitize HTTPoison errors to prevent API key exposure + defp with_retry(request_fn, max_retries, attempt \\ 0) + + defp with_retry(request_fn, max_retries, attempt) when max_retries > 10 do + with_retry(request_fn, 10, attempt) + end + + defp with_retry(request_fn, max_retries, attempt) do + case request_fn.() do + {:ok, %HTTPoison.Response{status_code: status, body: body}} + when status >= 200 and status < 300 -> + {:ok, body} + + # Fixes CRITICAL-08: Handle 3xx redirects + {:ok, %HTTPoison.Response{status_code: status}} when status >= 300 and status < 400 -> + Logger.warning("Redirect received: #{status}") + {:error, "Redirect received: #{status}"} + + {:ok, %HTTPoison.Response{status_code: status}} when status >= 400 and status < 500 -> + Logger.error("Client error: #{status}") + {:error, "Client error: #{status}"} + + {:ok, %HTTPoison.Response{status_code: status}} when status >= 500 -> + retry_or_fail(request_fn, max_retries, attempt, "Server error: #{status}") + + {:error, %HTTPoison.Error{reason: reason}} -> + # Fixes CRITICAL-05: Sanitize error to prevent API key exposure + sanitized_reason = sanitize_error(reason) + retry_or_fail(request_fn, max_retries, attempt, "Network error: #{inspect(sanitized_reason)}") + end + end + + defp retry_or_fail(request_fn, max_retries, attempt, error_msg) do + if attempt < max_retries do + backoff = Application.get_env(:absmartly, :retry_backoff_base, 50) * :math.pow(2, attempt) + Logger.info("Retrying request after #{round(backoff)}ms (attempt #{attempt + 1}/#{max_retries})") + Process.sleep(round(backoff)) + with_retry(request_fn, max_retries, attempt + 1) + else + Logger.error(error_msg) + {:error, error_msg} + end + end + + # Fixes CRITICAL-05: Sanitize HTTPoison errors to remove headers with API keys + defp sanitize_error(%{headers: _headers} = map) do + Map.drop(map, [:headers]) + end + + defp sanitize_error(other), do: other +end diff --git a/lib/absmartly/json_expr/evaluator.ex b/lib/absmartly/json_expr/evaluator.ex new file mode 100644 index 0000000..6e0beef --- /dev/null +++ b/lib/absmartly/json_expr/evaluator.ex @@ -0,0 +1,297 @@ +defmodule ABSmartly.JSONExpr.Evaluator do + @moduledoc """ + JSON expression evaluator for audience targeting. + + Mirrors the JavaScript SDK's `jsonexpr` (src/jsonexpr): the same operators, + the same type conversions and the same `null` results. Supported operators: + and, or, not, null, var, value, eq, gt, gte, lt, lte, in, match, plus + `contains` (an alias of `in`, haystack first). + + - A list expression is an implicit `and`; a map expression is dispatched on + its operator key; anything else evaluates to `nil`. + - Binary operators return `nil` when either operand is `nil`. + - `in`/`contains` take `[haystack, needle]`: a list haystack matches an + element that compares equal, a string haystack matches a substring (the + needle is converted to a string) and a map haystack matches a key. Any + other haystack gives `nil`. + - `match` gives `nil` for an invalid (or over-long) regular expression. + """ + + require Logger + + @max_regex_length 1000 + @regex_timeout_ms 100 + + @binary_operators ~w(eq gt gte lt lte in contains match) + + @doc """ + Evaluate a JSON expression with given variables. + + ## Examples + + iex> evaluate(%{"value" => true}, %{}) + true + + iex> evaluate(%{"var" => "age"}, %{"age" => 25}) + 25 + + iex> evaluate(%{"eq" => [%{"var" => "age"}, %{"value" => 25}]}, %{"age" => 25}) + true + """ + def evaluate(expr, vars) when is_list(expr), do: eval_and(expr, vars) + + def evaluate(expr, vars) when is_map(expr) and map_size(expr) > 0 do + [{op, args} | _] = Map.to_list(expr) + + case op do + "and" -> eval_and(args, vars) + "or" -> eval_or(args, vars) + "not" -> not boolean_convert(evaluate(args, vars)) + "null" -> evaluate(args, vars) == nil + "var" -> eval_var(args, vars) + "value" -> args + op when op in @binary_operators -> eval_binary(op, args, vars) + _ -> + Logger.warning("Unknown operator in expression: #{inspect(op)}") + nil + end + end + + def evaluate(_expr, _vars), do: nil + + defp eval_and(exprs, vars) when is_list(exprs) do + Enum.all?(exprs, &boolean_convert(evaluate(&1, vars))) + end + + defp eval_and(_exprs, _vars), do: nil + + defp eval_or([], _vars), do: true + + defp eval_or(exprs, vars) when is_list(exprs) do + Enum.any?(exprs, &boolean_convert(evaluate(&1, vars))) + end + + defp eval_or(_exprs, _vars), do: nil + + defp eval_var(%{"path" => path}, vars), do: eval_var(path, vars) + + defp eval_var(path, vars) when is_binary(path) do + path + |> String.split("/") + |> get_nested(vars || %{}) + end + + defp eval_var(_path, _vars), do: nil + + defp get_nested([], value), do: value + + defp get_nested([key | rest], value) when is_map(value) do + get_nested(rest, Map.get(value, key)) + end + + defp get_nested([key | rest], value) when is_list(value) do + case Integer.parse(key) do + {index, ""} when index >= 0 and index < length(value) -> + get_nested(rest, Enum.at(value, index)) + + _ -> + nil + end + end + + defp get_nested(_path, _value), do: nil + + # Binary operators evaluate the left operand first and return nil without + # evaluating the right one when it is nil (JS operators/binary.ts). + defp eval_binary(op, args, vars) when is_list(args) do + lhs = evaluate(Enum.at(args, 0), vars) + + if lhs == nil do + nil + else + rhs = evaluate(Enum.at(args, 1), vars) + if rhs == nil, do: nil, else: binary(op, lhs, rhs) + end + end + + defp eval_binary(_op, _args, _vars), do: nil + + defp binary("eq", lhs, rhs), do: compare_result(compare(lhs, rhs), &(&1 == 0)) + defp binary("gt", lhs, rhs), do: compare_result(compare(lhs, rhs), &(&1 > 0)) + defp binary("gte", lhs, rhs), do: compare_result(compare(lhs, rhs), &(&1 >= 0)) + defp binary("lt", lhs, rhs), do: compare_result(compare(lhs, rhs), &(&1 < 0)) + defp binary("lte", lhs, rhs), do: compare_result(compare(lhs, rhs), &(&1 <= 0)) + defp binary("in", haystack, needle), do: eval_in(haystack, needle) + defp binary("contains", haystack, needle), do: eval_in(haystack, needle) + defp binary("match", text, pattern), do: eval_match(text, pattern) + + defp compare_result(nil, _fun), do: nil + defp compare_result(cmp, fun), do: fun.(cmp) + + defp eval_in(haystack, needle) when is_list(haystack) do + Enum.any?(haystack, &(compare(&1, needle) == 0)) + end + + defp eval_in(haystack, needle) when is_binary(haystack) do + case string_convert(needle) do + nil -> false + needle_string -> String.contains?(haystack, needle_string) + end + end + + defp eval_in(haystack, needle) when is_map(haystack) do + case string_convert(needle) do + nil -> false + needle_string -> Map.has_key?(haystack, needle_string) + end + end + + defp eval_in(_haystack, _needle), do: nil + + defp eval_match(text, pattern) do + with text when is_binary(text) <- string_convert(text), + pattern when is_binary(pattern) <- string_convert(pattern) do + run_match(text, pattern) + else + _ -> nil + end + end + + defp run_match(text, pattern) do + if String.length(pattern) > @max_regex_length do + Logger.warning( + "Regex pattern too long (#{String.length(pattern)}), rejecting: #{String.slice(pattern, 0, 100)}..." + ) + + nil + else + case Regex.compile(pattern) do + {:ok, regex} -> + task = Task.async(fn -> Regex.match?(regex, text) end) + + case Task.yield(task, @regex_timeout_ms) || Task.shutdown(task) do + {:ok, result} -> + result + + nil -> + Logger.error( + "Regex timeout (#{@regex_timeout_ms}ms): pattern=#{pattern}, text=#{String.slice(text, 0, 100)}" + ) + + nil + end + + {:error, reason} -> + Logger.error("Invalid regex pattern: #{pattern}, reason: #{inspect(reason)}") + nil + end + end + end + + @doc """ + Compare two values the way the JavaScript SDK's `Evaluator.compare` does. + + The right operand is converted to the left operand's type. Returns 0 when + equal, 1 / -1 for an ordering, and nil when the values cannot be compared. + Lists and maps only compare as equal (0) or not comparable (nil). + """ + def compare(nil, nil), do: 0 + def compare(nil, _), do: nil + def compare(_, nil), do: nil + + def compare(lhs, rhs) when is_number(lhs) do + case number_convert(rhs) do + nil -> nil + rvalue -> order(lhs, rvalue) + end + end + + def compare(lhs, rhs) when is_binary(lhs) do + case string_convert(rhs) do + nil -> nil + rvalue -> order(lhs, rvalue) + end + end + + def compare(lhs, rhs) when is_boolean(lhs) do + rvalue = boolean_convert(rhs) + + cond do + lhs == rvalue -> 0 + lhs -> 1 + true -> -1 + end + end + + def compare(lhs, rhs) do + if lhs == rhs, do: 0, else: nil + end + + defp order(lhs, rhs) do + cond do + lhs == rhs -> 0 + lhs > rhs -> 1 + true -> -1 + end + end + + @doc false + def boolean_convert(x) when is_boolean(x), do: x + def boolean_convert(x) when is_number(x), do: x != 0 + def boolean_convert(x) when is_binary(x), do: x not in ["false", "0", ""] + def boolean_convert(x), do: x != nil + + @doc false + def number_convert(x) when is_number(x), do: x + def number_convert(true), do: 1 + def number_convert(false), do: 0 + def number_convert(x) when is_binary(x), do: parse_float(x) + def number_convert(_), do: nil + + @doc false + def string_convert(x) when is_binary(x), do: x + def string_convert(x) when is_boolean(x), do: Atom.to_string(x) + + def string_convert(x) when is_number(x) do + x = x * 1.0 + + cond do + x == 0 -> + "0" + + abs(x) >= 1.0e21 -> + # JS toFixed falls back to exponent notation, e.g. "1e+21". + [mantissa, exp] = String.split(:erlang.float_to_binary(x, [:short]), "e") + exp = if String.starts_with?(exp, "-"), do: exp, else: "+" <> exp + String.replace_suffix(mantissa, ".0", "") <> "e" <> exp + + true -> + # JS: x.toFixed(15).replace(/\.?0{0,15}$/, "") + x + |> :erlang.float_to_binary(decimals: 15) + |> String.replace(~r/\.?0{0,15}$/, "", global: false) + end + end + + def string_convert(_), do: nil + + # JS parseFloat: leading whitespace is skipped and the longest numeric + # prefix is used; anything that is not a finite number gives nil. + defp parse_float(s) do + regex = ~r/^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?/ + [_, sign, int, frac, exp] = Regex.run(regex, String.trim_leading(s)) ++ ["", "", "", ""] |> Enum.take(5) + + if int == "" and frac == "" do + nil + else + digits = fn d -> if d == "", do: "0", else: d end + + case Float.parse("#{sign}#{digits.(int)}.#{digits.(frac)}e#{digits.(exp)}") do + {num, ""} -> num + _ -> nil + end + end + rescue + ArgumentError -> nil + end +end diff --git a/lib/absmartly/matcher.ex b/lib/absmartly/matcher.ex new file mode 100644 index 0000000..a96a2ce --- /dev/null +++ b/lib/absmartly/matcher.ex @@ -0,0 +1,89 @@ +defmodule ABSmartly.Matcher do + @moduledoc """ + Audience matcher for experiment targeting. + + Fixes HIGH-07: Returns `true` for unrecognized filters. + """ + + alias ABSmartly.JSONExpr.Evaluator + + require Logger + + @doc """ + Evaluate audience filter against attributes. + + Returns: + - nil if filter is nil + - true if filter is empty map (matches all) + - result of filter evaluation otherwise + - nil when the filter value is not a list or map + - false for invalid filter structures (fail closed) + + ## Examples + + iex> evaluate(nil, %{}) + nil + + iex> evaluate(%{}, %{}) + true + + iex> evaluate(%{"filter" => [%{"value" => true}]}, %{}) + true + + iex> evaluate(%{"filter" => [%{"value" => false}]}, %{}) + false + """ + def evaluate(nil, _attributes), do: nil + def evaluate(filter, _attributes) when map_size(filter) == 0, do: true + + def evaluate(filter, attributes) when is_map(filter) do + case Map.get(filter, "filter") do + nil -> + # Fixes HIGH-07: Check if %{"invalid" => true} sentinel from parse_audience + if Map.get(filter, "invalid") == true do + Logger.error("Invalid audience filter detected") + false + else + true + end + + filter_expr when is_list(filter_expr) or is_map(filter_expr) -> + vars = attributes_to_vars(attributes) + + Evaluator.evaluate(filter_expr, vars) == true + + other -> + # Like the JavaScript SDK: a filter that is neither an array nor an + # object cannot be evaluated, so it is neither a match nor a mismatch. + Logger.error("Filter key is not a list or map: #{inspect(other)}") + nil + end + end + + # Fixes HIGH-07: Fail closed for invalid filter types + def evaluate(filter, _attributes) do + Logger.error("Invalid filter type: #{inspect(filter)}") + false + end + + defp attributes_to_vars(attributes) when is_list(attributes) do + Enum.reduce(attributes, %{}, fn attr, acc -> + case attr do + %{"name" => name, "value" => value} -> + Map.put(acc, name, value) + + {name, value} -> + Map.put(acc, to_string(name), value) + + _ -> + acc + end + end) + end + + defp attributes_to_vars(attributes) when is_map(attributes) do + attributes + end + + defp attributes_to_vars(_), do: %{} +end diff --git a/lib/absmartly/murmur3.ex b/lib/absmartly/murmur3.ex new file mode 100644 index 0000000..4feb61c --- /dev/null +++ b/lib/absmartly/murmur3.ex @@ -0,0 +1,29 @@ +defmodule ABSmartly.Murmur3 do + @moduledoc """ + Murmur3_32 hash implementation using the `murmur` library. + + This is a thin wrapper around the well-tested `murmur` hex package, + which provides a pure Elixir implementation of MurmurHash3. + + CRITICAL: All arithmetic is UNSIGNED 32-bit with LITTLE-ENDIAN byte order. + The `murmur` library handles this correctly. + """ + + @doc """ + Compute Murmur3_32 hash of binary data using the murmur library. + + ## Examples + + iex> ABSmartly.Murmur3.hash32("", 0) + 0x00000000 + + iex> ABSmartly.Murmur3.hash32("absmartly.com", 0) + 0x6D02F2B7 + + iex> ABSmartly.Murmur3.hash32("bleh@absmartly.com", 0) + 0x1498CA89 + """ + def hash32(data, seed \\ 0) when is_binary(data) do + Murmur.hash_x86_32(data, seed) + end +end diff --git a/lib/absmartly/sdk.ex b/lib/absmartly/sdk.ex new file mode 100644 index 0000000..0a00d62 --- /dev/null +++ b/lib/absmartly/sdk.ex @@ -0,0 +1,186 @@ +defmodule ABSmartly.SDK do + @moduledoc """ + Main SDK class for ABSmartly. + + Provides context creation methods: + - `create_context/3`: Synchronous - fetches data from API, blocks until complete + - `create_context_async/3`: Async - starts context immediately, fetches data in background + - `create_context_with/4`: Uses pre-fetched data directly + """ + + require Logger + + alias ABSmartly.{Context, HTTP, Types} + + @type t :: %__MODULE__{ + config: Types.SDKConfig.t() + } + + defstruct [:config] + + @doc """ + Create a new SDK instance using keyword list configuration. + + ## Required Parameters + * `:endpoint` - The URL to your API endpoint (e.g., "https://your-company.absmartly.io/v1") + * `:api_key` - Your API key from the Web Console + * `:application` - The name of your application + * `:environment` - The environment name (e.g., "production", "development") + + ## Optional Parameters + * `:timeout` - Connection timeout in milliseconds (default: 3000) + * `:retries` - Number of retry attempts for failed requests (default: 5) + """ + def new(opts) when is_list(opts) do + with :ok <- validate_required_params(opts), + {:ok, config} <- build_config(opts) do + {:ok, %__MODULE__{config: config}} + end + end + + @doc """ + Set custom timeout for the SDK instance (pipe-friendly). + """ + def with_timeout({:ok, %__MODULE__{config: config} = sdk}, timeout) when is_integer(timeout) do + {:ok, %{sdk | config: %{config | timeout: timeout}}} + end + + def with_timeout({:error, _} = error, _timeout), do: error + + @doc """ + Set custom retry count for the SDK instance (pipe-friendly). + """ + def with_retries({:ok, %__MODULE__{config: config} = sdk}, retries) when is_integer(retries) do + {:ok, %{sdk | config: %{config | retries: retries}}} + end + + def with_retries({:error, _} = error, _retries), do: error + + defp validate_required_params(opts) do + required = [:endpoint, :api_key, :application, :environment] + missing = Enum.reject(required, &Keyword.has_key?(opts, &1)) + + case missing do + [] -> + :ok + + missing_keys -> + {:error, + "Missing required parameters: #{Enum.join(missing_keys, ", ")}. " <> + "Required: endpoint, api_key, application, environment"} + end + end + + defp build_config(opts) do + config = %Types.SDKConfig{ + endpoint: Keyword.fetch!(opts, :endpoint), + api_key: Keyword.fetch!(opts, :api_key), + application: Keyword.fetch!(opts, :application), + environment: Keyword.fetch!(opts, :environment), + timeout: Keyword.get(opts, :timeout, 3000), + retries: Keyword.get(opts, :retries, 5) + } + + {:ok, config} + rescue + e in [ArgumentError, KeyError] -> + {:error, "Failed to build config: #{Exception.message(e)}"} + end + + @doc """ + Create context with synchronous data fetching (blocks until HTTP fetch completes). + + Use `create_context_async/3` for non-blocking context creation. + """ + def create_context(sdk_or_result, units, options \\ %{}) + def create_context({:ok, sdk}, units, options), do: create_context(sdk, units, options) + + def create_context(%__MODULE__{} = sdk, units, options) do + config = sdk.config + + case HTTP.Client.fetch_context( + config.endpoint, + config.api_key, + config.application, + config.environment, + config.retries, + config.timeout + ) do + {:ok, data} -> + case convert_context_data(data) do + {:ok, context_data} -> create_context_with(sdk, units, context_data, options) + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Create context with async data fetching (non-blocking). + + The context is created immediately and data is fetched in the background. + Use `Context.wait_until_ready/2` to block until data is available. + """ + def create_context_async(sdk_or_result, units, options \\ %{}) + def create_context_async({:ok, sdk}, units, options), do: create_context_async(sdk, units, options) + + def create_context_async(%__MODULE__{} = sdk, units, options) do + config = sdk.config + + context_config = + options + |> Map.put(:units, units) + |> Types.ContextConfig.from_options() + + fetcher = fn -> + HTTP.Client.fetch_context( + config.endpoint, + config.api_key, + config.application, + config.environment, + config.retries, + config.timeout + ) + end + + child_spec = %{ + id: Context, + start: {Context, :start_link_async, [config, context_config, [data_fetcher: fetcher]]}, + type: :worker, + restart: :temporary, + shutdown: Context.shutdown_timeout(config) + } + + case DynamicSupervisor.start_child(ABSmartly.ContextSupervisor, child_spec) do + {:ok, ctx} -> + {:ok, ctx} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Create context with pre-fetched data (synchronous). + """ + def create_context_with(sdk_or_result, units, data, options \\ %{}) + + def create_context_with({:ok, sdk}, units, data, options), + do: create_context_with(sdk, units, data, options) + + def create_context_with(%__MODULE__{} = sdk, units, %Types.ContextData{} = data, options) do + context_config = + options + |> Map.put(:units, units) + |> Types.ContextConfig.from_options() + + DynamicSupervisor.start_child( + ABSmartly.ContextSupervisor, + {Context, [sdk.config, data, context_config]} + ) + end + + defp convert_context_data(data), do: Context.safe_context_data(data) +end diff --git a/lib/absmartly/types.ex b/lib/absmartly/types.ex new file mode 100644 index 0000000..bdf7845 --- /dev/null +++ b/lib/absmartly/types.ex @@ -0,0 +1,306 @@ +defmodule ABSmartly.Types do + @moduledoc """ + Core data structures for ABSmartly SDK. + """ + + defmodule SDKConfig do + @moduledoc """ + Configuration for SDK initialization. + """ + @enforce_keys [:endpoint, :api_key, :application, :environment] + defstruct [ + :endpoint, + :api_key, + :application, + :environment, + retries: 5, + timeout: 3000, + publisher: ABSmartly.DefaultContextPublisher + ] + end + + # Implement Inspect protocol to prevent API key exposure (CRITICAL-05) + defimpl Inspect, for: SDKConfig do + def inspect(config, _opts) do + "#SDKConfig" + end + end + + defmodule ExperimentData do + @moduledoc """ + Experiment configuration from API. + """ + defstruct [ + :id, + :name, + :unit_type, + :iteration, + :seed_hi, + :seed_lo, + :split, + :traffic_seed_hi, + :traffic_seed_lo, + :traffic_split, + :full_on_variant, + :applications, + :variants, + :audience_strict, + :audience, + :custom_field_values + ] + + defp validate_assignment_fields!(map, split), do: ABSmartly.Types.ExperimentValidation.validate!(map, split) + + @doc """ + Create ExperimentData from API JSON. + Fixes CRITICAL-11: Validates required fields. + """ + def from_map(map) when is_map(map) do + # Validate required fields (CRITICAL-11) + required_fields = ["id", "name", "unitType", "split"] + missing = Enum.filter(required_fields, &is_nil(map[&1])) + + if missing != [] do + raise ArgumentError, "Missing required experiment fields: #{inspect(missing)}" + end + + # Validate split array length (CRITICAL-12) + split = map["split"] || [] + if is_list(split) and length(split) > 100 do + raise ArgumentError, "Split array too large (#{length(split)}), maximum 100 variants allowed" + end + + validate_assignment_fields!(map, split) + + %__MODULE__{ + id: map["id"], + name: map["name"], + unit_type: map["unitType"], + iteration: map["iteration"] || 0, + seed_hi: map["seedHi"] || 0, + seed_lo: map["seedLo"] || 0, + split: split, + traffic_seed_hi: map["trafficSeedHi"], + traffic_seed_lo: map["trafficSeedLo"], + traffic_split: map["trafficSplit"] || [], + full_on_variant: map["fullOnVariant"], + applications: map["applications"] || [], + variants: map["variants"] || [], + audience_strict: map["audienceStrict"] || false, + audience: map["audience"], + custom_field_values: map["customFieldValues"] + } + end + end + + defmodule ExperimentValidation do + @moduledoc false + # Shape checks for the fields consumed by assignment, so schema-invalid + # collector payloads are rejected up front instead of crashing evaluation. + def validate!(map, split) do + check!(is_binary(map["name"]), "name must be a string") + check!(is_binary(map["unitType"]), "unitType must be a string") + check!(number_list?(split), "split must be a list of numbers") + + traffic = map["trafficSplit"] + check!(is_nil(traffic) or number_list?(traffic), "trafficSplit must be a list of numbers") + + for key <- ["iteration", "seedHi", "seedLo", "trafficSeedHi", "trafficSeedLo", "fullOnVariant"] do + value = map[key] + check!(is_nil(value) or is_integer(value), "#{key} must be an integer") + end + + for key <- ["variants", "applications"] do + value = map[key] + check!(is_nil(value) or is_list(value), "#{key} must be a list") + end + + cfv = map["customFieldValues"] + + check!( + is_nil(cfv) or (is_list(cfv) and Enum.all?(cfv, &custom_field?/1)), + "customFieldValues must be a list of objects with a string name, optional string type and scalar value" + ) + + :ok + end + + defp custom_field?(%{} = f) do + is_binary(f["name"]) and (is_nil(f["type"]) or is_binary(f["type"])) and + (is_nil(f["value"]) or is_binary(f["value"]) or is_number(f["value"]) or is_boolean(f["value"])) + end + + defp custom_field?(_), do: false + + defp number_list?(list), do: is_list(list) and Enum.all?(list, &is_number/1) + + defp check!(true, _msg), do: :ok + defp check!(_, msg), do: raise(ArgumentError, "Invalid experiment data: " <> msg) + end + + defmodule ContextData do + @moduledoc """ + Context data containing experiments. + """ + defstruct experiments: [] + + @doc """ + Create ContextData from API JSON. + Fixes HIGH-11: Validates input is a map. + """ + def from_map(map) when is_map(map) do + experiments = + (map["experiments"] || []) + |> Enum.map(&ExperimentData.from_map/1) + + %__MODULE__{experiments: experiments} + end + + def from_map(other) do + raise ArgumentError, "Expected context data to be a map, got: #{inspect(other)}" + end + end + + defmodule Assignment do + @moduledoc """ + Cached variant assignment. + """ + defstruct [ + :id, + :iteration, + :full_on_variant, + :traffic_split, + :variant, + assigned: false, + overridden: false, + eligible: true, + full_on: false, + custom: false, + audience_mismatch: false, + audience_match_seq: 0 + ] + end + + defmodule Exposure do + @moduledoc """ + Exposure event for publishing. + """ + defstruct [ + :id, + :name, + :unit, + :variant, + :exposed_at, + :assigned, + :eligible, + :overridden, + :full_on, + :custom, + :audience_mismatch + ] + + @doc """ + Convert to map for JSON serialization. + """ + def to_map(%__MODULE__{} = exposure) do + %{ + "id" => exposure.id, + "name" => exposure.name, + "unit" => exposure.unit, + "variant" => exposure.variant, + "exposedAt" => exposure.exposed_at, + "assigned" => exposure.assigned, + "eligible" => exposure.eligible, + "overridden" => exposure.overridden, + "fullOn" => exposure.full_on, + "custom" => exposure.custom, + "audienceMismatch" => exposure.audience_mismatch + } + end + end + + defmodule Goal do + @moduledoc """ + Goal achievement event. + """ + defstruct [:name, :achieved_at, :properties] + + @doc """ + Convert to map for JSON serialization. + """ + def to_map(%__MODULE__{} = goal) do + %{ + "name" => goal.name, + "achievedAt" => goal.achieved_at, + "properties" => goal.properties + } + end + end + + defmodule PublishEvent do + @moduledoc """ + Publish event data. + """ + defstruct [ + :hashed, + :published_at, + :units, + :exposures, + :goals, + :attributes + ] + + @doc """ + Convert to map for JSON serialization. + """ + def to_map(%__MODULE__{} = event) do + %{ + "hashed" => event.hashed, + "publishedAt" => event.published_at, + "units" => event.units, + "exposures" => Enum.map(event.exposures, &Exposure.to_map/1), + "goals" => Enum.map(event.goals, &Goal.to_map/1), + "attributes" => event.attributes + } + end + end + + defmodule ContextConfig do + @moduledoc """ + Configuration for context creation. + """ + defstruct [ + units: %{}, + attributes: [], + overrides: %{}, + custom_assignments: %{}, + publish_delay: -1, + refresh_period: 0, + event_handler: nil + ] + + @doc """ + Create from options map. + Fixes HIGH-08: Falsy values treated as missing. + """ + def from_options(opts) when is_map(opts) do + %__MODULE__{ + units: get_opt(opts, "units", :units, %{}), + attributes: get_opt(opts, "attributes", :attributes, []), + overrides: get_opt(opts, "overrides", :overrides, %{}), + custom_assignments: get_opt(opts, "customAssignments", :custom_assignments, %{}), + publish_delay: get_opt(opts, "publishDelay", :publish_delay, -1), + refresh_period: get_opt(opts, "refreshPeriod", :refresh_period, 0), + event_handler: get_opt(opts, "eventHandler", :event_handler, nil) + } + end + + defp get_opt(opts, string_key, atom_key, default) do + cond do + Map.has_key?(opts, string_key) -> Map.get(opts, string_key, default) + Map.has_key?(opts, atom_key) -> Map.get(opts, atom_key, default) + true -> default + end + end + end +end diff --git a/lib/absmartly/utils.ex b/lib/absmartly/utils.ex new file mode 100644 index 0000000..5ef65cf --- /dev/null +++ b/lib/absmartly/utils.ex @@ -0,0 +1,101 @@ +defmodule ABSmartly.Utils do + @moduledoc """ + Utility functions for ABSmartly SDK. + """ + + @doc """ + Hash a unit identifier using MD5 and encode as base64url (no padding). + + ## Examples + + iex> ABSmartly.Utils.hash_unit("bleh@absmartly.com") + "rcH4hfF5JRm9-sOLmWKubA" + """ + def hash_unit(uid) when is_binary(uid) do + :crypto.hash(:md5, uid) + |> Base.url_encode64(padding: false) + end + + def hash_unit(uid) when is_integer(uid) do + hash_unit(Integer.to_string(uid)) + end + + def hash_unit(uid) do + hash_unit(to_string(uid)) + end + + @doc """ + Choose a variant based on cumulative split probabilities. + + Returns the index of the first variant where cumulative probability >= value. + + ## Examples + + iex> ABSmartly.Utils.choose_variant([0.0, 1.0], 0.0) + 1 + + iex> ABSmartly.Utils.choose_variant([0.0, 1.0], 0.5) + 1 + + iex> ABSmartly.Utils.choose_variant([0.5, 0.5], 0.0) + 0 + + iex> ABSmartly.Utils.choose_variant([0.5, 0.5], 0.5) + 1 + + iex> ABSmartly.Utils.choose_variant([0.33, 0.33, 0.34], 0.66) + 2 + """ + def choose_variant(split, probability) when is_list(split) and is_number(probability) do + do_choose_variant(split, probability, 0, 0.0, length(split)) + end + + defp do_choose_variant([], _probability, _index, _cumulative, split_length) do + max(split_length - 1, 0) + end + + defp do_choose_variant([fraction | rest], probability, index, cumulative, split_length) do + cumulative = cumulative + fraction + + if probability < cumulative do + index + else + do_choose_variant(rest, probability, index + 1, cumulative, split_length) + end + end + + # Fixes MEDIUM-15: to_string_value is dead code - removed + + @doc """ + Convert any value to a number (integer or float). + Returns nil if conversion fails. + """ + def to_number(nil), do: nil + def to_number(n) when is_number(n), do: n + def to_number(true), do: 1 + def to_number(false), do: 0 + + def to_number(""), do: 0 + + def to_number(s) when is_binary(s) do + case Float.parse(s) do + {num, ""} -> num + _ -> nil + end + end + + def to_number(_), do: nil + + @doc """ + Convert any value to a boolean. + """ + def to_boolean(nil), do: nil + def to_boolean(false), do: false + def to_boolean(0), do: false + def to_boolean(+0.0), do: false + def to_boolean(""), do: false + def to_boolean(_), do: true + + # Fixes MEDIUM-13: deep_copy is dead code - removed (Elixir data is immutable) + # Fixes MEDIUM-14: snake_to_camel is dead code - removed +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..d31f41e --- /dev/null +++ b/mix.exs @@ -0,0 +1,40 @@ +defmodule ABSmartly.MixProject do + use Mix.Project + + def project do + [ + app: :absmartly, + version: "1.0.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps(), + description: "ABSmartly SDK for Elixir", + package: package(), + name: "ABSmartly", + source_url: "https://github.com/absmartly/elixir-sdk" + ] + end + + def application do + [ + mod: {ABSmartly.Application, []}, + extra_applications: [:logger, :crypto] + ] + end + + defp deps do + [ + {:jason, "~> 1.4"}, + {:httpoison, "~> 3.0"}, + {:murmur, "~> 2.0"}, + {:ex_doc, "~> 0.30", only: :dev, runtime: false} + ] + end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => "https://github.com/absmartly/elixir-sdk"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..a4f0dbe --- /dev/null +++ b/mix.lock @@ -0,0 +1,22 @@ +%{ + "certifi": {:hex, :certifi, "2.17.0", "835748414307e15e05b17d0e518190228ce648b08d569a5cc93a85a40f3e5c9b", [:rebar3], [], "hexpm", "8122798a17f0293c80daada25d0f81c7f4d708c73fef782c7c9b1950e26e4d21"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_doc": {:hex, :ex_doc, "0.40.0", "2635974389b80fd3ca61b0f993d459dad05b4a8f9b069dcfbbc5f6a8a6aef60e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "c040735250e2752b6e1102eeb4aa3f1dca74c316db873ae09f955d42136e7e5b"}, + "h2": {:hex, :h2, "0.12.4", "f8cb769ccc17369d185717135353bee1d50f2cd193a9e8cd998d88ce6cafc51d", [:rebar3], [], "hexpm", "238553579d2599e823f1f7b1cacb763362ee6e2df8e8f3b0c8fc67da982c0675"}, + "hackney": {:hex, :hackney, "4.8.5", "214a58c2e77115b0e2c171f01e59b7fbcb257e07471fc0ae1814b4654f8e2fa4", [:rebar3], [{:certifi, "~> 2.17.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "~> 0.12.4", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.5", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.2", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "~> 2.1.1", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:webtransport, "~> 0.4.8", [hex: :webtransport, repo: "hexpm", optional: false]}], "hexpm", "d1c18324ce5eaa5e45a245f31e68d212c4f3f24ee0a27e4eac09c5959831c138"}, + "httpoison": {:hex, :httpoison, "3.0.0", "8566a933bb9175236d1ec335978445b67cd1f5b5d3ead6ca4b80be469d41f5d9", [:mix], [{:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "9130197b7658901c493d6fcfb842fb9676300fa8a6c8ed058c8889cf1a77f3c2"}, + "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, + "murmur": {:hex, :murmur, "2.0.0", "ffd64af879014b51c449aec38e2d460547270b88280f052cecda0a0ce4589188", [:mix], [], "hexpm", "2e96baf65a1a8b5af82ee509d6089ddf717af7e8ce0ed80d4c53819ca2fc2ffd"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"}, + "quic": {:hex, :quic, "2.1.1", "e5099110061f5f68fcd57ece30dfb419a2360cbaff2f58a40517b08f831b630f", [:rebar3], [], "hexpm", "305d78a857298d940f6621a9acd7391c02980555d5e7fffef364eccd330fd64d"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "webtransport": {:hex, :webtransport, "0.4.8", "3c340d7ea381e3f639ece90378d85fb46b1da4eece17bba3223051e833166501", [:rebar3], [{:h2, "~> 0.12", [hex: :h2, repo: "hexpm", optional: false]}, {:quic, "~> 2.0", [hex: :quic, repo: "hexpm", optional: false]}], "hexpm", "08477a2195ccbf944da0b78f85dc4e6ee4498790e3dd7fe84ee7a245938e1b26"}, +} diff --git a/test/absmartly/assigner_test.exs b/test/absmartly/assigner_test.exs new file mode 100644 index 0000000..fb8c435 --- /dev/null +++ b/test/absmartly/assigner_test.exs @@ -0,0 +1,65 @@ +defmodule ABSmartly.VariantAssignerTest do + use ExUnit.Case, async: true + + alias ABSmartly.{VariantAssigner, Utils} + + @variant_assignment_cases [ + {"bleh@absmartly.com", [0.5, 0.5], 0, 0, 0}, + {"bleh@absmartly.com", [0.5, 0.5], 0, 1, 1}, + {"bleh@absmartly.com", [0.5, 0.5], 2_148_875_375, 2_130_045_848, 0}, + {"bleh@absmartly.com", [0.5, 0.5], 993_098_128, 3_399_917_389, 0}, + {"bleh@absmartly.com", [0.5, 0.5], 1_388_054_103, 3_528_952_622, 0}, + {"bleh@absmartly.com", [0.5, 0.5], 2_257_487_056, 2_853_234_714, 0}, + {"bleh@absmartly.com", [0.5, 0.5], 668_037_254, 2_218_017_209, 1}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 0, 0, 0}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 0, 1, 2}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 2_148_875_375, 2_130_045_848, 0}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 993_098_128, 3_399_917_389, 1}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 1_388_054_103, 3_528_952_622, 1}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 2_257_487_056, 2_853_234_714, 0}, + {"bleh@absmartly.com", [0.33, 0.33, 0.34], 668_037_254, 2_218_017_209, 1}, + {"123456789", [0.5, 0.5], 0, 0, 1}, + {"123456789", [0.5, 0.5], 0, 1, 0}, + {"123456789", [0.5, 0.5], 2_148_875_375, 2_130_045_848, 1}, + {"123456789", [0.5, 0.5], 993_098_128, 3_399_917_389, 0}, + {"123456789", [0.5, 0.5], 1_388_054_103, 3_528_952_622, 1}, + {"123456789", [0.5, 0.5], 2_257_487_056, 2_853_234_714, 0}, + {"123456789", [0.5, 0.5], 668_037_254, 2_218_017_209, 0}, + {"123456789", [0.33, 0.33, 0.34], 0, 0, 2}, + {"123456789", [0.33, 0.33, 0.34], 0, 1, 1}, + {"123456789", [0.33, 0.33, 0.34], 2_148_875_375, 2_130_045_848, 1}, + {"123456789", [0.33, 0.33, 0.34], 993_098_128, 3_399_917_389, 0}, + {"123456789", [0.33, 0.33, 0.34], 1_388_054_103, 3_528_952_622, 2}, + {"123456789", [0.33, 0.33, 0.34], 2_257_487_056, 2_853_234_714, 0}, + {"123456789", [0.33, 0.33, 0.34], 668_037_254, 2_218_017_209, 0}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0, 0, 1}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 0, 1, 0}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 2_148_875_375, 2_130_045_848, 1}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 993_098_128, 3_399_917_389, 0}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 1_388_054_103, 3_528_952_622, 1}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 2_257_487_056, 2_853_234_714, 1}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.5, 0.5], 668_037_254, 2_218_017_209, 0}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0, 0, 2}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 0, 1, 0}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 2_148_875_375, 2_130_045_848, 1}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 993_098_128, 3_399_917_389, 0}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 1_388_054_103, 3_528_952_622, 2}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 2_257_487_056, 2_853_234_714, 1}, + {"e791e240fcd3df7d238cfc285f475e8152fcc0ec", [0.33, 0.33, 0.34], 668_037_254, 2_218_017_209, 1} + ] + + describe "assign/4 should be deterministic" do + for {unit, split, seed_hi, seed_lo, expected} <- @variant_assignment_cases do + split_str = inspect(split) + @tag_unit unit + @tag_split split + @tag_seed_hi seed_hi + @tag_seed_lo seed_lo + @tag_expected expected + test "unit=#{unit} split=#{split_str} seedHi=#{seed_hi} seedLo=#{seed_lo} -> #{expected}" do + hashed = Utils.hash_unit(@tag_unit) + assert VariantAssigner.assign(hashed, @tag_split, @tag_seed_hi, @tag_seed_lo) == @tag_expected + end + end + end +end diff --git a/test/absmartly/context_test.exs b/test/absmartly/context_test.exs new file mode 100644 index 0000000..2f44ce2 --- /dev/null +++ b/test/absmartly/context_test.exs @@ -0,0 +1,1235 @@ +defmodule ABSmartly.ContextTest do + use ExUnit.Case, async: true + + alias ABSmartly.{Context, Types} + + @context_params %{ + "session_id" => "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id" => "123456789" + } + + @get_context_response %{ + "experiments" => [ + %{ + "id" => 1, + "name" => "exp_test_ab", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 3603515, + "seedLo" => 233373850, + "split" => [0.5, 0.5], + "trafficSeedHi" => 449867249, + "trafficSeedLo" => 455443629, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"banner.border\":1,\"banner.size\":\"large\"}"} + ], + "audience" => nil, + "customFieldValues" => nil + }, + %{ + "id" => 2, + "name" => "exp_test_abc", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 55006150, + "seedLo" => 47189152, + "split" => [0.34, 0.33, 0.33], + "trafficSeedHi" => 705671872, + "trafficSeedLo" => 212903484, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"button.color\":\"blue\"}"}, + %{"name" => "C", "config" => "{\"button.color\":\"red\"}"} + ], + "audience" => "", + "customFieldValues" => [ + %{"name" => "country", "value" => "US,PT,ES,DE,FR", "type" => "string"}, + %{"name" => "json_object", "value" => "{\"123\":1,\"456\":0}", "type" => "json"}, + %{"name" => "json_array", "value" => "[\"hello\", \"world\"]", "type" => "json"}, + %{"name" => "json_number", "value" => "123", "type" => "json"}, + %{"name" => "json_string", "value" => "\"hello\"", "type" => "json"}, + %{"name" => "json_boolean", "value" => "true", "type" => "json"}, + %{"name" => "json_null", "value" => "null", "type" => "json"}, + %{"name" => "json_invalid", "value" => "invalid", "type" => "json"} + ] + }, + %{ + "id" => 3, + "name" => "exp_test_not_eligible", + "iteration" => 1, + "unitType" => "user_id", + "seedHi" => 503266407, + "seedLo" => 144942754, + "split" => [0.34, 0.33, 0.33], + "trafficSeedHi" => 87768905, + "trafficSeedLo" => 511357582, + "trafficSplit" => [0.99, 0.01], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"card.width\":\"80%\"}"}, + %{"name" => "C", "config" => "{\"card.width\":\"75%\"}"} + ], + "audience" => "{}", + "customFieldValues" => nil + }, + %{ + "id" => 4, + "name" => "exp_test_fullon", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 856061641, + "seedLo" => 990838475, + "split" => [0.25, 0.25, 0.25, 0.25], + "trafficSeedHi" => 360868579, + "trafficSeedLo" => 330937933, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 2, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"submit.color\":\"red\",\"submit.shape\":\"circle\"}"}, + %{"name" => "C", "config" => "{\"submit.color\":\"blue\",\"submit.shape\":\"rect\"}"}, + %{"name" => "D", "config" => "{\"submit.color\":\"green\",\"submit.shape\":\"square\"}"} + ], + "audience" => "null", + "customFieldValues" => nil + }, + %{ + "id" => 5, + "name" => "exp_test_custom_fields", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 9372617, + "seedLo" => 121364805, + "split" => [0.5, 0.5], + "trafficSeedHi" => 318746944, + "trafficSeedLo" => 359812364, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"submit.size\":\"sm\"}"} + ], + "audience" => nil, + "customFieldValues" => [ + %{"name" => "country", "value" => "US,PT,ES", "type" => "string"}, + %{"name" => "languages", "value" => "en-US,en-GB,pt-PT,pt-BR,es-ES,es-MX", "type" => "string"}, + %{"name" => "text_field", "value" => "hello text", "type" => "text"}, + %{"name" => "string_field", "value" => "hello string", "type" => "string"}, + %{"name" => "number_field", "value" => "123", "type" => "number"}, + %{"name" => "boolean_field", "value" => "true", "type" => "boolean"}, + %{"name" => "false_boolean_field", "value" => "false", "type" => "boolean"}, + %{"name" => "invalid_type_field", "value" => "invalid", "type" => "invalid"} + ] + } + ] + } + + @expected_variants %{ + "exp_test_ab" => 1, + "exp_test_abc" => 2, + "exp_test_not_eligible" => 0, + "exp_test_fullon" => 2, + "exp_test_custom_fields" => 1 + } + + @expected_variables %{ + "banner.border" => 1, + "banner.size" => "large", + "button.color" => "red", + "submit.color" => "blue", + "submit.shape" => "rect", + "submit.size" => "sm" + } + + @refresh_context_response %{ + "experiments" => [ + %{ + "id" => 6, + "name" => "exp_test_new", + "iteration" => 2, + "unitType" => "session_id", + "seedHi" => 934590467, + "seedLo" => 714771373, + "split" => [0.5, 0.5], + "trafficSeedHi" => 940553836, + "trafficSeedLo" => 270705624, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 1, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"show-modal\":true}"} + ] + } + ] ++ (@get_context_response["experiments"]) + } + + defp audience_context_response do + update_experiment(@get_context_response, "exp_test_ab", fn exp -> + Map.put(exp, "audience", Jason.encode!(%{ + "filter" => [%{"gte" => [%{"var" => "age"}, %{"value" => 20}]}] + })) + end) + end + + defp audience_strict_context_response do + response = audience_context_response() + update_experiment(response, "exp_test_ab", fn exp -> + exp + |> Map.put("audienceStrict", true) + |> Map.update!("variants", fn variants -> + Enum.map(variants, fn v -> + if v["name"] == "A" do + %{v | "config" => "{\"banner.size\":\"tiny\"}"} + else + v + end + end) + end) + end) + end + + defp disjointed_context_response do + response = @get_context_response + response = update_experiment(response, "exp_test_ab", fn exp -> + exp + |> Map.put("audienceStrict", true) + |> Map.put("audience", Jason.encode!(%{ + "filter" => [%{"gte" => [%{"var" => "age"}, %{"value" => 20}]}] + })) + |> Map.update!("variants", fn variants -> + Enum.with_index(variants) + |> Enum.map(fn {v, i} -> + if i == @expected_variants["exp_test_ab"] do + %{v | "config" => Jason.encode!(%{"icon" => "arrow"})} + else + v + end + end) + end) + end) + update_experiment(response, "exp_test_abc", fn exp -> + exp + |> Map.put("audienceStrict", true) + |> Map.put("audience", Jason.encode!(%{ + "filter" => [%{"lt" => [%{"var" => "age"}, %{"value" => 20}]}] + })) + |> Map.update!("variants", fn variants -> + Enum.with_index(variants) + |> Enum.map(fn {v, i} -> + if i == @expected_variants["exp_test_abc"] do + %{v | "config" => Jason.encode!(%{"icon" => "circle"})} + else + v + end + end) + end) + end) + end + + defp update_experiment(response, name, fun) do + experiments = Enum.map(response["experiments"], fn exp -> + if exp["name"] == name, do: fun.(exp), else: exp + end) + %{response | "experiments" => experiments} + end + + defp start_context(response, units \\ nil) do + context_units = units || @context_params + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_data = Types.ContextData.from_map(response) + context_config = %Types.ContextConfig{ + units: context_units, + overrides: %{}, + custom_assignments: %{} + } + {:ok, pid} = Context.start_link(sdk_config, context_data, context_config) + pid + end + + defp start_context_with_refresh(response, refresh_response, units \\ nil) do + context_units = units || @context_params + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_data = Types.ContextData.from_map(response) + context_config = %Types.ContextConfig{ + units: context_units, + overrides: %{}, + custom_assignments: %{} + } + refresh_data = Types.ContextData.from_map(refresh_response) + data_fetcher = fn -> {:ok, refresh_data} end + {:ok, pid} = Context.start_link(sdk_config, context_data, context_config, data_fetcher: data_fetcher) + pid + end + + describe "constructor and initialization" do + test "should be ready with data" do + ctx = start_context(@get_context_response) + assert Context.is_ready?(ctx) == true + assert Context.is_failed?(ctx) == false + end + + test "should not be finalized initially" do + ctx = start_context(@get_context_response) + assert Context.is_finalized?(ctx) == false + assert Context.is_finalizing?(ctx) == false + end + + test "should load experiment data" do + ctx = start_context(@get_context_response) + experiment_names = Context.experiments(ctx) + expected_names = Enum.map(@get_context_response["experiments"], & &1["name"]) + assert experiment_names == expected_names + end + + test "should return correct treatment for each experiment" do + ctx = start_context(@get_context_response) + for exp <- @get_context_response["experiments"] do + expected = Map.get(@expected_variants, exp["name"]) + assert Context.treatment(ctx, exp["name"]) == expected, + "Expected treatment #{expected} for #{exp["name"]}" + end + end + + test "should return correct peek for each experiment" do + ctx = start_context(@get_context_response) + for exp <- @get_context_response["experiments"] do + expected = Map.get(@expected_variants, exp["name"]) + assert Context.peek(ctx, exp["name"]) == expected, + "Expected peek #{expected} for #{exp["name"]}" + end + end + end + + describe "unit management" do + test "should set a unit" do + ctx = start_context(@get_context_response, %{}) + Context.set_unit(ctx, "session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec") + Context.set_unit(ctx, "user_id", "123456789") + Context.set_unit(ctx, "email", "bleh@absmartly.com") + + assert Context.get_unit(ctx, "session_id") == "e791e240fcd3df7d238cfc285f475e8152fcc0ec" + assert Context.get_unit(ctx, "user_id") == "123456789" + assert Context.get_unit(ctx, "email") == "bleh@absmartly.com" + + units = Context.get_units(ctx) + assert units == %{ + "session_id" => "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id" => "123456789", + "email" => "bleh@absmartly.com" + } + end + + test "should error on duplicate unit type set with different value" do + ctx = start_context(@get_context_response) + assert {:error, "Unit 'session_id' UID already set."} = Context.set_unit(ctx, "session_id", "new_id") + end + + test "should not error if set to same value" do + ctx = start_context(@get_context_response) + assert :ok = Context.set_unit(ctx, "session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec") + end + + test "should error after finalized call" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert {:error, "ABsmartly Context is finalized."} = Context.set_unit(ctx, "test", "test") + end + + test "should set multiple units" do + ctx = start_context(@get_context_response, %{}) + Context.set_units(ctx, %{ + "session_id" => "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id" => "123456789" + }) + assert Context.get_unit(ctx, "session_id") == "e791e240fcd3df7d238cfc285f475e8152fcc0ec" + assert Context.get_unit(ctx, "user_id") == "123456789" + end + end + + describe "attribute management" do + test "should get the last set attribute" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "attr1", "value1") + Context.set_attribute(ctx, "attr1", "value2") + assert Context.get_attribute(ctx, "attr1") == "value2" + end + + test "should set an attribute" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "attr1", "value1") + Context.set_attribute(ctx, "attr2", "value2") + Context.set_attribute(ctx, "attr3", 15) + assert Context.get_attribute(ctx, "attr1") == "value1" + assert Context.get_attribute(ctx, "attr2") == "value2" + assert Context.get_attribute(ctx, "attr3") == 15 + end + + test "should return nil for unset attribute" do + ctx = start_context(@get_context_response) + assert Context.get_attribute(ctx, "not_set") == nil + end + end + + describe "peek" do + test "should not queue exposures" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + for exp <- @get_context_response["experiments"] do + expected = Map.get(@expected_variants, exp["name"]) + assert Context.peek(ctx, exp["name"]) == expected + end + + assert Context.pending(ctx) == 0 + end + + test "should return override variant" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + for exp <- @get_context_response["experiments"] do + expected = Map.get(@expected_variants, exp["name"]) + Context.set_override(ctx, exp["name"], expected + 11) + end + Context.set_override(ctx, "not_found", 3) + + for exp <- @get_context_response["experiments"] do + expected = Map.get(@expected_variants, exp["name"]) + assert Context.peek(ctx, exp["name"]) == expected + 11 + end + assert Context.peek(ctx, "not_found") == 3 + + assert Context.pending(ctx) == 0 + end + + test "should return assigned variant on audience mismatch in non-strict mode" do + ctx = start_context(audience_context_response()) + assert Context.peek(ctx, "exp_test_ab") == 1 + end + + test "should return control variant on audience mismatch in strict mode" do + ctx = start_context(audience_strict_context_response()) + assert Context.peek(ctx, "exp_test_ab") == 0 + end + end + + describe "treatment" do + test "should queue exposures" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + for exp <- @get_context_response["experiments"] do + Context.treatment(ctx, exp["name"]) + end + + assert Context.pending(ctx) == length(@get_context_response["experiments"]) + end + + test "should queue exposures after peek" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + for exp <- @get_context_response["experiments"] do + Context.peek(ctx, exp["name"]) + end + + assert Context.pending(ctx) == 0 + + for exp <- @get_context_response["experiments"] do + Context.treatment(ctx, exp["name"]) + end + + assert Context.pending(ctx) == length(@get_context_response["experiments"]) + end + + test "should queue exposures only once" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + for exp <- @get_context_response["experiments"] do + Context.treatment(ctx, exp["name"]) + end + + assert Context.pending(ctx) == length(@get_context_response["experiments"]) + + for exp <- @get_context_response["experiments"] do + Context.treatment(ctx, exp["name"]) + end + + assert Context.pending(ctx) == length(@get_context_response["experiments"]) + end + + test "should return 0 for unknown/stopped experiment" do + ctx = start_context(@get_context_response) + assert Context.treatment(ctx, "not_found") == 0 + assert Context.pending(ctx) == 1 + end + + test "should not re-queue exposure on unknown experiment" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + assert Context.treatment(ctx, "not_found") == 0 + assert Context.pending(ctx) == 1 + + assert Context.treatment(ctx, "not_found") == 0 + assert Context.pending(ctx) == 1 + end + + test "should queue exposure with audienceMismatch true on audience mismatch in non-strict mode" do + test_pid = self() + context_data = Types.ContextData.from_map(audience_context_response()) + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_config = %Types.ContextConfig{ + units: @context_params, + overrides: %{}, + custom_assignments: %{}, + event_handler: fn type, data -> send(test_pid, {:ev, type, data}) end + } + {:ok, ctx} = Context.start_link(sdk_config, context_data, context_config) + + assert Context.treatment(ctx, "exp_test_ab") == 1 + assert Context.pending(ctx) == 1 + assert_received {:ev, :exposure, exposure} + assert exposure.audience_mismatch == true + end + + test "should return control variant on audience mismatch in strict mode" do + ctx = start_context(audience_strict_context_response()) + assert Context.treatment(ctx, "exp_test_ab") == 0 + assert Context.pending(ctx) == 1 + end + + test "should queue exposure with override variant" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + Context.set_override(ctx, "exp_test_ab", 5) + Context.set_override(ctx, "not_found", 3) + + assert Context.treatment(ctx, "exp_test_ab") == 5 + assert Context.treatment(ctx, "not_found") == 3 + + assert Context.pending(ctx) == 2 + end + + test "should return 0 after finalized call" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + + Context.finalize(ctx) + # JS context.ts:533-540,505-508 - _checkReady(true) throws after finalize + assert Context.treatment(ctx, "exp_test_ab") == {:error, "ABsmartly Context is finalized."} + end + + test "should return full-on variant when full_on_variant is set" do + ctx = start_context(@get_context_response) + assert Context.treatment(ctx, "exp_test_fullon") == 2 + end + + test "should return correct variant for each experiment" do + ctx = start_context(@get_context_response) + for {name, expected} <- @expected_variants do + assert Context.treatment(ctx, name) == expected + end + end + end + + describe "variable_value" do + test "should return default when unassigned in strict mode" do + ctx = start_context(audience_strict_context_response()) + assert Context.variable_value(ctx, "banner.size", 17) == 17 + end + + test "should return variable value when overridden" do + ctx = start_context(audience_strict_context_response()) + Context.set_override(ctx, "exp_test_ab", 0) + assert Context.variable_value(ctx, "banner.size", 17) == "tiny" + end + + test "should return correct variable values" do + ctx = start_context(@get_context_response) + for {key, expected} <- @expected_variables do + assert Context.variable_value(ctx, key, 17) == expected, + "Expected #{inspect(expected)} for variable #{key}" + end + end + + test "should return default for ineligible experiment" do + ctx = start_context(@get_context_response) + assert Context.variable_value(ctx, "card.width", "default") == "default" + end + + test "should queue exposures" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + all_variable_keys = Map.merge(@expected_variables, %{"card.width" => "default"}) + for {key, _expected} <- all_variable_keys do + Context.variable_value(ctx, key, 17) + end + + assert Context.pending(ctx) == length(@get_context_response["experiments"]) + end + + test "should queue exposures only once" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + all_variable_keys = Map.merge(@expected_variables, %{"card.width" => "default"}) + for {key, _expected} <- all_variable_keys do + Context.variable_value(ctx, key, 17) + end + + count = Context.pending(ctx) + + for {key, _expected} <- all_variable_keys do + Context.variable_value(ctx, key, 17) + end + + assert Context.pending(ctx) == count + end + + test "should return default for unknown variable" do + ctx = start_context(@get_context_response) + assert Context.variable_value(ctx, "not.found", 17) == 17 + end + + # JS context.ts:445-448,533-540 - variableValue calls _checkReady(true) + test "should return a finalized error after finalized call" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert Context.variable_value(ctx, "banner.size", 17) == {:error, "ABsmartly Context is finalized."} + end + + test "conflicting key disjoint audiences" do + ctx1 = start_context(disjointed_context_response()) + ctx2 = start_context(disjointed_context_response()) + + Context.set_attribute(ctx1, "age", 20) + assert Context.variable_value(ctx1, "icon", "square") == "arrow" + + Context.set_attribute(ctx2, "age", 19) + assert Context.variable_value(ctx2, "icon", "square") == "circle" + end + end + + describe "peek_variable_value" do + test "should return default when unassigned in strict mode" do + ctx = start_context(audience_strict_context_response()) + assert Context.peek_variable_value(ctx, "banner.size", 17) == 17 + end + + test "should return variable value when overridden" do + ctx = start_context(audience_strict_context_response()) + Context.set_override(ctx, "exp_test_ab", 0) + assert Context.peek_variable_value(ctx, "banner.size", 17) == "tiny" + end + + test "should return correct variable values" do + ctx = start_context(@get_context_response) + for {key, expected} <- @expected_variables do + assert Context.peek_variable_value(ctx, key, 17) == expected, + "Expected #{inspect(expected)} for variable #{key}" + end + end + + test "should not queue exposures" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + for {key, _expected} <- @expected_variables do + Context.peek_variable_value(ctx, key, 17) + end + + assert Context.pending(ctx) == 0 + end + + test "should queue exposures after peek via variable_value" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + all_variable_keys = Map.merge(@expected_variables, %{"card.width" => "default"}) + for {key, _expected} <- all_variable_keys do + Context.peek_variable_value(ctx, key, 17) + end + + assert Context.pending(ctx) == 0 + + for {key, _expected} <- all_variable_keys do + Context.variable_value(ctx, key, 17) + end + + assert Context.pending(ctx) == length(@get_context_response["experiments"]) + end + + test "conflicting key disjoint audiences" do + ctx1 = start_context(disjointed_context_response()) + ctx2 = start_context(disjointed_context_response()) + + Context.set_attribute(ctx1, "age", 20) + assert Context.peek_variable_value(ctx1, "icon", "square") == "arrow" + + Context.set_attribute(ctx2, "age", 19) + assert Context.peek_variable_value(ctx2, "icon", "square") == "circle" + end + end + + describe "variable_keys" do + test "should return all active variable keys" do + ctx = start_context(@get_context_response) + keys = Context.variable_keys(ctx) + assert is_map(keys) + assert Map.has_key?(keys, "banner.border") + assert Map.has_key?(keys, "banner.size") + assert Map.has_key?(keys, "button.color") + assert Map.has_key?(keys, "card.width") + assert Map.has_key?(keys, "submit.color") + assert Map.has_key?(keys, "submit.shape") + assert Map.has_key?(keys, "submit.size") + assert "exp_test_ab" in keys["banner.border"] + assert "exp_test_ab" in keys["banner.size"] + end + end + + describe "track" do + test "should queue goals" do + ctx = start_context(@get_context_response) + Context.track(ctx, "goal1", %{"amount" => 125, "hours" => 245}) + Context.track(ctx, "goal2", %{"tries" => 7}) + + assert Context.pending(ctx) == 2 + end + + test "should accept nil properties" do + ctx = start_context(@get_context_response) + Context.track(ctx, "goal1") + assert Context.pending(ctx) == 1 + end + + test "should error after finalized call" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert {:error, "ABsmartly Context is finalized."} = Context.track(ctx, "goal1") + end + + test "should include goals with exposures in pending count" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + Context.track(ctx, "goal1") + assert Context.pending(ctx) == 2 + end + end + + describe "publish" do + test "should not publish when queue is empty" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + assert :ok = Context.publish(ctx) + end + + test "should clear queue on success" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + result = Context.publish(ctx) + assert result == :ok + assert Context.pending(ctx) == 0 + end + + test "should error after finalized call" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert {:error, "ABsmartly Context is finalized."} = Context.publish(ctx) + end + + test "should include exposures and goals" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + Context.track(ctx, "goal1", %{"amount" => 125}) + assert Context.pending(ctx) == 2 + result = Context.publish(ctx) + assert result == :ok + assert Context.pending(ctx) == 0 + end + end + + describe "finalize" do + test "should publish pending data" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + assert Context.pending(ctx) == 0 + end + + test "should be idempotent" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + end + + test "should not publish when queue is empty" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + end + + test "should clear queue on success" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + Context.track(ctx, "goal1") + assert Context.pending(ctx) == 2 + Context.finalize(ctx) + assert Context.pending(ctx) == 0 + end + end + + describe "override" do + test "should override treatment" do + ctx = start_context(@get_context_response) + Context.set_override(ctx, "exp_test_ab", 5) + assert Context.treatment(ctx, "exp_test_ab") == 5 + end + + test "should override for unknown experiment" do + ctx = start_context(@get_context_response) + Context.set_override(ctx, "not_found", 3) + assert Context.treatment(ctx, "not_found") == 3 + end + + test "should succeed after finalized call" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert :ok = Context.set_override(ctx, "exp_test_ab", 5) + end + + test "should override multiple experiments" do + ctx = start_context(@get_context_response) + Context.set_overrides(ctx, %{ + "exp_test_ab" => 5, + "exp_test_abc" => 3 + }) + assert Context.treatment(ctx, "exp_test_ab") == 5 + assert Context.treatment(ctx, "exp_test_abc") == 3 + end + end + + describe "custom_assignment" do + test "should override natural assignment and set custom flag" do + ctx = start_context(@get_context_response) + Context.set_custom_assignment(ctx, "exp_test_ab", 3) + assert Context.treatment(ctx, "exp_test_ab") == 3 + end + + test "should not override full-on assignment" do + ctx = start_context(@get_context_response) + Context.set_custom_assignment(ctx, "exp_test_fullon", 3) + assert Context.treatment(ctx, "exp_test_fullon") == 2 + end + + test "should not override non-eligible assignment" do + ctx = start_context(@get_context_response) + Context.set_custom_assignment(ctx, "exp_test_not_eligible", 3) + assert Context.treatment(ctx, "exp_test_not_eligible") == 0 + end + + test "should error after finalized call" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert {:error, "ABsmartly Context is finalized."} = Context.set_custom_assignment(ctx, "exp_test_ab", 3) + end + + test "should set multiple custom assignments" do + ctx = start_context(@get_context_response) + Context.set_custom_assignments(ctx, %{ + "exp_test_ab" => 3, + "exp_test_abc" => 1 + }) + assert Context.treatment(ctx, "exp_test_ab") == 3 + assert Context.treatment(ctx, "exp_test_abc") == 1 + end + end + + describe "refresh" do + test "should load new data" do + ctx = start_context_with_refresh(@get_context_response, @refresh_context_response) + Context.refresh(ctx) + + experiment_names = Context.experiments(ctx) + expected_names = Enum.map(@refresh_context_response["experiments"], & &1["name"]) + assert experiment_names == expected_names + end + + test "should re-queue exposures after refresh" do + ctx = start_context_with_refresh(@get_context_response, @refresh_context_response) + + for exp <- @get_context_response["experiments"] do + Context.treatment(ctx, exp["name"]) + end + + num_old = length(@get_context_response["experiments"]) + assert Context.pending(ctx) == num_old + + Context.refresh(ctx) + + for exp <- @refresh_context_response["experiments"] do + Context.treatment(ctx, exp["name"]) + end + + num_added = length(@refresh_context_response["experiments"]) - num_old + assert Context.pending(ctx) == num_old + num_added + end + + test "should not re-queue after refresh when experiment unchanged" do + ctx = start_context_with_refresh(audience_strict_context_response(), audience_strict_context_response()) + + assert Context.treatment(ctx, "exp_test_ab") == 0 + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_ab") == 0 + assert Context.pending(ctx) == 1 + end + + test "should not re-queue after refresh with override when experiment unchanged" do + ctx = start_context_with_refresh(audience_strict_context_response(), audience_strict_context_response()) + + Context.set_override(ctx, "exp_test_ab", 3) + assert Context.treatment(ctx, "exp_test_ab") == 3 + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_ab") == 3 + assert Context.pending(ctx) == 1 + end + + test "should error after finalized call" do + ctx = start_context_with_refresh(@get_context_response, @refresh_context_response) + Context.finalize(ctx) + assert {:error, "ABsmartly Context is finalized."} = Context.refresh(ctx) + end + + test "should keep overrides" do + ctx = start_context_with_refresh(@get_context_response, @refresh_context_response) + Context.set_override(ctx, "not_found", 3) + assert Context.peek(ctx, "not_found") == 3 + + Context.refresh(ctx) + assert Context.peek(ctx, "not_found") == 3 + end + + test "should keep custom assignments" do + ctx = start_context_with_refresh(@get_context_response, @refresh_context_response) + Context.set_custom_assignment(ctx, "exp_test_ab", 3) + assert Context.peek(ctx, "exp_test_ab") == 3 + + Context.refresh(ctx) + assert Context.peek(ctx, "exp_test_ab") == 3 + end + + test "should pick up changes in experiment stopped" do + stopped_response = %{ + @get_context_response | + "experiments" => Enum.filter(@get_context_response["experiments"], fn e -> + e["name"] != "exp_test_abc" + end) + } + ctx = start_context_with_refresh(@get_context_response, stopped_response) + + assert Context.treatment(ctx, "exp_test_abc") == @expected_variants["exp_test_abc"] + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_abc") == 0 + assert Context.pending(ctx) == 2 + end + + test "should pick up changes in experiment started" do + ctx = start_context_with_refresh(@get_context_response, @refresh_context_response) + + assert Context.treatment(ctx, "exp_test_new") == 0 + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_new") == 1 + assert Context.pending(ctx) == 2 + end + + test "should pick up changes in experiment fullon" do + full_on_response = update_experiment(@get_context_response, "exp_test_abc", fn exp -> + Map.put(exp, "fullOnVariant", 1) + end) + ctx = start_context_with_refresh(@get_context_response, full_on_response) + + assert Context.treatment(ctx, "exp_test_abc") == @expected_variants["exp_test_abc"] + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_abc") == 1 + assert Context.pending(ctx) == 2 + end + + test "should pick up changes in experiment traffic split" do + scaled_up_response = update_experiment(@get_context_response, "exp_test_not_eligible", fn exp -> + Map.put(exp, "trafficSplit", [0.0, 1.0]) + end) + ctx = start_context_with_refresh(@get_context_response, scaled_up_response) + + assert Context.treatment(ctx, "exp_test_not_eligible") == @expected_variants["exp_test_not_eligible"] + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_not_eligible") == 2 + assert Context.pending(ctx) == 2 + end + + test "should pick up changes in experiment iteration" do + iterated_response = update_experiment(@get_context_response, "exp_test_abc", fn exp -> + exp + |> Map.put("iteration", 2) + |> Map.put("trafficSeedHi", 398724581) + |> Map.put("seedHi", 34737352) + end) + ctx = start_context_with_refresh(@get_context_response, iterated_response) + + assert Context.treatment(ctx, "exp_test_abc") == @expected_variants["exp_test_abc"] + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_abc") == 1 + assert Context.pending(ctx) == 2 + end + + test "should pick up changes in experiment id" do + id_changed_response = update_experiment(@get_context_response, "exp_test_abc", fn exp -> + exp + |> Map.put("id", 11) + |> Map.put("trafficSeedHi", 398724581) + |> Map.put("seedHi", 34737352) + end) + ctx = start_context_with_refresh(@get_context_response, id_changed_response) + + assert Context.treatment(ctx, "exp_test_abc") == @expected_variants["exp_test_abc"] + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + + assert Context.treatment(ctx, "exp_test_abc") == 1 + assert Context.pending(ctx) == 2 + end + end + + describe "custom_field_keys" do + test "should return all custom field keys across all experiments" do + ctx = start_context(@get_context_response) + keys = Context.custom_field_keys(ctx) + assert "country" in keys + assert "json_object" in keys + assert "json_array" in keys + assert "json_number" in keys + assert "json_string" in keys + assert "json_boolean" in keys + assert "json_null" in keys + assert "json_invalid" in keys + end + + test "should return empty list when no experiments have custom fields" do + ctx = start_context(%{"experiments" => []}) + assert Context.custom_field_keys(ctx) == [] + end + end + + describe "custom_field_value" do + test "should return string custom field value" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "country") == "US,PT,ES,DE,FR" + end + + test "should return parsed json object field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_object") == %{"123" => 1, "456" => 0} + end + + test "should return parsed json array field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_array") == ["hello", "world"] + end + + test "should return parsed json number field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_number") == 123 + end + + test "should return parsed json string field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_string") == "hello" + end + + test "should return parsed json boolean field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_boolean") == true + end + + test "should return parsed json null field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_null") == nil + end + + test "should return nil for invalid json field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "json_invalid") == nil + end + + test "should return string field from custom fields experiment" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "country") == "US,PT,ES" + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "languages") == "en-US,en-GB,pt-PT,pt-BR,es-ES,es-MX" + end + + test "should return text field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "text_field") == "hello text" + end + + test "should return string_field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "string_field") == "hello string" + end + + test "should return number field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "number_field") == 123.0 + end + + test "should return boolean true field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "boolean_field") == true + end + + test "should return boolean false field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "false_boolean_field") == false + end + + test "should return value as-is for invalid type" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_custom_fields", "invalid_type_field") == "invalid" + end + + test "should return nil for non-existent field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_abc", "not_found") == nil + end + + test "should return nil for experiment without custom fields" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "exp_test_ab", "country") == nil + end + + test "should return nil for unknown experiment" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value(ctx, "not_found", "country") == nil + end + end + + describe "custom_field_value_type" do + test "should return custom field value type" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value_type(ctx, "exp_test_abc", "country") == "string" + assert Context.custom_field_value_type(ctx, "exp_test_abc", "json_object") == "json" + end + + test "should return nil for non-existent field" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value_type(ctx, "exp_test_abc", "not_found") == nil + end + + test "should return nil for unknown experiment" do + ctx = start_context(@get_context_response) + assert Context.custom_field_value_type(ctx, "not_found", "country") == nil + end + end + + describe "close/is_closed?/is_closing? aliases" do + test "close is alias for finalize" do + ctx = start_context(@get_context_response) + assert Context.is_finalized?(ctx) == false + assert Context.close(ctx) == :ok + assert Context.is_finalized?(ctx) == true + end + + test "is_closed? is alias for is_finalized?" do + ctx = start_context(@get_context_response) + assert Context.is_closed?(ctx) == false + Context.finalize(ctx) + assert Context.is_closed?(ctx) == true + end + + test "is_closing? is alias for is_finalizing?" do + ctx = start_context(@get_context_response) + assert Context.is_closing?(ctx) == Context.is_finalizing?(ctx) + end + end + + describe "standardized error messages" do + test "finalized error message" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert Context.track(ctx, "goal") == {:error, "ABsmartly Context is finalized."} + end + + test "unit UID already set error message" do + ctx = start_context(@get_context_response) + assert {:error, msg} = Context.set_unit(ctx, "session_id", "different-uid") + assert msg =~ "UID already set." + end + + test "unit UID must not be blank error message" do + ctx = start_context(@get_context_response) + assert {:error, "Unit 'new_unit' UID must not be blank."} = Context.set_unit(ctx, "new_unit", "") + end + end +end diff --git a/test/absmartly/failure_semantics_test.exs b/test/absmartly/failure_semantics_test.exs new file mode 100644 index 0000000..65dcf87 --- /dev/null +++ b/test/absmartly/failure_semantics_test.exs @@ -0,0 +1,785 @@ +defmodule ABSmartly.FailureSemanticsTest do + @moduledoc """ + A failed publish must not lose pending events, and a failed load must be + reported through the `:error` event. + + Cross-SDK scenarios: 193 (publish failure keeps pending events) and 200 + (a failed context load emits an `error` event). + """ + + use ExUnit.Case, async: true + + alias ABSmartly.{Context, Types} + alias ABSmartly.Test.ScriptedPublisher + + @units %{"session_id" => "e791e240fcd3df7d238cfc285f475e8152fcc0ec"} + + @context_data %{ + "experiments" => [ + %{ + "id" => 1, + "name" => "exp_test_ab", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 3_603_515, + "seedLo" => 233_373_850, + "split" => [0.5, 0.5], + "trafficSeedHi" => 449_867_249, + "trafficSeedLo" => 455_443_629, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => nil} + ], + "audience" => nil, + "customFieldValues" => nil + } + ] + } + + defp event_recorder do + test_pid = self() + fn type, data -> send(test_pid, {:context_event, type, data}) end + end + + defp sdk_config(endpoint) do + %Types.SDKConfig{ + endpoint: endpoint, + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ScriptedPublisher + } + end + + defp context_config do + %Types.ContextConfig{ + units: @units, + overrides: %{}, + custom_assignments: %{}, + event_handler: event_recorder() + } + end + + defp start_context(publisher_script) do + endpoint = ScriptedPublisher.start(publisher_script) + data = Types.ContextData.from_map(@context_data) + {:ok, ctx} = Context.start_link(sdk_config(endpoint), data, context_config()) + assert_received {:context_event, :ready, _} + {ctx, endpoint} + end + + defp exposure_names(event_map), do: Enum.map(event_map["exposures"], & &1["name"]) + + describe "publish/1 when the publisher fails" do + test "returns the publisher error and keeps the pending events" do + {ctx, _endpoint} = start_context([{:error, "Publish failed"}]) + + assert Context.treatment(ctx, "exp_test_ab") == 1 + assert Context.pending(ctx) == 1 + + assert {:error, "Publish failed"} = Context.publish(ctx) + assert Context.pending(ctx) == 1 + end + + test "emits an :error event and no :publish event" do + {ctx, _endpoint} = start_context([{:error, "Publish failed"}]) + Context.treatment(ctx, "exp_test_ab") + assert_received {:context_event, :exposure, _} + + assert {:error, "Publish failed"} = Context.publish(ctx) + + assert_received {:context_event, :error, "Publish failed"} + refute_received {:context_event, :publish, _} + end + + test "a later publish sends the kept events and then clears them" do + {ctx, endpoint} = start_context([{:error, "Publish failed"}, :ok]) + Context.treatment(ctx, "exp_test_ab") + Context.track(ctx, "purchase", %{"amount" => 10}) + assert Context.pending(ctx) == 2 + + assert {:error, "Publish failed"} = Context.publish(ctx) + assert Context.pending(ctx) == 2 + + assert :ok = Context.publish(ctx) + assert Context.pending(ctx) == 0 + + assert [failed, published] = ScriptedPublisher.attempts(endpoint) + assert exposure_names(failed) == ["exp_test_ab"] + assert exposure_names(published) == ["exp_test_ab"] + assert Enum.map(published["goals"], & &1["name"]) == ["purchase"] + + assert_received {:context_event, :publish, event_map} + assert exposure_names(event_map) == ["exp_test_ab"] + end + + test "a raising publisher is reported as an error and does not crash the context" do + {ctx, _endpoint} = start_context([{:raise, "connection reset"}]) + Context.treatment(ctx, "exp_test_ab") + + assert {:error, "connection reset"} = Context.publish(ctx) + assert Process.alive?(ctx) + assert Context.pending(ctx) == 1 + assert_received {:context_event, :error, "connection reset"} + end + end + + describe "publish/1 when the publisher succeeds" do + test "emits :publish only after the publisher accepts the events" do + {ctx, endpoint} = start_context([:ok]) + Context.treatment(ctx, "exp_test_ab") + + assert :ok = Context.publish(ctx) + assert Context.pending(ctx) == 0 + assert [_attempt] = ScriptedPublisher.attempts(endpoint) + assert_received {:context_event, :publish, _} + refute_received {:context_event, :error, _} + end + + test "does not call the publisher when nothing is pending" do + {ctx, endpoint} = start_context([]) + + assert :ok = Context.publish(ctx) + assert ScriptedPublisher.attempts(endpoint) == [] + refute_received {:context_event, :publish, _} + end + end + + describe "finalize/1 when the publisher fails" do + test "returns the error, keeps the events and leaves the context open" do + {ctx, _endpoint} = start_context([{:error, "Publish failed"}]) + Context.treatment(ctx, "exp_test_ab") + + assert {:error, "Publish failed"} = Context.finalize(ctx) + assert Context.is_finalized?(ctx) == false + assert Context.is_finalizing?(ctx) == false + assert Context.pending(ctx) == 1 + assert_received {:context_event, :error, "Publish failed"} + refute_received {:context_event, :finalize, _} + end + + test "a later finalize publishes the kept events and seals the context" do + {ctx, endpoint} = start_context([{:error, "Publish failed"}, :ok]) + Context.treatment(ctx, "exp_test_ab") + + assert {:error, "Publish failed"} = Context.finalize(ctx) + assert :ok = Context.finalize(ctx) + + assert Context.is_finalized?(ctx) == true + assert Context.pending(ctx) == 0 + assert [_failed, published] = ScriptedPublisher.attempts(endpoint) + assert exposure_names(published) == ["exp_test_ab"] + assert_received {:context_event, :finalize, nil} + end + end + + describe "failed context load" do + test "a failing data_fetcher emits one :error event with the reason" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:error, "Context load failed"} end + ) + + assert {:error, "Context load failed"} = Context.wait_until_ready(ctx, 1_000) + assert Context.is_failed?(ctx) == true + assert Context.is_ready?(ctx) == true + assert Context.ready_error(ctx) == "Context load failed" + + assert_received {:context_event, :error, "Context load failed"} + refute_received {:context_event, :error, _} + refute_received {:context_event, :ready, _} + end + + test "set_failed/2 emits an :error event with the reason" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> + Process.sleep(1_000) + {:error, "late fetch result"} + end + ) + + assert :ok = Context.set_failed(ctx, :fetch_error) + assert Context.is_failed?(ctx) == true + assert Context.ready_error(ctx) == :fetch_error + assert_received {:context_event, :error, :fetch_error} + end + + test "SDK.create_context_async/3 starts a supervised context that reaches a terminal state" do + {:ok, sdk} = + ABSmartly.SDK.new( + endpoint: "http://127.0.0.1:1", + api_key: "k", + application: "app", + environment: "test", + retries: 0, + timeout: 500 + ) + + assert {:ok, ctx} = ABSmartly.SDK.create_context_async(sdk, %{"user_id" => "u1"}) + assert is_pid(ctx) + assert {:error, _reason} = Context.wait_until_ready(ctx, 5_000) + assert Context.is_failed?(ctx) == true + end + + test "SDK timeout bounds each HTTP request" do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + {:ok, port} = :inet.port(l) + spawn_link(fn -> + {:ok, c} = :gen_tcp.accept(l) + Process.sleep(2_000) + :gen_tcp.close(c) + end) + + t0 = System.monotonic_time(:millisecond) + assert {:error, _} = ABSmartly.HTTP.Client.fetch_context("http://127.0.0.1:#{port}", "k", "a", "e", 0, 100) + assert System.monotonic_time(:millisecond) - t0 < 1_000 + end + + test "dispose/1 finalizes and releases the supervised context process" do + {:ok, sdk} = + ABSmartly.SDK.new(endpoint: "http://127.0.0.1:1", api_key: "k", application: "app", environment: "test", retries: 0, timeout: 200) + + {:ok, ctx} = ABSmartly.SDK.create_context_async(sdk, %{"user_id" => "u1"}) + ref = Process.monitor(ctx) + before = DynamicSupervisor.count_children(ABSmartly.ContextSupervisor).active + assert :ok = Context.dispose(ctx) + assert_receive {:DOWN, ^ref, :process, ^ctx, _}, 1_000 + refute Process.alive?(ctx) + assert DynamicSupervisor.count_children(ABSmartly.ContextSupervisor).active < before + end + + test "dispose/1 releases the process even when the final publish keeps failing" do + defmodule FailingPublisher do + def publish(_e, _k, _a, _env, _events, _retries), do: {:error, "collector down"} + end + + {:ok, sdk} = + ABSmartly.SDK.new(endpoint: "http://127.0.0.1:1", api_key: "k", application: "app", environment: "test", retries: 0, timeout: 200) + + sdk = %{sdk | config: Map.put(sdk.config, :publisher, FailingPublisher)} + {:ok, ctx} = ABSmartly.SDK.create_context_async(sdk, %{"user_id" => "u1"}) + :ok = Context.set_data(ctx, %{"experiments" => []}) + :ok = Context.track(ctx, "goal", %{}) + ref = Process.monitor(ctx) + + assert {:error, {:disposed_unpublished, "collector down"}} = + Context.dispose(ctx, attempts: 2, backoff: 1) + + assert_receive {:DOWN, ^ref, :process, ^ctx, _}, 1_000 + end + + test "dispose/1 is not held up by a blocking publisher at termination" do + defmodule BlockingPublisher do + def publish(_e, _k, _a, _env, _events, _retries), do: {:error, "first"} + end + + {:ok, sdk} = + ABSmartly.SDK.new(endpoint: "http://127.0.0.1:1", api_key: "k", application: "app", environment: "test", retries: 0, timeout: 50) + + sdk = %{sdk | config: Map.put(sdk.config, :publisher, BlockingPublisher)} + {:ok, ctx} = ABSmartly.SDK.create_context_async(sdk, %{"user_id" => "u1"}) + :ok = Context.set_data(ctx, %{"experiments" => []}) + :ok = Context.track(ctx, "goal", %{}) + t0 = System.monotonic_time(:millisecond) + assert {:error, {:disposed_unpublished, "first"}} = Context.dispose(ctx, attempts: 1) + assert System.monotonic_time(:millisecond) - t0 < 2_000 + refute Process.alive?(ctx) + end + + test "malformed refresh data returns an error and keeps the context alive" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:ok, %{"experiments" => []}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + assert {:error, _} = Context.refresh(ctx, %{"experiments" => %{"x" => 1}}) + assert Process.alive?(ctx) + bad = %{"experiments" => [%{"id" => 1, "name" => "e", "unitType" => "user", "split" => [0.5, 0.5], "variants" => 42}]} + assert {:error, _} = Context.refresh(ctx, bad) + assert Process.alive?(ctx) + assert :ok = Context.refresh(ctx, %{"experiments" => []}) + end + + test "split with a non-list value is rejected without killing the context" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:ok, %{"experiments" => []}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + bad = %{"experiments" => [%{"id" => 1, "name" => "e", "unitType" => "user", "split" => 42}]} + assert {:error, _} = Context.refresh(ctx, bad) + assert Process.alive?(ctx) + end + + # D7 / JS context.ts:363-376,579-631 - a plain cached assignment is not + # evicted by adding its unit. + test "adding a missing unit keeps a cached assignment" do + exp = fn aud -> + %{"id" => 1, "name" => "e", "unitType" => "user", "iteration" => 1, "seedHi" => 1, "seedLo" => 2, + "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, "trafficSplit" => [0, 1], + "variants" => [%{"config" => nil}, %{"config" => nil}], + "audienceStrict" => true, "audience" => Jason.encode!(%{"filter" => [%{"value" => aud}]})} + end + + cfg = %{context_config() | units: %{}} + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), cfg, + data_fetcher: fn -> {:ok, %{"experiments" => [exp.(true)]}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + assert Context.peek(ctx, "e") == 0 + assert :ok = Context.set_unit(ctx, "user", "u") + assert Context.peek(ctx, "e") == 0 + end + + test "an audience-only refresh keeps a cached assignment the new audience would reject" do + exp = fn aud -> + %{"id" => 1, "name" => "e", "unitType" => "session_id", "iteration" => 1, "seedHi" => 1, "seedLo" => 2, + "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, "trafficSplit" => [0, 1], + "variants" => [%{"config" => nil}, %{"config" => nil}], + "audienceStrict" => true, "audience" => Jason.encode!(%{"filter" => [%{"value" => aud}]})} + end + + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:ok, %{"experiments" => [exp.(true)]}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + assert Context.peek(ctx, "e") == 1 + assert :ok = Context.refresh(ctx, %{"experiments" => [exp.(false)]}) + assert Context.peek(ctx, "e") == 1 + end + + test "malformed customFieldValues is rejected without killing the context" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:ok, %{"experiments" => []}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + bad = %{"experiments" => [%{"id" => 1, "name" => "e", "unitType" => "user", "split" => [0, 1], "customFieldValues" => 42}]} + assert {:error, _} = Context.refresh(ctx, bad) + assert Process.alive?(ctx) + assert Context.custom_field_keys(ctx) == [] + end + + # D7 / JS context.ts:363-376,579-631: adding a unit does not evict a plain + # assignment and audience is only re-evaluated on attribute change, so + # neither a later unit nor an audience-only refresh recalculates it. + test "treatments stay sticky after unit add / audience-only refresh" do + defmodule CapturePublisher do + def publish(_e, _k, _a, _env, events, _r) do + send(:capture_pub, {:published, events}) + :ok + end + end + + exp = fn aud -> + %{"id" => 1, "name" => "e", "unitType" => "user", "iteration" => 1, "seedHi" => 1, "seedLo" => 2, + "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, "trafficSplit" => [0, 1], + "variants" => [%{"config" => nil}, %{"config" => nil}], + "audienceStrict" => true, "audience" => Jason.encode!(%{"filter" => [%{"value" => aud}]})} + end + + Process.register(self(), :capture_pub) + cfg = %{context_config() | units: %{}} + sdk = %{sdk_config("unused") | publisher: CapturePublisher} + + {:ok, ctx} = + Context.start_link_async(sdk, cfg, data_fetcher: fn -> {:ok, %{"experiments" => [exp.(true)]}} end) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + assert Context.treatment(ctx, "e") == 0 + :ok = Context.set_unit(ctx, "user", "u") + assert Context.treatment(ctx, "e") == 0 + :ok = Context.publish(ctx) + assert_receive {:published, ev1}, 1_000 + assert [%{"name" => "e", "variant" => 0}] = ev1["exposures"] + + :ok = Context.refresh(ctx, %{"experiments" => [exp.(false)]}) + assert Context.treatment(ctx, "e") == 0 + assert Context.pending(ctx) == 0 + end + + test "json custom field with non-string value does not kill the context" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:ok, %{"experiments" => []}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + data = %{"experiments" => [%{"id" => 1, "name" => "e", "unitType" => "user", "split" => [0, 1], + "customFieldValues" => [%{"name" => "meta", "type" => "json", "value" => 42}]}]} + assert :ok = Context.refresh(ctx, data) + assert Context.custom_field_value(ctx, "e", "meta") == nil + assert Process.alive?(ctx) + end + + # D4 / JS context.ts:1323-1346 - refresh on a failed context is a no-op success + test "refresh on a failed context is a successful no-op" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> {:error, "down"} end + ) + + assert {:error, "down"} = Context.wait_until_ready(ctx, 1_000) + data = %{"experiments" => [%{"id" => 1, "name" => "e", "unitType" => "session_id", "iteration" => 1, + "seedHi" => 1, "seedLo" => 2, "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, + "trafficSplit" => [0, 1], "variants" => [%{"config" => nil}, %{"config" => nil}]}]} + assert :ok = Context.refresh(ctx, data) + assert Context.is_failed?(ctx) + assert Context.treatment(ctx, "e") == 0 + end + + test "unchanged set_unit keeps exposure dedup; supervisor shutdown publishes pending events" do + defmodule ShutdownPublisher do + def publish(_e, _k, _a, _env, events, _r) do + send(:shutdown_pub, {:published, events}) + :ok + end + end + + Process.register(self(), :shutdown_pub) + sdk = %{sdk_config("unused") | publisher: ShutdownPublisher} + + data = %{"experiments" => [%{"id" => 1, "name" => "e", "unitType" => "session_id", "iteration" => 1, + "seedHi" => 1, "seedLo" => 2, "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, + "trafficSplit" => [0, 1], "variants" => [%{"config" => nil}, %{"config" => nil}]}]} + + spec = %{ + id: Context, + start: {Context, :start_link_async, [sdk, context_config(), [data_fetcher: fn -> {:ok, data} end]]}, + restart: :temporary, + shutdown: 5_000 + } + + {:ok, ctx} = DynamicSupervisor.start_child(ABSmartly.ContextSupervisor, spec) + assert :ok = Context.wait_until_ready(ctx, 1_000) + assert Context.treatment(ctx, "e") == 1 + :ok = Context.set_unit(ctx, "session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec") + assert Context.treatment(ctx, "e") == 1 + :ok = Context.track(ctx, "g", %{}) + :ok = DynamicSupervisor.terminate_child(ABSmartly.ContextSupervisor, ctx) + assert_receive {:published, ev}, 2_000 + assert length(ev["exposures"]) == 1 + assert length(ev["goals"]) == 1 + end + + # D1 / JS context.ts:318-319,533-536 - refresh before ready is rejected; + # readiness waiters are still released when the load completes. + test "refresh on a loading context is rejected and waiters still resolve" do + {:ok, ctx} = + Context.start_link_async(sdk_config("unused"), context_config(), + data_fetcher: fn -> + Process.sleep(300) + {:ok, %{"experiments" => []}} + end + ) + + waiter = Task.async(fn -> Context.wait_until_ready(ctx, 2_000) end) + Process.sleep(50) + assert {:error, "ABsmartly Context is not yet ready."} = Context.refresh(ctx, %{"experiments" => []}) + assert :ok = Task.await(waiter, 3_000) + end + + test "child_spec shutdown budget covers the publisher-aware termination deadline" do + cfg = %{sdk_config("unused") | timeout: 50, retries: 0} + assert Context.child_spec([cfg, %Types.ContextData{}, context_config()]).shutdown >= 7_000 + assert Context.shutdown_timeout(%{cfg | publisher: ABSmartly.DefaultContextPublisher}) >= 2_000 + end + + @tag timeout: 20_000 + test "supervisor shutdown cancels a blocking trap_exit publisher within the child budget" do + defmodule StuckPublisher do + def publish(_e, _k, _a, _env, _events, _r) do + Process.flag(:trap_exit, true) + send(:stuck_pub, {:publisher_pid, self()}) + Process.sleep(:infinity) + end + end + + Process.register(self(), :stuck_pub) + sdk = %{sdk_config("unused") | publisher: StuckPublisher, timeout: 50, retries: 0} + spec = Context.child_spec([sdk, %Types.ContextData{}, context_config()]) + + {:ok, ctx} = DynamicSupervisor.start_child(ABSmartly.ContextSupervisor, spec) + :ok = Context.track(ctx, "g", %{}) + ref = Process.monitor(ctx) + :ok = DynamicSupervisor.terminate_child(ABSmartly.ContextSupervisor, ctx) + assert_receive {:DOWN, ^ref, :process, ^ctx, reason}, 10_000 + assert reason == :shutdown + assert_received {:publisher_pid, pub} + refute Process.alive?(pub) + end + + @tag timeout: 60_000 + test "queue overflow evicts the oldest pending exposures and goals" do + defmodule OverflowPublisher do + def publish(_e, _k, _a, _env, events, _r) do + send(:overflow_pub, {:published, events}) + :ok + end + end + + Process.register(self(), :overflow_pub) + sdk = %{sdk_config("unused") | publisher: OverflowPublisher} + + {:ok, ctx} = + Context.start_link_async(sdk, context_config(), data_fetcher: fn -> {:ok, %{"experiments" => []}} end) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + + for i <- 1..10_002 do + Context.set_override(ctx, "exp_#{i}", 1) + Context.treatment(ctx, "exp_#{i}") + Context.track(ctx, "goal_#{i}", nil) + end + + assert Context.pending(ctx) == 20_000 + :ok = Context.publish(ctx) + assert_receive {:published, ev}, 30_000 + + exp_names = MapSet.new(ev["exposures"], & &1["name"]) + goal_names = MapSet.new(ev["goals"], & &1["name"]) + assert MapSet.size(exp_names) == 10_000 + assert MapSet.size(goal_names) == 10_000 + + for i <- [1, 2] do + refute MapSet.member?(exp_names, "exp_#{i}") + refute MapSet.member?(goal_names, "goal_#{i}") + end + + for i <- [3, 10_001, 10_002] do + assert MapSet.member?(exp_names, "exp_#{i}") + assert MapSet.member?(goal_names, "goal_#{i}") + end + end + + @tag timeout: 30_000 + test "supervisor shutdown during an in-flight publish stays within the child budget" do + defmodule InflightPublisher do + def publish(_e, _k, _a, _env, _events, _r) do + Process.flag(:trap_exit, true) + send(:inflight_pub, {:publisher_pid, self()}) + Process.sleep(:infinity) + end + end + + Process.register(self(), :inflight_pub) + sdk = %{sdk_config("unused") | publisher: InflightPublisher, timeout: 500, retries: 10} + spec = Context.child_spec([sdk, %Types.ContextData{}, context_config()]) + {:ok, ctx} = DynamicSupervisor.start_child(ABSmartly.ContextSupervisor, spec) + :ok = Context.track(ctx, "g", %{}) + ref = Process.monitor(ctx) + spawn(fn -> Context.publish(ctx) end) + assert_receive {:publisher_pid, pub1}, 2_000 + + t0 = System.monotonic_time(:millisecond) + :ok = DynamicSupervisor.terminate_child(ABSmartly.ContextSupervisor, ctx) + assert_receive {:DOWN, ^ref, :process, ^ctx, reason}, 20_000 + assert reason == :shutdown + assert System.monotonic_time(:millisecond) - t0 < spec.shutdown + refute Process.alive?(pub1) + # the termination publish's task must not be orphaned either + receive do + {:publisher_pid, pub2} -> refute Process.alive?(pub2) + after + 0 -> :ok + end + end + + defp exp_map(name, unit, extra \\ %{}) do + Map.merge( + %{"id" => 1, "name" => name, "unitType" => unit, "iteration" => 1, "seedHi" => 1, "seedLo" => 2, + "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, "trafficSplit" => [0, 1], + "variants" => [%{"config" => nil}, %{"config" => nil}]}, + extra + ) + end + + defp ready_ctx(experiments, cfg \\ nil, sdk \\ nil) do + {:ok, ctx} = + Context.start_link_async(sdk || sdk_config("unused"), cfg || context_config(), + data_fetcher: fn -> {:ok, %{"experiments" => experiments}} end + ) + + assert :ok = Context.wait_until_ready(ctx, 1_000) + ctx + end + + test "custom assignment cannot bypass strict audience exclusion" do + aud = Jason.encode!(%{"filter" => [%{"value" => false}]}) + e = exp_map("e", "session_id", %{"audienceStrict" => true, "audience" => aud}) + + ctx = ready_ctx([e]) + assert Context.peek(ctx, "e") == 0 + :ok = Context.set_custom_assignment(ctx, "e", 1) + assert Context.peek(ctx, "e") == 0 + + ctx2 = ready_ctx([e]) + :ok = Context.set_custom_assignment(ctx2, "e", 1) + assert Context.peek(ctx2, "e") == 0 + end + + test "variable_value stops exposing candidates after the winner" do + full = fn name, color -> + exp_map(name, "session_id", %{"fullOnVariant" => 1, "variants" => [%{"config" => nil}, %{"config" => Jason.encode!(%{"color" => color})}]}) + end + + defmodule VarPublisher do + def publish(_e, _k, _a, _env, events, _r) do + send(:var_pub, {:published, events}) + :ok + end + end + + Process.register(self(), :var_pub) + ctx = ready_ctx([full.("e", "red"), full.("f", "blue")], nil, %{sdk_config("unused") | publisher: VarPublisher}) + assert Context.variable_value(ctx, "color", "d") == "red" + :ok = Context.publish(ctx) + assert_receive {:published, ev}, 1_000 + assert Enum.map(ev["exposures"], & &1["name"]) == ["e"] + end + + test "refreshing only the unit type recalculates and re-exposes" do + defmodule UnitTypePublisher do + def publish(_e, _k, _a, _env, events, _r) do + send(:ut_pub, {:published, events}) + :ok + end + end + + Process.register(self(), :ut_pub) + ctx = ready_ctx([exp_map("e", "session_id")], nil, %{sdk_config("unused") | publisher: UnitTypePublisher}) + assert Context.treatment(ctx, "e") == 1 + :ok = Context.publish(ctx) + assert_receive {:published, _}, 1_000 + + :ok = Context.refresh(ctx, %{"experiments" => [exp_map("e", "device")]}) + assert Context.treatment(ctx, "e") == 0 + :ok = Context.publish(ctx) + assert_receive {:published, ev}, 1_000 + assert [%{"name" => "e", "assigned" => false}] = Enum.map(ev["exposures"], &Map.take(&1, ["name", "assigned"])) + end + + @tag timeout: 30_000 + test "supervisor shutdown during an in-flight refresh stays within the child budget" do + defmodule RefreshPublisher do + def publish(_e, _k, _a, _env, events, _r) do + send(:rf_pub, {:published, events}) + :ok + end + end + + Process.register(self(), :rf_pub) + sdk = %{sdk_config("unused") | publisher: RefreshPublisher, endpoint: "http://127.0.0.1:1", timeout: 50, retries: 10} + spec = Context.child_spec([sdk, %Types.ContextData{}, context_config()]) + {:ok, ctx} = DynamicSupervisor.start_child(ABSmartly.ContextSupervisor, spec) + :ok = Context.track(ctx, "g", %{}) + ref = Process.monitor(ctx) + spawn(fn -> catch_exit(Context.refresh(ctx)) end) + Process.sleep(200) + + t0 = System.monotonic_time(:millisecond) + :ok = DynamicSupervisor.terminate_child(ABSmartly.ContextSupervisor, ctx) + assert_receive {:DOWN, ^ref, :process, ^ctx, :shutdown}, 20_000 + assert System.monotonic_time(:millisecond) - t0 < spec.shutdown + assert_received {:published, ev} + assert length(ev["goals"]) == 1 + end + + test "a redirected event upload is not acknowledged and keeps events queued" do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + {:ok, port} = :inet.port(l) + parent = self() + + acceptor = + spawn_link(fn -> + loop = fn loop -> + case :gen_tcp.accept(l, 10_000) do + {:ok, c} -> + {:ok, req} = :gen_tcp.recv(c, 0, 5_000) + send(parent, {:request, hd(String.split(req, " "))}) + :gen_tcp.send(c, "HTTP/1.1 301 Moved Permanently\r\nlocation: /canonical/context\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") + :gen_tcp.close(c) + loop.(loop) + + _ -> + :ok + end + end + + loop.(loop) + end) + + sdk = %{sdk_config("unused") | endpoint: "http://127.0.0.1:#{port}", retries: 0, timeout: 2_000, publisher: ABSmartly.DefaultContextPublisher} + {:ok, ctx} = Context.start_link_async(sdk, context_config(), data_fetcher: fn -> {:ok, %{"experiments" => []}} end) + assert :ok = Context.wait_until_ready(ctx, 1_000) + :ok = Context.track(ctx, "g", %{}) + assert {:error, _} = Context.publish(ctx) + assert Context.pending(ctx) == 1 + assert_receive {:request, "PUT"}, 5_000 + refute_receive {:request, "GET"}, 300 + Process.unlink(acceptor) + Process.exit(acceptor, :kill) + end + + @tag timeout: 30_000 + test "SDK requests bypass an exhausted default hackney pool" do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + {:ok, port} = :inet.port(l) + + acceptor = + spawn_link(fn -> + loop = fn loop -> + case :gen_tcp.accept(l, 20_000) do + {:ok, c} -> + spawn(fn -> + _ = :gen_tcp.recv(c, 0, 5_000) + body = ~s({"experiments":[]}) + :gen_tcp.send(c, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: #{byte_size(body)}\r\nconnection: close\r\n\r\n#{body}") + :gen_tcp.close(c) + end) + + loop.(loop) + + _ -> + :ok + end + end + + loop.(loop) + end) + + # Occupy every default-pool per-host slot with a request that never completes. + hog_url = "http://127.0.0.1:#{port}/hog" + + {:ok, hog_listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + {:ok, hog_port} = :inet.port(hog_listen) + _ = hog_url + + hogs = + for _ <- 1..60 do + spawn(fn -> HTTPoison.get("http://127.0.0.1:#{hog_port}/x", [], recv_timeout: 20_000) end) + end + + Process.sleep(500) + + # SDK fetch to the responsive server on the same host must still work. + assert {:ok, %{"experiments" => []}} = + ABSmartly.HTTP.Client.fetch_context("http://127.0.0.1:#{port}", "k", "a", "e", 0, 1_000) + + Enum.each(hogs, &Process.exit(&1, :kill)) + Process.unlink(acceptor) + Process.exit(acceptor, :kill) + end + end +end diff --git a/test/absmartly/fix_plan_test.exs b/test/absmartly/fix_plan_test.exs new file mode 100644 index 0000000..94b549b --- /dev/null +++ b/test/absmartly/fix_plan_test.exs @@ -0,0 +1,846 @@ +defmodule ABSmartly.FixPlanTest do + use ExUnit.Case, async: true + + alias ABSmartly.{Context, Types, Utils} + alias ABSmartly.JSONExpr.Evaluator + alias ABSmartly.Matcher + + @context_params %{ + "session_id" => "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id" => "123456789" + } + + @get_context_response %{ + "experiments" => [ + %{ + "id" => 1, + "name" => "exp_test_ab", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 3603515, + "seedLo" => 233373850, + "split" => [0.5, 0.5], + "trafficSeedHi" => 449867249, + "trafficSeedLo" => 455443629, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"banner.border\":1,\"banner.size\":\"large\"}"} + ], + "audience" => nil, + "customFieldValues" => nil + }, + %{ + "id" => 2, + "name" => "exp_test_abc", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 55006150, + "seedLo" => 47189152, + "split" => [0.34, 0.33, 0.33], + "trafficSeedHi" => 705671872, + "trafficSeedLo" => 212903484, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"button.color\":\"blue\"}"}, + %{"name" => "C", "config" => "{\"button.color\":\"red\"}"} + ], + "audience" => "", + "customFieldValues" => nil + } + ] + } + + defp start_context(response, units \\ nil) do + context_units = units || @context_params + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_data = Types.ContextData.from_map(response) + context_config = %Types.ContextConfig{ + units: context_units, + overrides: %{}, + custom_assignments: %{} + } + {:ok, pid} = Context.start_link(sdk_config, context_data, context_config) + pid + end + + defp start_context_with_refresh(response, refresh_response, units \\ nil) do + context_units = units || @context_params + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_data = Types.ContextData.from_map(response) + context_config = %Types.ContextConfig{ + units: context_units, + overrides: %{}, + custom_assignments: %{} + } + refresh_data = Types.ContextData.from_map(refresh_response) + data_fetcher = fn -> {:ok, refresh_data} end + {:ok, pid} = Context.start_link(sdk_config, context_data, context_config, data_fetcher: data_fetcher) + pid + end + + # Fix #4: Cross-type comparison + describe "fix #4 - cross-type comparison" do + test "compare number to string coerces string" do + assert Evaluator.compare(1, "1.0") == 0 + assert Evaluator.compare(2, "1") == 1 + assert Evaluator.compare(1, "2") == -1 + end + + # JS jsonexpr/evaluator.ts compare(): string lhs converts the number rhs + # with stringConvert and compares lexically + test "compare string to number converts the number to a string" do + assert Evaluator.compare("1.0", 1) == 1 + assert Evaluator.compare("1", 1) == 0 + assert Evaluator.compare("3", 2) == 1 + assert Evaluator.compare("1", 2) == -1 + end + + test "compare number to non-numeric string returns nil" do + assert Evaluator.compare(1, "abc") == nil + assert Evaluator.compare("abc", 1) == 1 + end + + test "eq works across types" do + expr = %{"eq" => [%{"value" => 1}, %{"value" => "1.0"}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + test "gt works across types" do + expr = %{"gt" => [%{"value" => 2}, %{"value" => "1"}]} + assert Evaluator.evaluate(expr, %{}) == true + end + end + + # Fix #5: Wrong app name in config + describe "fix #5 - config app name" do + test "config uses :absmartly app name" do + config = Application.get_all_env(:absmartly) + assert Keyword.get(config, :json_library) == Jason + end + + test "elixir_wrapper config does not exist" do + config = Application.get_all_env(:elixir_wrapper) + assert config == [] + end + end + + # Fix #6: Attribute list storage + describe "fix #6 - attribute list storage" do + test "attributes stored as list and get_attribute returns last value" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "key1", "val1") + Context.set_attribute(ctx, "key2", "val2") + assert Context.get_attribute(ctx, "key1") == "val1" + assert Context.get_attribute(ctx, "key2") == "val2" + end + + test "set_attribute appends and get_attribute returns last value" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "key", "old") + Context.set_attribute(ctx, "key", "new") + assert Context.get_attribute(ctx, "key") == "new" + end + + test "both entries stored in list when set twice" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "key", "old") + Context.set_attribute(ctx, "key", "new") + attrs = Context.get_attributes(ctx) + assert length(Enum.filter(attrs, fn a -> a.name == "key" end)) == 2 + end + + test "batch set_attributes with map" do + ctx = start_context(@get_context_response) + Context.set_attributes(ctx, %{"a" => 1, "b" => 2}) + assert Context.get_attribute(ctx, "a") == 1 + assert Context.get_attribute(ctx, "b") == 2 + end + end + + # Fix #7 & #18: Cached audience JSON and single audience_match? call + describe "fix #7/#18 - cached audience and single evaluation" do + test "audience matching works with cached parsed JSON" do + response = put_in( + @get_context_response, + ["experiments", Access.at(0), "audience"], + Jason.encode!(%{"filter" => [%{"gte" => [%{"var" => "age"}, %{"value" => 18}]}]}) + ) + response = put_in(response, ["experiments", Access.at(0), "audienceStrict"], true) + + ctx = start_context(response) + Context.set_attribute(ctx, "age", 20) + assert Context.treatment(ctx, "exp_test_ab") == 1 + + ctx2 = start_context(response) + Context.set_attribute(ctx2, "age", 15) + assert Context.treatment(ctx2, "exp_test_ab") == 0 + end + end + + # Fix #8: Variable index ordering + describe "fix #8 - variable index ordering" do + test "variable index preserves experiment order" do + shared = fn name, color -> + %{ + "id" => 1, "name" => name, "unitType" => "session_id", "iteration" => 1, + "seedHi" => 1, "seedLo" => 2, "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, + "trafficSplit" => [0, 1], "fullOnVariant" => 1, + "variants" => [%{"config" => nil}, %{"config" => Jason.encode!(%{"color" => color})}] + } + end + + ctx = start_context(%{"experiments" => [shared.("first", "red"), shared.("second", "blue")]}) + assert Context.variable_keys(ctx)["color"] == ["first", "second"] + assert Context.variable_value(ctx, "color", "none") == "red" + end + end + + # Fix #10: set_overrides clears exposed_experiments + describe "fix #10 - set_overrides exposed_experiments cleanup" do + test "set_overrides clears exposed_experiments for each key" do + ctx = start_context(@get_context_response) + + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + + Context.set_overrides(ctx, %{"exp_test_ab" => 0}) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 2 + end + end + + # Fix #11: Evaluator scalar values + describe "fix #11 - evaluate scalar values" do + # JS jsonexpr/evaluator.ts:65-79 - bare scalars evaluate to null + test "returns nil for number expression" do + assert Evaluator.evaluate(42, %{}) == nil + assert Evaluator.evaluate(3.14, %{}) == nil + end + + test "returns nil for string expression" do + assert Evaluator.evaluate("hello", %{}) == nil + end + + test "returns nil for boolean expression" do + assert Evaluator.evaluate(true, %{}) == nil + assert Evaluator.evaluate(false, %{}) == nil + end + + test "still returns nil for nil" do + assert Evaluator.evaluate(nil, %{}) == nil + end + end + + # Fix #12/#34: to_number("") returns 0 (integer) + describe "fix #12/#34 - to_number empty string" do + test "to_number of empty string returns integer 0" do + result = Utils.to_number("") + assert result == 0 + assert is_integer(result) + end + end + + # Fix #13: Context type spec + describe "fix #13 - type spec" do + test "Context module has GenServer.server type" do + ctx = start_context(@get_context_response) + assert is_pid(ctx) + end + end + + # Fix #15: terminate/2 synchronous publish + describe "fix #15 - terminate synchronous publish" do + test "context process terminates cleanly" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + ref = Process.monitor(ctx) + GenServer.stop(ctx, :normal) + assert_receive {:DOWN, ^ref, :process, ^ctx, :normal}, 10_000 + end + end + + # Fix #16/#35: set_failed and wait_until_ready with failed state + describe "fix #16/#35 - failed state handling" do + test "set_failed marks context as failed" do + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_config = %Types.ContextConfig{ + units: @context_params, + overrides: %{}, + custom_assignments: %{} + } + {:ok, ctx} = Context.start_link_async(sdk_config, context_config) + assert Context.is_ready?(ctx) == false + assert Context.is_failed?(ctx) == false + + Context.set_failed(ctx, :fetch_error) + assert Context.is_failed?(ctx) == true + assert Context.is_ready?(ctx) == true + assert Context.treatment(ctx, "exp_test_ab") == 0 + assert Context.variable_value(ctx, "banner.size", 17) == 17 + end + + test "wait_until_ready returns error when failed" do + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_config = %Types.ContextConfig{ + units: @context_params, + overrides: %{}, + custom_assignments: %{} + } + {:ok, ctx} = Context.start_link_async(sdk_config, context_config) + + spawn(fn -> + Process.sleep(50) + Context.set_failed(ctx, :test_error) + end) + + assert {:error, :test_error} = Context.wait_until_ready(ctx, 5000) + end + end + + # Fix #17: set_custom_assignments clears exposed_experiments + describe "fix #17 - set_custom_assignments exposed_experiments cleanup" do + test "set_custom_assignments clears exposed_experiments for each key" do + ctx = start_context(@get_context_response) + + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + + Context.set_custom_assignments(ctx, %{"exp_test_ab" => 0}) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 2 + end + end + + # Fix #19/#24: Counter-based pending, O(1) queue overflow + describe "fix #19/#24 - counter-based pending" do + test "pending uses counter fields" do + ctx = start_context(@get_context_response) + assert Context.pending(ctx) == 0 + + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + + Context.track(ctx, "goal1") + assert Context.pending(ctx) == 2 + end + end + + # Fix #20: Matcher uses AND semantics (via evaluator) + describe "fix #20 - matcher filter AND semantics" do + test "filter with multiple expressions uses AND" do + filter = %{ + "filter" => [ + %{"gte" => [%{"var" => "age"}, %{"value" => 18}]}, + %{"eq" => [%{"var" => "country"}, %{"value" => "US"}]} + ] + } + + attrs_both = [%{"name" => "age", "value" => 25}, %{"name" => "country", "value" => "US"}] + assert Matcher.evaluate(filter, attrs_both) == true + + attrs_age_only = [%{"name" => "age", "value" => 25}, %{"name" => "country", "value" => "UK"}] + assert Matcher.evaluate(filter, attrs_age_only) == false + + attrs_country_only = [%{"name" => "age", "value" => 15}, %{"name" => "country", "value" => "US"}] + assert Matcher.evaluate(filter, attrs_country_only) == false + end + end + + # Fix #22: Unless instead of negated condition (code style, tested via queue_exposure) + describe "fix #22 - idiomatic unless" do + test "exposure only queued once per experiment" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + end + end + + # Fix #26: hash_unit computed once per assign_variant + describe "fix #26 - hash_unit efficiency" do + test "assignment still works correctly with single hash computation" do + ctx = start_context(@get_context_response) + v1 = Context.treatment(ctx, "exp_test_ab") + v2 = Context.peek(ctx, "exp_test_ab") + assert v1 == v2 + assert is_integer(v1) + end + end + + # Fix #27: get_opt handles explicit nil + describe "fix #27 - ContextConfig get_opt nil handling" do + test "explicit nil value is respected" do + config = Types.ContextConfig.from_options(%{event_handler: nil}) + assert config.event_handler == nil + end + + test "missing key uses default" do + config = Types.ContextConfig.from_options(%{}) + assert config.publish_delay == -1 + assert config.refresh_period == 0 + end + + test "false value is accepted" do + config = Types.ContextConfig.from_options(%{"publishDelay" => 0}) + assert config.publish_delay == 0 + end + end + + # Fix #28: child_spec for DynamicSupervisor + describe "fix #28 - child_spec" do + test "Context module has child_spec" do + spec = Context.child_spec([%{}, %{}, %{}]) + assert spec.id == Context + assert spec.type == :worker + assert spec.restart == :temporary + end + end + + # Fix #29: Experiment index for O(1) lookup + describe "fix #29 - experiment index" do + test "experiment lookup by name is O(1)" do + ctx = start_context(@get_context_response) + assert Context.treatment(ctx, "exp_test_ab") == 1 + assert Context.treatment(ctx, "exp_test_abc") == 2 + assert Context.treatment(ctx, "not_found") == 0 + end + end + + # Fix #32: Split changes in invalidation + describe "fix #32 - split changes invalidation" do + # D7 / JS context.ts:579-586 - only id/unitType/iteration/fullOnVariant/ + # trafficSplit invalidate a cached assignment; a split change is sticky. + test "refresh keeps the assignment sticky across split changes" do + changed_split_response = %{@get_context_response | + "experiments" => Enum.map(@get_context_response["experiments"], fn exp -> + if exp["name"] == "exp_test_ab" do + Map.put(exp, "split", [0.9, 0.1]) + else + exp + end + end) + } + ctx = start_context_with_refresh(@get_context_response, changed_split_response) + v1 = Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + + Context.refresh(ctx) + v2 = Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + assert v2 == v1 + end + end + + # Fix #38: defdelegate create_context_async + describe "fix #38 - ABSmartly.create_context_async delegated" do + test "create_context_async is accessible via ABSmartly module" do + funs = ABSmartly.__info__(:functions) + assert {:create_context_async, 2} in funs + assert {:create_context_async, 3} in funs + end + end + + # Fix #39: Retry cap + describe "fix #39 - retry cap" do + test "HTTP client caps retries at 10" do + Application.put_env(:absmartly, :retry_backoff_base, 0) + on_exit(fn -> Application.delete_env(:absmartly, :retry_backoff_base) end) + + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + {:ok, port} = :inet.port(l) + counter = :counters.new(1, []) + + acceptor = + spawn_link(fn -> + loop = fn loop -> + case :gen_tcp.accept(l, 20_000) do + {:ok, c} -> + :counters.add(counter, 1, 1) + _ = :gen_tcp.recv(c, 0, 1_000) + :gen_tcp.send(c, "HTTP/1.1 500 Internal Server Error\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") + :gen_tcp.close(c) + loop.(loop) + + _ -> + :ok + end + end + + loop.(loop) + end) + + assert {:error, _} = + ABSmartly.HTTP.Client.fetch_context("http://127.0.0.1:#{port}", "k", "a", "e", 50, 1_000) + + Process.unlink(acceptor) + Process.exit(acceptor, :kill) + # 1 initial attempt + 10 retries + assert :counters.get(counter, 1) == 11 + end + end + + # Fix #3: DynamicSupervisor usage + describe "fix #3 - DynamicSupervisor" do + test "context can be started under DynamicSupervisor" do + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_data = Types.ContextData.from_map(@get_context_response) + context_config = %Types.ContextConfig{ + units: @context_params, + overrides: %{}, + custom_assignments: %{} + } + + {:ok, ctx} = DynamicSupervisor.start_child( + ABSmartly.ContextSupervisor, + {Context, [sdk_config, context_data, context_config]} + ) + + assert is_pid(ctx) + assert Context.is_ready?(ctx) == true + assert Context.treatment(ctx, "exp_test_ab") == 1 + end + end + + # Fix #2: Publish uses Task.async with timeout (tested via existing publish tests passing) + describe "fix #2 - publish with Task.async" do + test "publish clears queue" do + ctx = start_context(@get_context_response) + Context.treatment(ctx, "exp_test_ab") + assert Context.pending(ctx) == 1 + assert :ok = Context.publish(ctx) + assert Context.pending(ctx) == 0 + end + end + + # Fix #21: Publish events format + describe "fix #21 - publish event format" do + test "publish event map contains expected keys" do + events_captured = :ets.new(:test_events, [:set, :public]) + handler = fn event_type, data -> + :ets.insert(events_captured, {event_type, data}) + end + + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_data = Types.ContextData.from_map(@get_context_response) + context_config = %Types.ContextConfig{ + units: @context_params, + overrides: %{}, + custom_assignments: %{}, + event_handler: handler + } + {:ok, ctx} = Context.start_link(sdk_config, context_data, context_config) + + Context.treatment(ctx, "exp_test_ab") + Context.publish(ctx) + + Process.sleep(100) + [{:publish, event_map}] = :ets.lookup(events_captured, :publish) + assert Map.has_key?(event_map, "hashed") + assert Map.has_key?(event_map, "units") + assert Map.has_key?(event_map, "exposures") + assert Map.has_key?(event_map, "goals") + assert Map.has_key?(event_map, "attributes") + + :ets.delete(events_captured) + end + end + + # Fix #40: Test coverage for terminate, handle_info, queue overflow, etc. + describe "fix #40 - additional test coverage" do + test "handle_info for unexpected messages" do + ctx = start_context(@get_context_response) + send(ctx, :unexpected_message) + assert Context.is_ready?(ctx) == true + end + + test "handle_info for non-EXIT signals" do + ctx = start_context(@get_context_response) + send(ctx, {:some_other_message, self(), :data}) + Process.sleep(50) + assert Process.alive?(ctx) + assert Context.is_ready?(ctx) == true + end + + test "data returns context data" do + ctx = start_context(@get_context_response) + data = Context.data(ctx) + assert length(data.experiments) == 2 + end + + test "experiments returns experiment names" do + ctx = start_context(@get_context_response) + names = Context.experiments(ctx) + assert "exp_test_ab" in names + assert "exp_test_abc" in names + end + + test "is_finalizing returns false initially" do + ctx = start_context(@get_context_response) + assert Context.is_finalizing?(ctx) == false + end + + test "finalize is idempotent" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + end + + test "set_units with duplicate returns error" do + ctx = start_context(@get_context_response) + result = Context.set_units(ctx, %{"session_id" => "different_id"}) + assert {:error, "Unit 'session_id' UID already set."} = result + end + + test "track with non-map properties sanitizes to nil" do + test_pid = self() + ctx = start_context(@get_context_response) + # install an event handler through a fresh context config + {:ok, ctx2} = + Context.start_link( + %Types.SDKConfig{endpoint: "https://t.absmartly.io/v1", api_key: "k", application: "a", environment: "e", publisher: ABSmartly.Test.OkPublisher}, + Context.data(ctx), + %Types.ContextConfig{units: %{"session_id" => "x"}, overrides: %{}, custom_assignments: %{}, + event_handler: fn type, data -> send(test_pid, {:ev, type, data}) end} + ) + + Context.track(ctx2, "goal1", "not a map") + assert Context.pending(ctx2) == 1 + assert_received {:ev, :goal, goal} + assert goal.properties == nil + end + + test "get_attributes returns list format" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "key", "value") + attrs = Context.get_attributes(ctx) + assert is_list(attrs) + assert Enum.any?(attrs, fn attr -> attr.name == "key" && attr.value == "value" end) + end + end + + # Test for Matcher edge cases + describe "matcher edge cases" do + test "nil filter returns nil" do + assert Matcher.evaluate(nil, %{}) == nil + end + + test "empty map returns true" do + assert Matcher.evaluate(%{}, %{}) == true + end + + test "invalid filter sentinel returns false" do + assert Matcher.evaluate(%{"invalid" => true}, %{}) == false + end + + test "non-list, non-map filter value is indeterminate" do + assert Matcher.evaluate(%{"filter" => "not_a_list"}, %{}) == nil + end + + test "non-map filter fails closed" do + assert Matcher.evaluate("string", %{}) == false + end + end + + # Phase 3.2: ready_error + describe "phase 3.2 - ready_error" do + test "ready_error returns nil when context loaded successfully" do + ctx = start_context(@get_context_response) + assert Context.ready_error(ctx) == nil + end + + test "ready_error returns the reason when set_failed called" do + sdk_config = %Types.SDKConfig{ + endpoint: "https://test.absmartly.io/v1", + api_key: "test-api-key", + application: "website", + environment: "development", + publisher: ABSmartly.Test.OkPublisher + } + context_config = %Types.ContextConfig{ + units: @context_params, + overrides: %{}, + custom_assignments: %{} + } + {:ok, ctx} = Context.start_link_async(sdk_config, context_config) + Context.set_failed(ctx, :fetch_error) + assert Context.ready_error(ctx) == :fetch_error + end + end + + # Phase 4.4: global custom_field_keys + describe "phase 4.4 - global custom_field_keys" do + @get_context_response_with_custom_fields %{ + "experiments" => [ + %{ + "id" => 1, + "name" => "exp_test_ab", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 3603515, + "seedLo" => 233373850, + "split" => [0.5, 0.5], + "trafficSeedHi" => 449867249, + "trafficSeedLo" => 455443629, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [%{"name" => "A", "config" => nil}, %{"name" => "B", "config" => nil}], + "audience" => nil, + "customFieldValues" => [ + %{"name" => "key1", "value" => "val1", "type" => "string"}, + %{"name" => "key2", "value" => "val2", "type" => "string"} + ] + }, + %{ + "id" => 2, + "name" => "exp_test_abc", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 55006150, + "seedLo" => 47189152, + "split" => [0.34, 0.33, 0.33], + "trafficSeedHi" => 705671872, + "trafficSeedLo" => 212903484, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [%{"name" => "A", "config" => nil}, %{"name" => "B", "config" => nil}], + "audience" => nil, + "customFieldValues" => [ + %{"name" => "key2", "value" => "val2b", "type" => "string"}, + %{"name" => "key3", "value" => "val3", "type" => "string"} + ] + } + ] + } + + test "custom_field_keys returns all unique keys across all experiments" do + ctx = start_context(@get_context_response_with_custom_fields) + keys = Context.custom_field_keys(ctx) + assert "key1" in keys + assert "key2" in keys + assert "key3" in keys + assert length(Enum.uniq(keys)) == length(keys) + end + + test "custom_field_keys returns empty list when no custom fields" do + ctx = start_context(@get_context_response) + keys = Context.custom_field_keys(ctx) + assert keys == [] + end + end + + # Phase 4.5: attribute list storage model + describe "phase 4.5 - attribute list storage model" do + test "set_attribute appends entries, both in list" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "color", "red") + Context.set_attribute(ctx, "color", "blue") + attrs = Context.get_attributes(ctx) + color_attrs = Enum.filter(attrs, fn a -> a.name == "color" end) + assert length(color_attrs) == 2 + assert Enum.at(color_attrs, 0).value == "red" + assert Enum.at(color_attrs, 1).value == "blue" + end + + test "get_attribute returns last value for name" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "color", "red") + Context.set_attribute(ctx, "color", "blue") + assert Context.get_attribute(ctx, "color") == "blue" + end + + test "attributes have set_at timestamp" do + ctx = start_context(@get_context_response) + Context.set_attribute(ctx, "key", "val") + attrs = Context.get_attributes(ctx) + assert length(attrs) == 1 + attr = hd(attrs) + assert Map.has_key?(attr, :set_at) + assert is_integer(attr.set_at) + assert attr.set_at > 0 + end + end + + describe "fix 4.1 - set_override succeeds after finalize" do + test "set_override returns :ok after finalize" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + assert Context.set_override(ctx, "exp_test_ab", 2) == :ok + end + + test "set_overrides returns :ok after finalize" do + ctx = start_context(@get_context_response) + Context.finalize(ctx) + assert Context.is_finalized?(ctx) == true + assert Context.set_overrides(ctx, %{"exp_test_ab" => 2}) == :ok + end + + test "integer unit set at creation is not treated as a changed identity" do + ctx = start_context(@get_context_response, %{"user" => 123}) + assert :ok = Context.set_unit(ctx, "user", 123) + assert :ok = Context.set_units(ctx, %{"user" => 123}) + assert {:error, _} = Context.set_unit(ctx, "user", 124) + end + + test "a nil initial UID is treated as a missing unit, not an empty identity" do + exp = %{ + "id" => 1, "name" => "e", "unitType" => "user", "iteration" => 1, "seedHi" => 1, "seedLo" => 2, + "split" => [0, 1], "trafficSeedHi" => 3, "trafficSeedLo" => 4, "trafficSplit" => [0, 1], + "variants" => [%{"config" => nil}, %{"config" => nil}] + } + + ctx = start_context(%{"experiments" => [exp]}, %{"user" => nil}) + assert Context.peek(ctx, "e") == 0 + assert :ok = Context.set_unit(ctx, "user", "real-user") + # D7 / JS context.ts:363-376 - setting a unit only evicts assignments + # resolved by a rule or holdout without it, so this one stays sticky. + assert Context.peek(ctx, "e") == 0 + end + end +end diff --git a/test/absmartly/http_integration_test.exs b/test/absmartly/http_integration_test.exs new file mode 100644 index 0000000..7473867 --- /dev/null +++ b/test/absmartly/http_integration_test.exs @@ -0,0 +1,247 @@ +defmodule ABSmartly.HTTPIntegrationTest do + @moduledoc """ + Hermetic local-HTTP-server integration test. + + Drives the PUBLIC SDK surface (`ABSmartly.SDK.create_context/3` -> + `Context.treatment/2` -> `Context.track/3` -> `Context.publish/1`) so that the + REAL HTTPoison-backed `ABSmartly.HTTP.Client` performs an actual + `GET /context` and `PUT /context` against a local loopback server, and asserts + the documented ABsmartly collector wire contract: + + GET /context with ?application=&environment= (drives ready) + PUT /context with X-API-Key / X-Application / X-Environment / + X-Application-Version / X-Agent / Content-Type and a + body containing hashed / units / publishedAt. + + The server is a tiny `:gen_tcp` HTTP/1.1 implementation bound to an ephemeral + loopback port, so the test stays hermetic with no extra dependency and no live + backend. + """ + use ExUnit.Case, async: false + + alias ABSmartly.{Context, SDK} + + # A single valid experiment so `treatment/2` yields an exposure to publish. + @get_body Jason.encode!(%{ + "experiments" => [ + %{ + "id" => 1, + "name" => "exp_test_ab", + "iteration" => 1, + "unitType" => "session_id", + "seedHi" => 3_603_515, + "seedLo" => 233_373_850, + "split" => [0.5, 0.5], + "trafficSeedHi" => 449_867_249, + "trafficSeedLo" => 455_443_629, + "trafficSplit" => [0.0, 1.0], + "fullOnVariant" => 0, + "applications" => [%{"name" => "website"}], + "variants" => [ + %{"name" => "A", "config" => nil}, + %{"name" => "B", "config" => "{\"banner.border\":1}"} + ], + "audience" => nil, + "customFieldValues" => nil + } + ] + }) + + setup do + {:ok, server} = start_server() + on_exit(fn -> stop_server(server) end) + {:ok, server: server} + end + + test "real HTTP client hits local server with the wire contract", %{server: server} do + endpoint = "http://127.0.0.1:#{server.port}" + + {:ok, sdk} = + SDK.new( + endpoint: endpoint, + api_key: "test-api-key", + application: "website", + environment: "test", + retries: 0 + ) + + # createContext -> blocks on the real GET /context. + {:ok, ctx} = + SDK.create_context(sdk, %{ + "session_id" => "e791e240fcd3df7d238cfc285f475e8152fcc0ec" + }) + + # Queue an exposure + a goal so publish() has something to send. + assert Context.treatment(ctx, "exp_test_ab") in 0..1 + Context.track(ctx, "payment", %{"amount" => 1000}) + + # publish() -> real PUT /context. + Context.publish(ctx) + + requests = collect_requests(2, []) + assert length(requests) == 2 + + get = Enum.find(requests, &(&1.method == "GET")) + put = Enum.find(requests, &(&1.method == "PUT")) + + # ---- GET /context (fetch -> ready) ---- + assert get + assert get.path == "/context" + assert get.query =~ "application=website" + assert get.query =~ "environment=test" + + # ---- PUT /context (publish) ---- + assert put + assert put.path == "/context" + refute put.path =~ "?" + + # Required auth / identity headers (exact names per the wire contract). + assert header(put, "x-api-key") == "test-api-key" + assert header(put, "x-application") == "website" + assert header(put, "x-environment") == "test" + assert header(put, "x-application-version") == "0" + assert header(put, "x-agent") not in [nil, ""] + assert header(put, "content-type") =~ "application/json" + + # Body must carry the required publish fields. + body = Jason.decode!(put.body) + assert body["hashed"] == true + assert is_list(body["units"]) + assert length(body["units"]) >= 1 + assert is_integer(body["publishedAt"]) and body["publishedAt"] > 0 + # We tracked a goal, so goals must be present and non-empty. + assert is_list(body["goals"]) + assert length(body["goals"]) >= 1 + assert hd(body["goals"])["name"] == "payment" + end + + # --------------------------------------------------------------------------- + # Tiny hermetic HTTP/1.1 server over :gen_tcp. + # --------------------------------------------------------------------------- + + defp start_server do + test_pid = self() + + {:ok, listen} = + :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true, ip: {127, 0, 0, 1}]) + + {:ok, port} = :inet.port(listen) + + acceptor = + spawn_link(fn -> accept_loop(listen, test_pid) end) + + {:ok, %{listen: listen, port: port, acceptor: acceptor}} + end + + defp stop_server(%{listen: listen}) do + :gen_tcp.close(listen) + end + + defp accept_loop(listen, test_pid) do + case :gen_tcp.accept(listen) do + {:ok, socket} -> + handle_connection(socket, test_pid) + accept_loop(listen, test_pid) + + {:error, :closed} -> + :ok + + {:error, _other} -> + :ok + end + end + + defp handle_connection(socket, test_pid) do + request = read_request(socket, "") + send(test_pid, {:request, request}) + + body = + case request.method do + "GET" -> @get_body + _ -> "{}" + end + + response = + "HTTP/1.1 200 OK\r\n" <> + "Content-Type: application/json\r\n" <> + "Content-Length: #{byte_size(body)}\r\n" <> + "Connection: close\r\n\r\n" <> body + + :gen_tcp.send(socket, response) + :gen_tcp.close(socket) + end + + # Read until full headers are present, then read Content-Length bytes of body. + defp read_request(socket, acc) do + case String.split(acc, "\r\n\r\n", parts: 2) do + [head, rest] -> + {method, path, query, headers} = parse_head(head) + content_length = header_value(headers, "content-length") + body = read_body(socket, rest, content_length) + %{method: method, path: path, query: query, headers: headers, body: body} + + [_partial] -> + {:ok, chunk} = :gen_tcp.recv(socket, 0, 5000) + read_request(socket, acc <> chunk) + end + end + + defp read_body(_socket, rest, 0), do: rest + + defp read_body(socket, rest, content_length) do + if byte_size(rest) >= content_length do + rest + else + {:ok, chunk} = :gen_tcp.recv(socket, 0, 5000) + read_body(socket, rest <> chunk, content_length) + end + end + + defp parse_head(head) do + [request_line | header_lines] = String.split(head, "\r\n") + [method, target | _] = String.split(request_line, " ") + + {path, query} = + case String.split(target, "?", parts: 2) do + [p, q] -> {p, q} + [p] -> {p, ""} + end + + headers = + header_lines + |> Enum.map(fn line -> + case String.split(line, ":", parts: 2) do + [name, value] -> {String.downcase(String.trim(name)), String.trim(value)} + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + + {method, path, query, headers} + end + + defp header_value(headers, name) do + case List.keyfind(headers, name, 0) do + {_, value} -> String.to_integer(value) + nil -> 0 + end + end + + defp header(request, name) do + case List.keyfind(request.headers, name, 0) do + {_, value} -> value + nil -> nil + end + end + + # Collect up to `n` recorded requests (with a generous timeout). + defp collect_requests(0, acc), do: Enum.reverse(acc) + + defp collect_requests(n, acc) do + receive do + {:request, request} -> collect_requests(n - 1, [request | acc]) + after + 5000 -> Enum.reverse(acc) + end + end +end diff --git a/test/absmartly/json_expr/evaluator_test.exs b/test/absmartly/json_expr/evaluator_test.exs new file mode 100644 index 0000000..63db096 --- /dev/null +++ b/test/absmartly/json_expr/evaluator_test.exs @@ -0,0 +1,461 @@ +defmodule ABSmartly.JSONExpr.EvaluatorTest do + use ExUnit.Case, async: true + + alias ABSmartly.JSONExpr.Evaluator + + describe "JS numeric conversion boundaries" do + test "number_convert follows parseFloat for exponent forms" do + assert Evaluator.number_convert("1.e2") == 100.0 + assert Evaluator.number_convert(" 12abc") == 12.0 + assert Evaluator.number_convert("-.5e1") == -5.0 + assert Evaluator.number_convert("e5") == nil + assert Evaluator.compare(1, "1.e2") == -1 + end + + test "string_convert follows toFixed for negative zero and large numbers" do + assert Evaluator.string_convert(-0.0) == "0" + assert Evaluator.string_convert(1.0e21) == "1e+21" + assert Evaluator.compare("0", -0.0) == 0 + assert Evaluator.compare("1e+21", 1.0e21) == 0 + end + end + + describe "evaluate/2" do + test "returns nil for nil expression" do + assert Evaluator.evaluate(nil, %{}) == nil + end + + # JS jsonexpr/evaluator.ts:65-79 - a bare scalar (not array/object) evaluates to null + test "returns nil for bare scalar expressions" do + assert Evaluator.evaluate(42, %{}) == nil + assert Evaluator.evaluate("hello", %{}) == nil + assert Evaluator.evaluate(true, %{}) == nil + assert Evaluator.evaluate(false, %{}) == nil + end + + test "returns nil when no recognized operator" do + assert Evaluator.evaluate(%{"unknown" => 42}, %{}) == nil + end + + test "treats array as implicit AND" do + assert Evaluator.evaluate([%{"value" => true}, %{"value" => true}], %{}) == true + assert Evaluator.evaluate([%{"value" => true}, %{"value" => false}], %{}) == false + end + end + + describe "value operator" do + test "returns the value" do + assert Evaluator.evaluate(%{"value" => true}, %{}) == true + assert Evaluator.evaluate(%{"value" => false}, %{}) == false + assert Evaluator.evaluate(%{"value" => 42}, %{}) == 42 + assert Evaluator.evaluate(%{"value" => "hello"}, %{}) == "hello" + assert Evaluator.evaluate(%{"value" => nil}, %{}) == nil + end + end + + describe "var operator" do + test "extracts simple variables" do + vars = %{"a" => 1, "b" => true, "c" => "hello"} + assert Evaluator.evaluate(%{"var" => "a"}, vars) == 1 + assert Evaluator.evaluate(%{"var" => "b"}, vars) == true + assert Evaluator.evaluate(%{"var" => "c"}, vars) == "hello" + end + + test "extracts nested variables with path" do + vars = %{"f" => %{"y" => %{"x" => 3}}} + assert Evaluator.evaluate(%{"var" => "f/y/x"}, vars) == 3 + assert Evaluator.evaluate(%{"var" => "f/y"}, vars) == %{"x" => 3} + end + + test "extracts array elements by index" do + vars = %{"d" => [1, 2, 3]} + assert Evaluator.evaluate(%{"var" => "d/0"}, vars) == 1 + assert Evaluator.evaluate(%{"var" => "d/1"}, vars) == 2 + assert Evaluator.evaluate(%{"var" => "d/2"}, vars) == 3 + assert Evaluator.evaluate(%{"var" => "d/3"}, vars) == nil + end + + test "extracts nested array objects" do + vars = %{"e" => [1, %{"z" => 2}, 3]} + assert Evaluator.evaluate(%{"var" => "e/1/z"}, vars) == 2 + end + + test "returns nil for non-existent paths" do + vars = %{"a" => 1} + assert Evaluator.evaluate(%{"var" => "b"}, vars) == nil + assert Evaluator.evaluate(%{"var" => "a/0"}, vars) == nil + end + + test "returns nil for non-string var" do + assert Evaluator.evaluate(%{"var" => 42}, %{}) == nil + end + end + + describe "and operator" do + test "returns true when all expressions are truthy" do + expr = %{"and" => [%{"value" => true}, %{"value" => 1}, %{"value" => "abc"}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + test "returns false when any expression is falsy" do + expr = %{"and" => [%{"value" => true}, %{"value" => false}, %{"value" => "abc"}]} + assert Evaluator.evaluate(expr, %{}) == false + end + + test "returns true for empty list" do + assert Evaluator.evaluate(%{"and" => []}, %{}) == true + end + + test "short-circuits on false" do + expr = %{"and" => [%{"value" => false}, %{"value" => true}]} + assert Evaluator.evaluate(expr, %{}) == false + end + + # JS operators/and.ts:7-9 - null is booleanConverted to false + test "returns false when encountering nil" do + expr = %{"and" => [%{"value" => nil}]} + assert Evaluator.evaluate(expr, %{}) == false + end + end + + describe "or operator" do + test "returns true when any expression is truthy" do + expr = %{"or" => [%{"value" => false}, %{"value" => true}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + test "returns false when all expressions are falsy" do + expr = %{"or" => [%{"value" => false}, %{"value" => 0}, %{"value" => ""}]} + assert Evaluator.evaluate(expr, %{}) == false + end + + # JS operators/or.ts:11 - returns args.length === 0 + test "returns true for empty list" do + assert Evaluator.evaluate(%{"or" => []}, %{}) == true + end + + test "short-circuits on true" do + expr = %{"or" => [%{"value" => true}, %{"value" => false}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + # JS operators/or.ts:7-11 - null is booleanConverted to false + test "returns false when encountering nil" do + expr = %{"or" => [%{"value" => nil}]} + assert Evaluator.evaluate(expr, %{}) == false + end + end + + describe "not operator" do + test "negates boolean" do + assert Evaluator.evaluate(%{"not" => %{"value" => true}}, %{}) == false + assert Evaluator.evaluate(%{"not" => %{"value" => false}}, %{}) == true + end + + test "negates truthy values" do + assert Evaluator.evaluate(%{"not" => %{"value" => 1}}, %{}) == false + assert Evaluator.evaluate(%{"not" => %{"value" => 0}}, %{}) == true + assert Evaluator.evaluate(%{"not" => %{"value" => "abc"}}, %{}) == false + assert Evaluator.evaluate(%{"not" => %{"value" => ""}}, %{}) == true + end + + # JS operators/not.ts:6 - !booleanConvert(null) == true + test "returns true for nil" do + assert Evaluator.evaluate(%{"not" => %{"value" => nil}}, %{}) == true + end + end + + describe "null operator" do + test "returns true for nil" do + assert Evaluator.evaluate(%{"null" => %{"value" => nil}}, %{}) == true + end + + test "returns false for non-nil" do + assert Evaluator.evaluate(%{"null" => %{"value" => 1}}, %{}) == false + assert Evaluator.evaluate(%{"null" => %{"value" => ""}}, %{}) == false + assert Evaluator.evaluate(%{"null" => %{"value" => false}}, %{}) == false + end + + test "returns true for undefined variable" do + assert Evaluator.evaluate(%{"null" => %{"var" => "missing"}}, %{}) == true + end + end + + describe "string lhs with integer rhs" do + test "converts integer rhs like JS stringConvert instead of crashing" do + assert Evaluator.compare("1", 1) == 0 + assert Evaluator.compare("abc", 1) == 1 + assert Evaluator.string_convert(1) == "1" + assert Evaluator.evaluate(%{"eq" => [%{"var" => "count"}, %{"value" => 1}]}, %{"count" => "1"}) == true + assert Evaluator.evaluate(%{"eq" => [%{"var" => "count"}, %{"value" => 1}]}, %{"count" => "abc"}) == false + end + end + + describe "eq operator" do + test "compares equal values" do + assert Evaluator.evaluate(%{"eq" => [%{"value" => 1}, %{"value" => 1}]}, %{}) == true + assert Evaluator.evaluate(%{"eq" => [%{"value" => "abc"}, %{"value" => "abc"}]}, %{}) == true + assert Evaluator.evaluate(%{"eq" => [%{"value" => true}, %{"value" => true}]}, %{}) == true + # A null operand short-circuits to nil (canonical: null == null is not a match). + assert Evaluator.evaluate(%{"eq" => [%{"value" => nil}, %{"value" => nil}]}, %{}) == nil + end + + test "compares unequal values" do + assert Evaluator.evaluate(%{"eq" => [%{"value" => 1}, %{"value" => 2}]}, %{}) == false + assert Evaluator.evaluate(%{"eq" => [%{"value" => "abc"}, %{"value" => "def"}]}, %{}) == false + assert Evaluator.evaluate(%{"eq" => [%{"value" => true}, %{"value" => false}]}, %{}) == false + end + + test "returns nil for incomparable / null types" do + assert Evaluator.evaluate(%{"eq" => [%{"value" => 1}, %{"value" => nil}]}, %{}) == nil + assert Evaluator.evaluate(%{"eq" => [%{"value" => nil}, %{"value" => 1}]}, %{}) == nil + end + end + + describe "gt operator" do + test "compares greater values" do + assert Evaluator.evaluate(%{"gt" => [%{"value" => 2}, %{"value" => 1}]}, %{}) == true + assert Evaluator.evaluate(%{"gt" => [%{"value" => 1}, %{"value" => 2}]}, %{}) == false + assert Evaluator.evaluate(%{"gt" => [%{"value" => 1}, %{"value" => 1}]}, %{}) == false + end + + test "compares strings" do + assert Evaluator.evaluate(%{"gt" => [%{"value" => "bcd"}, %{"value" => "abc"}]}, %{}) == true + assert Evaluator.evaluate(%{"gt" => [%{"value" => "abc"}, %{"value" => "bcd"}]}, %{}) == false + end + end + + describe "gte operator" do + test "compares greater or equal values" do + assert Evaluator.evaluate(%{"gte" => [%{"value" => 2}, %{"value" => 1}]}, %{}) == true + assert Evaluator.evaluate(%{"gte" => [%{"value" => 1}, %{"value" => 1}]}, %{}) == true + assert Evaluator.evaluate(%{"gte" => [%{"value" => 1}, %{"value" => 2}]}, %{}) == false + end + end + + describe "lt operator" do + test "compares less values" do + assert Evaluator.evaluate(%{"lt" => [%{"value" => 1}, %{"value" => 2}]}, %{}) == true + assert Evaluator.evaluate(%{"lt" => [%{"value" => 2}, %{"value" => 1}]}, %{}) == false + assert Evaluator.evaluate(%{"lt" => [%{"value" => 1}, %{"value" => 1}]}, %{}) == false + end + end + + describe "lte operator" do + test "compares less or equal values" do + assert Evaluator.evaluate(%{"lte" => [%{"value" => 1}, %{"value" => 2}]}, %{}) == true + assert Evaluator.evaluate(%{"lte" => [%{"value" => 1}, %{"value" => 1}]}, %{}) == true + assert Evaluator.evaluate(%{"lte" => [%{"value" => 2}, %{"value" => 1}]}, %{}) == false + end + end + + describe "in operator" do + # Operand order is haystack-first: [haystack, needle]. + test "checks string containment" do + expr = %{"in" => [%{"value" => "abcd"}, %{"value" => "bc"}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + test "checks string not contained" do + expr = %{"in" => [%{"value" => "abcd"}, %{"value" => "xyz"}]} + assert Evaluator.evaluate(expr, %{}) == false + end + + test "checks array containment" do + expr = %{"in" => [%{"value" => [1, 2, 3]}, %{"value" => 2}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + test "checks array not contained" do + expr = %{"in" => [%{"value" => [1, 2, 3]}, %{"value" => 4}]} + assert Evaluator.evaluate(expr, %{}) == false + end + + # JS operators/binary.ts:9-16 - null operand short-circuits to null + test "returns nil for nil needle" do + expr = %{"in" => [%{"value" => [1, 2]}, %{"value" => nil}]} + assert Evaluator.evaluate(expr, %{}) == nil + end + + # JS operators/binary.ts:9-16 - null operand short-circuits to null + test "returns nil for nil haystack" do + expr = %{"in" => [%{"value" => nil}, %{"value" => 1}]} + assert Evaluator.evaluate(expr, %{}) == nil + end + + test "contains is an alias for in (haystack-first)" do + assert Evaluator.evaluate(%{"contains" => [%{"value" => [1, 2, 3]}, %{"value" => 2}]}, %{}) == true + assert Evaluator.evaluate(%{"contains" => [%{"value" => "abcd"}, %{"value" => "bc"}]}, %{}) == true + assert Evaluator.evaluate(%{"contains" => [%{"value" => [1, 2, 3]}, %{"value" => 4}]}, %{}) == false + end + end + + describe "match operator" do + test "matches regex pattern" do + expr = %{"match" => [%{"value" => "hello world"}, %{"value" => "hel.*rld"}]} + assert Evaluator.evaluate(expr, %{}) == true + end + + test "does not match non-matching pattern" do + expr = %{"match" => [%{"value" => "hello world"}, %{"value" => "^xyz$"}]} + assert Evaluator.evaluate(expr, %{}) == false + end + + # JS operators/binary.ts:9-16 - null operand short-circuits to null + test "returns nil for nil text" do + expr = %{"match" => [%{"value" => nil}, %{"value" => "pattern"}]} + assert Evaluator.evaluate(expr, %{}) == nil + end + + # JS operators/binary.ts:9-16 - null operand short-circuits to null + test "returns nil for nil pattern" do + expr = %{"match" => [%{"value" => "text"}, %{"value" => nil}]} + assert Evaluator.evaluate(expr, %{}) == nil + end + + # JS operators/match.ts:13-14 - invalid regex gives null + test "returns nil for invalid regex" do + expr = %{"match" => [%{"value" => "text"}, %{"value" => "[invalid"}]} + assert Evaluator.evaluate(expr, %{}) == nil + end + end + + describe "compare/2" do + test "nil comparisons" do + assert Evaluator.compare(nil, nil) == 0 + assert Evaluator.compare(nil, 0) == nil + assert Evaluator.compare(nil, true) == nil + assert Evaluator.compare(nil, "") == nil + assert Evaluator.compare(0, nil) == nil + assert Evaluator.compare(true, nil) == nil + assert Evaluator.compare("", nil) == nil + end + + test "boolean comparisons" do + assert Evaluator.compare(true, true) == 0 + assert Evaluator.compare(false, false) == 0 + assert Evaluator.compare(true, false) == 1 + assert Evaluator.compare(false, true) == -1 + end + + test "number comparisons" do + assert Evaluator.compare(0, 0) == 0 + assert Evaluator.compare(1, 1) == 0 + assert Evaluator.compare(2, 1) == 1 + assert Evaluator.compare(1, 2) == -1 + assert Evaluator.compare(1.5, 1) == 1 + assert Evaluator.compare(1, 1.5) == -1 + end + + test "string comparisons" do + assert Evaluator.compare("", "") == 0 + assert Evaluator.compare("abc", "abc") == 0 + assert Evaluator.compare("bcd", "abc") == 1 + assert Evaluator.compare("abc", "bcd") == -1 + end + + # JS jsonexpr/evaluator.ts compare(): rhs is converted to lhs's type + test "incomparable type pairs return nil" do + assert Evaluator.compare(1, "abc") == nil + assert Evaluator.compare([1], [2]) == nil + end + + test "rhs is converted to the lhs type" do + assert Evaluator.compare("abc", 1) == 1 + assert Evaluator.compare(true, "abc") == 0 + assert Evaluator.compare(1, true) == 0 + end + end + + describe "complex audience expressions" do + setup do + john = %{"age" => 20, "language" => "en-US", "returning" => false} + terry = %{"age" => 20, "language" => "en-GB", "returning" => true} + kate = %{"age" => 50, "language" => "es-ES", "returning" => false} + maria = %{"age" => 52, "language" => "pt-PT", "returning" => true} + + age_twenty_and_us = [ + %{"eq" => [%{"var" => "age"}, %{"value" => 20}]}, + %{"eq" => [%{"var" => "language"}, %{"value" => "en-US"}]} + ] + + age_over_fifty = [%{"gte" => [%{"var" => "age"}, %{"value" => 50}]}] + + age_twenty_and_us_or_over_fifty = [ + %{"or" => [age_twenty_and_us, age_over_fifty]} + ] + + returning = [%{"eq" => [%{"var" => "returning"}, %{"value" => true}]}] + + returning_and_age_twenty_and_us_or_over_fifty = [ + returning, + age_twenty_and_us_or_over_fifty + ] |> List.flatten() + + not_returning_and_spanish = [ + %{"not" => returning}, + %{"eq" => [%{"var" => "language"}, %{"value" => "es-ES"}]} + ] + + %{ + john: john, + terry: terry, + kate: kate, + maria: maria, + age_twenty_and_us: age_twenty_and_us, + age_over_fifty: age_over_fifty, + age_twenty_and_us_or_over_fifty: age_twenty_and_us_or_over_fifty, + returning: returning, + returning_and_combo: returning_and_age_twenty_and_us_or_over_fifty, + not_returning_and_spanish: not_returning_and_spanish + } + end + + test "AgeTwentyAndUS", %{john: john, terry: terry, kate: kate, maria: maria, age_twenty_and_us: expr} do + assert evaluate_boolean(expr, john) == true + assert evaluate_boolean(expr, terry) == false + assert evaluate_boolean(expr, kate) == false + assert evaluate_boolean(expr, maria) == false + end + + test "AgeOverFifty", %{john: john, terry: terry, kate: kate, maria: maria, age_over_fifty: expr} do + assert evaluate_boolean(expr, john) == false + assert evaluate_boolean(expr, terry) == false + assert evaluate_boolean(expr, kate) == true + assert evaluate_boolean(expr, maria) == true + end + + test "AgeTwentyAndUS_Or_AgeOverFifty", %{john: john, terry: terry, kate: kate, maria: maria, age_twenty_and_us_or_over_fifty: expr} do + assert evaluate_boolean(expr, john) == true + assert evaluate_boolean(expr, terry) == false + assert evaluate_boolean(expr, kate) == true + assert evaluate_boolean(expr, maria) == true + end + + test "Returning", %{john: john, terry: terry, kate: kate, maria: maria, returning: expr} do + assert evaluate_boolean(expr, john) == false + assert evaluate_boolean(expr, terry) == true + assert evaluate_boolean(expr, kate) == false + assert evaluate_boolean(expr, maria) == true + end + + test "Returning_And_AgeTwentyAndUS_Or_AgeOverFifty", %{john: john, terry: terry, kate: kate, maria: maria, returning_and_combo: expr} do + assert evaluate_boolean(expr, john) == false + assert evaluate_boolean(expr, terry) == false + assert evaluate_boolean(expr, kate) == false + assert evaluate_boolean(expr, maria) == true + end + + test "NotReturning_And_Spanish", %{john: john, terry: terry, kate: kate, maria: maria, not_returning_and_spanish: expr} do + assert evaluate_boolean(expr, john) == false + assert evaluate_boolean(expr, terry) == false + assert evaluate_boolean(expr, kate) == true + assert evaluate_boolean(expr, maria) == false + end + end + + defp evaluate_boolean(exprs, vars) when is_list(exprs) do + result = Evaluator.evaluate(%{"and" => exprs}, vars) + result == true + end +end diff --git a/test/absmartly/matcher_test.exs b/test/absmartly/matcher_test.exs new file mode 100644 index 0000000..d2b4bac --- /dev/null +++ b/test/absmartly/matcher_test.exs @@ -0,0 +1,47 @@ +defmodule ABSmartly.MatcherTest do + use ExUnit.Case, async: true + + alias ABSmartly.Matcher + + describe "evaluate/2" do + test "returns nil on empty audience" do + assert Matcher.evaluate(nil, %{}) == nil + end + + test "returns nil if filter not object or array" do + assert Matcher.evaluate(nil, []) == nil + assert Matcher.evaluate(nil, %{}) == nil + assert Matcher.evaluate(%{"filter" => "invalid"}, %{}) == nil + assert Matcher.evaluate(%{"filter" => 1}, %{}) == nil + end + + test "evaluates map filter expressions" do + assert Matcher.evaluate(%{"filter" => %{"value" => true}}, %{}) == true + assert Matcher.evaluate(%{"filter" => %{"value" => false}}, %{}) == false + end + + test "returns boolean for filter expressions" do + assert Matcher.evaluate(%{"filter" => [%{"value" => true}]}, %{}) == true + assert Matcher.evaluate(%{"filter" => [%{"value" => false}]}, %{}) == false + end + + test "returns true for empty map" do + assert Matcher.evaluate(%{}, %{}) == true + end + + test "returns true for map without filter key" do + assert Matcher.evaluate(%{"something" => "else"}, %{}) == true + end + + test "evaluates variable comparisons" do + filter = %{ + "filter" => [ + %{"gte" => [%{"var" => "age"}, %{"value" => 20}]} + ] + } + + assert Matcher.evaluate(filter, [%{"name" => "age", "value" => 25}]) == true + assert Matcher.evaluate(filter, [%{"name" => "age", "value" => 15}]) == false + end + end +end diff --git a/test/absmartly/md5_test.exs b/test/absmartly/md5_test.exs new file mode 100644 index 0000000..0aa3675 --- /dev/null +++ b/test/absmartly/md5_test.exs @@ -0,0 +1,37 @@ +defmodule ABSmartly.MD5Test do + use ExUnit.Case, async: true + + alias ABSmartly.Utils + + @md5_test_cases [ + {"", "1B2M2Y8AsgTpgAmY7PhCfg"}, + {" ", "chXunH2dwinSkhpA6JnsXw"}, + {"t", "41jvpIn1gGLxDdcxa2Vkng"}, + {"te", "Vp73JkK-D63XEdakaNaO4Q"}, + {"tes", "KLZi2IO212_Zbk3cXpungA"}, + {"test", "CY9rzUYh03PK3k6DJie09g"}, + {"testy", "K5I_V6RgP8c6sYKz-TVn8g"}, + {"testy1", "8fT8xGipOhPkZ2DncKU-1A"}, + {"testy12", "YqRAtOz000gIu61ErEH18A"}, + {"testy123", "pfV2H07L6WvdqlY0zHuYIw"}, + {"special characters açb↓c", "4PIrO7lKtTxOcj2eMYlG7A"}, + {"The quick brown fox jumps over the lazy dog", "nhB9nTcrtoJr2B01QqQZ1g"}, + {"The quick brown fox jumps over the lazy dog and eats a pie", "iM-8ECRrLUQzixl436y96A"}, + {"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", "24m7XOq4f5wPzCqzbBicLA"}, + # Characters outside the BMP are stored as UTF-16 surrogate pairs and must encode to 4-byte UTF-8; these canonical hashes are shared across all SDKs. + {"😀", "KgLqw51xanDs83V5GFkntg"}, + {"😀😁", "ZJuDalvUWRJnVtkspj-2bQ"}, + {"世界你好", "v2CJG7YcjjWncKOSCzF2GA"}, + {"user_世界_123", "SCgk4OzXlFMvo1UMsP88fA"} + ] + + describe "hash_unit/1 should match known MD5 hashes" do + for {input, expected} <- @md5_test_cases do + @tag_input input + @tag_expected expected + test "hashes #{inspect(String.slice(input, 0, 40))} to #{expected}" do + assert Utils.hash_unit(@tag_input) == @tag_expected + end + end + end +end diff --git a/test/absmartly/murmur3_test.exs b/test/absmartly/murmur3_test.exs new file mode 100644 index 0000000..03850aa --- /dev/null +++ b/test/absmartly/murmur3_test.exs @@ -0,0 +1,81 @@ +defmodule ABSmartly.Murmur3Test do + use ExUnit.Case, async: true + + alias ABSmartly.Murmur3 + + @seed_0 0x00000000 + @seed_deadbeef 0xDEADBEEF + @seed_1 0x00000001 + + # Expected values are independent reference vectors (from the ABsmartly + # JavaScript SDK murmur3_32 test suite), NOT computed by this implementation. + @murmur3_test_cases [ + {"", @seed_0, 0x00000000}, + {" ", @seed_0, 0x7EF49B98}, + {"t", @seed_0, 0xCA87DF4D}, + {"te", @seed_0, 0xEDB8EE1B}, + {"tes", @seed_0, 0x0BB90E5A}, + {"test", @seed_0, 0xBA6BD213}, + {"testy", @seed_0, 0x44AF8342}, + {"testy1", @seed_0, 0x8A1A243A}, + {"testy12", @seed_0, 0x845461B9}, + {"testy123", @seed_0, 0x47628AC4}, + {"special characters açb↓c", @seed_0, 0xBE83B140}, + {"The quick brown fox jumps over the lazy dog", @seed_0, 0x2E4FF723}, + {"", @seed_deadbeef, 0x0DE5C6A9}, + {" ", @seed_deadbeef, 0x25ACCE43}, + {"t", @seed_deadbeef, 0x3B15DCF8}, + {"te", @seed_deadbeef, 0xAC981332}, + {"tes", @seed_deadbeef, 0xC1C78DDA}, + {"test", @seed_deadbeef, 0xAA22D41A}, + {"testy", @seed_deadbeef, 0x84F5F623}, + {"testy1", @seed_deadbeef, 0x09ED28E9}, + {"testy12", @seed_deadbeef, 0x22467835}, + {"testy123", @seed_deadbeef, 0xD633060D}, + {"special characters açb↓c", @seed_deadbeef, 0xF7FDD8A2}, + {"The quick brown fox jumps over the lazy dog", @seed_deadbeef, 0x3A7B3F4D}, + {"", @seed_1, 0x514E28B7}, + {" ", @seed_1, 0x4F0F7132}, + {"t", @seed_1, 0x5DB1831E}, + {"te", @seed_1, 0xD248BB2E}, + {"tes", @seed_1, 0xD432EB74}, + {"test", @seed_1, 0x99C02AE2}, + {"testy", @seed_1, 0xC5B2DC1E}, + {"testy1", @seed_1, 0x33925CEB}, + {"testy12", @seed_1, 0xD92C9F23}, + {"testy123", @seed_1, 0x3BC1712D}, + {"special characters açb↓c", @seed_1, 0x293327B5}, + {"The quick brown fox jumps over the lazy dog", @seed_1, 0x78E69E27} + ] + + describe "hash32/2 should match known hashes" do + for {input, seed, expected} <- @murmur3_test_cases do + seed_label = case seed do + 0x00000000 -> "0x00000000" + 0xDEADBEEF -> "0xDEADBEEF" + 0x00000001 -> "0x00000001" + end + + @tag_input input + @tag_seed seed + @tag_expected expected + test "hashes #{inspect(input)} with seed #{seed_label} to #{expected}" do + assert Murmur3.hash32(@tag_input, @tag_seed) == @tag_expected + end + end + end + + describe "hash32/2 known reference values" do + test "empty string with seed 0 returns 0" do + assert Murmur3.hash32("", 0) == 0 + end + + test "test with seed 0 returns 0xBA6BD213" do + assert Murmur3.hash32("test", 0) == 0xBA6BD213 + end + + test "test with seed 0xDEADBEEF returns 0xAA22D41A" do + assert Murmur3.hash32("test", 0xDEADBEEF) == 0xAA22D41A + end + end +end diff --git a/test/absmartly/sdk_test.exs b/test/absmartly/sdk_test.exs new file mode 100644 index 0000000..a22ac90 --- /dev/null +++ b/test/absmartly/sdk_test.exs @@ -0,0 +1,293 @@ +defmodule ABSmartly.SDKTest do + use ExUnit.Case, async: true + + alias ABSmartly.SDK + + describe "new/1 with keyword list" do + test "creates SDK with required parameters" do + assert {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + + assert sdk.config.endpoint == "https://test.absmartly.io/v1" + assert sdk.config.api_key == "test-key" + assert sdk.config.application == "test-app" + assert sdk.config.environment == "test" + assert sdk.config.timeout == 3000 + assert sdk.config.retries == 5 + end + + test "creates SDK with custom timeout and retries" do + assert {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test", + timeout: 5000, + retries: 3 + ) + + assert sdk.config.timeout == 5000 + assert sdk.config.retries == 3 + end + + test "returns error when endpoint is missing" do + assert {:error, error} = + SDK.new( + api_key: "test-key", + application: "test-app", + environment: "test" + ) + + assert error =~ "Missing required parameters" + assert error =~ "endpoint" + end + + test "returns error when api_key is missing" do + assert {:error, error} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + application: "test-app", + environment: "test" + ) + + assert error =~ "Missing required parameters" + assert error =~ "api_key" + end + + test "returns error when application is missing" do + assert {:error, error} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + environment: "test" + ) + + assert error =~ "Missing required parameters" + assert error =~ "application" + end + + test "returns error when environment is missing" do + assert {:error, error} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app" + ) + + assert error =~ "Missing required parameters" + assert error =~ "environment" + end + + test "returns error listing all missing parameters" do + assert {:error, error} = SDK.new([]) + + assert error =~ "Missing required parameters" + assert error =~ "endpoint" + assert error =~ "api_key" + assert error =~ "application" + assert error =~ "environment" + end + end + + describe "with_timeout/2" do + test "updates timeout for SDK instance" do + result = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + |> SDK.with_timeout(10_000) + + assert {:ok, sdk} = result + assert sdk.config.timeout == 10_000 + end + + test "preserves error from new/1" do + result = + SDK.new(api_key: "test-key") + |> SDK.with_timeout(5000) + + assert {:error, _} = result + end + + test "can be chained" do + result = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + |> SDK.with_timeout(8000) + |> SDK.with_retries(2) + + assert {:ok, sdk} = result + assert sdk.config.timeout == 8000 + assert sdk.config.retries == 2 + end + end + + describe "with_retries/2" do + test "updates retries for SDK instance" do + result = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + |> SDK.with_retries(10) + + assert {:ok, sdk} = result + assert sdk.config.retries == 10 + end + + test "preserves error from new/1" do + result = + SDK.new(api_key: "test-key") + |> SDK.with_retries(3) + + assert {:error, _} = result + end + end + + describe "pipe operator usage" do + test "works with full pipe chain" do + result = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + |> SDK.with_timeout(5000) + |> SDK.with_retries(3) + + assert {:ok, sdk} = result + assert sdk.config.endpoint == "https://test.absmartly.io/v1" + assert sdk.config.timeout == 5000 + assert sdk.config.retries == 3 + end + + test "handles error in pipe chain gracefully" do + result = + SDK.new(endpoint: "https://test.absmartly.io/v1") + |> SDK.with_timeout(5000) + |> SDK.with_retries(3) + + assert {:error, error} = result + assert error =~ "Missing required parameters" + end + end + + describe "idiomatic usage patterns" do + test "direct usage without unwrapping" do + {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + + assert sdk.config.application == "test-app" + end + + test "pattern matching on success" do + case SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) do + {:ok, sdk} -> + assert sdk.config.api_key == "test-key" + + {:error, _} -> + flunk("Expected success") + end + end + + test "pattern matching on failure" do + case SDK.new(api_key: "test-key") do + {:ok, _sdk} -> + flunk("Expected failure") + + {:error, error} -> + assert error =~ "Missing required parameters" + end + end + + test "with statement unwrapping" do + with {:ok, sdk} <- + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) do + assert sdk.config.environment == "test" + end + end + end + + describe "configuration values" do + test "applies default timeout of 3000ms" do + {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + + assert sdk.config.timeout == 3000 + end + + test "applies default retries of 5" do + {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test" + ) + + assert sdk.config.retries == 5 + end + + test "accepts timeout override" do + {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test", + timeout: 15_000 + ) + + assert sdk.config.timeout == 15_000 + end + + test "accepts retries override" do + {:ok, sdk} = + SDK.new( + endpoint: "https://test.absmartly.io/v1", + api_key: "test-key", + application: "test-app", + environment: "test", + retries: 1 + ) + + assert sdk.config.retries == 1 + end + end +end diff --git a/test/absmartly/utils_test.exs b/test/absmartly/utils_test.exs new file mode 100644 index 0000000..cd9e8fe --- /dev/null +++ b/test/absmartly/utils_test.exs @@ -0,0 +1,92 @@ +defmodule ABSmartly.UtilsTest do + use ExUnit.Case, async: true + + alias ABSmartly.Utils + + describe "hash_unit/1" do + test "hashes string unit" do + result = Utils.hash_unit("bleh@absmartly.com") + assert is_binary(result) + assert String.length(result) > 0 + end + + test "hashes integer unit" do + result = Utils.hash_unit(123456789) + assert is_binary(result) + end + end + + describe "choose_variant/2" do + test "returns correct variant for probability 0" do + assert Utils.choose_variant([0.0, 1.0], 0.0) == 1 + assert Utils.choose_variant([0.5, 0.5], 0.0) == 0 + assert Utils.choose_variant([0.33, 0.33, 0.34], 0.0) == 0 + end + + test "returns correct variant for 50/50 split" do + assert Utils.choose_variant([0.5, 0.5], 0.0) == 0 + assert Utils.choose_variant([0.5, 0.5], 0.25) == 0 + assert Utils.choose_variant([0.5, 0.5], 0.49999999) == 0 + assert Utils.choose_variant([0.5, 0.5], 0.5) == 1 + assert Utils.choose_variant([0.5, 0.5], 0.50000001) == 1 + assert Utils.choose_variant([0.5, 0.5], 0.75) == 1 + assert Utils.choose_variant([0.5, 0.5], 1.0) == 1 + end + + test "returns correct variant for three-way split" do + assert Utils.choose_variant([0.33, 0.33, 0.34], 0.0) == 0 + assert Utils.choose_variant([0.33, 0.33, 0.34], 0.33) == 1 + assert Utils.choose_variant([0.33, 0.33, 0.34], 0.66) == 2 + assert Utils.choose_variant([0.33, 0.33, 0.34], 1.0) == 2 + end + + test "returns correct variant for 0/100 split" do + assert Utils.choose_variant([0.0, 1.0], 0.5) == 1 + assert Utils.choose_variant([0.0, 1.0], 1.0) == 1 + end + end + + describe "to_number/1" do + test "returns nil for nil" do + assert Utils.to_number(nil) == nil + end + + test "returns number unchanged" do + assert Utils.to_number(42) == 42 + assert Utils.to_number(3.14) == 3.14 + end + + test "converts boolean to number" do + assert Utils.to_number(true) == 1 + assert Utils.to_number(false) == 0 + end + + test "converts string to number" do + assert Utils.to_number("123") == 123.0 + assert Utils.to_number("3.14") == 3.14 + assert Utils.to_number("") == 0 + assert Utils.to_number("abc") == nil + end + end + + describe "to_boolean/1" do + test "returns nil for nil" do + assert Utils.to_boolean(nil) == nil + end + + test "returns false for falsy values" do + assert Utils.to_boolean(false) == false + assert Utils.to_boolean(0) == false + assert Utils.to_boolean(0.0) == false + assert Utils.to_boolean("") == false + end + + test "returns true for truthy values" do + assert Utils.to_boolean(true) == true + assert Utils.to_boolean(1) == true + assert Utils.to_boolean("abc") == true + assert Utils.to_boolean([]) == true + assert Utils.to_boolean(%{}) == true + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..799f725 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,65 @@ +defmodule ABSmartly.Test.OkPublisher do + @moduledoc """ + Publisher stub that always reports success, so tests of queue/flush logic do + not depend on (and silently swallow failures of) a real network transport. + """ + + @behaviour ABSmartly.ContextPublisher + + @impl ABSmartly.ContextPublisher + def publish(_endpoint, _api_key, _application, _environment, _event_map, _retries), do: :ok +end + +defmodule ABSmartly.Test.ScriptedPublisher do + @moduledoc """ + Publisher stub driven by a per-test script of responses. + + `start/1` starts an Agent (linked to the test process) holding the scripted + responses and returns its name as a string. Pass that string as the + `SDKConfig.endpoint`; `publish/6` uses it to find the script. Each publish + attempt consumes the next response and records the event map it was given. + Once the script is exhausted every attempt succeeds. + + Responses: `:ok`, `{:error, reason}`, `{:raise, message}` or `{:sleep, ms}`. + """ + + @behaviour ABSmartly.ContextPublisher + + def start(responses) when is_list(responses) do + name = :"scripted_publisher_#{System.unique_integer([:positive])}" + {:ok, _pid} = Agent.start_link(fn -> %{responses: responses, attempts: []} end, name: name) + Atom.to_string(name) + end + + def attempts(endpoint) do + Agent.get(String.to_existing_atom(endpoint), fn state -> Enum.reverse(state.attempts) end) + end + + @impl ABSmartly.ContextPublisher + def publish(endpoint, _api_key, _application, _environment, event_map, _retries) do + response = + Agent.get_and_update(String.to_existing_atom(endpoint), fn state -> + {next, rest} = + case state.responses do + [] -> {:ok, []} + [head | tail] -> {head, tail} + end + + {next, %{state | responses: rest, attempts: [event_map | state.attempts]}} + end) + + case response do + {:raise, message} -> + raise message + + {:sleep, ms} -> + Process.sleep(ms) + :ok + + other -> + other + end + end +end + +ExUnit.start()