diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..dd63627 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,51 @@ +cmake_minimum_required(VERSION 3.16) +project(absmartly-sdk VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include(FetchContent) + +FetchContent_Declare( + json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz +) +FetchContent_MakeAvailable(json) + +add_library(absmartly-sdk + src/md5.cpp + src/murmur3.cpp + src/variant_assigner.cpp + src/utils.cpp + src/context.cpp + src/client.cpp + src/default_context_data_provider.cpp + src/default_context_event_publisher.cpp + src/sdk.cpp + src/json_expr/evaluator.cpp + src/json_expr/operators.cpp + src/audience_matcher.cpp +) + +target_include_directories(absmartly-sdk PUBLIC + $ + $ +) + +target_link_libraries(absmartly-sdk PUBLIC nlohmann_json::nlohmann_json) + +find_package(CURL QUIET) +if(CURL_FOUND) + target_sources(absmartly-sdk PRIVATE src/default_http_client.cpp) + target_compile_definitions(absmartly-sdk PUBLIC ABSMARTLY_HAVE_CURL) + target_link_libraries(absmartly-sdk PRIVATE CURL::libcurl) + message(STATUS "ABsmartly: CURL found, DefaultHTTPClient enabled") +else() + message(STATUS "ABsmartly: CURL not found, DefaultHTTPClient disabled") +endif() + +option(ABSMARTLY_BUILD_TESTS "Build tests" ON) +if(ABSMARTLY_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9374e3 --- /dev/null +++ b/README.md @@ -0,0 +1,469 @@ +# A/B Smartly SDK + +A/B Smartly - C++ SDK + +## Compatibility + +The A/B Smartly C++ SDK is compatible with C++17 and later. It requires a compiler with C++17 support (GCC 7+, Clang 5+, MSVC 2017+). The SDK uses [nlohmann/json](https://github.com/nlohmann/json) for JSON handling, which is automatically fetched via CMake's FetchContent. + +## Installation + +#### CMake (FetchContent) + +Add the following to your `CMakeLists.txt` to include the SDK directly from the repository: + +```cmake +include(FetchContent) + +FetchContent_Declare( + absmartly-sdk + GIT_REPOSITORY https://github.com/absmartly/cpp-sdk.git + GIT_TAG main +) +FetchContent_MakeAvailable(absmartly-sdk) + +target_link_libraries(your_target PRIVATE absmartly-sdk) +``` + +#### CMake (Local) + +If you have cloned the repository locally, you can add it as a subdirectory: + +```cmake +add_subdirectory(path/to/cpp-sdk) +target_link_libraries(your_target PRIVATE absmartly-sdk) +``` + +#### Building from Source + +```bash +mkdir build && cd build +cmake .. +cmake --build . +``` + +To build without tests: + +```bash +cmake -DABSMARTLY_BUILD_TESTS=OFF .. +cmake --build . +``` + +## 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 A/B Smartly web console. + +#### Recommended: Using the SDK Wrapper + +The SDK wrapper manages HTTP communication, context data fetching, and event publishing automatically. This requires libcurl or a custom `HTTPClient` implementation. + +```cpp +#include +#include +#include +#include +#include + +int main() { + absmartly::ClientConfig client_config; + client_config.endpoint = "https://your-company.absmartly.io"; + client_config.api_key = "YOUR_API_KEY"; + client_config.application = "website"; + client_config.environment = "production"; + + auto http_client = std::make_shared(); + auto client = std::make_shared(client_config, http_client); + + absmartly::SDKConfig sdk_config; + sdk_config.client = client; + + auto sdk = absmartly::SDK::create(sdk_config); + + absmartly::ContextConfig ctx_config; + ctx_config.units = {{"session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"}}; + + // Async context creation (fetches data from API) + auto context = sdk->create_context(ctx_config); + context->wait_until_ready(); + + // Or with pre-fetched data + auto data_future = sdk->get_context_data(); + auto data = data_future.get(); + auto context2 = sdk->create_context_with(ctx_config, data); + + return 0; +} +``` + +#### Direct Construction (without SDK wrapper) + +```cpp +#include +#include +#include +#include + +int main() { + std::string json_response = fetch_context_data(); // your HTTP client + + absmartly::ContextData data = nlohmann::json::parse(json_response) + .get(); + + absmartly::ContextConfig config; + config.units = {{"session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"}}; + + absmartly::Context context(config, data); + + return 0; +} +``` + +#### With Optional Parameters + +```cpp +absmartly::ContextConfig config; +config.units = {{"session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"}}; +config.publish_delay = 100; // delay before publishing in milliseconds +config.refresh_period = 0; // auto-refresh period (0 = disabled) +config.overrides = { // pre-set treatment overrides + {"exp_test_experiment", 1} +}; +config.custom_assignments = { // pre-set custom assignments + {"exp_another_experiment", 0} +}; + +absmartly::Context context(config, data); +``` + +#### Advanced Configuration + +For advanced use cases where you need to handle SDK events, provide a custom event handler: + +```cpp +#include + +class MyEventHandler : public absmartly::ContextEventHandler { +public: + void handle_event(absmartly::Context& context, + const std::string& event_type, + const nlohmann::json& data) override { + if (event_type == "exposure") { + std::cout << "Exposed to: " << data["name"] << std::endl; + } else if (event_type == "goal") { + std::cout << "Goal tracked: " << data["name"] << std::endl; + } + } +}; + +// Pass the event handler as a shared_ptr +auto event_handler = std::make_shared(); +absmartly::Context context(config, data, event_handler); +``` + +**SDK Options (ContextConfig)** + +| Config | Type | Required? | Default | Description | +| :------------------- | :-------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| units | `std::map` | ✅ | `{}` | A map of unit types to unit identifiers (e.g., `{{"session_id", "abc123"}}`). | +| publish_delay | `int` | ❌ | `-1` | Delay in milliseconds before publishing events. Use `-1` to publish immediately. | +| refresh_period | `int` | ❌ | `0` | Period in milliseconds for automatic context refresh. Use `0` to disable. | +| overrides | `std::map` | ❌ | `{}` | Pre-set treatment overrides for experiments. | +| custom_assignments | `std::map` | ❌ | `{}` | Pre-set custom assignments for experiments. | + +## Creating a New Context + +### Using the SDK Wrapper (Recommended) + +```cpp +auto sdk = absmartly::SDK::create(sdk_config); + +// Async: SDK fetches context data from the API +auto context = sdk->create_context(ctx_config); +context->wait_until_ready(); + +// With pre-fetched data: context is immediately ready +auto context2 = sdk->create_context_with(ctx_config, data); +``` + +### Direct Construction + +You provide the context data obtained from your HTTP call to the A/B Smartly collector: + +```cpp +absmartly::ContextConfig config; +config.units = {{"session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"}}; + +absmartly::ContextData data = nlohmann::json::parse(json_response) + .get(); + +absmartly::Context context(config, data); +assert(context.is_ready()); +``` + +### With Pre-fetched Data + +Creating a context involves obtaining data from the A/B Smartly event collector. You can avoid repeating the round-trip by re-using previously retrieved data: + +```cpp +absmartly::ContextConfig config; +config.units = {{"session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"}}; + +absmartly::Context context(config, data); + +absmartly::ContextConfig another_config; +another_config.units = {{"session_id", "another-session-id"}}; + +absmartly::Context another_context(another_config, context.data()); +assert(another_context.is_ready()); +``` + +### Refreshing the Context with Fresh Experiment Data + +For long-running contexts, experiments started after the context was created will not be triggered. Call the `refresh()` method with updated data to incorporate new experiments: + +```cpp +// Fetch fresh context data from the collector +absmartly::ContextData fresh_data = nlohmann::json::parse(fresh_json_response) + .get(); + +context.refresh(fresh_data); +``` + +### Setting Extra Units + +You can add additional units to a context by calling the `set_unit()` or `set_units()` methods. This is useful when a user logs in and you want to associate the new identity with the context. Note that you cannot override an already set unit type, as that would be a change of identity and will throw an exception. In this case, you must create a new context instead. + +```cpp +context.set_unit("db_user_id", "1000013"); + +context.set_units({ + {"db_user_id", "1000013"} +}); +``` + +## Basic Usage + +### Selecting a Treatment + +```cpp +if (context.treatment("exp_test_experiment") == 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +### Treatment Variables + +```cpp +nlohmann::json variable = context.variable_value("my_variable", nlohmann::json("default")); +``` + +Variables can be of any JSON type: + +```cpp +nlohmann::json button_color = context.variable_value("button_color", "blue"); +nlohmann::json show_banner = context.variable_value("show_banner", false); +nlohmann::json banner_height = context.variable_value("banner_height", 200); +``` + +### Peek at Treatment Variants + +Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. The A/B Smartly SDK provides `peek()` and `peek_variable_value()` methods for that. + +```cpp +if (context.peek("exp_test_experiment") == 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +#### Peeking at Variables + +```cpp +nlohmann::json variable = context.peek_variable_value("my_variable", nlohmann::json("default")); +``` + +### Overriding Treatment Variants + +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `set_override()` and/or `set_overrides()` methods. + +```cpp +context.set_override("exp_test_experiment", 1); + +context.set_overrides({ + {"exp_test_experiment", 1}, + {"exp_another_experiment", 0} +}); +``` + +## Advanced + +### Context Attributes + +```cpp +context.set_attribute("user_agent", "Mozilla/5.0..."); + +context.set_attributes({ + {"customer_age", "new_customer"}, + {"url", "/products/123"} +}); +``` + +### Custom Assignments + +```cpp +context.set_custom_assignment("exp_test_experiment", 1); + +context.set_custom_assignments({ + {"exp_test_experiment", 1}, + {"exp_another_experiment", 0} +}); +``` + +### Custom Field Values + +```cpp +nlohmann::json field_value = context.custom_field_value("exp_test_experiment", "my_field"); +std::vector field_keys = context.custom_field_keys(); +``` + +### Variable Keys + +```cpp +auto keys = context.variable_keys(); +// Returns std::map> +// mapping variable names to the experiments that define them +``` + +### Tracking Goals + +Goals are created in the A/B Smartly web console. + +```cpp +context.track("payment", { + {"item_count", 1}, + {"total_amount", 1999.99} +}); +``` + +Track a goal without properties: + +```cpp +context.track("page_view"); +``` + +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. You can explicitly call the `publish()` method, which returns a `PublishEvent` containing all pending data. + +```cpp +absmartly::PublishEvent event = context.publish(); +// Serialize and send event to the A/B Smartly collector +nlohmann::json event_json = event; +send_to_collector(event_json.dump()); +``` + +### Finalizing + +The `finalize()` method will ensure all events have been published, like `publish()`, and will also "seal" the context, throwing a `ContextFinalizedException` if any method that could generate an event is called. + +```cpp +absmartly::PublishEvent event = context.finalize(); +// Send final event to collector +``` + +### Using RAII for Context Lifetime + +In C++, you can use RAII patterns to ensure the context is properly finalized: + +```cpp +{ + absmartly::Context context(config, data); + + int treatment = context.treatment("exp_test_experiment"); + // ... use treatment + + context.track("conversion"); + + // Publish and send to collector before scope ends + auto event = context.finalize(); + send_to_collector(nlohmann::json(event).dump()); +} // context destroyed here +``` + +### Custom Event Handler + +The A/B Smartly SDK can be instantiated with an event handler. Implement the `ContextEventHandler` interface to receive SDK lifecycle events. + +```cpp +class CustomEventHandler : public absmartly::ContextEventHandler { +public: + void handle_event(absmartly::Context& context, + const std::string& event_type, + const nlohmann::json& data) override { + if (event_type == "exposure") { + std::cout << "Exposed to experiment: " << data["name"] << std::endl; + } else if (event_type == "goal") { + std::cout << "Goal tracked: " << data["name"] << std::endl; + } else if (event_type == "error") { + std::cerr << "Error: " << data.dump() << std::endl; + } + } +}; +``` + +Usage: + +```cpp +auto handler = std::make_shared(); +absmartly::Context context(config, data, handler); +``` + +**Event Types** + +| Event | When | Data | +| ---------- | ---------------------------------------------------------- | -------------------------------------- | +| `error` | `Context` receives an error | JSON object with error details | +| `ready` | `Context` is constructed and ready | JSON with experiment list | +| `refresh` | `Context::refresh()` method succeeds | JSON with updated experiment list | +| `publish` | `Context::publish()` method succeeds | `PublishEvent` serialized as JSON | +| `exposure` | `Context::treatment()` succeeds on first exposure | `Exposure` serialized as JSON | +| `goal` | `Context::track()` method succeeds | `GoalAchievement` serialized as JSON | +| `finalize` | `Context::finalize()` method succeeds the first time | empty JSON | + +### Exception Types + +The SDK defines the following exception types: + +| Exception | When | +| ---------------------------- | --------------------------------------------------------- | +| `ContextFinalizedException` | A method is called on a finalized or finalizing context | +| `ContextNotReadyException` | A method is called on a context that is not ready | + +## 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: +- [Java SDK](https://www.github.com/absmartly/java-sdk) +- [JavaScript SDK](https://www.github.com/absmartly/javascript-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) +- [Angular SDK](https://www.github.com/absmartly/angular-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) +- [C++ SDK](https://www.github.com/absmartly/cpp-sdk) (this package) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) diff --git a/include/absmartly/audience_matcher.h b/include/absmartly/audience_matcher.h new file mode 100644 index 0000000..7d37f37 --- /dev/null +++ b/include/absmartly/audience_matcher.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include + +#include "absmartly/json_expr/evaluator.h" + +namespace absmartly { + +class AudienceMatcher { +public: + AudienceMatcher(); + + std::optional evaluate(const std::string& audience_string, const nlohmann::json& attributes) const; + +private: + Evaluator evaluator_; +}; + +} diff --git a/include/absmartly/context.h b/include/absmartly/context.h new file mode 100644 index 0000000..aa46833 --- /dev/null +++ b/include/absmartly/context.h @@ -0,0 +1,154 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace absmartly { + +struct Assignment { + int id = 0; + int iteration = 0; + int full_on_variant = 0; + std::string unit_type; + int variant = 0; + bool assigned = false; + bool eligible = true; + bool overridden = false; + bool full_on = false; + bool custom = false; + bool audience_mismatch = false; + bool exposed = false; + std::vector traffic_split; + std::map variables; + bool has_variables = false; + int attrs_seq = 0; +}; + +struct ExperimentIndex { + const ExperimentData* data = nullptr; + std::vector> variables; +}; + +class Context { +public: + Context(const ContextConfig& config, ContextData data, + std::shared_ptr event_handler = nullptr, + std::shared_ptr event_publisher = nullptr); + + Context(const ContextConfig& config, std::future data_future, + std::shared_ptr event_handler = nullptr, + std::shared_ptr event_publisher = nullptr); + + void wait_until_ready(); + + bool is_ready() const; + bool is_failed() const; + bool is_finalized() const; + bool is_finalizing() const; + int pending() const; + + const ContextData& data() const; + std::vector experiments() const; + + void set_unit(const std::string& unit_type, const std::string& uid); + void set_units(const std::map& units); + std::optional get_unit(const std::string& unit_type) const; + std::map get_units() const; + + void set_attribute(const std::string& name, const nlohmann::json& value); + void set_attributes(const std::map& attrs); + nlohmann::json get_attribute(const std::string& name) const; + std::map get_attributes() const; + + void set_override(const std::string& experiment_name, int variant); + void set_overrides(const std::map& overrides); + + void set_custom_assignment(const std::string& experiment_name, int variant); + void set_custom_assignments(const std::map& assignments); + + int treatment(const std::string& experiment_name); + int peek(const std::string& experiment_name); + + nlohmann::json variable_value(const std::string& key, const nlohmann::json& default_value); + nlohmann::json peek_variable_value(const std::string& key, const nlohmann::json& default_value); + std::map> variable_keys() const; + + nlohmann::json custom_field_value(const std::string& experiment_name, const std::string& key) const; + std::vector custom_field_keys() const; + + void track(const std::string& goal_name, const nlohmann::json& properties = nlohmann::json()); + + PublishEvent publish(); + + PublishEvent finalize(); + + void refresh(const ContextData& new_data); + +private: + Assignment& get_or_create_assignment(const std::string& experiment_name); + void queue_exposure(const std::string& experiment_name, const Assignment& assignment); + + void init(const ContextData& data); + void build_index(); + + bool experiment_matches(const ExperimentData& experiment, const Assignment& assignment) const; + bool audience_matches(const ExperimentData& experiment, Assignment& assignment); + nlohmann::json get_attributes_map() const; + std::string unit_hash(const std::string& unit_type); + + void check_not_finalized() const; + void check_ready() const; + + void emit_event(const std::string& type, const nlohmann::json& data = nlohmann::json()); + void setup_from_config(); + void become_ready(ContextData data); + + ContextConfig config_; + ContextData data_; + bool ready_ = false; + bool failed_ = false; + bool finalized_ = false; + bool finalizing_ = false; + int pending_ = 0; + + std::map units_; + std::vector attrs_; + int attrs_seq_ = 0; + + std::map assignments_; + + std::map overrides_; + std::map cassignments_; + + std::vector exposures_; + std::vector goals_; + + std::map index_; + std::map> index_variables_; + + std::map hashes_; + std::map assigners_; + + std::shared_ptr event_handler_; + std::shared_ptr event_publisher_; + + std::future data_future_; + + AudienceMatcher audience_matcher_; +}; + +} // namespace absmartly diff --git a/include/absmartly/context_config.h b/include/absmartly/context_config.h new file mode 100644 index 0000000..4e9e23b --- /dev/null +++ b/include/absmartly/context_config.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +namespace absmartly { + +struct ContextConfig { + int publish_delay = -1; + int refresh_period = 0; + std::map units; + std::map overrides; + std::map custom_assignments; +}; + +} // namespace absmartly diff --git a/include/absmartly/context_event_handler.h b/include/absmartly/context_event_handler.h new file mode 100644 index 0000000..d55db8c --- /dev/null +++ b/include/absmartly/context_event_handler.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +namespace absmartly { + +class Context; + +class ContextEventHandler { +public: + virtual ~ContextEventHandler() = default; + virtual void handle_event(Context& context, const std::string& event_type, const nlohmann::json& data) = 0; +}; + +} // namespace absmartly diff --git a/include/absmartly/errors.h b/include/absmartly/errors.h new file mode 100644 index 0000000..05d7b8e --- /dev/null +++ b/include/absmartly/errors.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include + +namespace absmartly { + +class ContextFinalizedException : public std::runtime_error { +public: + ContextFinalizedException() : std::runtime_error("Context finalized") {} +}; + +class ContextNotReadyException : public std::runtime_error { +public: + ContextNotReadyException() : std::runtime_error("Context not ready") {} +}; + +} // namespace absmartly diff --git a/include/absmartly/hashing.h b/include/absmartly/hashing.h new file mode 100644 index 0000000..c2539c3 --- /dev/null +++ b/include/absmartly/hashing.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include + +namespace absmartly { + +uint32_t murmur3_32(const uint8_t* data, size_t len, uint32_t seed); +uint32_t murmur3_32(const std::string& str, uint32_t seed); + +std::string md5_hex(const std::string& input); +std::vector md5_raw(const std::string& input); + +std::string hash_unit(const std::string& unit); +std::string base64url_no_padding(const uint8_t* data, size_t len); + +int choose_variant(const std::vector& split, double probability); + +} // namespace absmartly diff --git a/include/absmartly/json_expr/evaluator.h b/include/absmartly/json_expr/evaluator.h new file mode 100644 index 0000000..0e5cd66 --- /dev/null +++ b/include/absmartly/json_expr/evaluator.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "absmartly/json_expr/operators.h" + +namespace absmartly { + +class Evaluator { +public: + Evaluator(); + + nlohmann::json evaluate(const nlohmann::json& expr, const nlohmann::json& vars) const; + bool evaluate_boolean(const nlohmann::json& expr, const nlohmann::json& vars) const; + + static std::optional to_boolean(const nlohmann::json& value); + static std::optional to_number(const nlohmann::json& value); + static std::optional to_string_value(const nlohmann::json& value); + static std::optional compare(const nlohmann::json& a, const nlohmann::json& b); + static nlohmann::json extract_var(const nlohmann::json& vars, const std::string& path); + +private: + std::map> operators_; +}; + +} diff --git a/include/absmartly/json_expr/operators.h b/include/absmartly/json_expr/operators.h new file mode 100644 index 0000000..4e9ff0e --- /dev/null +++ b/include/absmartly/json_expr/operators.h @@ -0,0 +1,141 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace absmartly { + +class Evaluator; + +class Operator { +public: + virtual ~Operator() = default; + virtual nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const = 0; +}; + +class ValueOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class VarOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class AndOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class OrOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class NotOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class NullOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class EqOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class GtOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class GteOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class LtOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class LteOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class InOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +class MatchOperator : public Operator { +public: + nlohmann::json evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars + ) const override; +}; + +} diff --git a/include/absmartly/models.h b/include/absmartly/models.h new file mode 100644 index 0000000..d3b0cee --- /dev/null +++ b/include/absmartly/models.h @@ -0,0 +1,285 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace absmartly { + +struct ExperimentVariant { + std::string name; + nlohmann::json config; +}; + +inline void to_json(nlohmann::json& j, const ExperimentVariant& v) { + j = nlohmann::json{{"name", v.name}, {"config", v.config}}; +} + +inline void from_json(const nlohmann::json& j, ExperimentVariant& v) { + j.at("name").get_to(v.name); + if (j.contains("config")) { + v.config = j.at("config"); + } +} + +struct CustomFieldValue { + std::string name; + std::string type; + std::optional value; +}; + +inline void to_json(nlohmann::json& j, const CustomFieldValue& v) { + j = nlohmann::json{{"name", v.name}, {"type", v.type}}; + if (v.value.has_value()) { + j["value"] = v.value.value(); + } else { + j["value"] = nullptr; + } +} + +inline void from_json(const nlohmann::json& j, CustomFieldValue& v) { + j.at("name").get_to(v.name); + j.at("type").get_to(v.type); + if (j.contains("value") && !j.at("value").is_null()) { + v.value = j.at("value").get(); + } +} + +struct ExperimentData { + int id = 0; + std::string name; + std::string unitType; + int iteration = 0; + int seedHi = 0; + int seedLo = 0; + std::vector split; + bool seedStrictMode = false; + int trafficSeedHi = 0; + int trafficSeedLo = 0; + std::vector trafficSplit; + int fullOnVariant = 0; + nlohmann::json audience; + std::vector variants; + bool audienceStrict = false; + std::vector customFieldValues; + std::string application; + std::string environment; +}; + +inline void to_json(nlohmann::json& j, const ExperimentData& e) { + j = nlohmann::json{ + {"id", e.id}, + {"name", e.name}, + {"unitType", e.unitType}, + {"iteration", e.iteration}, + {"seedHi", e.seedHi}, + {"seedLo", e.seedLo}, + {"split", e.split}, + {"seedStrictMode", e.seedStrictMode}, + {"trafficSeedHi", e.trafficSeedHi}, + {"trafficSeedLo", e.trafficSeedLo}, + {"trafficSplit", e.trafficSplit}, + {"fullOnVariant", e.fullOnVariant}, + {"audience", e.audience}, + {"variants", e.variants}, + {"audienceStrict", e.audienceStrict}, + {"customFieldValues", e.customFieldValues}, + {"application", e.application}, + {"environment", e.environment} + }; +} + +inline void from_json(const nlohmann::json& j, ExperimentData& e) { + j.at("id").get_to(e.id); + j.at("name").get_to(e.name); + j.at("unitType").get_to(e.unitType); + j.at("iteration").get_to(e.iteration); + j.at("seedHi").get_to(e.seedHi); + j.at("seedLo").get_to(e.seedLo); + if (j.contains("split") && j["split"].is_array()) { + j.at("split").get_to(e.split); + } + if (j.contains("seedStrictMode") && j["seedStrictMode"].is_boolean()) { + j.at("seedStrictMode").get_to(e.seedStrictMode); + } + if (j.contains("trafficSeedHi") && j["trafficSeedHi"].is_number()) { + j.at("trafficSeedHi").get_to(e.trafficSeedHi); + } + if (j.contains("trafficSeedLo") && j["trafficSeedLo"].is_number()) { + j.at("trafficSeedLo").get_to(e.trafficSeedLo); + } + if (j.contains("trafficSplit") && j["trafficSplit"].is_array()) { + j.at("trafficSplit").get_to(e.trafficSplit); + } + if (j.contains("fullOnVariant") && j["fullOnVariant"].is_number()) { + j.at("fullOnVariant").get_to(e.fullOnVariant); + } + if (j.contains("audience")) { + e.audience = j.at("audience"); + } + if (j.contains("variants") && j["variants"].is_array()) { + j.at("variants").get_to(e.variants); + } + if (j.contains("audienceStrict") && j["audienceStrict"].is_boolean()) { + j.at("audienceStrict").get_to(e.audienceStrict); + } + if (j.contains("customFieldValues") && j["customFieldValues"].is_array()) { + j.at("customFieldValues").get_to(e.customFieldValues); + } + if (j.contains("application") && j["application"].is_string()) { + j.at("application").get_to(e.application); + } else if (j.contains("applications") && j["applications"].is_array() && !j["applications"].empty()) { + auto& first = j["applications"][0]; + if (first.contains("name") && first["name"].is_string()) { + e.application = first["name"].get(); + } + } + if (j.contains("environment") && j["environment"].is_string()) { + j.at("environment").get_to(e.environment); + } +} + +struct ContextData { + std::vector experiments; +}; + +inline void to_json(nlohmann::json& j, const ContextData& c) { + j = nlohmann::json{{"experiments", c.experiments}}; +} + +inline void from_json(const nlohmann::json& j, ContextData& c) { + j.at("experiments").get_to(c.experiments); +} + +struct Exposure { + int id = 0; + std::string name; + std::string unit; + int variant = 0; + int64_t exposedAt = 0; + bool assigned = false; + bool eligible = false; + bool overridden = false; + bool fullOn = false; + bool custom = false; + bool audienceMismatch = false; +}; + +inline void to_json(nlohmann::json& j, const Exposure& e) { + j = nlohmann::json{ + {"id", e.id}, + {"name", e.name}, + {"unit", e.unit}, + {"variant", e.variant}, + {"exposedAt", e.exposedAt}, + {"assigned", e.assigned}, + {"eligible", e.eligible}, + {"overridden", e.overridden}, + {"fullOn", e.fullOn}, + {"custom", e.custom}, + {"audienceMismatch", e.audienceMismatch} + }; +} + +inline void from_json(const nlohmann::json& j, Exposure& e) { + j.at("id").get_to(e.id); + j.at("name").get_to(e.name); + if (j.contains("unit")) j.at("unit").get_to(e.unit); + j.at("variant").get_to(e.variant); + j.at("exposedAt").get_to(e.exposedAt); + j.at("assigned").get_to(e.assigned); + j.at("eligible").get_to(e.eligible); + if (j.contains("overridden")) j.at("overridden").get_to(e.overridden); + if (j.contains("fullOn")) j.at("fullOn").get_to(e.fullOn); + if (j.contains("custom")) j.at("custom").get_to(e.custom); + if (j.contains("audienceMismatch")) j.at("audienceMismatch").get_to(e.audienceMismatch); +} + +struct GoalAchievement { + std::string name; + int64_t achievedAt = 0; + std::map properties; +}; + +inline void to_json(nlohmann::json& j, const GoalAchievement& g) { + j = nlohmann::json{ + {"name", g.name}, + {"achievedAt", g.achievedAt}, + {"properties", g.properties} + }; +} + +inline void from_json(const nlohmann::json& j, GoalAchievement& g) { + j.at("name").get_to(g.name); + j.at("achievedAt").get_to(g.achievedAt); + if (j.contains("properties")) { + for (auto& [key, val] : j.at("properties").items()) { + if (val.is_number()) { + g.properties[key] = val.get(); + } + } + } +} + +struct Attribute { + std::string name; + nlohmann::json value; + int64_t setAt = 0; +}; + +inline void to_json(nlohmann::json& j, const Attribute& a) { + j = nlohmann::json{{"name", a.name}, {"value", a.value}, {"setAt", a.setAt}}; +} + +inline void from_json(const nlohmann::json& j, Attribute& a) { + j.at("name").get_to(a.name); + if (j.contains("value")) a.value = j.at("value"); + j.at("setAt").get_to(a.setAt); +} + +struct Unit { + std::string type; + std::string uid; +}; + +inline void to_json(nlohmann::json& j, const Unit& u) { + j = nlohmann::json{{"type", u.type}, {"uid", u.uid}}; +} + +inline void from_json(const nlohmann::json& j, Unit& u) { + j.at("type").get_to(u.type); + j.at("uid").get_to(u.uid); +} + +struct PublishEvent { + bool hashed = false; + std::vector units; + int64_t publishedAt = 0; + std::vector exposures; + std::vector goals; + std::vector attributes; +}; + +inline void to_json(nlohmann::json& j, const PublishEvent& p) { + j = nlohmann::json{ + {"hashed", p.hashed}, + {"units", p.units}, + {"publishedAt", p.publishedAt}, + {"exposures", p.exposures}, + {"goals", p.goals}, + {"attributes", p.attributes} + }; +} + +inline void from_json(const nlohmann::json& j, PublishEvent& p) { + j.at("hashed").get_to(p.hashed); + j.at("units").get_to(p.units); + j.at("publishedAt").get_to(p.publishedAt); + if (j.contains("exposures")) j.at("exposures").get_to(p.exposures); + if (j.contains("goals")) j.at("goals").get_to(p.goals); + if (j.contains("attributes")) j.at("attributes").get_to(p.attributes); +} + +} // namespace absmartly diff --git a/include/absmartly/sdk.h b/include/absmartly/sdk.h new file mode 100644 index 0000000..ff72592 --- /dev/null +++ b/include/absmartly/sdk.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +namespace absmartly { + +class SDK { +public: + static std::shared_ptr create(SDKConfig config); + + std::unique_ptr create_context(const ContextConfig& config); + std::unique_ptr create_context_with(const ContextConfig& config, ContextData data); + + std::future get_context_data(); + +private: + explicit SDK(SDKConfig config); + + std::shared_ptr client_; + std::shared_ptr context_data_provider_; + std::shared_ptr context_event_handler_; + std::shared_ptr context_event_publisher_; +}; + +} // namespace absmartly diff --git a/include/absmartly/variant_assigner.h b/include/absmartly/variant_assigner.h new file mode 100644 index 0000000..d374bd1 --- /dev/null +++ b/include/absmartly/variant_assigner.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +namespace absmartly { + +class VariantAssigner { +public: + explicit VariantAssigner(const std::string& hashed_unit); + int assign(const std::vector& split, int seed_hi, int seed_lo) const; + +private: + uint32_t unit_hash_; +}; + +} // namespace absmartly diff --git a/src/audience_matcher.cpp b/src/audience_matcher.cpp new file mode 100644 index 0000000..4f8c456 --- /dev/null +++ b/src/audience_matcher.cpp @@ -0,0 +1,35 @@ +#include "absmartly/audience_matcher.h" + +namespace absmartly { + +AudienceMatcher::AudienceMatcher() = default; + +std::optional AudienceMatcher::evaluate(const std::string& audience_string, const nlohmann::json& attributes) const { + if (audience_string.empty()) { + return std::nullopt; + } + + nlohmann::json audience; + try { + audience = nlohmann::json::parse(audience_string); + } catch (...) { + return std::nullopt; + } + + if (audience.is_null() || !audience.is_object()) { + return std::nullopt; + } + + if (!audience.contains("filter")) { + return std::nullopt; + } + + const auto& filter = audience["filter"]; + if (!filter.is_array() && !filter.is_object()) { + return std::nullopt; + } + + return evaluator_.evaluate_boolean(filter, attributes); +} + +} diff --git a/src/context.cpp b/src/context.cpp new file mode 100644 index 0000000..b40525a --- /dev/null +++ b/src/context.cpp @@ -0,0 +1,769 @@ +#include "absmartly/context.h" +#include "absmartly/errors.h" + +#include +#include + +namespace absmartly { + +static int64_t now_millis() { + auto now = std::chrono::system_clock::now(); + return std::chrono::duration_cast(now.time_since_epoch()).count(); +} + +static bool array_equals_shallow(const std::vector& a, const std::vector& b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) { + if (a[i] != b[i]) return false; + } + return true; +} + +Context::Context(const ContextConfig& config, ContextData data, + std::shared_ptr event_handler, + std::shared_ptr event_publisher) + : config_(config) + , event_handler_(std::move(event_handler)) + , event_publisher_(std::move(event_publisher)) { + setup_from_config(); + become_ready(std::move(data)); +} + +Context::Context(const ContextConfig& config, std::future data_future, + std::shared_ptr event_handler, + std::shared_ptr event_publisher) + : config_(config) + , event_handler_(std::move(event_handler)) + , event_publisher_(std::move(event_publisher)) + , data_future_(std::move(data_future)) { + setup_from_config(); +} + +void Context::setup_from_config() { + for (const auto& [type, uid] : config_.units) { + units_[type] = uid; + } + + for (const auto& [name, variant] : config_.overrides) { + overrides_[name] = variant; + } + + for (const auto& [name, variant] : config_.custom_assignments) { + cassignments_[name] = variant; + } +} + +void Context::become_ready(ContextData data) { + data_ = std::move(data); + init(data_); + ready_ = true; + + nlohmann::json ready_data; + nlohmann::json exps = nlohmann::json::array(); + for (const auto& exp : data_.experiments) { + exps.push_back({{"id", exp.id}, {"name", exp.name}}); + } + ready_data["experiments"] = exps; + emit_event("ready", ready_data); +} + +void Context::wait_until_ready() { + if (ready_ || failed_) { + return; + } + + if (!data_future_.valid()) { + failed_ = true; + emit_event("error", {{"message", "No data future available"}}); + return; + } + + try { + auto data = data_future_.get(); + become_ready(std::move(data)); + } catch (const std::exception& e) { + failed_ = true; + emit_event("error", {{"message", e.what()}}); + } +} + +bool Context::is_ready() const { + return ready_; +} + +bool Context::is_failed() const { + return failed_; +} + +bool Context::is_finalized() const { + return finalized_; +} + +bool Context::is_finalizing() const { + return !finalized_ && finalizing_; +} + +int Context::pending() const { + return pending_; +} + +const ContextData& Context::data() const { + return data_; +} + +std::vector Context::experiments() const { + std::vector result; + result.reserve(data_.experiments.size()); + for (const auto& exp : data_.experiments) { + result.push_back(exp.name); + } + return result; +} + +void Context::set_unit(const std::string& unit_type, const std::string& uid) { + check_not_finalized(); + + if (uid.empty()) { + throw std::runtime_error("Unit '" + unit_type + "' UID must not be blank."); + } + + auto it = units_.find(unit_type); + if (it != units_.end() && it->second != uid) { + throw std::runtime_error("Unit '" + unit_type + "' UID already set."); + } + + units_[unit_type] = uid; +} + +void Context::set_units(const std::map& units) { + for (const auto& [type, uid] : units) { + set_unit(type, uid); + } +} + +std::optional Context::get_unit(const std::string& unit_type) const { + auto it = units_.find(unit_type); + if (it != units_.end()) { + return it->second; + } + return std::nullopt; +} + +std::map Context::get_units() const { + return units_; +} + +void Context::set_attribute(const std::string& name, const nlohmann::json& value) { + check_not_finalized(); + + Attribute attr; + attr.name = name; + attr.value = value; + attr.setAt = now_millis(); + attrs_.push_back(std::move(attr)); + attrs_seq_++; +} + +void Context::set_attributes(const std::map& attrs) { + for (const auto& [name, value] : attrs) { + set_attribute(name, value); + } +} + +nlohmann::json Context::get_attribute(const std::string& name) const { + nlohmann::json result = nullptr; + for (const auto& attr : attrs_) { + if (attr.name == name) { + result = attr.value; + } + } + return result; +} + +std::map Context::get_attributes() const { + std::map result; + for (const auto& attr : attrs_) { + result[attr.name] = attr.value; + } + return result; +} + +void Context::set_override(const std::string& experiment_name, int variant) { + overrides_[experiment_name] = variant; +} + +void Context::set_overrides(const std::map& overrides) { + for (const auto& [name, variant] : overrides) { + set_override(name, variant); + } +} + +void Context::set_custom_assignment(const std::string& experiment_name, int variant) { + check_not_finalized(); + cassignments_[experiment_name] = variant; +} + +void Context::set_custom_assignments(const std::map& assignments) { + for (const auto& [name, variant] : assignments) { + set_custom_assignment(name, variant); + } +} + +int Context::treatment(const std::string& experiment_name) { + check_ready(); + check_not_finalized(); + + auto& assignment = get_or_create_assignment(experiment_name); + + if (!assignment.exposed) { + assignment.exposed = true; + queue_exposure(experiment_name, assignment); + } + + return assignment.variant; +} + +int Context::peek(const std::string& experiment_name) { + check_ready(); + check_not_finalized(); + + auto& assignment = get_or_create_assignment(experiment_name); + return assignment.variant; +} + +nlohmann::json Context::variable_value(const std::string& key, const nlohmann::json& default_value) { + check_ready(); + check_not_finalized(); + + auto var_it = index_variables_.find(key); + if (var_it == index_variables_.end()) { + return default_value; + } + + for (const auto* experiment_index : var_it->second) { + const std::string& experiment_name = experiment_index->data->name; + auto& assignment = get_or_create_assignment(experiment_name); + + if (assignment.has_variables) { + if (!assignment.exposed) { + assignment.exposed = true; + queue_exposure(experiment_name, assignment); + } + + if ((assignment.assigned || assignment.overridden) && + assignment.variables.count(key) > 0) { + return assignment.variables.at(key); + } + } + } + + return default_value; +} + +nlohmann::json Context::peek_variable_value(const std::string& key, const nlohmann::json& default_value) { + check_ready(); + check_not_finalized(); + + auto var_it = index_variables_.find(key); + if (var_it == index_variables_.end()) { + return default_value; + } + + for (const auto* experiment_index : var_it->second) { + const std::string& experiment_name = experiment_index->data->name; + auto& assignment = get_or_create_assignment(experiment_name); + + if (assignment.has_variables) { + if ((assignment.assigned || assignment.overridden) && + assignment.variables.count(key) > 0) { + return assignment.variables.at(key); + } + } + } + + return default_value; +} + +std::map> Context::variable_keys() const { + std::map> result; + + for (const auto& [key, experiments_for_var] : index_variables_) { + for (const auto* exp_index : experiments_for_var) { + result[key].push_back(exp_index->data->name); + } + } + + return result; +} + +nlohmann::json Context::custom_field_value(const std::string& experiment_name, const std::string& key) const { + auto it = index_.find(experiment_name); + if (it == index_.end()) { + return nullptr; + } + + const auto* exp_data = it->second.data; + for (const auto& field : exp_data->customFieldValues) { + if (field.name == key) { + if (!field.value.has_value()) { + return nullptr; + } + const std::string& val = field.value.value(); + if (field.type == "text" || field.type == "string") { + return val; + } + if (field.type == "number") { + try { + return std::stod(val); + } catch (...) { + return nullptr; + } + } + if (field.type == "json") { + try { + if (val == "null") return nullptr; + if (val.empty()) return ""; + return nlohmann::json::parse(val); + } catch (...) { + return nullptr; + } + } + if (field.type == "boolean") { + return val == "true"; + } + return nullptr; + } + } + + return nullptr; +} + +std::vector Context::custom_field_keys() const { + std::set keys; + for (const auto& exp : data_.experiments) { + for (const auto& field : exp.customFieldValues) { + keys.insert(field.name); + } + } + return {keys.begin(), keys.end()}; +} + +void Context::track(const std::string& goal_name, const nlohmann::json& properties) { + check_ready(); + check_not_finalized(); + + GoalAchievement goal; + goal.name = goal_name; + goal.achievedAt = now_millis(); + + if (properties.is_object()) { + for (const auto& [key, val] : properties.items()) { + if (val.is_number()) { + goal.properties[key] = val.get(); + } + } + } + + nlohmann::json goal_data; + goal_data["name"] = goal.name; + goal_data["achievedAt"] = goal.achievedAt; + if (properties.is_null() || !properties.is_object()) { + goal_data["properties"] = nullptr; + } else { + goal_data["properties"] = properties; + } + + goals_.push_back(std::move(goal)); + pending_++; + + emit_event("goal", goal_data); +} + +PublishEvent Context::publish() { + if (pending_ == 0) { + return {}; + } + + PublishEvent event; + event.hashed = true; + event.publishedAt = now_millis(); + + for (const auto& [type, uid] : units_) { + Unit u; + u.type = type; + u.uid = unit_hash(type); + event.units.push_back(std::move(u)); + } + + if (!exposures_.empty()) { + event.exposures = std::move(exposures_); + exposures_.clear(); + } + + if (!goals_.empty()) { + event.goals = std::move(goals_); + goals_.clear(); + } + + if (!attrs_.empty()) { + event.attributes = attrs_; + } + + pending_ = 0; + + nlohmann::json pub_data = event; + emit_event("publish", pub_data); + + return event; +} + +PublishEvent Context::finalize() { + if (finalized_) { + return {}; + } + + finalizing_ = true; + auto result = publish(); + finalized_ = true; + finalizing_ = false; + + emit_event("finalize"); + + return result; +} + +void Context::refresh(const ContextData& new_data) { + check_not_finalized(); + + data_ = new_data; + + assignments_.clear(); + + build_index(); + hashes_.clear(); + assigners_.clear(); + + nlohmann::json refresh_data; + nlohmann::json refresh_exps = nlohmann::json::array(); + for (const auto& exp : data_.experiments) { + refresh_exps.push_back({{"id", exp.id}, {"name", exp.name}}); + } + refresh_data["experiments"] = refresh_exps; + emit_event("refresh", refresh_data); +} + +Assignment& Context::get_or_create_assignment(const std::string& experiment_name) { + bool has_custom = cassignments_.count(experiment_name) > 0; + bool has_override = overrides_.count(experiment_name) > 0; + auto exp_it = index_.find(experiment_name); + const ExperimentIndex* experiment = (exp_it != index_.end()) ? &exp_it->second : nullptr; + + auto ass_it = assignments_.find(experiment_name); + if (ass_it != assignments_.end()) { + auto& existing = ass_it->second; + if (has_override) { + if (existing.overridden && existing.variant == overrides_[experiment_name]) { + return existing; + } + } else if (experiment == nullptr) { + if (!existing.assigned) { + return existing; + } + } else if (!has_custom || cassignments_[experiment_name] == existing.variant) { + if (experiment_matches(*experiment->data, existing) && + audience_matches(*experiment->data, existing)) { + return existing; + } + } + } + + Assignment assignment; + assignment.id = 0; + assignment.iteration = 0; + assignment.full_on_variant = 0; + assignment.variant = 0; + assignment.overridden = false; + assignment.assigned = false; + assignment.exposed = false; + assignment.eligible = true; + assignment.full_on = false; + assignment.custom = false; + assignment.audience_mismatch = false; + assignment.has_variables = false; + + if (has_override) { + if (experiment != nullptr) { + assignment.id = experiment->data->id; + assignment.unit_type = experiment->data->unitType; + } + assignment.overridden = true; + assignment.variant = overrides_[experiment_name]; + } else { + if (experiment != nullptr) { + const auto& exp_data = *experiment->data; + const std::string& unit_type = exp_data.unitType; + + if (!exp_data.audience.is_null()) { + std::string audience_str; + if (exp_data.audience.is_string()) { + audience_str = exp_data.audience.get(); + } else { + audience_str = exp_data.audience.dump(); + } + + if (!audience_str.empty()) { + auto result = audience_matcher_.evaluate(audience_str, get_attributes_map()); + if (result.has_value()) { + assignment.audience_mismatch = !result.value(); + } + } + } + + if (exp_data.audienceStrict && assignment.audience_mismatch) { + assignment.variant = 0; + } else if (exp_data.fullOnVariant == 0) { + if (!unit_type.empty()) { + auto unit_it = units_.find(unit_type); + if (unit_it != units_.end()) { + std::string hashed = unit_hash(unit_type); + if (!hashed.empty()) { + auto assigner_it = assigners_.find(unit_type); + if (assigner_it == assigners_.end()) { + assigner_it = assigners_.emplace(unit_type, VariantAssigner(hashed)).first; + } + auto& assigner = assigner_it->second; + + bool eligible = assigner.assign( + exp_data.trafficSplit, + exp_data.trafficSeedHi, + exp_data.trafficSeedLo) == 1; + + assignment.assigned = true; + assignment.eligible = eligible; + + if (eligible) { + if (has_custom) { + assignment.variant = cassignments_[experiment_name]; + assignment.custom = true; + } else { + assignment.variant = assigner.assign( + exp_data.split, exp_data.seedHi, exp_data.seedLo); + } + } else { + assignment.variant = 0; + } + } + } + } + } else { + assignment.assigned = true; + assignment.eligible = true; + assignment.variant = exp_data.fullOnVariant; + assignment.full_on = true; + } + + assignment.unit_type = unit_type; + assignment.id = exp_data.id; + assignment.iteration = exp_data.iteration; + assignment.traffic_split = exp_data.trafficSplit; + assignment.full_on_variant = exp_data.fullOnVariant; + assignment.attrs_seq = attrs_seq_; + } + } + + if (experiment != nullptr && + assignment.variant >= 0 && + static_cast(assignment.variant) < experiment->variables.size()) { + assignment.variables = experiment->variables[assignment.variant]; + assignment.has_variables = true; + } + + assignments_[experiment_name] = std::move(assignment); + return assignments_[experiment_name]; +} + +void Context::queue_exposure(const std::string& experiment_name, const Assignment& assignment) { + Exposure exposure; + exposure.id = assignment.id; + exposure.name = experiment_name; + exposure.unit = assignment.unit_type; + exposure.variant = assignment.variant; + exposure.exposedAt = now_millis(); + exposure.assigned = assignment.assigned; + exposure.eligible = assignment.eligible; + exposure.overridden = assignment.overridden; + exposure.fullOn = assignment.full_on; + exposure.custom = assignment.custom; + exposure.audienceMismatch = assignment.audience_mismatch; + + emit_event("exposure", nlohmann::json{ + {"id", exposure.id}, + {"name", exposure.name}, + {"unit", exposure.unit.empty() ? nlohmann::json(nullptr) : nlohmann::json(exposure.unit)}, + {"variant", exposure.variant}, + {"exposedAt", exposure.exposedAt}, + {"assigned", exposure.assigned}, + {"eligible", exposure.eligible}, + {"overridden", exposure.overridden}, + {"fullOn", exposure.fullOn}, + {"custom", exposure.custom}, + {"audienceMismatch", exposure.audienceMismatch} + }); + + exposures_.push_back(std::move(exposure)); + pending_++; +} + +void Context::init(const ContextData& data) { + data_ = data; + build_index(); +} + +void Context::build_index() { + index_.clear(); + index_variables_.clear(); + + for (const auto& exp : data_.experiments) { + ExperimentIndex entry; + entry.data = &exp; + + for (size_t i = 0; i < exp.variants.size(); ++i) { + const auto& variant = exp.variants[i]; + std::map parsed; + + if (variant.config.is_string()) { + const auto& config_str = variant.config.get_ref(); + if (!config_str.empty()) { + try { + auto config_json = nlohmann::json::parse(config_str); + if (config_json.is_object()) { + for (auto it = config_json.begin(); it != config_json.end(); ++it) { + parsed[it.key()] = it.value(); + } + } + } catch (...) { + } + } + } else if (variant.config.is_object()) { + for (auto it = variant.config.begin(); it != variant.config.end(); ++it) { + parsed[it.key()] = it.value(); + } + } + + entry.variables.push_back(std::move(parsed)); + } + + index_[exp.name] = std::move(entry); + } + + for (auto& [exp_name, entry] : index_) { + for (const auto& var_map : entry.variables) { + for (const auto& [key, _] : var_map) { + auto& vec = index_variables_[key]; + bool found = false; + for (const auto* existing : vec) { + if (existing->data == entry.data) { + found = true; + break; + } + } + if (!found) { + bool inserted = false; + for (auto it = vec.begin(); it != vec.end(); ++it) { + if (entry.data->id < (*it)->data->id) { + vec.insert(it, &entry); + inserted = true; + break; + } + if (entry.data->id == (*it)->data->id) { + inserted = true; + break; + } + } + if (!inserted) { + vec.push_back(&entry); + } + } + } + } + } +} + +bool Context::experiment_matches(const ExperimentData& experiment, const Assignment& assignment) const { + return experiment.id == assignment.id + && experiment.unitType == assignment.unit_type + && experiment.iteration == assignment.iteration + && experiment.fullOnVariant == assignment.full_on_variant + && array_equals_shallow(experiment.trafficSplit, assignment.traffic_split); +} + +bool Context::audience_matches(const ExperimentData& experiment, Assignment& assignment) { + std::string audience_str; + if (experiment.audience.is_string()) { + audience_str = experiment.audience.get(); + } else if (!experiment.audience.is_null()) { + audience_str = experiment.audience.dump(); + } + + if (!audience_str.empty()) { + if (attrs_seq_ > assignment.attrs_seq) { + auto result = audience_matcher_.evaluate(audience_str, get_attributes_map()); + bool new_audience_mismatch = result.has_value() ? !result.value() : false; + + if (new_audience_mismatch != assignment.audience_mismatch) { + return false; + } + + assignment.attrs_seq = attrs_seq_; + } + } + return true; +} + +nlohmann::json Context::get_attributes_map() const { + nlohmann::json attrs = nlohmann::json::object(); + for (const auto& attr : attrs_) { + attrs[attr.name] = attr.value; + } + return attrs; +} + +std::string Context::unit_hash(const std::string& unit_type) { + auto it = hashes_.find(unit_type); + if (it != hashes_.end()) { + return it->second; + } + + auto unit_it = units_.find(unit_type); + if (unit_it == units_.end()) { + hashes_[unit_type] = ""; + return ""; + } + + std::string hashed = hash_unit(unit_it->second); + hashes_[unit_type] = hashed; + return hashed; +} + +void Context::check_not_finalized() const { + if (finalized_) { + throw ContextFinalizedException(); + } + if (finalizing_) { + throw ContextFinalizedException(); + } +} + +void Context::check_ready() const { + if (!ready_) { + throw ContextNotReadyException(); + } +} + +void Context::emit_event(const std::string& type, const nlohmann::json& data) { + if (event_handler_) { + event_handler_->handle_event(*this, type, data); + } +} + +} // namespace absmartly diff --git a/src/json_expr/evaluator.cpp b/src/json_expr/evaluator.cpp new file mode 100644 index 0000000..4fcbcfe --- /dev/null +++ b/src/json_expr/evaluator.cpp @@ -0,0 +1,218 @@ +#include "absmartly/json_expr/evaluator.h" + +#include +#include +#include +#include + +namespace absmartly { + +Evaluator::Evaluator() { + operators_["value"] = std::make_unique(); + operators_["var"] = std::make_unique(); + operators_["and"] = std::make_unique(); + operators_["or"] = std::make_unique(); + operators_["not"] = std::make_unique(); + operators_["null"] = std::make_unique(); + operators_["eq"] = std::make_unique(); + operators_["gt"] = std::make_unique(); + operators_["gte"] = std::make_unique(); + operators_["lt"] = std::make_unique(); + operators_["lte"] = std::make_unique(); + operators_["in"] = std::make_unique(); + operators_["match"] = std::make_unique(); +} + +nlohmann::json Evaluator::evaluate(const nlohmann::json& expr, const nlohmann::json& vars) const { + if (expr.is_array()) { + auto it = operators_.find("and"); + if (it != operators_.end()) { + return it->second->evaluate(*this, expr, vars); + } + return nullptr; + } + + if (expr.is_object()) { + for (auto it = expr.begin(); it != expr.end(); ++it) { + auto op_it = operators_.find(it.key()); + if (op_it != operators_.end()) { + return op_it->second->evaluate(*this, it.value(), vars); + } + break; + } + return nullptr; + } + + return nullptr; +} + +bool Evaluator::evaluate_boolean(const nlohmann::json& expr, const nlohmann::json& vars) const { + auto result = evaluate(expr, vars); + auto b = to_boolean(result); + return b.value_or(false); +} + +std::optional Evaluator::to_boolean(const nlohmann::json& value) { + if (value.is_null()) { + return std::nullopt; + } + if (value.is_boolean()) { + return value.get(); + } + if (value.is_number()) { + return value.get() != 0.0; + } + if (value.is_string()) { + return !value.get_ref().empty(); + } + if (value.is_array() || value.is_object()) { + return true; + } + return std::nullopt; +} + +std::optional Evaluator::to_number(const nlohmann::json& value) { + if (value.is_null()) { + return std::nullopt; + } + if (value.is_number()) { + return value.get(); + } + if (value.is_boolean()) { + return value.get() ? 1.0 : 0.0; + } + if (value.is_string()) { + const auto& str = value.get_ref(); + if (str.empty()) { + return std::nullopt; + } + try { + std::size_t pos = 0; + double result = std::stod(str, &pos); + if (pos == str.size() && std::isfinite(result)) { + return result; + } + } catch (...) { + } + return std::nullopt; + } + return std::nullopt; +} + +std::optional Evaluator::to_string_value(const nlohmann::json& value) { + if (value.is_null()) { + return std::nullopt; + } + if (value.is_string()) { + return value.get(); + } + if (value.is_boolean()) { + return value.get() ? std::string("true") : std::string("false"); + } + if (value.is_number()) { + double d = value.get(); + double int_part; + if (std::modf(d, &int_part) == 0.0 && std::abs(d) < 1e15) { + std::ostringstream oss; + oss << std::fixed << std::setprecision(0) << d; + return oss.str(); + } + std::ostringstream oss; + oss << std::setprecision(15) << d; + std::string result = oss.str(); + return result; + } + return std::nullopt; +} + +std::optional Evaluator::compare(const nlohmann::json& a, const nlohmann::json& b) { + if (a.is_null()) { + if (b.is_null()) { + return 0; + } + return std::nullopt; + } + if (b.is_null()) { + return std::nullopt; + } + + if (a.is_number()) { + auto rvalue = to_number(b); + if (rvalue.has_value()) { + double lv = a.get(); + double rv = *rvalue; + if (lv == rv) return 0; + return lv > rv ? 1 : -1; + } + return std::nullopt; + } + + if (a.is_string()) { + auto rvalue = to_string_value(b); + if (rvalue.has_value()) { + const auto& lv = a.get_ref(); + if (lv == *rvalue) return 0; + return lv > *rvalue ? 1 : -1; + } + return std::nullopt; + } + + if (a.is_boolean()) { + auto rvalue = to_boolean(b); + if (rvalue.has_value()) { + bool lv = a.get(); + bool rv = *rvalue; + if (lv == rv) return 0; + return lv > rv ? 1 : -1; + } + return std::nullopt; + } + + if (a == b) { + return 0; + } + + return std::nullopt; +} + +nlohmann::json Evaluator::extract_var(const nlohmann::json& vars, const std::string& path) { + std::vector fragments; + std::istringstream stream(path); + std::string fragment; + while (std::getline(stream, fragment, '/')) { + fragments.push_back(fragment); + } + + const nlohmann::json* current = vars.is_null() ? nullptr : &vars; + if (!current) { + return nullptr; + } + + for (const auto& frag : fragments) { + if (current->is_object()) { + auto it = current->find(frag); + if (it != current->end()) { + current = &(*it); + continue; + } + return nullptr; + } + if (current->is_array()) { + try { + std::size_t pos = 0; + int index = std::stoi(frag, &pos); + if (pos == frag.size() && index >= 0 && static_cast(index) < current->size()) { + current = &(*current)[index]; + continue; + } + } catch (...) { + } + return nullptr; + } + return nullptr; + } + + return *current; +} + +} diff --git a/src/json_expr/operators.cpp b/src/json_expr/operators.cpp new file mode 100644 index 0000000..311f0c9 --- /dev/null +++ b/src/json_expr/operators.cpp @@ -0,0 +1,277 @@ +#include "absmartly/json_expr/operators.h" +#include "absmartly/json_expr/evaluator.h" + +#include + +namespace absmartly { + +nlohmann::json ValueOperator::evaluate( + const Evaluator& /*evaluator*/, + const nlohmann::json& args, + const nlohmann::json& /*vars*/ +) const { + return args; +} + +nlohmann::json VarOperator::evaluate( + const Evaluator& /*evaluator*/, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + std::string path; + if (args.is_object() && args.contains("path")) { + path = args["path"].get(); + } else if (args.is_string()) { + path = args.get(); + } else { + return nullptr; + } + return Evaluator::extract_var(vars, path); +} + +nlohmann::json AndOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + for (const auto& expr : args) { + auto result = evaluator.evaluate(expr, vars); + auto b = Evaluator::to_boolean(result); + if (!b.value_or(false)) { + return false; + } + } + return true; +} + +nlohmann::json OrOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + for (const auto& expr : args) { + auto result = evaluator.evaluate(expr, vars); + auto b = Evaluator::to_boolean(result); + if (b.value_or(false)) { + return true; + } + } + return args.empty(); +} + +nlohmann::json NotOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + auto result = evaluator.evaluate(args, vars); + auto b = Evaluator::to_boolean(result); + return !b.value_or(false); +} + +nlohmann::json NullOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + auto result = evaluator.evaluate(args, vars); + return result.is_null(); +} + +nlohmann::json EqOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto lhs = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + auto rhs = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + auto result = Evaluator::compare(lhs, rhs); + if (result.has_value()) { + return *result == 0; + } + return nullptr; +} + +nlohmann::json GtOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto lhs = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + if (lhs.is_null()) { + return nullptr; + } + auto rhs = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + if (rhs.is_null()) { + return nullptr; + } + auto result = Evaluator::compare(lhs, rhs); + if (result.has_value()) { + return *result > 0; + } + return nullptr; +} + +nlohmann::json GteOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto lhs = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + if (lhs.is_null()) { + return nullptr; + } + auto rhs = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + if (rhs.is_null()) { + return nullptr; + } + auto result = Evaluator::compare(lhs, rhs); + if (result.has_value()) { + return *result >= 0; + } + return nullptr; +} + +nlohmann::json LtOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto lhs = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + if (lhs.is_null()) { + return nullptr; + } + auto rhs = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + if (rhs.is_null()) { + return nullptr; + } + auto result = Evaluator::compare(lhs, rhs); + if (result.has_value()) { + return *result < 0; + } + return nullptr; +} + +nlohmann::json LteOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto lhs = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + if (lhs.is_null()) { + return nullptr; + } + auto rhs = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + if (rhs.is_null()) { + return nullptr; + } + auto result = Evaluator::compare(lhs, rhs); + if (result.has_value()) { + return *result <= 0; + } + return nullptr; +} + +nlohmann::json InOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto needle = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + if (needle.is_null()) { + return nullptr; + } + auto haystack = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + if (haystack.is_null()) { + return nullptr; + } + + if (haystack.is_array()) { + for (const auto& item : haystack) { + auto cmp = Evaluator::compare(item, needle); + if (cmp.has_value() && *cmp == 0) { + return true; + } + } + return false; + } + + if (haystack.is_string()) { + auto needle_str = Evaluator::to_string_value(needle); + if (needle_str.has_value()) { + const auto& hs = haystack.get_ref(); + return hs.find(*needle_str) != std::string::npos; + } + return false; + } + + if (haystack.is_object()) { + auto needle_str = Evaluator::to_string_value(needle); + if (needle_str.has_value()) { + return haystack.contains(*needle_str); + } + return false; + } + + return nullptr; +} + +nlohmann::json MatchOperator::evaluate( + const Evaluator& evaluator, + const nlohmann::json& args, + const nlohmann::json& vars +) const { + if (!args.is_array()) { + return nullptr; + } + auto text_val = args.size() > 0 ? evaluator.evaluate(args[0], vars) : nlohmann::json(nullptr); + if (text_val.is_null()) { + return nullptr; + } + auto pattern_val = args.size() > 1 ? evaluator.evaluate(args[1], vars) : nlohmann::json(nullptr); + if (pattern_val.is_null()) { + return nullptr; + } + + auto text = Evaluator::to_string_value(text_val); + if (!text.has_value()) { + return nullptr; + } + auto pattern = Evaluator::to_string_value(pattern_val); + if (!pattern.has_value()) { + return nullptr; + } + + try { + std::regex re(*pattern); + return std::regex_search(*text, re); + } catch (...) { + return nullptr; + } +} + +} diff --git a/src/md5.cpp b/src/md5.cpp new file mode 100644 index 0000000..5180004 --- /dev/null +++ b/src/md5.cpp @@ -0,0 +1,209 @@ +#include "absmartly/hashing.h" +#include +#include + +namespace absmartly { + +static inline uint32_t rotl(uint32_t a, int s) { + return (a << s) | (a >> (32 - s)); +} + +static inline uint32_t cmn(uint32_t q, uint32_t a, uint32_t b, uint32_t x, int s, uint32_t t) { + a = a + q + x + t; + return rotl(a, s) + b; +} + +static inline uint32_t ff(uint32_t a, uint32_t b, uint32_t c, uint32_t d, uint32_t x, int s, uint32_t t) { + return cmn((b & c) | (~b & d), a, b, x, s, t); +} + +static inline uint32_t gg(uint32_t a, uint32_t b, uint32_t c, uint32_t d, uint32_t x, int s, uint32_t t) { + return cmn((b & d) | (c & ~d), a, b, x, s, t); +} + +static inline uint32_t hh(uint32_t a, uint32_t b, uint32_t c, uint32_t d, uint32_t x, int s, uint32_t t) { + return cmn(b ^ c ^ d, a, b, x, s, t); +} + +static inline uint32_t ii(uint32_t a, uint32_t b, uint32_t c, uint32_t d, uint32_t x, int s, uint32_t t) { + return cmn(c ^ (b | ~d), a, b, x, s, t); +} + +static void md5cycle(uint32_t state[4], const uint32_t k[16]) { + uint32_t a = state[0]; + uint32_t b = state[1]; + uint32_t c = state[2]; + uint32_t d = state[3]; + + a = ff(a, b, c, d, k[0], 7, 0xd76aa478); + d = ff(d, a, b, c, k[1], 12, 0xe8c7b756); + c = ff(c, d, a, b, k[2], 17, 0x242070db); + b = ff(b, c, d, a, k[3], 22, 0xc1bdceee); + a = ff(a, b, c, d, k[4], 7, 0xf57c0faf); + d = ff(d, a, b, c, k[5], 12, 0x4787c62a); + c = ff(c, d, a, b, k[6], 17, 0xa8304613); + b = ff(b, c, d, a, k[7], 22, 0xfd469501); + a = ff(a, b, c, d, k[8], 7, 0x698098d8); + d = ff(d, a, b, c, k[9], 12, 0x8b44f7af); + c = ff(c, d, a, b, k[10],17, 0xffff5bb1); + b = ff(b, c, d, a, k[11],22, 0x895cd7be); + a = ff(a, b, c, d, k[12], 7, 0x6b901122); + d = ff(d, a, b, c, k[13],12, 0xfd987193); + c = ff(c, d, a, b, k[14],17, 0xa679438e); + b = ff(b, c, d, a, k[15],22, 0x49b40821); + + a = gg(a, b, c, d, k[1], 5, 0xf61e2562); + d = gg(d, a, b, c, k[6], 9, 0xc040b340); + c = gg(c, d, a, b, k[11],14, 0x265e5a51); + b = gg(b, c, d, a, k[0], 20, 0xe9b6c7aa); + a = gg(a, b, c, d, k[5], 5, 0xd62f105d); + d = gg(d, a, b, c, k[10], 9, 0x02441453); + c = gg(c, d, a, b, k[15],14, 0xd8a1e681); + b = gg(b, c, d, a, k[4], 20, 0xe7d3fbc8); + a = gg(a, b, c, d, k[9], 5, 0x21e1cde6); + d = gg(d, a, b, c, k[14], 9, 0xc33707d6); + c = gg(c, d, a, b, k[3], 14, 0xf4d50d87); + b = gg(b, c, d, a, k[8], 20, 0x455a14ed); + a = gg(a, b, c, d, k[13], 5, 0xa9e3e905); + d = gg(d, a, b, c, k[2], 9, 0xfcefa3f8); + c = gg(c, d, a, b, k[7], 14, 0x676f02d9); + b = gg(b, c, d, a, k[12],20, 0x8d2a4c8a); + + a = hh(a, b, c, d, k[5], 4, 0xfffa3942); + d = hh(d, a, b, c, k[8], 11, 0x8771f681); + c = hh(c, d, a, b, k[11],16, 0x6d9d6122); + b = hh(b, c, d, a, k[14],23, 0xfde5380c); + a = hh(a, b, c, d, k[1], 4, 0xa4beea44); + d = hh(d, a, b, c, k[4], 11, 0x4bdecfa9); + c = hh(c, d, a, b, k[7], 16, 0xf6bb4b60); + b = hh(b, c, d, a, k[10],23, 0xbebfbc70); + a = hh(a, b, c, d, k[13], 4, 0x289b7ec6); + d = hh(d, a, b, c, k[0], 11, 0xeaa127fa); + c = hh(c, d, a, b, k[3], 16, 0xd4ef3085); + b = hh(b, c, d, a, k[6], 23, 0x04881d05); + a = hh(a, b, c, d, k[9], 4, 0xd9d4d039); + d = hh(d, a, b, c, k[12],11, 0xe6db99e5); + c = hh(c, d, a, b, k[15],16, 0x1fa27cf8); + b = hh(b, c, d, a, k[2], 23, 0xc4ac5665); + + a = ii(a, b, c, d, k[0], 6, 0xf4292244); + d = ii(d, a, b, c, k[7], 10, 0x432aff97); + c = ii(c, d, a, b, k[14],15, 0xab9423a7); + b = ii(b, c, d, a, k[5], 21, 0xfc93a039); + a = ii(a, b, c, d, k[12], 6, 0x655b59c3); + d = ii(d, a, b, c, k[3], 10, 0x8f0ccc92); + c = ii(c, d, a, b, k[10],15, 0xffeff47d); + b = ii(b, c, d, a, k[1], 21, 0x85845dd1); + a = ii(a, b, c, d, k[8], 6, 0x6fa87e4f); + d = ii(d, a, b, c, k[15],10, 0xfe2ce6e0); + c = ii(c, d, a, b, k[6], 15, 0xa3014314); + b = ii(b, c, d, a, k[13],21, 0x4e0811a1); + a = ii(a, b, c, d, k[4], 6, 0xf7537e82); + d = ii(d, a, b, c, k[11],10, 0xbd3af235); + c = ii(c, d, a, b, k[2], 15, 0x2ad7d2bb); + b = ii(b, c, d, a, k[9], 21, 0xeb86d391); + + state[0] += a; + state[1] += b; + state[2] += c; + state[3] += d; +} + +static inline uint32_t get_le32(const uint8_t* data) { + return static_cast(data[0]) + | (static_cast(data[1]) << 8) + | (static_cast(data[2]) << 16) + | (static_cast(data[3]) << 24); +} + +static void md5_compute(const uint8_t* data, size_t len, uint32_t state_out[4]) { + uint32_t state[4] = {0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476}; + uint32_t block[16]; + + size_t n = len & ~static_cast(63); + size_t i = 0; + + for (; i < n; i += 64) { + for (int w = 0; w < 16; ++w) { + block[w] = get_le32(data + i + (w << 2)); + } + md5cycle(state, block); + } + + int w = 0; + size_t m = len & ~static_cast(3); + for (; i < m; i += 4) { + block[w++] = get_le32(data + i); + } + + size_t p = len & 3; + switch (p) { + case 3: + block[w++] = 0x80000000u + | static_cast(data[i]) + | (static_cast(data[i + 1]) << 8) + | (static_cast(data[i + 2]) << 16); + break; + case 2: + block[w++] = 0x00800000u + | static_cast(data[i]) + | (static_cast(data[i + 1]) << 8); + break; + case 1: + block[w++] = 0x00008000u + | static_cast(data[i]); + break; + default: + block[w++] = 0x00000080u; + break; + } + + if (w > 14) { + for (; w < 16; ++w) { + block[w] = 0; + } + md5cycle(state, block); + w = 0; + } + + for (; w < 16; ++w) { + block[w] = 0; + } + + block[14] = static_cast(len << 3); + md5cycle(state, block); + + state_out[0] = state[0]; + state_out[1] = state[1]; + state_out[2] = state[2]; + state_out[3] = state[3]; +} + +std::vector md5_raw(const std::string& input) { + const auto* data = reinterpret_cast(input.data()); + uint32_t state[4]; + md5_compute(data, input.size(), state); + + std::vector result(16); + for (int i = 0; i < 4; ++i) { + result[i * 4 + 0] = static_cast(state[i] & 0xFF); + result[i * 4 + 1] = static_cast((state[i] >> 8) & 0xFF); + result[i * 4 + 2] = static_cast((state[i] >> 16) & 0xFF); + result[i * 4 + 3] = static_cast((state[i] >> 24) & 0xFF); + } + return result; +} + +std::string md5_hex(const std::string& input) { + auto raw = md5_raw(input); + std::string hex; + hex.reserve(32); + static const char digits[] = "0123456789abcdef"; + for (uint8_t byte : raw) { + hex.push_back(digits[byte >> 4]); + hex.push_back(digits[byte & 0x0F]); + } + return hex; +} + +} // namespace absmartly diff --git a/src/murmur3.cpp b/src/murmur3.cpp new file mode 100644 index 0000000..ad71706 --- /dev/null +++ b/src/murmur3.cpp @@ -0,0 +1,75 @@ +#include "absmartly/hashing.h" +#include + +namespace absmartly { + +static constexpr uint32_t C1 = 0xcc9e2d51; +static constexpr uint32_t C2 = 0x1b873593; +static constexpr uint32_t C3 = 0xe6546b64; + +static inline uint32_t rotl32(uint32_t a, int b) { + return (a << b) | (a >> (32 - b)); +} + +static inline uint32_t scramble32(uint32_t block) { + block *= C1; + block = rotl32(block, 15); + block *= C2; + return block; +} + +static inline uint32_t fmix32(uint32_t h) { + h ^= h >> 16; + h *= 0x85ebca6b; + h ^= h >> 13; + h *= 0xc2b2ae35; + h ^= h >> 16; + return h; +} + +static inline uint32_t get_le32(const uint8_t* data) { + return static_cast(data[0]) + | (static_cast(data[1]) << 8) + | (static_cast(data[2]) << 16) + | (static_cast(data[3]) << 24); +} + +uint32_t murmur3_32(const uint8_t* data, size_t len, uint32_t seed) { + uint32_t h = seed; + + size_t n = len & ~static_cast(3); + size_t i = 0; + + for (; i < n; i += 4) { + uint32_t chunk = get_le32(data + i); + h ^= scramble32(chunk); + h = rotl32(h, 13); + h = h * 5 + C3; + } + + uint32_t remaining = 0; + switch (len & 3) { + case 3: + remaining ^= static_cast(data[i + 2]) << 16; + [[fallthrough]]; + case 2: + remaining ^= static_cast(data[i + 1]) << 8; + [[fallthrough]]; + case 1: + remaining ^= static_cast(data[i]); + h ^= scramble32(remaining); + [[fallthrough]]; + default: + break; + } + + h ^= static_cast(len); + h = fmix32(h); + return h; +} + +uint32_t murmur3_32(const std::string& str, uint32_t seed) { + return murmur3_32(reinterpret_cast(str.data()), str.size(), seed); +} + +} // namespace absmartly diff --git a/src/utils.cpp b/src/utils.cpp new file mode 100644 index 0000000..8f6d964 --- /dev/null +++ b/src/utils.cpp @@ -0,0 +1,66 @@ +#include "absmartly/hashing.h" +#include + +namespace absmartly { + +static const char BASE64URL_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +std::string base64url_no_padding(const uint8_t* data, size_t len) { + size_t remaining = len % 3; + size_t full_triples = len / 3; + size_t encode_len = full_triples * 4 + (remaining == 0 ? 0 : (remaining == 1 ? 2 : 3)); + + std::string result; + result.reserve(encode_len); + + size_t i = 0; + size_t end = len - remaining; + for (; i < end; i += 3) { + uint32_t bytes = (static_cast(data[i]) << 16) + | (static_cast(data[i + 1]) << 8) + | static_cast(data[i + 2]); + result.push_back(BASE64URL_CHARS[(bytes >> 18) & 63]); + result.push_back(BASE64URL_CHARS[(bytes >> 12) & 63]); + result.push_back(BASE64URL_CHARS[(bytes >> 6) & 63]); + result.push_back(BASE64URL_CHARS[bytes & 63]); + } + + switch (remaining) { + case 2: { + uint32_t bytes = (static_cast(data[i]) << 16) + | (static_cast(data[i + 1]) << 8); + result.push_back(BASE64URL_CHARS[(bytes >> 18) & 63]); + result.push_back(BASE64URL_CHARS[(bytes >> 12) & 63]); + result.push_back(BASE64URL_CHARS[(bytes >> 6) & 63]); + break; + } + case 1: { + uint32_t bytes = static_cast(data[i]) << 16; + result.push_back(BASE64URL_CHARS[(bytes >> 18) & 63]); + result.push_back(BASE64URL_CHARS[(bytes >> 12) & 63]); + break; + } + default: + break; + } + + return result; +} + +std::string hash_unit(const std::string& unit) { + auto raw = md5_raw(unit); + return base64url_no_padding(raw.data(), raw.size()); +} + +int choose_variant(const std::vector& split, double probability) { + double cum_sum = 0.0; + for (size_t i = 0; i < split.size(); ++i) { + cum_sum += split[i]; + if (probability < cum_sum) { + return static_cast(i); + } + } + return static_cast(split.size()) - 1; +} + +} // namespace absmartly diff --git a/src/variant_assigner.cpp b/src/variant_assigner.cpp new file mode 100644 index 0000000..65ee422 --- /dev/null +++ b/src/variant_assigner.cpp @@ -0,0 +1,28 @@ +#include "absmartly/variant_assigner.h" +#include "absmartly/hashing.h" +#include + +namespace absmartly { + +VariantAssigner::VariantAssigner(const std::string& hashed_unit) + : unit_hash_(murmur3_32(hashed_unit, 0)) {} + +static inline void put_le32(uint8_t* buf, uint32_t val) { + buf[0] = static_cast(val & 0xFF); + buf[1] = static_cast((val >> 8) & 0xFF); + buf[2] = static_cast((val >> 16) & 0xFF); + buf[3] = static_cast((val >> 24) & 0xFF); +} + +int VariantAssigner::assign(const std::vector& split, int seed_hi, int seed_lo) const { + uint8_t buffer[12]; + put_le32(buffer, static_cast(seed_lo)); + put_le32(buffer + 4, static_cast(seed_hi)); + put_le32(buffer + 8, unit_hash_); + + uint32_t hash = murmur3_32(buffer, 12, 0); + double probability = static_cast(hash) / 0xFFFFFFFF; + return choose_variant(split, probability); +} + +} // namespace absmartly diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..e640b73 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +FetchContent_Declare( + Catch2 + URL https://github.com/catchorg/Catch2/archive/refs/tags/v3.5.2.tar.gz +) +FetchContent_MakeAvailable(Catch2) + +add_executable(absmartly_tests + murmur3_test.cpp + md5_test.cpp + variant_assigner_test.cpp + utils_test.cpp + json_expr_test.cpp + operators_test.cpp + audience_matcher_test.cpp + context_test.cpp +) + +target_link_libraries(absmartly_tests PRIVATE absmartly-sdk Catch2::Catch2WithMain) + +include(CTest) +include(Catch) +catch_discover_tests(absmartly_tests) diff --git a/tests/audience_matcher_test.cpp b/tests/audience_matcher_test.cpp new file mode 100644 index 0000000..96c0b39 --- /dev/null +++ b/tests/audience_matcher_test.cpp @@ -0,0 +1,94 @@ +#include + +#include "absmartly/audience_matcher.h" + +using namespace absmartly; +using json = nlohmann::json; + +TEST_CASE("AudienceMatcher", "[audience_matcher]") { + AudienceMatcher matcher; + + SECTION("returns nullopt on empty audience string") { + REQUIRE_FALSE(matcher.evaluate("", nullptr).has_value()); + } + + SECTION("returns nullopt on empty object string") { + REQUIRE_FALSE(matcher.evaluate("{}", nullptr).has_value()); + } + + SECTION("returns nullopt on null JSON string") { + REQUIRE_FALSE(matcher.evaluate("null", nullptr).has_value()); + } + + SECTION("returns nullopt if filter is not object or array") { + REQUIRE_FALSE(matcher.evaluate(R"({"filter":null})", nullptr).has_value()); + REQUIRE_FALSE(matcher.evaluate(R"({"filter":false})", nullptr).has_value()); + REQUIRE_FALSE(matcher.evaluate(R"({"filter":5})", nullptr).has_value()); + REQUIRE_FALSE(matcher.evaluate(R"({"filter":"a"})", nullptr).has_value()); + } + + SECTION("returns boolean for value expressions") { + REQUIRE(matcher.evaluate(R"({"filter":[{"value":5}]})", nullptr).value() == true); + REQUIRE(matcher.evaluate(R"({"filter":[{"value":true}]})", nullptr).value() == true); + REQUIRE(matcher.evaluate(R"({"filter":[{"value":1}]})", nullptr).value() == true); + REQUIRE(matcher.evaluate(R"({"filter":[{"value":null}]})", nullptr).value() == false); + REQUIRE(matcher.evaluate(R"({"filter":[{"value":0}]})", nullptr).value() == false); + } + + SECTION("evaluates with variables") { + json returning_true = {{"returning", true}}; + json returning_false = {{"returning", false}}; + + auto result1 = matcher.evaluate(R"({"filter":[{"not":{"var":"returning"}}]})", returning_true); + REQUIRE(result1.has_value()); + REQUIRE(result1.value() == false); + + auto result2 = matcher.evaluate(R"({"filter":[{"not":{"var":"returning"}}]})", returning_false); + REQUIRE(result2.has_value()); + REQUIRE(result2.value() == true); + } +} + +TEST_CASE("AudienceMatcher filter as object", "[audience_matcher]") { + AudienceMatcher matcher; + + SECTION("filter can be an object expression") { + auto result = matcher.evaluate(R"({"filter":{"value":true}})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == true); + + result = matcher.evaluate(R"({"filter":{"value":false}})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == false); + } +} + +TEST_CASE("AudienceMatcher complex expressions", "[audience_matcher]") { + AudienceMatcher matcher; + + SECTION("not expression") { + auto result = matcher.evaluate(R"({"filter":[{"not":{"value":true}}]})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == false); + } + + SECTION("and expression") { + auto result = matcher.evaluate(R"({"filter":[{"and":[{"value":true},{"value":true}]}]})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == true); + + result = matcher.evaluate(R"({"filter":[{"and":[{"value":true},{"value":false}]}]})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == false); + } + + SECTION("or expression") { + auto result = matcher.evaluate(R"({"filter":[{"or":[{"value":false},{"value":true}]}]})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == true); + + result = matcher.evaluate(R"({"filter":[{"or":[{"value":false},{"value":false}]}]})", nullptr); + REQUIRE(result.has_value()); + REQUIRE(result.value() == false); + } +} diff --git a/tests/context_test.cpp b/tests/context_test.cpp new file mode 100644 index 0000000..88b4fff --- /dev/null +++ b/tests/context_test.cpp @@ -0,0 +1,1591 @@ +#include +#include +#include + +using namespace absmartly; + +static ContextData make_test_data() { + ContextData data; + + { + ExperimentData exp; + exp.id = 1; + exp.name = "exp_test_ab"; + exp.unitType = "session_id"; + exp.iteration = 1; + exp.seedHi = 3603515; + exp.seedLo = 233373850; + exp.split = {0.5, 0.5}; + exp.trafficSeedHi = 449867249; + exp.trafficSeedLo = 455443629; + exp.trafficSplit = {0.0, 1.0}; + exp.fullOnVariant = 0; + exp.audience = nullptr; + + ExperimentVariant v0; + v0.name = "A"; + v0.config = nullptr; + + ExperimentVariant v1; + v1.name = "B"; + v1.config = nlohmann::json(R"({"banner.border":1,"banner.size":"large"})"); + + exp.variants = {v0, v1}; + data.experiments.push_back(exp); + } + + { + ExperimentData exp; + exp.id = 2; + exp.name = "exp_test_abc"; + exp.unitType = "session_id"; + exp.iteration = 1; + exp.seedHi = 55006150; + exp.seedLo = 47189152; + exp.split = {0.34, 0.33, 0.33}; + exp.trafficSeedHi = 705671872; + exp.trafficSeedLo = 212903484; + exp.trafficSplit = {0.0, 1.0}; + exp.fullOnVariant = 0; + exp.audience = nlohmann::json(""); + + ExperimentVariant v0; + v0.name = "A"; + v0.config = nullptr; + + ExperimentVariant v1; + v1.name = "B"; + v1.config = nlohmann::json(R"({"button.color":"blue"})"); + + ExperimentVariant v2; + v2.name = "C"; + v2.config = nlohmann::json(R"({"button.color":"red"})"); + + CustomFieldValue cf; + cf.name = "country"; + cf.value = "US,PT,ES,DE,FR"; + cf.type = "string"; + exp.customFieldValues.push_back(cf); + + exp.variants = {v0, v1, v2}; + data.experiments.push_back(exp); + } + + { + ExperimentData exp; + exp.id = 3; + exp.name = "exp_test_not_eligible"; + exp.unitType = "user_id"; + exp.iteration = 1; + exp.seedHi = 503266407; + exp.seedLo = 144942754; + exp.split = {0.34, 0.33, 0.33}; + exp.trafficSeedHi = 87768905; + exp.trafficSeedLo = 511357582; + exp.trafficSplit = {0.99, 0.01}; + exp.fullOnVariant = 0; + exp.audience = nlohmann::json("{}"); + + ExperimentVariant v0; + v0.name = "A"; + v0.config = nullptr; + + ExperimentVariant v1; + v1.name = "B"; + v1.config = nlohmann::json(R"({"card.width":"80%"})"); + + ExperimentVariant v2; + v2.name = "C"; + v2.config = nlohmann::json(R"({"card.width":"75%"})"); + + exp.variants = {v0, v1, v2}; + data.experiments.push_back(exp); + } + + { + ExperimentData exp; + exp.id = 4; + exp.name = "exp_test_fullon"; + exp.unitType = "session_id"; + exp.iteration = 1; + exp.seedHi = 856061641; + exp.seedLo = 990838475; + exp.split = {0.25, 0.25, 0.25, 0.25}; + exp.trafficSeedHi = 360868579; + exp.trafficSeedLo = 330937933; + exp.trafficSplit = {0.0, 1.0}; + exp.fullOnVariant = 2; + exp.audience = nlohmann::json("null"); + + ExperimentVariant v0; + v0.name = "A"; + v0.config = nullptr; + + ExperimentVariant v1; + v1.name = "B"; + v1.config = nlohmann::json(R"({"submit.color":"red","submit.shape":"circle"})"); + + ExperimentVariant v2; + v2.name = "C"; + v2.config = nlohmann::json(R"({"submit.color":"blue","submit.shape":"rect"})"); + + ExperimentVariant v3; + v3.name = "D"; + v3.config = nlohmann::json(R"({"submit.color":"green","submit.shape":"square"})"); + + exp.variants = {v0, v1, v2, v3}; + data.experiments.push_back(exp); + } + + { + ExperimentData exp; + exp.id = 5; + exp.name = "exp_test_custom_fields"; + exp.unitType = "session_id"; + exp.iteration = 1; + exp.seedHi = 9372617; + exp.seedLo = 121364805; + exp.split = {0.5, 0.5}; + exp.trafficSeedHi = 318746944; + exp.trafficSeedLo = 359812364; + exp.trafficSplit = {0.0, 1.0}; + exp.fullOnVariant = 0; + exp.audience = nullptr; + + ExperimentVariant v0; + v0.name = "A"; + v0.config = nullptr; + + ExperimentVariant v1; + v1.name = "B"; + v1.config = nlohmann::json(R"({"submit.size":"sm"})"); + + CustomFieldValue cf1; + cf1.name = "country"; + cf1.value = "US,PT,ES"; + cf1.type = "string"; + exp.customFieldValues.push_back(cf1); + + CustomFieldValue cf2; + cf2.name = "languages"; + cf2.value = "en-US,en-GB,pt-PT,pt-BR,es-ES,es-MX"; + cf2.type = "string"; + exp.customFieldValues.push_back(cf2); + + CustomFieldValue cf3; + cf3.name = "text_field"; + cf3.value = "hello text"; + cf3.type = "text"; + exp.customFieldValues.push_back(cf3); + + CustomFieldValue cf4; + cf4.name = "number_field"; + cf4.value = "123"; + cf4.type = "number"; + exp.customFieldValues.push_back(cf4); + + CustomFieldValue cf5; + cf5.name = "boolean_field"; + cf5.value = "true"; + cf5.type = "boolean"; + exp.customFieldValues.push_back(cf5); + + CustomFieldValue cf6; + cf6.name = "false_boolean_field"; + cf6.value = "false"; + cf6.type = "boolean"; + exp.customFieldValues.push_back(cf6); + + exp.variants = {v0, v1}; + data.experiments.push_back(exp); + } + + return data; +} + +static ContextData make_audience_data() { + auto data = make_test_data(); + for (auto& exp : data.experiments) { + if (exp.name == "exp_test_ab") { + exp.audience = nlohmann::json(R"({"filter":[{"gte":[{"var":"age"},{"value":20}]}]})"); + } + } + return data; +} + +static ContextData make_audience_strict_data() { + auto data = make_audience_data(); + for (auto& exp : data.experiments) { + if (exp.name == "exp_test_ab") { + exp.audienceStrict = true; + for (auto& v : exp.variants) { + if (v.name == "A") { + v.config = nlohmann::json(R"({"banner.size":"tiny"})"); + } + } + } + } + return data; +} + +static ContextData make_refresh_data() { + auto data = make_test_data(); + + ExperimentData exp; + exp.id = 6; + exp.name = "exp_test_new"; + exp.unitType = "session_id"; + exp.iteration = 2; + exp.seedHi = 934590467; + exp.seedLo = 714771373; + exp.split = {0.5, 0.5}; + exp.trafficSeedHi = 940553836; + exp.trafficSeedLo = 270705624; + exp.trafficSplit = {0.0, 1.0}; + exp.fullOnVariant = 1; + + ExperimentVariant v0; + v0.name = "A"; + v0.config = nullptr; + + ExperimentVariant v1; + v1.name = "B"; + v1.config = nlohmann::json(R"({"show-modal":true})"); + + exp.variants = {v0, v1}; + data.experiments.insert(data.experiments.begin(), exp); + + return data; +} + +static ContextConfig make_test_config() { + ContextConfig config; + config.publish_delay = -1; + config.refresh_period = 0; + config.units = { + {"session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec"}, + {"user_id", "123456789"} + }; + return config; +} + +class MockEventHandler : public ContextEventHandler { +public: + struct Event { + std::string type; + nlohmann::json data; + }; + + void handle_event(Context& context, const std::string& event_type, const nlohmann::json& data) override { + (void)context; + events.push_back({event_type, data}); + } + + std::vector events; + + void clear() { events.clear(); } + + int count_events(const std::string& type) const { + int c = 0; + for (const auto& e : events) { + if (e.type == type) c++; + } + return c; + } +}; + +TEST_CASE("Context construction", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should be ready with data") { + Context ctx(config, data); + REQUIRE(ctx.is_ready()); + REQUIRE_FALSE(ctx.is_failed()); + REQUIRE_FALSE(ctx.is_finalized()); + REQUIRE_FALSE(ctx.is_finalizing()); + } + + SECTION("should load experiment names") { + Context ctx(config, data); + auto exps = ctx.experiments(); + REQUIRE(exps.size() == 5); + REQUIRE(exps[0] == "exp_test_ab"); + REQUIRE(exps[1] == "exp_test_abc"); + REQUIRE(exps[2] == "exp_test_not_eligible"); + REQUIRE(exps[3] == "exp_test_fullon"); + REQUIRE(exps[4] == "exp_test_custom_fields"); + } + + SECTION("should emit ready event") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + REQUIRE(handler->count_events("ready") == 1); + } +} + +TEST_CASE("Context treatment", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should return correct variants") { + Context ctx(config, data); + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.treatment("exp_test_not_eligible") == 0); + REQUIRE(ctx.treatment("exp_test_fullon") == 2); + REQUIRE(ctx.treatment("exp_test_custom_fields") == 1); + } + + SECTION("should queue exposures") { + Context ctx(config, data); + REQUIRE(ctx.pending() == 0); + + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + + ctx.treatment("exp_test_abc"); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should queue exposures only once") { + Context ctx(config, data); + REQUIRE(ctx.pending() == 0); + + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should queue exposure on unknown experiment") { + Context ctx(config, data); + REQUIRE(ctx.treatment("not_found") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.treatment("not_found"); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should return 0 for not-eligible experiment") { + Context ctx(config, data); + REQUIRE(ctx.treatment("exp_test_not_eligible") == 0); + } + + SECTION("full-on variant should return full-on value") { + Context ctx(config, data); + REQUIRE(ctx.treatment("exp_test_fullon") == 2); + } + + SECTION("should emit exposure event") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + + handler->clear(); + ctx.treatment("exp_test_ab"); + + REQUIRE(handler->count_events("exposure") == 1); + + auto& evt = handler->events[0]; + REQUIRE(evt.data["name"] == "exp_test_ab"); + REQUIRE(evt.data["variant"] == 1); + REQUIRE(evt.data["assigned"] == true); + REQUIRE(evt.data["eligible"] == true); + REQUIRE(evt.data["overridden"] == false); + REQUIRE(evt.data["fullOn"] == false); + REQUIRE(evt.data["custom"] == false); + REQUIRE(evt.data["audienceMismatch"] == false); + } + + SECTION("should not emit exposure event on second call") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + + handler->clear(); + ctx.treatment("exp_test_ab"); + REQUIRE(handler->count_events("exposure") == 1); + + handler->clear(); + ctx.treatment("exp_test_ab"); + REQUIRE(handler->count_events("exposure") == 0); + } + + SECTION("should throw after finalize") { + Context ctx(config, data); + ctx.finalize(); + REQUIRE_THROWS_AS(ctx.treatment("exp_test_ab"), ContextFinalizedException); + } +} + +TEST_CASE("Context peek", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should return correct variants without queuing exposures") { + Context ctx(config, data); + REQUIRE(ctx.pending() == 0); + + REQUIRE(ctx.peek("exp_test_ab") == 1); + REQUIRE(ctx.peek("exp_test_abc") == 2); + REQUIRE(ctx.peek("exp_test_not_eligible") == 0); + REQUIRE(ctx.peek("exp_test_fullon") == 2); + REQUIRE(ctx.peek("exp_test_custom_fields") == 1); + + REQUIRE(ctx.pending() == 0); + } + + SECTION("should return override variant") { + Context ctx(config, data); + ctx.set_override("exp_test_ab", 5); + ctx.set_override("not_found", 3); + + REQUIRE(ctx.peek("exp_test_ab") == 5); + REQUIRE(ctx.peek("not_found") == 3); + REQUIRE(ctx.pending() == 0); + } + + SECTION("treatment after peek should still queue exposure") { + Context ctx(config, data); + ctx.peek("exp_test_ab"); + REQUIRE(ctx.pending() == 0); + + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + } +} + +TEST_CASE("Context audience mismatch", "[context]") { + auto config = make_test_config(); + + SECTION("non-strict mode: should return assigned variant on audience mismatch") { + auto data = make_audience_data(); + Context ctx(config, data); + REQUIRE(ctx.peek("exp_test_ab") == 1); + } + + SECTION("strict mode: should return control variant on audience mismatch") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + REQUIRE(ctx.peek("exp_test_ab") == 0); + } + + SECTION("strict mode: should return assigned variant when audience matches") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + ctx.set_attribute("age", 25); + REQUIRE(ctx.peek("exp_test_ab") == 1); + } + + SECTION("non-strict mode: audience match should still return assigned variant") { + auto data = make_audience_data(); + Context ctx(config, data); + ctx.set_attribute("age", 25); + REQUIRE(ctx.peek("exp_test_ab") == 1); + } +} + +TEST_CASE("Context audience re-evaluation", "[context]") { + auto config = make_test_config(); + + SECTION("strict mode: should re-evaluate when attributes change") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 2); + } + + SECTION("non-strict mode: should re-evaluate when attributes change") { + auto data = make_audience_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should not re-evaluate when no new attributes set") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + ctx.set_attribute("age", 15); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should not re-evaluate for experiments without audience filter") { + auto data = make_test_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should not invalidate cache when audience result unchanged") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + ctx.set_attribute("age", 15); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 18); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should re-evaluate from mismatch to match in strict mode") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 30); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should not re-evaluate when attribute set before assignment") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 1); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should re-evaluate when attribute set after assignment") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.treatment("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should update attrs_seq after checking unchanged audience") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + ctx.set_attribute("age", 15); + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 16); + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + ctx.set_attribute("age", 17); + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + + REQUIRE(ctx.treatment("exp_test_ab") == 0); + REQUIRE(ctx.pending() == 1); + } + + SECTION("peek: should re-evaluate in strict mode") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + + REQUIRE(ctx.peek("exp_test_ab") == 0); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.peek("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 0); + } + + SECTION("peek: should re-evaluate in non-strict mode") { + auto data = make_audience_data(); + Context ctx(config, data); + + REQUIRE(ctx.peek("exp_test_ab") == 1); + + ctx.set_attribute("age", 25); + + REQUIRE(ctx.peek("exp_test_ab") == 1); + REQUIRE(ctx.pending() == 0); + } +} + +TEST_CASE("Context variables", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should return variable value for assigned variant") { + Context ctx(config, data); + REQUIRE(ctx.variable_value("banner.border", 0) == 1); + REQUIRE(ctx.variable_value("banner.size", "small") == "large"); + REQUIRE(ctx.variable_value("button.color", "green") == "red"); + } + + SECTION("should return default value for unknown variable") { + Context ctx(config, data); + REQUIRE(ctx.variable_value("not.found", 17) == 17); + REQUIRE(ctx.pending() == 0); + } + + SECTION("should queue exposures") { + Context ctx(config, data); + REQUIRE(ctx.pending() == 0); + + ctx.variable_value("banner.border", 0); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should return default for not-eligible experiment variable") { + Context ctx(config, data); + REQUIRE(ctx.variable_value("card.width", "100%") == "100%"); + } + + SECTION("should return full-on experiment variable value") { + Context ctx(config, data); + REQUIRE(ctx.variable_value("submit.color", "white") == "blue"); + REQUIRE(ctx.variable_value("submit.shape", "square") == "rect"); + } + + SECTION("should return default for audience strict mismatch") { + auto strict_data = make_audience_strict_data(); + Context ctx(config, strict_data); + REQUIRE(ctx.variable_value("banner.size", "small") == "small"); + } + + SECTION("should return variable value when overridden") { + auto strict_data = make_audience_strict_data(); + Context ctx(config, strict_data); + + ctx.set_override("exp_test_ab", 0); + + REQUIRE(ctx.variable_value("banner.size", 17) == "tiny"); + } + + SECTION("should return default for unknown override variant") { + Context ctx(config, data); + ctx.set_override("exp_test_ab", 11); + REQUIRE(ctx.variable_value("banner.size", 17) == 17); + } + + SECTION("should throw after finalize") { + Context ctx(config, data); + ctx.finalize(); + REQUIRE_THROWS_AS(ctx.variable_value("banner.size", 0), ContextFinalizedException); + } +} + +TEST_CASE("Context peek variable value", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should not queue exposures") { + Context ctx(config, data); + REQUIRE(ctx.pending() == 0); + + REQUIRE(ctx.peek_variable_value("banner.border", 0) == 1); + REQUIRE(ctx.peek_variable_value("banner.size", "small") == "large"); + REQUIRE(ctx.peek_variable_value("button.color", "green") == "red"); + + REQUIRE(ctx.pending() == 0); + } + + SECTION("should return default for not-eligible experiment") { + Context ctx(config, data); + REQUIRE(ctx.peek_variable_value("card.width", "100%") == "100%"); + REQUIRE(ctx.pending() == 0); + } + + SECTION("should return default for audience strict mismatch") { + auto strict_data = make_audience_strict_data(); + Context ctx(config, strict_data); + REQUIRE(ctx.peek_variable_value("banner.size", "small") == "small"); + } + + SECTION("should return variable value when overridden") { + auto strict_data = make_audience_strict_data(); + Context ctx(config, strict_data); + + ctx.set_override("exp_test_ab", 0); + REQUIRE(ctx.peek_variable_value("banner.size", 17) == "tiny"); + } + + SECTION("should return default for unknown override variant") { + Context ctx(config, data); + ctx.set_override("exp_test_ab", 11); + REQUIRE(ctx.peek_variable_value("banner.size", 17) == 17); + REQUIRE(ctx.pending() == 0); + } + + SECTION("variable_value after peek_variable_value should queue exposure") { + Context ctx(config, data); + ctx.peek_variable_value("banner.border", 0); + REQUIRE(ctx.pending() == 0); + + ctx.variable_value("banner.border", 0); + REQUIRE(ctx.pending() == 1); + } +} + +TEST_CASE("Context variable keys", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should return all variable keys") { + Context ctx(config, data); + auto keys = ctx.variable_keys(); + REQUIRE(keys.count("banner.border") == 1); + REQUIRE(keys.count("banner.size") == 1); + REQUIRE(keys.count("button.color") == 1); + REQUIRE(keys.count("card.width") == 1); + REQUIRE(keys.count("submit.color") == 1); + REQUIRE(keys.count("submit.shape") == 1); + REQUIRE(keys.count("submit.size") == 1); + + REQUIRE(keys["banner.border"] == std::vector{"exp_test_ab"}); + REQUIRE(keys["button.color"] == std::vector{"exp_test_abc"}); + } +} + +TEST_CASE("Context overrides", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should override variant") { + Context ctx(config, data); + ctx.set_override("exp_test_ab", 5); + + REQUIRE(ctx.treatment("exp_test_ab") == 5); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should override unknown experiment") { + Context ctx(config, data); + ctx.set_override("not_found", 3); + + REQUIRE(ctx.treatment("not_found") == 3); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should override multiple experiments") { + Context ctx(config, data); + ctx.set_overrides({{"exp_test_ab", 3}, {"exp_test_abc", 4}}); + REQUIRE(ctx.peek("exp_test_ab") == 3); + REQUIRE(ctx.peek("exp_test_abc") == 4); + } + + SECTION("exposure should reflect override") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + + ctx.set_override("exp_test_ab", 5); + handler->clear(); + ctx.treatment("exp_test_ab"); + + REQUIRE(handler->count_events("exposure") == 1); + auto& evt = handler->events[0]; + REQUIRE(evt.data["variant"] == 5); + REQUIRE(evt.data["overridden"] == true); + } +} + +TEST_CASE("Context custom assignments", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should use custom assignment") { + Context ctx(config, data); + ctx.set_custom_assignment("exp_test_ab", 3); + REQUIRE(ctx.peek("exp_test_ab") == 3); + } + + SECTION("should set multiple custom assignments") { + Context ctx(config, data); + ctx.set_custom_assignments({{"exp_test_ab", 3}, {"exp_test_abc", 4}}); + REQUIRE(ctx.peek("exp_test_ab") == 3); + REQUIRE(ctx.peek("exp_test_abc") == 4); + } + + SECTION("should throw after finalize") { + Context ctx(config, data); + ctx.finalize(); + REQUIRE_THROWS_AS(ctx.set_custom_assignment("exp_test_ab", 1), ContextFinalizedException); + } +} + +TEST_CASE("Context track", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should queue goals") { + Context ctx(config, data); + REQUIRE(ctx.pending() == 0); + + ctx.track("goal1", {{"amount", 125}, {"hours", 245}}); + REQUIRE(ctx.pending() == 1); + + ctx.track("goal2", {{"tries", 7}}); + REQUIRE(ctx.pending() == 2); + + ctx.track("goal2", {{"tests", 12}}); + REQUIRE(ctx.pending() == 3); + } + + SECTION("should emit goal event") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + + handler->clear(); + ctx.track("goal1", {{"amount", 125}}); + REQUIRE(handler->count_events("goal") == 1); + } + + SECTION("should filter non-numeric properties") { + Context ctx(config, data); + ctx.track("goal1", { + {"amount", 125}, + {"name", "test"}, + {"flag", true}, + {"count", 7} + }); + + auto event = ctx.publish(); + REQUIRE(event.goals.size() == 1); + REQUIRE(event.goals[0].properties.count("amount") == 1); + REQUIRE(event.goals[0].properties.count("count") == 1); + REQUIRE(event.goals[0].properties.count("name") == 0); + REQUIRE(event.goals[0].properties.count("flag") == 0); + } + + SECTION("should handle empty properties") { + Context ctx(config, data); + ctx.track("goal1"); + REQUIRE(ctx.pending() == 1); + } + + SECTION("should throw after finalize") { + Context ctx(config, data); + ctx.finalize(); + REQUIRE_THROWS_AS(ctx.track("goal1", {{"amount", 125}}), ContextFinalizedException); + } +} + +TEST_CASE("Context publish", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should return empty when queue is empty") { + Context ctx(config, data); + auto event = ctx.publish(); + REQUIRE(event.units.empty()); + REQUIRE(event.exposures.empty()); + REQUIRE(event.goals.empty()); + } + + SECTION("should collect exposures and goals") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + ctx.track("goal1", {{"amount", 125}}); + + auto event = ctx.publish(); + + REQUIRE(event.hashed == true); + REQUIRE(event.units.size() == 2); + REQUIRE(event.exposures.size() == 1); + REQUIRE(event.goals.size() == 1); + REQUIRE(event.publishedAt > 0); + } + + SECTION("should hash units") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + + auto event = ctx.publish(); + REQUIRE(event.hashed == true); + + for (const auto& u : event.units) { + REQUIRE_FALSE(u.uid.empty()); + auto original = ctx.get_unit(u.type); + REQUIRE(original.has_value()); + REQUIRE(u.uid != original.value()); + } + } + + SECTION("should clear queues after publish") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + ctx.track("goal1", {{"amount", 125}}); + + REQUIRE(ctx.pending() == 2); + ctx.publish(); + REQUIRE(ctx.pending() == 0); + } + + SECTION("should include attributes") { + Context ctx(config, data); + ctx.set_attribute("attr1", "value1"); + ctx.treatment("exp_test_ab"); + + auto event = ctx.publish(); + REQUIRE(event.attributes.size() == 1); + REQUIRE(event.attributes[0].name == "attr1"); + REQUIRE(event.attributes[0].value == "value1"); + } + + SECTION("should emit publish event") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + ctx.treatment("exp_test_ab"); + + handler->clear(); + ctx.publish(); + REQUIRE(handler->count_events("publish") == 1); + } + + SECTION("exposure data should be correct") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + ctx.treatment("exp_test_not_eligible"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 2); + + auto& exp1 = event.exposures[0]; + REQUIRE(exp1.id == 1); + REQUIRE(exp1.name == "exp_test_ab"); + REQUIRE(exp1.unit == "session_id"); + REQUIRE(exp1.variant == 1); + REQUIRE(exp1.assigned == true); + REQUIRE(exp1.eligible == true); + REQUIRE(exp1.overridden == false); + REQUIRE(exp1.fullOn == false); + REQUIRE(exp1.custom == false); + REQUIRE(exp1.audienceMismatch == false); + + auto& exp2 = event.exposures[1]; + REQUIRE(exp2.id == 3); + REQUIRE(exp2.name == "exp_test_not_eligible"); + REQUIRE(exp2.unit == "user_id"); + REQUIRE(exp2.variant == 0); + REQUIRE(exp2.assigned == true); + REQUIRE(exp2.eligible == false); + } + + SECTION("full-on exposure should have fullOn flag") { + Context ctx(config, data); + ctx.treatment("exp_test_fullon"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 1); + REQUIRE(event.exposures[0].fullOn == true); + REQUIRE(event.exposures[0].variant == 2); + } + + SECTION("override exposure data") { + Context ctx(config, data); + ctx.set_override("exp_test_ab", 5); + ctx.set_override("not_found", 3); + ctx.treatment("exp_test_ab"); + ctx.treatment("not_found"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 2); + + REQUIRE(event.exposures[0].id == 1); + REQUIRE(event.exposures[0].overridden == true); + REQUIRE(event.exposures[0].variant == 5); + REQUIRE(event.exposures[0].assigned == false); + REQUIRE(event.exposures[0].unit == "session_id"); + + REQUIRE(event.exposures[1].id == 0); + REQUIRE(event.exposures[1].overridden == true); + REQUIRE(event.exposures[1].variant == 3); + REQUIRE(event.exposures[1].assigned == false); + REQUIRE(event.exposures[1].unit == ""); + } +} + +TEST_CASE("Context finalize", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should publish pending events and seal") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + + auto result = ctx.finalize(); + REQUIRE(ctx.is_finalized()); + REQUIRE(ctx.pending() == 0); + REQUIRE(result.exposures.size() == 1); + } + + SECTION("should return empty on second finalize") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + + ctx.finalize(); + auto result = ctx.finalize(); + REQUIRE(result.units.empty()); + } + + SECTION("should emit finalize event") { + auto handler = std::make_shared(); + Context ctx(config, data, handler); + ctx.treatment("exp_test_ab"); + + handler->clear(); + ctx.finalize(); + REQUIRE(handler->count_events("finalize") == 1); + } + + SECTION("should seal context") { + Context ctx(config, data); + ctx.finalize(); + + REQUIRE_THROWS_AS(ctx.treatment("exp_test_ab"), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.peek("exp_test_ab"), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.track("goal1"), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.set_attribute("a", 1), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.set_unit("test", "test"), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.set_custom_assignment("exp", 1), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.variable_value("key", 0), ContextFinalizedException); + REQUIRE_THROWS_AS(ctx.peek_variable_value("key", 0), ContextFinalizedException); + } +} + +TEST_CASE("Context refresh/cache invalidation", "[context]") { + auto config = make_test_config(); + + SECTION("should clear cache for started experiment") { + auto data = make_test_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_new") == 0); + REQUIRE(ctx.pending() == 1); + + auto refresh_data = make_refresh_data(); + ctx.refresh(refresh_data); + + REQUIRE(ctx.treatment("exp_test_new") == 1); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should clear cache for stopped experiment") { + auto data = make_test_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.pending() == 1); + + auto stopped_data = data; + stopped_data.experiments.erase( + std::remove_if(stopped_data.experiments.begin(), + stopped_data.experiments.end(), + [](const ExperimentData& e) { return e.name == "exp_test_abc"; }), + stopped_data.experiments.end()); + + ctx.refresh(stopped_data); + + REQUIRE(ctx.treatment("exp_test_abc") == 0); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should clear cache when experiment ID changes") { + auto data = make_test_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.pending() == 1); + + auto changed_data = data; + for (auto& exp : changed_data.experiments) { + if (exp.name == "exp_test_abc") { + exp.id = 11; + exp.trafficSeedHi = 54870830; + exp.trafficSeedLo = 398724581; + exp.seedHi = 77498863; + exp.seedLo = 34737352; + } + } + + ctx.refresh(changed_data); + + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should clear cache when full-on changes") { + auto data = make_test_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_abc") == 2); + REQUIRE(ctx.pending() == 1); + + auto fullon_data = data; + for (auto& exp : fullon_data.experiments) { + if (exp.name == "exp_test_abc") { + exp.fullOnVariant = 1; + } + } + + ctx.refresh(fullon_data); + + REQUIRE(ctx.treatment("exp_test_abc") == 1); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should clear cache when traffic split changes") { + auto data = make_test_data(); + Context ctx(config, data); + + REQUIRE(ctx.treatment("exp_test_not_eligible") == 0); + REQUIRE(ctx.pending() == 1); + + auto split_data = data; + for (auto& exp : split_data.experiments) { + if (exp.name == "exp_test_not_eligible") { + exp.trafficSplit = {0.0, 1.0}; + } + } + + ctx.refresh(split_data); + + REQUIRE(ctx.treatment("exp_test_not_eligible") == 2); + REQUIRE(ctx.pending() == 2); + } + + SECTION("should re-queue exposures after refresh even when not changed") { + auto data = make_test_data(); + Context ctx(config, data); + + for (const auto& exp : data.experiments) { + ctx.treatment(exp.name); + } + REQUIRE(ctx.pending() == static_cast(data.experiments.size())); + + auto refresh_data = make_refresh_data(); + ctx.refresh(refresh_data); + + REQUIRE(ctx.pending() == static_cast(data.experiments.size())); + + for (const auto& exp : data.experiments) { + ctx.treatment(exp.name); + } + REQUIRE(ctx.pending() == static_cast(data.experiments.size()) * 2); + } + + SECTION("should keep overrides after refresh") { + auto data = make_test_data(); + Context ctx(config, data); + + ctx.set_override("not_found", 3); + REQUIRE(ctx.peek("not_found") == 3); + + auto refresh_data = make_refresh_data(); + ctx.refresh(refresh_data); + + REQUIRE(ctx.peek("not_found") == 3); + } + + SECTION("should keep custom assignments after refresh") { + auto data = make_test_data(); + Context ctx(config, data); + + ctx.set_custom_assignment("exp_test_ab", 3); + REQUIRE(ctx.peek("exp_test_ab") == 3); + + auto refresh_data = make_refresh_data(); + ctx.refresh(refresh_data); + + REQUIRE(ctx.peek("exp_test_ab") == 3); + } + + SECTION("should throw after finalize") { + auto data = make_test_data(); + Context ctx(config, data); + ctx.finalize(); + + REQUIRE_THROWS_AS(ctx.refresh(data), ContextFinalizedException); + } + + SECTION("should emit refresh event") { + auto data = make_test_data(); + auto handler = std::make_shared(); + Context ctx(config, data, handler); + + handler->clear(); + ctx.refresh(data); + REQUIRE(handler->count_events("refresh") == 1); + } + + SECTION("should re-queue overridden experiment exposure after refresh") { + auto data = make_test_data(); + Context ctx(config, data); + + ctx.set_override("exp_test_ab", 3); + REQUIRE(ctx.treatment("exp_test_ab") == 3); + REQUIRE(ctx.pending() == 1); + + auto changed_data = data; + for (auto& exp : changed_data.experiments) { + if (exp.name == "exp_test_ab") { + exp.id = 99; + } + } + + ctx.refresh(changed_data); + + REQUIRE(ctx.treatment("exp_test_ab") == 3); + REQUIRE(ctx.pending() == 2); + } +} + +TEST_CASE("Context units", "[context]") { + auto data = make_test_data(); + + SECTION("should set and get unit") { + ContextConfig config; + config.publish_delay = -1; + Context ctx(config, data); + + ctx.set_unit("session_id", "abc123"); + REQUIRE(ctx.get_unit("session_id").value() == "abc123"); + } + + SECTION("should return nullopt for unknown unit") { + ContextConfig config; + config.publish_delay = -1; + Context ctx(config, data); + + REQUIRE_FALSE(ctx.get_unit("unknown").has_value()); + } + + SECTION("should throw on blank uid") { + ContextConfig config; + config.publish_delay = -1; + Context ctx(config, data); + + REQUIRE_THROWS(ctx.set_unit("session_id", "")); + } + + SECTION("should throw on duplicate unit with different value") { + auto config = make_test_config(); + Context ctx(config, data); + + REQUIRE_THROWS(ctx.set_unit("session_id", "new_id")); + } + + SECTION("should not throw on duplicate unit with same value") { + auto config = make_test_config(); + Context ctx(config, data); + + REQUIRE_NOTHROW(ctx.set_unit("session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec")); + } + + SECTION("should set multiple units") { + ContextConfig config; + config.publish_delay = -1; + Context ctx(config, data); + + ctx.set_units({{"session_id", "abc"}, {"user_id", "123"}}); + REQUIRE(ctx.get_units().size() == 2); + } + + SECTION("should throw after finalize") { + auto config = make_test_config(); + Context ctx(config, data); + ctx.finalize(); + REQUIRE_THROWS_AS(ctx.set_unit("test", "test"), ContextFinalizedException); + } +} + +TEST_CASE("Context attributes", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should set and get attribute") { + Context ctx(config, data); + ctx.set_attribute("attr1", "value1"); + REQUIRE(ctx.get_attribute("attr1") == "value1"); + } + + SECTION("should get last set value") { + Context ctx(config, data); + ctx.set_attribute("attr1", "value1"); + ctx.set_attribute("attr1", "value2"); + REQUIRE(ctx.get_attribute("attr1") == "value2"); + } + + SECTION("should set multiple attributes") { + Context ctx(config, data); + ctx.set_attributes({{"attr1", "value1"}, {"attr2", 15}}); + auto attrs = ctx.get_attributes(); + REQUIRE(attrs.size() == 2); + REQUIRE(attrs["attr1"] == "value1"); + REQUIRE(attrs["attr2"] == 15); + } + + SECTION("should return null for unknown attribute") { + Context ctx(config, data); + REQUIRE(ctx.get_attribute("unknown").is_null()); + } + + SECTION("should throw after finalize") { + Context ctx(config, data); + ctx.finalize(); + REQUIRE_THROWS_AS(ctx.set_attribute("a", 1), ContextFinalizedException); + } +} + +TEST_CASE("Context custom fields", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should return string custom field") { + Context ctx(config, data); + REQUIRE(ctx.custom_field_value("exp_test_custom_fields", "country") == "US,PT,ES"); + } + + SECTION("should return text custom field") { + Context ctx(config, data); + REQUIRE(ctx.custom_field_value("exp_test_custom_fields", "text_field") == "hello text"); + } + + SECTION("should return number custom field") { + Context ctx(config, data); + REQUIRE(ctx.custom_field_value("exp_test_custom_fields", "number_field") == 123.0); + } + + SECTION("should return boolean custom field") { + Context ctx(config, data); + REQUIRE(ctx.custom_field_value("exp_test_custom_fields", "boolean_field") == true); + REQUIRE(ctx.custom_field_value("exp_test_custom_fields", "false_boolean_field") == false); + } + + SECTION("should return null for unknown field") { + Context ctx(config, data); + REQUIRE(ctx.custom_field_value("exp_test_custom_fields", "unknown").is_null()); + } + + SECTION("should return null for unknown experiment") { + Context ctx(config, data); + REQUIRE(ctx.custom_field_value("not_found", "country").is_null()); + } + + SECTION("should list custom field keys") { + Context ctx(config, data); + auto keys = ctx.custom_field_keys(); + REQUIRE_FALSE(keys.empty()); + } +} + +TEST_CASE("Context exposure de-duplication", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("treatment called twice queues only one exposure") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + ctx.treatment("exp_test_ab"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 1); + } + + SECTION("treatment on different experiments queues multiple exposures") { + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + ctx.treatment("exp_test_abc"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 2); + } + + SECTION("peek then treatment queues exactly one exposure") { + Context ctx(config, data); + ctx.peek("exp_test_ab"); + REQUIRE(ctx.pending() == 0); + + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + + ctx.treatment("exp_test_ab"); + REQUIRE(ctx.pending() == 1); + } + + SECTION("all experiments queued once") { + Context ctx(config, data); + for (const auto& exp : data.experiments) { + ctx.treatment(exp.name); + } + + REQUIRE(ctx.pending() == static_cast(data.experiments.size())); + + for (const auto& exp : data.experiments) { + ctx.treatment(exp.name); + } + + REQUIRE(ctx.pending() == static_cast(data.experiments.size())); + } +} + +TEST_CASE("Context audience mismatch exposure data", "[context]") { + auto config = make_test_config(); + + SECTION("non-strict: exposure should have audienceMismatch true") { + auto data = make_audience_data(); + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 1); + REQUIRE(event.exposures[0].audienceMismatch == true); + REQUIRE(event.exposures[0].variant == 1); + REQUIRE(event.exposures[0].assigned == true); + } + + SECTION("non-strict: exposure should have audienceMismatch false when matched") { + auto data = make_audience_data(); + Context ctx(config, data); + ctx.set_attribute("age", 21); + ctx.treatment("exp_test_ab"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 1); + REQUIRE(event.exposures[0].audienceMismatch == false); + REQUIRE(event.exposures[0].variant == 1); + } + + SECTION("strict: exposure should have audienceMismatch true and control variant") { + auto data = make_audience_strict_data(); + Context ctx(config, data); + ctx.treatment("exp_test_ab"); + + auto event = ctx.publish(); + REQUIRE(event.exposures.size() == 1); + REQUIRE(event.exposures[0].audienceMismatch == true); + REQUIRE(event.exposures[0].variant == 0); + REQUIRE(event.exposures[0].assigned == false); + } +} + +TEST_CASE("Context config from constructor", "[context]") { + auto data = make_test_data(); + + SECTION("should apply units from config") { + ContextConfig config; + config.units = {{"session_id", "abc123"}}; + Context ctx(config, data); + REQUIRE(ctx.get_unit("session_id").value() == "abc123"); + } + + SECTION("should apply overrides from config") { + ContextConfig config; + config.overrides = {{"exp_test_ab", 5}}; + config.units = {{"session_id", "abc123"}}; + Context ctx(config, data); + REQUIRE(ctx.peek("exp_test_ab") == 5); + } + + SECTION("should apply custom assignments from config") { + auto config = make_test_config(); + config.custom_assignments = {{"exp_test_ab", 3}}; + Context ctx(config, data); + REQUIRE(ctx.peek("exp_test_ab") == 3); + } +} + +TEST_CASE("Context conflicting variable keys", "[context]") { + auto data = make_test_data(); + auto config = make_test_config(); + + SECTION("should pick lowest experiment id on conflicting key") { + auto conflict_data = data; + for (auto& exp : conflict_data.experiments) { + if (exp.name == "exp_test_ab") { + exp.id = 99; + for (auto& v : exp.variants) { + if (v.name == "B") { + v.config = nlohmann::json(R"({"icon":"arrow"})"); + } + } + } + if (exp.name == "exp_test_abc") { + exp.id = 1; + for (auto& v : exp.variants) { + if (v.name == "C") { + v.config = nlohmann::json(R"({"icon":"circle"})"); + } + } + } + } + + Context ctx(config, conflict_data); + REQUIRE(ctx.peek_variable_value("icon", "square") == "circle"); + } +} + +TEST_CASE("Context disjointed audiences", "[context]") { + auto config = make_test_config(); + + SECTION("should resolve variable from matching audience experiment") { + auto data = make_test_data(); + for (auto& exp : data.experiments) { + if (exp.name == "exp_test_ab") { + exp.audienceStrict = true; + exp.audience = nlohmann::json(R"({"filter":[{"gte":[{"var":"age"},{"value":20}]}]})"); + for (auto& v : exp.variants) { + if (v.name == "B") { + v.config = nlohmann::json(R"({"icon":"arrow"})"); + } + } + } + if (exp.name == "exp_test_abc") { + exp.audienceStrict = true; + exp.audience = nlohmann::json(R"({"filter":[{"lt":[{"var":"age"},{"value":20}]}]})"); + for (auto& v : exp.variants) { + if (v.name == "C") { + v.config = nlohmann::json(R"({"icon":"circle"})"); + } + } + } + } + + Context ctx1(config, data); + ctx1.set_attribute("age", 20); + REQUIRE(ctx1.variable_value("icon", "square") == "arrow"); + + Context ctx2(config, data); + ctx2.set_attribute("age", 19); + REQUIRE(ctx2.variable_value("icon", "square") == "circle"); + } +} diff --git a/tests/json_expr_test.cpp b/tests/json_expr_test.cpp new file mode 100644 index 0000000..87b14df --- /dev/null +++ b/tests/json_expr_test.cpp @@ -0,0 +1,417 @@ +#include +#include + +#include "absmartly/json_expr/evaluator.h" + +using namespace absmartly; +using json = nlohmann::json; + +TEST_CASE("Evaluator::to_boolean", "[evaluator][boolean]") { + SECTION("null returns nullopt") { + REQUIRE_FALSE(Evaluator::to_boolean(nullptr).has_value()); + } + + SECTION("boolean values pass through") { + REQUIRE(Evaluator::to_boolean(true).value() == true); + REQUIRE(Evaluator::to_boolean(false).value() == false); + } + + SECTION("numbers: 0 is false, non-zero is true") { + REQUIRE(Evaluator::to_boolean(0).value() == false); + REQUIRE(Evaluator::to_boolean(1).value() == true); + REQUIRE(Evaluator::to_boolean(-1).value() == true); + REQUIRE(Evaluator::to_boolean(1.5).value() == true); + REQUIRE(Evaluator::to_boolean(2).value() == true); + } + + SECTION("strings: empty is false, non-empty is true") { + REQUIRE(Evaluator::to_boolean("").value() == false); + REQUIRE(Evaluator::to_boolean("abc").value() == true); + REQUIRE(Evaluator::to_boolean("0").value() == true); + REQUIRE(Evaluator::to_boolean("1").value() == true); + } + + SECTION("arrays and objects are always true") { + REQUIRE(Evaluator::to_boolean(json::array()).value() == true); + REQUIRE(Evaluator::to_boolean(json::object()).value() == true); + REQUIRE(Evaluator::to_boolean(json::array({1, 2})).value() == true); + REQUIRE(Evaluator::to_boolean(json{{"a", 1}}).value() == true); + } +} + +TEST_CASE("Evaluator::to_number", "[evaluator][number]") { + SECTION("null returns nullopt") { + REQUIRE_FALSE(Evaluator::to_number(nullptr).has_value()); + } + + SECTION("booleans convert to 0/1") { + REQUIRE(Evaluator::to_number(true).value() == 1.0); + REQUIRE(Evaluator::to_number(false).value() == 0.0); + } + + SECTION("numbers pass through") { + REQUIRE(Evaluator::to_number(0).value() == 0.0); + REQUIRE(Evaluator::to_number(1).value() == 1.0); + REQUIRE(Evaluator::to_number(1.5).value() == 1.5); + REQUIRE(Evaluator::to_number(-1.0).value() == -1.0); + REQUIRE(Evaluator::to_number(2.0).value() == 2.0); + REQUIRE(Evaluator::to_number(3.0).value() == 3.0); + REQUIRE(Evaluator::to_number(0x7fffffff).value() == 2147483647.0); + REQUIRE(Evaluator::to_number(-0x7fffffff).value() == -2147483647.0); + } + + SECTION("strings: numeric strings parse, empty and non-numeric are nullopt") { + REQUIRE_FALSE(Evaluator::to_number(json("")).has_value()); + REQUIRE(Evaluator::to_number(json("0")).value() == 0.0); + REQUIRE(Evaluator::to_number(json("1")).value() == 1.0); + REQUIRE(Evaluator::to_number(json("1.5")).value() == 1.5); + REQUIRE(Evaluator::to_number(json("123")).value() == 123.0); + REQUIRE(Evaluator::to_number(json("-1")).value() == -1.0); + REQUIRE(Evaluator::to_number(json("2")).value() == 2.0); + REQUIRE(Evaluator::to_number(json("3.0")).value() == 3.0); + REQUIRE_FALSE(Evaluator::to_number(json("abc")).has_value()); + REQUIRE_FALSE(Evaluator::to_number(json("x1234")).has_value()); + } + + SECTION("arrays and objects return nullopt") { + REQUIRE_FALSE(Evaluator::to_number(json::array()).has_value()); + REQUIRE_FALSE(Evaluator::to_number(json::object()).has_value()); + } +} + +TEST_CASE("Evaluator::to_string_value", "[evaluator][string]") { + SECTION("null returns nullopt") { + REQUIRE_FALSE(Evaluator::to_string_value(nullptr).has_value()); + } + + SECTION("booleans convert to string") { + REQUIRE(Evaluator::to_string_value(true).value() == "true"); + REQUIRE(Evaluator::to_string_value(false).value() == "false"); + } + + SECTION("strings pass through") { + REQUIRE(Evaluator::to_string_value(json("")).value() == ""); + REQUIRE(Evaluator::to_string_value(json("abc")).value() == "abc"); + } + + SECTION("numbers convert to string representation") { + REQUIRE(Evaluator::to_string_value(0).value() == "0"); + REQUIRE(Evaluator::to_string_value(1).value() == "1"); + REQUIRE(Evaluator::to_string_value(-1).value() == "-1"); + REQUIRE(Evaluator::to_string_value(1.5).value() == "1.5"); + REQUIRE(Evaluator::to_string_value(2.0).value() == "2"); + REQUIRE(Evaluator::to_string_value(3.0).value() == "3"); + REQUIRE(Evaluator::to_string_value(-1.0).value() == "-1"); + REQUIRE(Evaluator::to_string_value(0.0).value() == "0"); + REQUIRE(Evaluator::to_string_value(2147483647.0).value() == "2147483647"); + REQUIRE(Evaluator::to_string_value(-2147483647.0).value() == "-2147483647"); + } + + SECTION("arrays and objects return nullopt") { + REQUIRE_FALSE(Evaluator::to_string_value(json::array()).has_value()); + REQUIRE_FALSE(Evaluator::to_string_value(json::object()).has_value()); + } +} + +TEST_CASE("Evaluator::compare", "[evaluator][compare]") { + SECTION("null comparisons") { + REQUIRE(Evaluator::compare(nullptr, nullptr).value() == 0); + + REQUIRE_FALSE(Evaluator::compare(nullptr, 0).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, 1).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, true).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, false).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, json("")).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, json("abc")).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, json::object()).has_value()); + REQUIRE_FALSE(Evaluator::compare(nullptr, json::array()).has_value()); + + REQUIRE_FALSE(Evaluator::compare(0, nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(1, nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(true, nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(false, nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(json(""), nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(json("abc"), nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), nullptr).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), nullptr).has_value()); + } + + SECTION("object/array comparisons") { + REQUIRE_FALSE(Evaluator::compare(json::object(), 0).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), 1).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), true).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), false).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), json("")).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), json("abc")).has_value()); + REQUIRE(Evaluator::compare(json::object(), json::object()).value() == 0); + REQUIRE(Evaluator::compare(json{{"a", 1}}, json{{"a", 1}}).value() == 0); + REQUIRE_FALSE(Evaluator::compare(json{{"a", 1}}, json{{"b", 2}}).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::object(), json::array()).has_value()); + + REQUIRE_FALSE(Evaluator::compare(json::array(), 0).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), 1).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), true).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), false).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), json("")).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), json("abc")).has_value()); + REQUIRE_FALSE(Evaluator::compare(json::array(), json::object()).has_value()); + REQUIRE(Evaluator::compare(json::array(), json::array()).value() == 0); + REQUIRE(Evaluator::compare(json::array({1, 2}), json::array({1, 2})).value() == 0); + REQUIRE_FALSE(Evaluator::compare(json::array({1, 2}), json::array({3, 4})).has_value()); + } + + SECTION("boolean comparisons coerce rhs") { + REQUIRE(Evaluator::compare(false, 0).value() == 0); + REQUIRE(Evaluator::compare(false, 1).value() == -1); + REQUIRE(Evaluator::compare(false, true).value() == -1); + REQUIRE(Evaluator::compare(false, false).value() == 0); + REQUIRE(Evaluator::compare(false, json("")).value() == 0); + REQUIRE(Evaluator::compare(false, json("abc")).value() == -1); + REQUIRE(Evaluator::compare(false, json::object()).value() == -1); + REQUIRE(Evaluator::compare(false, json::array()).value() == -1); + + REQUIRE(Evaluator::compare(true, 0).value() == 1); + REQUIRE(Evaluator::compare(true, 1).value() == 0); + REQUIRE(Evaluator::compare(true, true).value() == 0); + REQUIRE(Evaluator::compare(true, false).value() == 1); + REQUIRE(Evaluator::compare(true, json("")).value() == 1); + REQUIRE(Evaluator::compare(true, json("abc")).value() == 0); + REQUIRE(Evaluator::compare(true, json::object()).value() == 0); + REQUIRE(Evaluator::compare(true, json::array()).value() == 0); + } + + SECTION("number comparisons") { + REQUIRE(Evaluator::compare(0, 0).value() == 0); + REQUIRE(Evaluator::compare(0, 1).value() == -1); + REQUIRE(Evaluator::compare(0, true).value() == -1); + REQUIRE(Evaluator::compare(0, false).value() == 0); + REQUIRE_FALSE(Evaluator::compare(0, json("")).has_value()); + REQUIRE_FALSE(Evaluator::compare(0, json("abc")).has_value()); + REQUIRE_FALSE(Evaluator::compare(0, json::object()).has_value()); + REQUIRE_FALSE(Evaluator::compare(0, json::array()).has_value()); + + REQUIRE(Evaluator::compare(1, 0).value() == 1); + REQUIRE(Evaluator::compare(1, 1).value() == 0); + REQUIRE(Evaluator::compare(1, true).value() == 0); + REQUIRE(Evaluator::compare(1, false).value() == 1); + REQUIRE_FALSE(Evaluator::compare(1, json("")).has_value()); + REQUIRE_FALSE(Evaluator::compare(1, json("abc")).has_value()); + REQUIRE_FALSE(Evaluator::compare(1, json::object()).has_value()); + REQUIRE_FALSE(Evaluator::compare(1, json::array()).has_value()); + + REQUIRE(Evaluator::compare(1.0, 1).value() == 0); + REQUIRE(Evaluator::compare(1.5, 1).value() == 1); + REQUIRE(Evaluator::compare(2.0, 1).value() == 1); + REQUIRE(Evaluator::compare(3.0, 1).value() == 1); + + REQUIRE(Evaluator::compare(1, 1.0).value() == 0); + REQUIRE(Evaluator::compare(1, 1.5).value() == -1); + REQUIRE(Evaluator::compare(1, 2.0).value() == -1); + REQUIRE(Evaluator::compare(1, 3.0).value() == -1); + } + + SECTION("string comparisons") { + REQUIRE(Evaluator::compare(json(""), json("")).value() == 0); + REQUIRE(Evaluator::compare(json("abc"), json("abc")).value() == 0); + REQUIRE(Evaluator::compare(json("0"), 0).value() == 0); + REQUIRE(Evaluator::compare(json("1"), 1).value() == 0); + REQUIRE(Evaluator::compare(json("true"), true).value() == 0); + REQUIRE(Evaluator::compare(json("false"), false).value() == 0); + REQUIRE_FALSE(Evaluator::compare(json(""), json::object()).has_value()); + REQUIRE_FALSE(Evaluator::compare(json("abc"), json::object()).has_value()); + REQUIRE_FALSE(Evaluator::compare(json(""), json::array()).has_value()); + REQUIRE_FALSE(Evaluator::compare(json("abc"), json::array()).has_value()); + + REQUIRE(Evaluator::compare(json("abc"), json("bcd")).value() == -1); + REQUIRE(Evaluator::compare(json("bcd"), json("abc")).value() == 1); + REQUIRE(Evaluator::compare(json("0"), json("1")).value() == -1); + REQUIRE(Evaluator::compare(json("1"), json("0")).value() == 1); + REQUIRE(Evaluator::compare(json("9"), json("100")).value() == 1); + REQUIRE(Evaluator::compare(json("100"), json("9")).value() == -1); + } +} + +TEST_CASE("Evaluator::extract_var", "[evaluator][extract_var]") { + json vars = { + {"a", 1}, + {"b", true}, + {"c", false}, + {"d", json::array({1, 2, 3})}, + {"e", json::array({1, json{{"z", 2}}, 3})}, + {"f", json{{"y", json{{"x", 3}, {"0", 10}}}}} + }; + + SECTION("top-level access") { + REQUIRE(Evaluator::extract_var(vars, "a") == 1); + REQUIRE(Evaluator::extract_var(vars, "b") == true); + REQUIRE(Evaluator::extract_var(vars, "c") == false); + REQUIRE(Evaluator::extract_var(vars, "d") == json::array({1, 2, 3})); + REQUIRE(Evaluator::extract_var(vars, "e") == json::array({1, json{{"z", 2}}, 3})); + REQUIRE(Evaluator::extract_var(vars, "f") == json{{"y", json{{"x", 3}, {"0", 10}}}}); + } + + SECTION("invalid paths on non-objects") { + REQUIRE(Evaluator::extract_var(vars, "a/0").is_null()); + REQUIRE(Evaluator::extract_var(vars, "a/b").is_null()); + REQUIRE(Evaluator::extract_var(vars, "b/0").is_null()); + REQUIRE(Evaluator::extract_var(vars, "b/e").is_null()); + } + + SECTION("array index access") { + REQUIRE(Evaluator::extract_var(vars, "d/0") == 1); + REQUIRE(Evaluator::extract_var(vars, "d/1") == 2); + REQUIRE(Evaluator::extract_var(vars, "d/2") == 3); + REQUIRE(Evaluator::extract_var(vars, "d/3").is_null()); + } + + SECTION("nested access") { + REQUIRE(Evaluator::extract_var(vars, "e/0") == 1); + REQUIRE(Evaluator::extract_var(vars, "e/1/z") == 2); + REQUIRE(Evaluator::extract_var(vars, "e/2") == 3); + REQUIRE(Evaluator::extract_var(vars, "e/1/0").is_null()); + + auto fy = Evaluator::extract_var(vars, "f/y"); + REQUIRE(fy.contains("x")); + REQUIRE(fy["x"] == 3); + REQUIRE(Evaluator::extract_var(vars, "f/y/x") == 3); + REQUIRE(Evaluator::extract_var(vars, "f/y/0") == 10); + } +} + +TEST_CASE("Evaluator::evaluate", "[evaluator]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns null if operator not found") { + REQUIRE(evaluator.evaluate(json{{"not_found", true}}, vars).is_null()); + } + + SECTION("calls operator with args") { + json expr = {{"value", json::array({1, 2, 3})}}; + auto result = evaluator.evaluate(expr, vars); + REQUIRE(result == json::array({1, 2, 3})); + } + + SECTION("array is treated as implicit AND") { + json expr = json::array({json{{"value", true}}, json{{"value", true}}}); + auto result = evaluator.evaluate(expr, vars); + REQUIRE(result == true); + + json expr2 = json::array({json{{"value", true}}, json{{"value", false}}}); + auto result2 = evaluator.evaluate(expr2, vars); + REQUIRE(result2 == false); + } + + SECTION("nested evaluation") { + json expr = {{"and", json::array({ + json{{"value", true}}, + json{{"gt", json::array({json{{"value", 5}}, json{{"value", 3}}})}} + })}}; + auto result = evaluator.evaluate(expr, vars); + REQUIRE(result == true); + } +} + +TEST_CASE("Evaluator::evaluate_boolean", "[evaluator]") { + Evaluator evaluator; + json vars = {}; + + SECTION("truthy values") { + REQUIRE(evaluator.evaluate_boolean(json{{"value", true}}, vars) == true); + REQUIRE(evaluator.evaluate_boolean(json{{"value", 1}}, vars) == true); + REQUIRE(evaluator.evaluate_boolean(json{{"value", 5}}, vars) == true); + } + + SECTION("falsy values") { + REQUIRE(evaluator.evaluate_boolean(json{{"value", false}}, vars) == false); + REQUIRE(evaluator.evaluate_boolean(json{{"value", 0}}, vars) == false); + REQUIRE(evaluator.evaluate_boolean(json{{"value", nullptr}}, vars) == false); + } +} + +TEST_CASE("JsonExpr integration tests", "[evaluator][integration]") { + Evaluator evaluator; + + auto value_for = [](const json& x) -> json { return {{"value", x}}; }; + auto var_for = [](const std::string& p) -> json { return {{"var", json{{"path", p}}}}; }; + auto binary_op = [](const std::string& op, const json& a, const json& b) -> json { + return {{op, json::array({a, b})}}; + }; + auto unary_op = [](const std::string& op, const json& arg) -> json { + return {{op, arg}}; + }; + + json john = {{"age", 20}, {"language", "en-US"}, {"returning", false}}; + json terry = {{"age", 20}, {"language", "en-GB"}, {"returning", true}}; + json kate = {{"age", 50}, {"language", "es-ES"}, {"returning", false}}; + json maria = {{"age", 52}, {"language", "pt-PT"}, {"returning", true}}; + + json age_twenty_and_us = json::array({ + binary_op("eq", var_for("age"), value_for(20)), + binary_op("eq", var_for("language"), value_for("en-US")) + }); + + json age_over_fifty = json::array({ + binary_op("gte", var_for("age"), value_for(50)) + }); + + json age_twenty_and_us_or_age_over_fifty = json::array({ + json{{"or", json::array({age_twenty_and_us, age_over_fifty})}} + }); + + json returning = json::array({ + binary_op("eq", var_for("returning"), value_for(true)) + }); + + json returning_and_age_twenty_and_us_or_age_over_fifty = json::array({ + returning[0], + age_twenty_and_us_or_age_over_fifty[0] + }); + + json not_returning_and_spanish = json::array({ + unary_op("not", returning), + binary_op("eq", var_for("language"), value_for("es-ES")) + }); + + SECTION("AgeTwentyAndUS") { + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us, john) == true); + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us, terry) == false); + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us, kate) == false); + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us, maria) == false); + } + + SECTION("AgeOverFifty") { + REQUIRE(evaluator.evaluate_boolean(age_over_fifty, john) == false); + REQUIRE(evaluator.evaluate_boolean(age_over_fifty, terry) == false); + REQUIRE(evaluator.evaluate_boolean(age_over_fifty, kate) == true); + REQUIRE(evaluator.evaluate_boolean(age_over_fifty, maria) == true); + } + + SECTION("AgeTwentyAndUS_Or_AgeOverFifty") { + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us_or_age_over_fifty, john) == true); + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us_or_age_over_fifty, terry) == false); + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us_or_age_over_fifty, kate) == true); + REQUIRE(evaluator.evaluate_boolean(age_twenty_and_us_or_age_over_fifty, maria) == true); + } + + SECTION("Returning") { + REQUIRE(evaluator.evaluate_boolean(returning, john) == false); + REQUIRE(evaluator.evaluate_boolean(returning, terry) == true); + REQUIRE(evaluator.evaluate_boolean(returning, kate) == false); + REQUIRE(evaluator.evaluate_boolean(returning, maria) == true); + } + + SECTION("Returning_And_AgeTwentyAndUS_Or_AgeOverFifty") { + REQUIRE(evaluator.evaluate_boolean(returning_and_age_twenty_and_us_or_age_over_fifty, john) == false); + REQUIRE(evaluator.evaluate_boolean(returning_and_age_twenty_and_us_or_age_over_fifty, terry) == false); + REQUIRE(evaluator.evaluate_boolean(returning_and_age_twenty_and_us_or_age_over_fifty, kate) == false); + REQUIRE(evaluator.evaluate_boolean(returning_and_age_twenty_and_us_or_age_over_fifty, maria) == true); + } + + SECTION("NotReturning_And_Spanish") { + REQUIRE(evaluator.evaluate_boolean(not_returning_and_spanish, john) == false); + REQUIRE(evaluator.evaluate_boolean(not_returning_and_spanish, terry) == false); + REQUIRE(evaluator.evaluate_boolean(not_returning_and_spanish, kate) == true); + REQUIRE(evaluator.evaluate_boolean(not_returning_and_spanish, maria) == false); + } +} diff --git a/tests/md5_test.cpp b/tests/md5_test.cpp new file mode 100644 index 0000000..78bb41a --- /dev/null +++ b/tests/md5_test.cpp @@ -0,0 +1,66 @@ +#include +#include "absmartly/hashing.h" +#include +#include + +using namespace absmartly; + +TEST_CASE("md5_hex matches known hashes", "[md5]") { + REQUIRE(md5_hex("") == "d41d8cd98f00b204e9800998ecf8427e"); + REQUIRE(md5_hex("a") == "0cc175b9c0f1b6a831c399e269772661"); + REQUIRE(md5_hex("abc") == "900150983cd24fb0d6963f7d28e17f72"); + REQUIRE(md5_hex("message digest") == "f96b697d7cb7938d525a2f31aaf161d0"); + REQUIRE(md5_hex("abcdefghijklmnopqrstuvwxyz") == "c3fcd3d76192e4007dfb496cca67e13b"); + REQUIRE(md5_hex("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") == "d174ab98d277d9f5a5611c2c9f419d9f"); + REQUIRE(md5_hex("12345678901234567890123456789012345678901234567890123456789012345678901234567890") == "57edf4a22be3c955ac49da2e2107b67a"); +} + +TEST_CASE("md5_raw returns 16 bytes", "[md5]") { + auto raw = md5_raw(""); + REQUIRE(raw.size() == 16); + REQUIRE(raw[0] == 0xd4); + REQUIRE(raw[1] == 0x1d); + REQUIRE(raw[2] == 0x8c); + REQUIRE(raw[3] == 0xd9); +} + +TEST_CASE("md5 hash_unit base64url matches JS SDK", "[md5]") { + struct TestCase { + std::string input; + std::string expected; + }; + + std::vector cases = { + {"", "1B2M2Y8AsgTpgAmY7PhCfg"}, + {" ", "chXunH2dwinSkhpA6JnsXw"}, + {"t", "41jvpIn1gGLxDdcxa2Vkng"}, + {"te", "Vp73JkK-D63XEdakaNaO4Q"}, + {"tes", "KLZi2IO212_Zbk3cXpungA"}, + {"test", "CY9rzUYh03PK3k6DJie09g"}, + {"testy", "K5I_V6RgP8c6sYKz-TVn8g"}, + {"testy1", "8fT8xGipOhPkZ2DncKU-1A"}, + {"testy12", "YqRAtOz000gIu61ErEH18A"}, + {"testy123", "pfV2H07L6WvdqlY0zHuYIw"}, + }; + + for (const auto& tc : cases) { + CAPTURE(tc.input); + auto raw = md5_raw(tc.input); + auto encoded = base64url_no_padding(raw.data(), raw.size()); + REQUIRE(encoded == tc.expected); + } +} + +TEST_CASE("md5 with longer strings", "[md5]") { + auto raw = md5_raw("The quick brown fox jumps over the lazy dog"); + auto encoded = base64url_no_padding(raw.data(), raw.size()); + REQUIRE(encoded == "nhB9nTcrtoJr2B01QqQZ1g"); + + raw = md5_raw("The quick brown fox jumps over the lazy dog and eats a pie"); + encoded = base64url_no_padding(raw.data(), raw.size()); + REQUIRE(encoded == "iM-8ECRrLUQzixl436y96A"); + + raw = md5_raw("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."); + encoded = base64url_no_padding(raw.data(), raw.size()); + REQUIRE(encoded == "24m7XOq4f5wPzCqzbBicLA"); +} diff --git a/tests/murmur3_test.cpp b/tests/murmur3_test.cpp new file mode 100644 index 0000000..749e5a0 --- /dev/null +++ b/tests/murmur3_test.cpp @@ -0,0 +1,90 @@ +#include +#include "absmartly/hashing.h" +#include +#include +#include + +using namespace absmartly; + +static std::vector utf8_bytes(const std::string& str) { + return std::vector(str.begin(), str.end()); +} + +TEST_CASE("murmur3_32 matches known hashes with seed 0", "[murmur3]") { + struct TestCase { + std::string input; + uint32_t seed; + uint32_t expected; + }; + + std::vector cases = { + {"", 0x00000000, 0x00000000}, + {" ", 0x00000000, 0x7ef49b98}, + {"t", 0x00000000, 0xca87df4d}, + {"te", 0x00000000, 0xedb8ee1b}, + {"tes", 0x00000000, 0x0bb90e5a}, + {"test", 0x00000000, 0xba6bd213}, + {"testy", 0x00000000, 0x44af8342}, + {"testy1", 0x00000000, 0x8a1a243a}, + {"testy12", 0x00000000, 0x845461b9}, + {"testy123", 0x00000000, 0x47628ac4}, + {"The quick brown fox jumps over the lazy dog", 0x00000000, 0x2e4ff723}, + + {"", 0xdeadbeef, 0x0de5c6a9}, + {" ", 0xdeadbeef, 0x25acce43}, + {"t", 0xdeadbeef, 0x3b15dcf8}, + {"te", 0xdeadbeef, 0xac981332}, + {"tes", 0xdeadbeef, 0xc1c78dda}, + {"test", 0xdeadbeef, 0xaa22d41a}, + {"testy", 0xdeadbeef, 0x84f5f623}, + {"testy1", 0xdeadbeef, 0x09ed28e9}, + {"testy12", 0xdeadbeef, 0x22467835}, + {"testy123", 0xdeadbeef, 0xd633060d}, + {"The quick brown fox jumps over the lazy dog", 0xdeadbeef, 0x3a7b3f4d}, + + {"", 0x00000001, 0x514e28b7}, + {" ", 0x00000001, 0x4f0f7132}, + {"t", 0x00000001, 0x5db1831e}, + {"te", 0x00000001, 0xd248bb2e}, + {"tes", 0x00000001, 0xd432eb74}, + {"test", 0x00000001, 0x99c02ae2}, + {"testy", 0x00000001, 0xc5b2dc1e}, + {"testy1", 0x00000001, 0x33925ceb}, + {"testy12", 0x00000001, 0xd92c9f23}, + {"testy123", 0x00000001, 0x3bc1712d}, + {"The quick brown fox jumps over the lazy dog", 0x00000001, 0x78e69e27}, + }; + + for (const auto& tc : cases) { + auto bytes = utf8_bytes(tc.input); + CAPTURE(tc.input, tc.seed); + REQUIRE(murmur3_32(bytes.data(), bytes.size(), tc.seed) == tc.expected); + } +} + +TEST_CASE("murmur3_32 string overload matches buffer overload", "[murmur3]") { + REQUIRE(murmur3_32("test", 0) == 0xba6bd213); + REQUIRE(murmur3_32("test", 0xdeadbeef) == 0xaa22d41a); + REQUIRE(murmur3_32("test", 1) == 0x99c02ae2); +} + +TEST_CASE("murmur3_32 with UTF-8 multi-byte characters", "[murmur3]") { + std::vector special = { + 's', 'p', 'e', 'c', 'i', 'a', 'l', ' ', + 'c', 'h', 'a', 'r', 'a', 'c', 't', 'e', 'r', 's', ' ', + 0x61, 0xc3, 0xa7, 0x62, 0xe2, 0x86, 0x93, 0x63 + }; + REQUIRE(murmur3_32(special.data(), special.size(), 0) == 0xbe83b140); + REQUIRE(murmur3_32(special.data(), special.size(), 0xdeadbeef) == 0xf7fdd8a2); + REQUIRE(murmur3_32(special.data(), special.size(), 1) == 0x293327b5); +} + +TEST_CASE("murmur3_32 specific SDK test vectors", "[murmur3]") { + REQUIRE(murmur3_32("absmartly.com", 0) == 0x727245C3); + + std::vector bleh = { + 'b', 'l', 'e', 'h', '@', 'a', 'b', 's', + 'm', 'a', 'r', 't', 'l', 'y', '.', 'c', 'o', 'm' + }; + REQUIRE(murmur3_32(bleh.data(), bleh.size(), 0) == 0x3660C387); +} diff --git a/tests/operators_test.cpp b/tests/operators_test.cpp new file mode 100644 index 0000000..3cce286 --- /dev/null +++ b/tests/operators_test.cpp @@ -0,0 +1,478 @@ +#include + +#include "absmartly/json_expr/evaluator.h" +#include "absmartly/json_expr/operators.h" + +using namespace absmartly; +using json = nlohmann::json; + +TEST_CASE("ValueOperator", "[operators][value]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns args directly without evaluating") { + json expr = {{"value", 0}}; + REQUIRE(evaluator.evaluate(expr, vars) == 0); + + expr = {{"value", 1}}; + REQUIRE(evaluator.evaluate(expr, vars) == 1); + + expr = {{"value", true}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"value", false}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"value", ""}}; + REQUIRE(evaluator.evaluate(expr, vars) == ""); + + expr = {{"value", nullptr}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + + expr = {{"value", json::object()}}; + REQUIRE(evaluator.evaluate(expr, vars) == json::object()); + + expr = {{"value", json::array()}}; + REQUIRE(evaluator.evaluate(expr, vars) == json::array()); + } +} + +TEST_CASE("VarOperator", "[operators][var]") { + Evaluator evaluator; + + SECTION("extracts variable using path string") { + json vars = {{"a", 1}, {"b", json{{"c", 2}}}}; + json expr = {{"var", "a"}}; + REQUIRE(evaluator.evaluate(expr, vars) == 1); + + expr = {{"var", "b/c"}}; + REQUIRE(evaluator.evaluate(expr, vars) == 2); + } + + SECTION("extracts variable using path object") { + json vars = {{"a", json{{"b", json{{"c", "abc"}}}}}}; + json expr = {{"var", json{{"path", "a/b/c"}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == "abc"); + } + + SECTION("returns null for missing paths") { + json vars = {{"a", 1}}; + json expr = {{"var", "x"}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} + +TEST_CASE("AndOperator", "[operators][and]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns true if all arguments are truthy") { + json expr = {{"and", json::array({json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"and", json::array({json{{"value", true}}, json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"and", json::array({json{{"value", true}}, json{{"value", true}}, json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false if any argument is falsy") { + json expr = {{"and", json::array({json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"and", json::array({json{{"value", true}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"and", json::array({json{{"value", false}}, json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"and", json::array({json{{"value", false}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"and", json::array({json{{"value", false}}, json{{"value", false}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("returns false if any argument evaluates to null") { + json expr = {{"and", json::array({json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("empty array returns true") { + json expr = {{"and", json::array()}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } +} + +TEST_CASE("OrOperator", "[operators][or]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns true if any argument is truthy") { + json expr = {{"or", json::array({json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"or", json::array({json{{"value", true}}, json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"or", json::array({json{{"value", true}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"or", json::array({json{{"value", false}}, json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false if all arguments are falsy") { + json expr = {{"or", json::array({json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"or", json::array({json{{"value", false}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"or", json::array({json{{"value", false}}, json{{"value", false}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("returns false if argument is null") { + json expr = {{"or", json::array({json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("empty array returns true") { + json expr = {{"or", json::array()}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } +} + +TEST_CASE("NotOperator", "[operators][not]") { + Evaluator evaluator; + json vars = {}; + + SECTION("negates truthy to false") { + json expr = {{"not", json{{"value", true}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("negates falsy to true") { + json expr = {{"not", json{{"value", false}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("negates null to true") { + json expr = {{"not", json{{"value", nullptr}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } +} + +TEST_CASE("NullOperator", "[operators][null]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns true if argument is null") { + json expr = {{"null", json{{"value", nullptr}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false if argument is not null") { + json expr = {{"null", json{{"value", true}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"null", json{{"value", false}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"null", json{{"value", 0}}}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } +} + +TEST_CASE("EqOperator", "[operators][eq]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns true when arguments are equal") { + json expr = {{"eq", json::array({json{{"value", 0}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"eq", json::array({json{{"value", 1}}, json{{"value", 1}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"eq", json::array({json{{"value", "abc"}}, json{{"value", "abc"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"eq", json::array({json{{"value", true}}, json{{"value", true}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"eq", json::array({json{{"value", false}}, json{{"value", false}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false when arguments are not equal") { + json expr = {{"eq", json::array({json{{"value", 1}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"eq", json::array({json{{"value", 0}}, json{{"value", 1}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("null == null is true via compare") { + REQUIRE(Evaluator::compare(nullptr, nullptr).has_value()); + REQUIRE(Evaluator::compare(nullptr, nullptr).value() == 0); + } + + SECTION("equal arrays") { + json expr = {{"eq", json::array({ + json{{"value", json::array({1, 2})}}, + json{{"value", json::array({1, 2})}} + })}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("unequal arrays return null") { + json expr = {{"eq", json::array({ + json{{"value", json::array({1, 2})}}, + json{{"value", json::array({3, 4})}} + })}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } + + SECTION("equal objects") { + json expr = {{"eq", json::array({ + json{{"value", json{{"a", 1}, {"b", 2}}}}, + json{{"value", json{{"a", 1}, {"b", 2}}}} + })}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("unequal objects return null") { + json expr = {{"eq", json::array({ + json{{"value", json{{"a", 1}, {"b", 2}}}}, + json{{"value", json{{"a", 3}, {"b", 4}}}} + })}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} + +TEST_CASE("GtOperator", "[operators][gt]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns false when equal") { + json expr = {{"gt", json::array({json{{"value", 0}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("returns true when greater") { + json expr = {{"gt", json::array({json{{"value", 1}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false when less") { + json expr = {{"gt", json::array({json{{"value", 0}}, json{{"value", 1}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("null arguments return null") { + json expr = {{"gt", json::array({json{{"value", nullptr}}, json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} + +TEST_CASE("GteOperator", "[operators][gte]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns true when equal") { + json expr = {{"gte", json::array({json{{"value", 0}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns true when greater") { + json expr = {{"gte", json::array({json{{"value", 1}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false when less") { + json expr = {{"gte", json::array({json{{"value", 0}}, json{{"value", 1}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("null arguments return null") { + json expr = {{"gte", json::array({json{{"value", nullptr}}, json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} + +TEST_CASE("LtOperator", "[operators][lt]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns false when equal") { + json expr = {{"lt", json::array({json{{"value", 0}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("returns false when greater") { + json expr = {{"lt", json::array({json{{"value", 1}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("returns true when less") { + json expr = {{"lt", json::array({json{{"value", 0}}, json{{"value", 1}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("null arguments return null") { + json expr = {{"lt", json::array({json{{"value", nullptr}}, json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} + +TEST_CASE("LteOperator", "[operators][lte]") { + Evaluator evaluator; + json vars = {}; + + SECTION("returns true when equal") { + json expr = {{"lte", json::array({json{{"value", 0}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("returns false when greater") { + json expr = {{"lte", json::array({json{{"value", 1}}, json{{"value", 0}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("returns true when less") { + json expr = {{"lte", json::array({json{{"value", 0}}, json{{"value", 1}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("null arguments return null") { + json expr = {{"lte", json::array({json{{"value", nullptr}}, json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} + +TEST_CASE("InOperator", "[operators][in]") { + Evaluator evaluator; + json vars = {}; + + SECTION("string containment") { + json expr = {{"in", json::array({json{{"value", "abc"}}, json{{"value", "abcdefghijk"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"in", json::array({json{{"value", "def"}}, json{{"value", "abcdefghijk"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"in", json::array({json{{"value", "xxx"}}, json{{"value", "abcdefghijk"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("null needle or haystack returns null") { + json expr = {{"in", json::array({json{{"value", nullptr}}, json{{"value", "abcdefghijk"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + + expr = {{"in", json::array({json{{"value", "abc"}}, json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } + + SECTION("empty array returns false") { + json expr = {{"in", json::array({json{{"value", 1}}, json{{"value", json::array()}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", "1"}}, json{{"value", json::array()}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", true}}, json{{"value", json::array()}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", false}}, json{{"value", json::array()}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("null needle with array returns null") { + json expr = {{"in", json::array({json{{"value", nullptr}}, json{{"value", json::array()}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } + + SECTION("array element search") { + json haystack01 = json::array({0, 1}); + json haystack12 = json::array({1, 2}); + + json expr = {{"in", json::array({json{{"value", 2}}, json{{"value", haystack01}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", 0}}, json{{"value", haystack12}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", 1}}, json{{"value", haystack12}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"in", json::array({json{{"value", 2}}, json{{"value", haystack12}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } + + SECTION("object key search") { + json haystackab = json{{"a", 1}, {"b", 2}}; + json haystackbc = json{{"b", 2}, {"c", 3}, {"0", 100}}; + + json expr = {{"in", json::array({json{{"value", "c"}}, json{{"value", haystackab}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", "a"}}, json{{"value", haystackbc}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + + expr = {{"in", json::array({json{{"value", "b"}}, json{{"value", haystackbc}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"in", json::array({json{{"value", "c"}}, json{{"value", haystackbc}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"in", json::array({json{{"value", 0}}, json{{"value", haystackbc}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + } +} + +TEST_CASE("MatchOperator", "[operators][match]") { + Evaluator evaluator; + json vars = {}; + + SECTION("regex matching") { + json expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", ""}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "abc"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "ijk"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "^abc"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "ijk$"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "def"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "b.*j"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == true); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", "xyz"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars) == false); + } + + SECTION("null arguments return null") { + json expr = {{"match", json::array({json{{"value", nullptr}}, json{{"value", "abc"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + + expr = {{"match", json::array({json{{"value", "abcdefghijk"}}, json{{"value", nullptr}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } + + SECTION("invalid regex returns null") { + json expr = {{"match", json::array({json{{"value", "abc"}}, json{{"value", "[invalid"}}})}}; + REQUIRE(evaluator.evaluate(expr, vars).is_null()); + } +} diff --git a/tests/utils_test.cpp b/tests/utils_test.cpp new file mode 100644 index 0000000..9be92c8 --- /dev/null +++ b/tests/utils_test.cpp @@ -0,0 +1,81 @@ +#include +#include "absmartly/hashing.h" +#include +#include + +using namespace absmartly; + +TEST_CASE("hash_unit matches JS SDK known hashes", "[utils]") { + REQUIRE(hash_unit("4a42766ca6313d26f49985e799ff4f3790fb86efa0fce46edb3ea8fbf1ea3408") == "H2jvj6o9YcAgNdhKqEbtWw"); + REQUIRE(hash_unit("bleh@absmarty.com") == "DRgslOje35bZMmpaohQjkA"); + REQUIRE(hash_unit("testy") == "K5I_V6RgP8c6sYKz-TVn8g"); +} + +TEST_CASE("hash_unit with numeric string", "[utils]") { + REQUIRE(hash_unit("123456778999") == "K4uy4bTeCy34W97lmceVRg"); +} + +TEST_CASE("choose_variant with [0.0, 1.0] split", "[utils]") { + REQUIRE(choose_variant({0.0, 1.0}, 0.0) == 1); + REQUIRE(choose_variant({0.0, 1.0}, 0.5) == 1); + REQUIRE(choose_variant({0.0, 1.0}, 1.0) == 1); +} + +TEST_CASE("choose_variant with [1.0, 0.0] split", "[utils]") { + REQUIRE(choose_variant({1.0, 0.0}, 0.0) == 0); + REQUIRE(choose_variant({1.0, 0.0}, 0.5) == 0); + REQUIRE(choose_variant({1.0, 0.0}, 1.0) == 1); +} + +TEST_CASE("choose_variant with [0.5, 0.5] split", "[utils]") { + REQUIRE(choose_variant({0.5, 0.5}, 0.0) == 0); + REQUIRE(choose_variant({0.5, 0.5}, 0.25) == 0); + REQUIRE(choose_variant({0.5, 0.5}, 0.49999999) == 0); + REQUIRE(choose_variant({0.5, 0.5}, 0.5) == 1); + REQUIRE(choose_variant({0.5, 0.5}, 0.50000001) == 1); + REQUIRE(choose_variant({0.5, 0.5}, 0.75) == 1); + REQUIRE(choose_variant({0.5, 0.5}, 1.0) == 1); +} + +TEST_CASE("choose_variant with [0.333, 0.333, 0.334] split", "[utils]") { + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.0) == 0); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.25) == 0); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.33299999) == 0); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.333) == 1); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.33300001) == 1); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.5) == 1); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.66599999) == 1); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.666) == 2); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.66600001) == 2); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 0.75) == 2); + REQUIRE(choose_variant({0.333, 0.333, 0.334}, 1.0) == 2); +} + +TEST_CASE("base64url_no_padding matches known encodings", "[utils]") { + struct TestCase { + std::string input; + std::string expected; + }; + + std::vector cases = { + {"", ""}, + {" ", "IA"}, + {"t", "dA"}, + {"te", "dGU"}, + {"tes", "dGVz"}, + {"test", "dGVzdA"}, + {"testy", "dGVzdHk"}, + {"testy1", "dGVzdHkx"}, + {"testy12", "dGVzdHkxMg"}, + {"testy123", "dGVzdHkxMjM"}, + }; + + for (const auto& tc : cases) { + CAPTURE(tc.input); + auto encoded = base64url_no_padding( + reinterpret_cast(tc.input.data()), + tc.input.size() + ); + REQUIRE(encoded == tc.expected); + } +} diff --git a/tests/variant_assigner_test.cpp b/tests/variant_assigner_test.cpp new file mode 100644 index 0000000..32aec42 --- /dev/null +++ b/tests/variant_assigner_test.cpp @@ -0,0 +1,79 @@ +#include +#include "absmartly/variant_assigner.h" +#include "absmartly/hashing.h" +#include +#include + +using namespace absmartly; + +TEST_CASE("VariantAssigner with bleh@absmartly.com", "[assigner]") { + VariantAssigner assigner(hash_unit("bleh@absmartly.com")); + + SECTION("50/50 split") { + REQUIRE(assigner.assign({0.5, 0.5}, 0x00000000, 0x00000000) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x00000000, 0x00000001) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x8015406f, 0x7ef49b98) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x3b2e7d90, 0xca87df4d) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x52c1f657, 0xd248bb2e) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x865a84d0, 0xaa22d41a) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x27d1dc86, 0x845461b9) == 1); + } + + SECTION("33/33/34 split") { + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x00000000, 0x00000000) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x00000000, 0x00000001) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x8015406f, 0x7ef49b98) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x3b2e7d90, 0xca87df4d) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x52c1f657, 0xd248bb2e) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x865a84d0, 0xaa22d41a) == 1); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x27d1dc86, 0x845461b9) == 1); + } +} + +TEST_CASE("VariantAssigner with 123456789", "[assigner]") { + VariantAssigner assigner(hash_unit("123456789")); + + SECTION("50/50 split") { + REQUIRE(assigner.assign({0.5, 0.5}, 0x00000000, 0x00000000) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x00000000, 0x00000001) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x8015406f, 0x7ef49b98) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x3b2e7d90, 0xca87df4d) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x52c1f657, 0xd248bb2e) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x865a84d0, 0xaa22d41a) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x27d1dc86, 0x845461b9) == 0); + } + + SECTION("33/33/34 split") { + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x00000000, 0x00000000) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x00000000, 0x00000001) == 1); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x8015406f, 0x7ef49b98) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x3b2e7d90, 0xca87df4d) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x52c1f657, 0xd248bb2e) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x865a84d0, 0xaa22d41a) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x27d1dc86, 0x845461b9) == 0); + } +} + +TEST_CASE("VariantAssigner with e791e240fcd3df7d238cfc285f475e8152fcc0ec", "[assigner]") { + VariantAssigner assigner(hash_unit("e791e240fcd3df7d238cfc285f475e8152fcc0ec")); + + SECTION("50/50 split") { + REQUIRE(assigner.assign({0.5, 0.5}, 0x00000000, 0x00000000) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x00000000, 0x00000001) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x8015406f, 0x7ef49b98) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x3b2e7d90, 0xca87df4d) == 1); + REQUIRE(assigner.assign({0.5, 0.5}, 0x52c1f657, 0xd248bb2e) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x865a84d0, 0xaa22d41a) == 0); + REQUIRE(assigner.assign({0.5, 0.5}, 0x27d1dc86, 0x845461b9) == 0); + } + + SECTION("33/33/34 split") { + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x00000000, 0x00000000) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x00000000, 0x00000001) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x8015406f, 0x7ef49b98) == 2); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x3b2e7d90, 0xca87df4d) == 1); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x52c1f657, 0xd248bb2e) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x865a84d0, 0xaa22d41a) == 0); + REQUIRE(assigner.assign({0.33, 0.33, 0.34}, 0x27d1dc86, 0x845461b9) == 1); + } +}