diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e6237d..e9b52de 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,11 +12,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Java 8 + - name: Setup Java 11 uses: actions/setup-java@v3 with: distribution: "adopt" - java-version: "8" + java-version: "11" cache: "gradle" - name: Build Java SDK diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 787ceb6..3611d93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,11 +11,11 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Java 8 + - name: Setup Java 11 uses: actions/setup-java@v3 with: distribution: "adopt" - java-version: "8" + java-version: "11" cache: "gradle" - name: Publish JavaSDK diff --git a/.gitignore b/.gitignore index 17d12f5..310f2ec 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.log build/ out/ +.claude/ diff --git a/README.md b/README.md index dc2d045..4696568 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ To install the ABSmartly SDK, place the following in your `pom.xml` and replace When targeting Android 6.0 or earlier, the default Java Security Provider will not work. Using [Conscrypt](https://github.com/google/conscrypt) is recommended. Follow these [instructions](https://github.com/google/conscrypt/blob/master/README.md) to install it as dependency. #### Proguard rules -ProGuard is a command-line tool that reduces app size by shrinking bytecode and obfuscates the names of classes, fields and methods. -It’s an ideal fit for developers working with Java or Kotlin who are primarily interested in an Android optimizer. +ProGuard is a command-line tool that reduces app size by shrinking bytecode and obfuscates the names of classes, fields and methods. +It's an ideal fit for developers working with Java or Kotlin who are primarily interested in an Android optimizer. If you are using [Proguard](https://github.com/Guardsquare/proguard), you will need to add the following rule to your Proguard configuration file. This prevent proguard to change data classes used by the SDK and the missing of this rule will result in problems in the serialization/deserialization of the data. ```proguard @@ -59,32 +59,62 @@ This prevent proguard to change data classes used by the SDK and the missing of Please follow the [installation](#installation) instructions before trying the following code: -#### Initialization +### Initialization + This example assumes an Api Key, an Application, and an Environment have been created in the A/B Smartly web console. + +#### Quickstart + ```java import com.absmartly.sdk.*; -public class Example { - static public void main(String[] args) { +final ABSmartly sdk = ABSmartly.builder() + .endpoint("https://your-company.absmartly.io/v1") + .apiKey(System.getenv("ABSMARTLY_APIKEY")) + .application("website") + .environment("production") + .build(); +``` - final ClientConfig clientConfig = ClientConfig.create() - .setEndpoint("https://your-company.absmartly.io/v1") - .setAPIKey("YOUR-API-KEY") - .setApplication("website") // created in the ABSmartly web console - .setEnvironment("development"); // created in the ABSmartly web console +The builder pattern lets you configure the SDK with named parameters, removing the need to configure `ClientConfig` and `ABSmartlyConfig` manually. - final Client absmartlyClient = Client.create(clientConfig); +#### Alternative: Using Configuration Objects - final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() - .setClient(absmartlyClient); +For use cases where you need full control over the Client and configuration: +```java +final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://your-company.absmartly.io/v1") + .setAPIKey("YOUR-API-KEY") + .setApplication("website") + .setEnvironment("development"); - final ABSmartly sdk = ABSmartly.create(sdkConfig); - // ... - } -} +final Client absmartlyClient = Client.create(clientConfig); + +final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + .setClient(absmartlyClient); + +final ABSmartly sdk = ABSmartly.create(sdkConfig); ``` +**SDK Options** + +| Config | Type | Required? | Default | Description | +| :---------------------- | :-------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `String` | ✅ | `null` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` | +| apiKey | `String` | ✅ | `null` | Your API key which can be found on the Web Console. | +| environment | `String` | ✅ | `null` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| application | `String` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| timeout | `int` | ❌ | `3000` | HTTP connection timeout in milliseconds | +| retries | `int` | ❌ | `5` | Maximum number of retry attempts for failed HTTP requests | +| contextEventLogger | `ContextEventLogger` | ❌ | `null` | Callback to handle SDK events (ready, exposure, goal, etc.) | +| contextDataProvider | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) | +| contextEventHandler | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) | +| variableParser | `VariableParser` | ❌ | auto | Custom parser for experiment variables (advanced usage) | +| audienceDeserializer | `AudienceDeserializer` | ❌ | auto | Custom deserializer for audience data (advanced usage) | +| scheduler | `ScheduledExecutorService` | ❌ | auto | Custom scheduler for context refresh (advanced usage) | +| httpClient | `HTTPClient` | ❌ | auto | Custom HTTP client implementation (advanced usage) | + #### Android 6.0 or earlier When targeting Android 6.0 or earlier, set the default Java Security Provider for SSL to *Conscrypt* by creating the *Client* instance as follows: @@ -96,8 +126,8 @@ import org.conscrypt.Conscrypt; final ClientConfig clientConfig = ClientConfig.create() .setEndpoint("https://your-company.absmartly.io/v1") .setAPIKey("YOUR-API-KEY") - .setApplication("website") // created in the ABSmartly web console - .setEnvironment("development"); // created in the ABSmartly web console + .setApplication("website") + .setEnvironment("development"); final DefaultHTTPClientConfig httpClientConfig = DefaultHTTPClientConfig.create() .setSecurityProvider(Conscrypt.newProvider()); @@ -113,46 +143,69 @@ import org.conscrypt.Conscrypt; // ... ``` -#### Creating a new Context synchronously +## Creating a New Context + +### Synchronously + ```java -// define a new context request - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the user +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); - final Context context = sdk.createContext(contextConfig) - .waitUntilReady(); +final Context context = sdk.createContext(contextConfig) + .waitUntilReady(); ``` -#### Creating a new Context asynchronously +### Asynchronously + ```java -// define a new context request - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the user +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); - final Context context = sdk.createContext(contextConfig) - .waitUntilReadyAsync() - .thenAccept(ctx -> System.out.printf("context ready!")); +final Context context = sdk.createContext(contextConfig) + .waitUntilReadyAsync() + .thenAccept(ctx -> System.out.printf("context ready!")); ``` -#### Creating a new Context with pre-fetched data +### With Pre-fetched Data + Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by re-using data previously retrieved. ```java - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the user +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); + +final Context context = sdk.createContext(contextConfig) + .waitUntilReady(); + +final ContextConfig anotherContextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); + +final Context anotherContext = sdk.createContextWith(anotherContextConfig, context.getData()); +assert(anotherContext.isReady()); // no need to wait +``` - final Context context = sdk.createContext(contextConfig) - .waitUntilReady(); +### Refreshing the Context with Fresh Experiment Data + +For long-running contexts, the context is usually created once when the application is first started. +However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. +To mitigate this, we can use the `setRefreshInterval()` method on the context config. + +```java +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") + .setRefreshInterval(TimeUnit.HOURS.toMillis(4)); // every 4 hours +``` - final ContextConfig anotherContextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"); // a unique id identifying the other user +Alternatively, the `refresh()` method can be called manually. +The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `getTreatment()` is called again. - final Context anotherContext = sdk.createContextWith(anotherContextConfig, context.getData()); - assert(anotherContext.isReady()); // no need to wait +```java +context.refresh(); ``` -#### Setting extra units for a context +### Setting Extra Units + You can add additional units to a context by calling the `setUnit()` or the `setUnits()` method. This method may be used for example, when a user logs in to your application, and you want to use the new unit type to the context. Please 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. @@ -162,162 +215,545 @@ The `setUnit()` and `setUnits()` methods can be called before the context is rea context.setUnit("db_user_id", "1000013"); context.setUnits(Map.of( - "db_user_id", "1000013" - )); + "db_user_id", "1000013" +)); ``` -#### Setting context attributes -The `setAttribute()` and `setAttributes()` methods can be called before the context is ready. +## Basic Usage + +### Selecting a Treatment + +```java +if (context.getTreatment("exp_test_experiment") == 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +### Treatment Variables + ```java - context.setAttribute('user_agent', req.getHeader("User-Agent")); +final Object variable = context.getVariable("my_variable"); +``` + +### Peek at Treatment Variants - context.setAttributes(Map.of( - "customer_age", "new_customer" - )); +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 a `peekTreatment()` method for that. + +```java +if (context.peekTreatment("exp_test_experiment") == 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} ``` -#### Selecting a treatment +#### Peeking at Variables + ```java - if (context.getTreament("exp_test_experiment") == 0) { - // user is in control group (variant 0) - } else { - // user is in treatment group - } +final Object variable = context.peekVariable("my_variable"); ``` -#### Selecting a treatment variable +### Overriding Treatment Variants + +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `setOverride()` and/or `setOverrides()` methods. +The `setOverride()` and `setOverrides()` methods can be called before the context is ready. + ```java - final Object variable = context.getVariable("my_variable"); +context.setOverride("exp_test_experiment", 1); +context.setOverrides(Map.of( + "exp_test_experiment", 1, + "exp_another_experiment", 0 +)); ``` -#### Tracking a goal achievement +## Advanced + +### Context Attributes + +The `setAttribute()` and `setAttributes()` methods can be called before the context is ready. + +```java +context.setAttribute("user_agent", req.getHeader("User-Agent")); + +context.setAttributes(Map.of( + "customer_age", "new_customer" +)); +``` + +### Tracking Goals + Goals are created in the A/B Smartly web console. + ```java - context.track("payment", Map.of( - "item_count", 1, - "total_amount", 1999.99 - )); +context.track("payment", Map.of( + "item_count", 1, + "total_amount", 1999.99 +)); ``` -#### Publishing pending data +### 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()` or `publishAsync()` methods. + ```java - context.publish(); +context.publish(); ``` -#### Finalizing +### Finalizing + The `close()` and `closeAsync()` methods will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. + ```java - context.close(); +context.close(); ``` -#### Refreshing the context with fresh experiment data -For long-running contexts, the context is usually created once when the application is first started. -However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. -To mitigate this, we can use the `setRefreshInterval()` method on the context config. +### Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. +In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. ```java - final ContextConfig contextConfig = ContextConfig.create() - .setUnit("session_id", "5ebf06d8cb5d8137290c4abb64155584fbdb64d8") - .setRefreshInterval(TimeUnit.HOURS.toMillis(4)); // every 4 hours +public class CustomEventLogger implements ContextEventLogger { + @Override + public void handleEvent(Context context, ContextEventLogger.EventType event, Object data) { + switch (event) { + case Exposure: + final Exposure exposure = (Exposure)data; + System.out.printf("exposed to experiment %s", exposure.name); + break; + case Goal: + final GoalAchievement goal = (GoalAchievement)data; + System.out.printf("goal tracked: %s", goal.name); + break; + case Error: + System.out.printf("error: %s", data); + break; + case Publish: + case Ready: + case Refresh: + case Close: + break; + } + } +} ``` -Alternatively, the `refresh()` method can be called manually. -The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `getTreatment()` is called again. +Usage: + ```java - context.refresh(); +// For all contexts, during SDK initialization +final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create(); +sdkConfig.setContextEventLogger(new CustomEventLogger()); + +// OR, alternatively, during a particular context initialization +final ContextConfig contextConfig = ContextConfig.create(); +contextConfig.setEventLogger(new CustomEventLogger()); ``` -#### Using a custom Event Logger -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. -In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. +**Event Types** + +| Event | When | Data | +| ---------- | ---------------------------------------------------------- | -------------------------------------- | +| `Error` | `Context` receives an error | `Throwable` object | +| `Ready` | `Context` turns ready | `ContextData` used to initialize | +| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh | +| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to collector | +| `Exposure` | `Context.getTreatment()` succeeds on first exposure | `Exposure` enqueued for publishing | +| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | +| `Close` | `Context.close()` method succeeds the first time | `null` | + +## Platform-Specific Examples + +### Using with Spring Boot + ```java - // example implementation - public class CustomEventLogger implements ContextEventLogger { - @Override - public void handleEvent(Context context, ContextEventLogger.EventType event, Object data) { - switch (event) { - case Exposure: - final Exposure exposure = (Exposure)data; - System.out.printf("exposed to experiment %s", exposure.name); - break; - case Goal: - final GoalAchievement goal = (GoalAchievement)data; - System.out.printf("goal tracked: %s", goal.name); - break; - case Error: - System.out.printf("error: %s", data); - break; - case Publish: - case Ready: - case Refresh: - case Close: - break; +// Application.java +import com.absmartly.sdk.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.beans.factory.annotation.Value; + +@SpringBootApplication +public class Application { + + @Value("${absmartly.endpoint}") + private String endpoint; + + @Value("${absmartly.apiKey}") + private String apiKey; + + @Value("${absmartly.application}") + private String application; + + @Value("${absmartly.environment}") + private String environment; + + @Bean + public ABSmartly absmartly() { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(endpoint) + .setAPIKey(apiKey) + .setApplication(application) + .setEnvironment(environment); + + final Client client = Client.create(clientConfig); + + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + .setClient(client); + + return ABSmartly.create(sdkConfig); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} + +// application.properties +absmartly.endpoint=https://your-company.absmartly.io/v1 +absmartly.apiKey=YOUR-API-KEY +absmartly.application=website +absmartly.environment=production + +// ProductController.java +import com.absmartly.sdk.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.ModelAndView; +import jakarta.servlet.http.HttpSession; + +@Controller +public class ProductController { + + @Autowired + private ABSmartly absmartly; + + @GetMapping("/product") + public ModelAndView showProduct(HttpSession session) { + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", session.getId()); + + final ModelAndView mav = new ModelAndView(); + + try (Context context = absmartly.createContext(contextConfig) + .waitUntilReady()) { + final int treatment = context.getTreatment("exp_product_layout"); + + if (treatment == 0) { + mav.setViewName("product_control"); + } else { + mav.setViewName("product_treatment"); } } + + return mav; } +} ``` +### Using with Jakarta EE / JAX-RS + ```java - // for all contexts, during sdk initialization - final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create(); - sdkConfig.setContextEventLogger(new CustomEventLogger()); - - // OR, alternatively, during a particular context initialization - final ContextConfig contextConfig = ContextConfig.create(); - contextConfig.setEventLogger(new CustomEventLogger()); -``` +// ABSmartlyProducer.java +import com.absmartly.sdk.*; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; -The data parameter depends on the type of event. -Currently, the SDK logs the following events: +@ApplicationScoped +public class ABSmartlyProducer { -| event | when | data | -|:---: |------------------------------------------------------------|---| -| `Error` | `Context` receives an error | `Throwable` object | -| `Ready` | `Context` turns ready | `ContextData` used to initialize the context | -| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh the context | -| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to the A/B Smartly event collector | -| `Exposure` | `Context.getTreatment()` method succeeds on first exposure | `Exposure` enqueued for publishing | -| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | -| `Close` | `Context.close()` method succeeds the first time | `null` | + @Produces + @ApplicationScoped + public ABSmartly produceABSmartly() { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(System.getenv("ABSMARTLY_ENDPOINT")) + .setAPIKey(System.getenv("ABSMARTLY_API_KEY")) + .setApplication("website") + .setEnvironment(System.getenv("ENV")); + final Client client = Client.create(clientConfig); -#### 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 a `peekTreatment()` method for that. + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + .setClient(client); + + return ABSmartly.create(sdkConfig); + } +} + +// ProductResource.java +import com.absmartly.sdk.*; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import jakarta.servlet.http.HttpServletRequest; + +@Path("/product") +public class ProductResource { + + @Inject + private ABSmartly absmartly; + + @Inject + private HttpServletRequest request; + + @GET + public Response getProduct() { + final String sessionId = request.getSession().getId(); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", sessionId); + + try (Context context = absmartly.createContext(contextConfig) + .waitUntilReady()) { + final int treatment = context.getTreatment("exp_product_layout"); + + return Response.ok() + .entity(Map.of("treatment", treatment)) + .build(); + } + } +} +``` + +### Using with Android Activities ```java - if (context.peekTreatment("exp_test_experiment") == 0) { - // user is in control group (variant 0) - } else { - // user is in treatment group +// MainActivity.java +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import com.absmartly.sdk.*; +import java.util.UUID; + +public class MainActivity extends AppCompatActivity { + + private static ABSmartly sdk; + private Context absmartlyContext; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Initialize SDK once (typically in Application class) + if (sdk == null) { + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://your-company.absmartly.io/v1") + .setAPIKey("YOUR-API-KEY") + .setApplication("android-app") + .setEnvironment("production"); + + final Client client = Client.create(clientConfig); + + final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + .setClient(client); + + sdk = ABSmartly.create(sdkConfig); + } + + // Create context for this user + String deviceId = getDeviceId(); // Get from SharedPreferences + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("device_id", deviceId); + + final Context contextInstance = sdk.createContext(contextConfig); + absmartlyContext = contextInstance; + + contextInstance.waitUntilReadyAsync() + .thenAccept(ctx -> { + runOnUiThread(() -> setupUI(ctx)); + }) + .exceptionally(throwable -> { + runOnUiThread(() -> setupUIWithDefault()); + return null; + }); } + + private void setupUI(Context context) { + int treatment = context.getTreatment("exp_button_color"); + + if (treatment == 0) { + setContentView(R.layout.activity_main_control); + } else { + setContentView(R.layout.activity_main_treatment); + } + } + + private void setupUIWithDefault() { + setContentView(R.layout.activity_main_control); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (absmartlyContext != null) { + absmartlyContext.close(); + } + } + + private String getDeviceId() { + // IMPORTANT: Device ID must be persisted across app sessions in SharedPreferences + // to ensure consistent experiment assignments for the same user/device. + // This example uses a random UUID for demonstration purposes only. + return UUID.randomUUID().toString(); + } +} ``` -##### Peeking at variables +## Advanced Request Configuration + +### HTTP Request Timeout Override + +Configure timeout for individual requests using DefaultHTTPClientConfig: + ```java - final Object variable = context.peekVariable("my_variable"); +import com.absmartly.sdk.*; + +// Create HTTP client with custom timeout +final DefaultHTTPClientConfig httpClientConfig = DefaultHTTPClientConfig.create() + .setConnectTimeout(1500) // 1.5 seconds + .setConnectionRequestTimeout(1500); + +final DefaultHTTPClient httpClient = DefaultHTTPClient.create(httpClientConfig); + +final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint("https://your-company.absmartly.io/v1") + .setAPIKey("YOUR-API-KEY") + .setApplication("website") + .setEnvironment("development"); + +final Client client = Client.create(clientConfig, httpClient); + +final ABSmartlyConfig sdkConfig = ABSmartlyConfig.create() + .setClient(client); + +final ABSmartly sdk = ABSmartly.create(sdkConfig); + +final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "abc123"); + +final Context context = sdk.createContext(contextConfig) + .waitUntilReady(); ``` -#### Overriding treatment variants -During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. -The `setOverride()` and `setOverrides()` methods can be called before the context is ready. +### Request Cancellation with CompletableFuture + +Cancel inflight requests when user navigates away: + ```java - context.setOverride("exp_test_experiment", 1); // force variant 1 of treatment - context.setOverrides(Map.of( - "exp_test_experiment", 1, - "exp_another_experiment", 0 - )); +import com.absmartly.sdk.*; +import java8.util.concurrent.CompletableFuture; +import java.util.concurrent.*; + +public class CancellableContextExample { + + public static void main(String[] args) throws Exception { + final ABSmartly sdk = ABSmartly.builder() + .endpoint("https://your-company.absmartly.io/v1") + .apiKey("YOUR-API-KEY") + .application("website") + .environment("development") + .build(); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("session_id", "abc123"); + + final Context context = sdk.createContext(contextConfig); + + // Create future for context initialization + final CompletableFuture future = context.waitUntilReadyAsync(); + + // Cancel after 1.5 seconds if not ready + final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + scheduler.schedule(() -> { + if (!future.isDone()) { + future.cancel(true); + System.out.println("Context creation cancelled"); + } + }, 1500, TimeUnit.MILLISECONDS); + + try { + final Context readyContext = future.get(); + System.out.println("Context ready!"); + readyContext.close(); + } catch (CancellationException e) { + System.out.println("Context creation was cancelled"); + } catch (ExecutionException e) { + System.out.println("Context creation failed: " + e.getCause()); + } finally { + scheduler.shutdown(); + } + } +} +``` + +### Android Activity Lifecycle Cancellation + +```java +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import com.absmartly.sdk.*; +import java8.util.concurrent.CompletableFuture; + +public class MainActivity extends AppCompatActivity { + + private static ABSmartly sdk; // Initialize SDK once (typically in Application class) + private CompletableFuture contextFuture; + private Context absmartlyContext; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("device_id", getDeviceId()); + + absmartlyContext = sdk.createContext(contextConfig); + contextFuture = absmartlyContext.waitUntilReadyAsync(); + + contextFuture.thenAccept(ctx -> { + runOnUiThread(() -> setupUI(ctx)); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // Cancel ongoing context creation if activity is destroyed + if (contextFuture != null && !contextFuture.isDone()) { + contextFuture.cancel(true); + } + + if (absmartlyContext != null) { + absmartlyContext.close(); + } + } +} ``` ## 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) +- [Java SDK](https://www.github.com/absmartly/java-sdk) (this package) - [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) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) diff --git a/build.gradle b/build.gradle index 6af3564..746a4d1 100644 --- a/build.gradle +++ b/build.gradle @@ -2,13 +2,12 @@ plugins { id "java" id "groovy" id "jacoco" - id "findbugs" - id "com.diffplug.spotless" version "5.8.2" - id "com.adarshr.test-logger" version "2.1.1" - id "org.barfuin.gradle.jacocolog" version "1.2.3" - id "io.github.gradle-nexus.publish-plugin" version "1.1.0" - id "org.owasp.dependencycheck" version "7.3.0" + id "com.diffplug.spotless" version "6.25.0" + id "com.adarshr.test-logger" version "4.0.0" + id "org.barfuin.gradle.jacocolog" version "3.1.0" + id "io.github.gradle-nexus.publish-plugin" version "1.3.0" + id "ru.vyarus.animalsniffer" version "1.7.1" apply false } @@ -21,8 +20,8 @@ ext { jacksonVersion = "2.13.4.2" jacksonDataTypeVersion = "2.13.4" - junitVersion = "5.7.0" - mockitoVersion = "3.6.28" + junitVersion = "5.10.2" + mockitoVersion = "5.11.0" } @@ -30,13 +29,12 @@ allprojects { group = GROUP_ID apply plugin: "java" - apply plugin: "org.owasp.dependencycheck" apply from: rootProject.file("gradle/repositories.gradle") apply from: rootProject.file("gradle/spotless.gradle") - apply from: rootProject.file("gradle/findbugs.gradle") apply from: rootProject.file("gradle/test-logger.gradle") apply from: rootProject.file("gradle/jacoco.gradle") apply from: rootProject.file("gradle/coverage-logger.gradle") + apply from: rootProject.file("gradle/compatibility.gradle") compileJava { sourceCompatibility = "1.6" diff --git a/core-api/build.gradle b/core-api/build.gradle index ca14315..c2ca4cd 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -26,18 +26,21 @@ dependencies { testImplementation group: "org.junit.jupiter", name: "junit-jupiter-params", version: junitVersion testRuntimeOnly group: "org.junit.jupiter", name: "junit-jupiter-engine", version: junitVersion testImplementation group: "org.mockito", name: "mockito-core", version: mockitoVersion - testImplementation group: "org.mockito", name: "mockito-inline", version: mockitoVersion testImplementation group: "org.mockito", name: "mockito-junit-jupiter", version: mockitoVersion } check.dependsOn jacocoTestCoverageVerification +def jacocoExcludes = [ + "com/absmartly/sdk/json/**/*", + "com/absmartly/sdk/deprecated/**/*", + "com/absmartly/sdk/java/**/*", +] + jacocoTestReport { afterEvaluate { getClassDirectories().setFrom(classDirectories.files.collect { - fileTree(dir: it, exclude: [ - "com/absmartly/core-api/json/**/*" - ]) + fileTree(dir: it, exclude: jacocoExcludes) }) } } @@ -67,9 +70,7 @@ jacocoTestCoverageVerification { afterEvaluate { getClassDirectories().setFrom(classDirectories.files.collect { - fileTree(dir: it, exclude: [ - "com/absmartly/core-api/json/**/*" - ]) + fileTree(dir: it, exclude: jacocoExcludes) }) } } @@ -77,6 +78,10 @@ jacocoTestCoverageVerification { test { useJUnitPlatform() + jvmArgs '--add-opens', 'java.base/java.lang=ALL-UNNAMED', + '--add-opens', 'java.base/java.lang.reflect=ALL-UNNAMED', + '--add-opens', 'java.base/java.util=ALL-UNNAMED', + '--add-opens', 'java.base/java.util.concurrent=ALL-UNNAMED' } publishToSonatype.dependsOn check diff --git a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java index 5f70c63..1a19001 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java +++ b/core-api/src/main/java/com/absmartly/sdk/ABSmartly.java @@ -17,6 +17,71 @@ public static ABSmartly create(@Nonnull ABSmartlyConfig config) { return new ABSmartly(config); } + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String endpoint; + private String apiKey; + private String application; + private String environment; + private ContextEventLogger eventLogger; + + Builder() {} + + public Builder endpoint(@Nonnull String endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder apiKey(@Nonnull String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder application(@Nonnull String application) { + this.application = application; + return this; + } + + public Builder environment(@Nonnull String environment) { + this.environment = environment; + return this; + } + + public Builder eventLogger(@Nonnull ContextEventLogger eventLogger) { + this.eventLogger = eventLogger; + return this; + } + + public ABSmartly build() { + if (endpoint == null) + throw new IllegalArgumentException("endpoint is required"); + if (apiKey == null) + throw new IllegalArgumentException("apiKey is required"); + if (application == null) + throw new IllegalArgumentException("application is required"); + if (environment == null) + throw new IllegalArgumentException("environment is required"); + + final ClientConfig clientConfig = ClientConfig.create() + .setEndpoint(endpoint) + .setAPIKey(apiKey) + .setApplication(application) + .setEnvironment(environment); + + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(Client.create(clientConfig)); + + if (eventLogger != null) { + config.setContextEventLogger(eventLogger); + } + + return create(config); + } + } + private ABSmartly(@Nonnull ABSmartlyConfig config) { contextDataProvider_ = config.getContextDataProvider(); contextEventHandler_ = config.getContextEventHandler(); @@ -24,6 +89,7 @@ private ABSmartly(@Nonnull ABSmartlyConfig config) { variableParser_ = config.getVariableParser(); audienceDeserializer_ = config.getAudienceDeserializer(); scheduler_ = config.getScheduler(); + ownsScheduler_ = scheduler_ == null; if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) { client_ = config.getClient(); @@ -54,36 +120,58 @@ private ABSmartly(@Nonnull ABSmartlyConfig config) { } public Context createContext(@Nonnull ContextConfig config) { + checkNotClosed(); return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(), contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, new AudienceMatcher(audienceDeserializer_)); } public Context createContextWith(@Nonnull ContextConfig config, ContextData data) { + checkNotClosed(); return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data), contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_, new AudienceMatcher(audienceDeserializer_)); } public CompletableFuture getContextData() { + checkNotClosed(); return contextDataProvider_.getContextData(); } + private void checkNotClosed() { + if (closed_) { + throw new IllegalStateException("ABSmartly instance is closed"); + } + } + @Override public void close() throws IOException { - if (client_ != null) { - client_.close(); - client_ = null; + if (closed_) { + return; } + closed_ = true; - if (scheduler_ != null) { - try { - scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS); - } catch (InterruptedException ignored) {} - scheduler_ = null; + try { + if (client_ != null) { + client_.close(); + } + } finally { + // A caller-supplied scheduler remains under caller ownership and must be left running. + if ((scheduler_ != null) && ownsScheduler_) { + scheduler_.shutdown(); + try { + if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) { + scheduler_.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + scheduler_.shutdownNow(); + } + } } } + private volatile boolean closed_; private Client client_; private ContextDataProvider contextDataProvider_; private ContextEventHandler contextEventHandler_; @@ -92,4 +180,5 @@ public void close() throws IOException { private AudienceDeserializer audienceDeserializer_; private ScheduledExecutorService scheduler_; + private final boolean ownsScheduler_; } diff --git a/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java b/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java index f3b9a7a..3fb3aea 100644 --- a/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java +++ b/core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java @@ -25,6 +25,9 @@ public boolean get() { } public Result evaluate(String audience, Map attributes) { + if (audience == null || audience.isEmpty()) { + return null; + } final byte[] bytes = audience.getBytes(StandardCharsets.UTF_8); final Map audienceMap = deserializer_.deserialize(bytes, 0, bytes.length); if (audienceMap != null) { diff --git a/core-api/src/main/java/com/absmartly/sdk/Client.java b/core-api/src/main/java/com/absmartly/sdk/Client.java index e172461..a10c713 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Client.java +++ b/core-api/src/main/java/com/absmartly/sdk/Client.java @@ -13,10 +13,15 @@ import javax.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.absmartly.sdk.json.ContextData; import com.absmartly.sdk.json.PublishEvent; public class Client implements Closeable { + private static final Logger log = LoggerFactory.getLogger(Client.class); + static public Client create(@Nonnull final ClientConfig config) { return new Client(config, DefaultHTTPClient.create(DefaultHTTPClientConfig.create())); } @@ -31,6 +36,15 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H throw new IllegalArgumentException("Missing Endpoint configuration"); } + if (!endpoint.startsWith("https://")) { + if (endpoint.startsWith("http://")) { + log.warn("ABSmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: {}", + endpoint); + } else { + throw new IllegalArgumentException("Endpoint must use http:// or https:// protocol: " + endpoint); + } + } + final String apiKey = config.getAPIKey(); if ((apiKey == null) || apiKey.isEmpty()) { throw new IllegalArgumentException("Missing APIKey configuration"); @@ -46,7 +60,9 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H throw new IllegalArgumentException("Missing Environment configuration"); } - url_ = endpoint + "/context"; + final String normalizedEndpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) + : endpoint; + url_ = normalizedEndpoint + "/context"; httpClient_ = httpClient; deserializer_ = config.getContextDataDeserializer(); serializer_ = config.getContextEventSerializer(); @@ -86,8 +102,19 @@ public void accept(HTTPClient.Response response) { final int code = response.getStatusCode(); if ((code / 100) == 2) { final byte[] content = response.getContent(); - dataFuture.complete( - deserializer_.deserialize(response.getContent(), 0, content.length)); + if (content == null || content.length == 0) { + dataFuture.completeExceptionally(new IllegalStateException( + "Empty response body from context data endpoint")); + } else { + final ContextData result = deserializer_.deserialize(content, 0, + content.length); + if (result != null) { + dataFuture.complete(result); + } else { + dataFuture.completeExceptionally(new IllegalStateException( + "Failed to deserialize context data response")); + } + } } else { dataFuture.completeExceptionally(new Exception(response.getStatusMessage())); } diff --git a/core-api/src/main/java/com/absmartly/sdk/Context.java b/core-api/src/main/java/com/absmartly/sdk/Context.java index a3d99b3..c673b85 100644 --- a/core-api/src/main/java/com/absmartly/sdk/Context.java +++ b/core-api/src/main/java/com/absmartly/sdk/Context.java @@ -7,16 +7,21 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java8.util.concurrent.CompletableFuture; import java8.util.concurrent.CompletionException; +import java8.util.function.BiFunction; import java8.util.function.Consumer; import java8.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.absmartly.sdk.internal.Algorithm; import com.absmartly.sdk.internal.Concurrency; import com.absmartly.sdk.internal.VariantAssigner; @@ -26,6 +31,8 @@ import com.absmartly.sdk.json.*; public class Context implements Closeable { + private static final Logger log = LoggerFactory.getLogger(Context.class); + public static Context create(@Nonnull final Clock clock, @Nonnull final ContextConfig config, @Nonnull final ScheduledExecutorService scheduler, @Nonnull final CompletableFuture dataFuture, @Nonnull final ContextDataProvider dataProvider, @@ -87,13 +94,16 @@ public Void apply(Throwable exception) { } }); } else { - readyFuture_ = new CompletableFuture(); + final CompletableFuture newReadyFuture = new CompletableFuture(); + readyFuture_.set(newReadyFuture); dataFuture.thenAccept(new Consumer() { @Override public void accept(ContextData data) { Context.this.setData(data); - readyFuture_.complete(null); - readyFuture_ = null; + final CompletableFuture rf = readyFuture_.getAndSet(COMPLETED_VOID_FUTURE); + if (rf != null) { + rf.complete(null); + } Context.this.logEvent(ContextEventLogger.EventType.Ready, data); @@ -105,8 +115,10 @@ public void accept(ContextData data) { @Override public Void apply(Throwable exception) { Context.this.setDataFailed(exception); - readyFuture_.complete(null); - readyFuture_ = null; + final CompletableFuture rf = readyFuture_.getAndSet(COMPLETED_VOID_FUTURE); + if (rf != null) { + rf.complete(null); + } Context.this.logError(exception); @@ -132,22 +144,34 @@ public boolean isClosing() { return !closed_.get() && closing_.get(); } + public boolean isFinalized() { + return isClosed(); + } + + public boolean isFinalizing() { + return isClosing(); + } + public CompletableFuture waitUntilReadyAsync() { if (data_ != null) { return CompletableFuture.completedFuture(this); } else { - return readyFuture_.thenApply(new Function() { - @Override - public Context apply(Void k) { - return Context.this; - } - }); + final CompletableFuture rf = readyFuture_.get(); + if (rf != null) { + return rf.thenApply(new Function() { + @Override + public Context apply(Void k) { + return Context.this; + } + }); + } + return CompletableFuture.completedFuture(this); } } public Context waitUntilReady() { if (data_ == null) { - final CompletableFuture future = readyFuture_; // cache here to avoid locking + final CompletableFuture future = readyFuture_.get(); // cache here to avoid locking if (future != null && !future.isDone()) { future.join(); } @@ -156,7 +180,9 @@ public Context waitUntilReady() { } public String[] getExperiments() { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return new String[0]; + } try { dataLock_.readLock().lock(); @@ -174,6 +200,10 @@ public String[] getExperiments() { } public String[] getCustomFieldKeys() { + if (!isReady() || isClosed() || isClosing()) { + return new String[0]; + } + final Set keys = new HashSet(); try { @@ -193,6 +223,10 @@ public String[] getCustomFieldKeys() { } public Object getCustomFieldValue(@Nonnull final String experimentName, @Nonnull final String key) { + if (!isReady() || isClosed() || isClosing()) { + return null; + } + try { dataLock_.readLock().lock(); final ContextExperiment experiment = index_.get(experimentName); @@ -209,6 +243,10 @@ public Object getCustomFieldValue(@Nonnull final String experimentName, @Nonnull } public Object getCustomFieldValueType(@Nonnull final String experimentName, @Nonnull final String key) { + if (!isReady() || isClosed() || isClosing()) { + return null; + } + try { dataLock_.readLock().lock(); final ContextExperiment experiment = index_.get(experimentName); @@ -236,8 +274,6 @@ public ContextData getData() { } public void setOverride(@Nonnull final String experimentName, final int variant) { - checkNotClosed(); - Concurrency.putRW(contextLock_, overrides_, experimentName, variant); } @@ -290,7 +326,7 @@ public void setUnit(@Nonnull final String unitType, @Nonnull final String uid) { final String previous = units_.get(unitType); if ((previous != null) && !previous.equals(uid)) { - throw new IllegalArgumentException(String.format("Unit '%s' already set.", unitType)); + throw new IllegalArgumentException(String.format("Unit '%s' UID already set.", unitType)); } final String trimmed = uid.trim(); @@ -343,6 +379,7 @@ public void setAttribute(@Nonnull final String name, @Nullable final Object valu checkNotClosed(); Concurrency.addRW(contextLock_, attributes_, new Attribute(name, value, clock_.millis())); + attrsSeq_.incrementAndGet(); } public Map getAttributes() { @@ -368,7 +405,13 @@ public void setAttributes(@Nonnull final Map attributes) { } public int getTreatment(@Nonnull final String experimentName) { - checkReady(true); + if (!isReady()) { + return 0; + } + + if (isClosed() || isClosing()) { + return 0; + } final Assignment assignment = getAssignment(experimentName); if (!assignment.exposed.get()) { @@ -408,13 +451,17 @@ private void queueExposure(final Assignment assignment) { } public int peekTreatment(@Nonnull final String experimentName) { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return 0; + } return getAssignment(experimentName).variant; } public Map> getVariableKeys() { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return new HashMap>(); + } final Map> variableKeys = new HashMap>(indexVariables_.size()); @@ -437,7 +484,9 @@ public Map> getVariableKeys() { } public Object getVariableValue(@Nonnull final String key, final Object defaultValue) { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return defaultValue; + } final Assignment assignment = getVariableAssignment(key); if (assignment != null) { @@ -455,7 +504,9 @@ public Object getVariableValue(@Nonnull final String key, final Object defaultVa } public Object peekVariableValue(@Nonnull final String key, final Object defaultValue) { - checkReady(true); + if (!isReady() || isClosed() || isClosing()) { + return defaultValue; + } final Assignment assignment = getVariableAssignment(key); if (assignment != null) { @@ -507,14 +558,15 @@ public CompletableFuture refreshAsync() { checkNotClosed(); if (refreshing_.compareAndSet(false, true)) { - refreshFuture_ = new CompletableFuture(); + final CompletableFuture newRefreshFuture = new CompletableFuture(); + refreshFuture_.set(newRefreshFuture); dataProvider_.getContextData().thenAccept(new Consumer() { @Override public void accept(ContextData data) { Context.this.setData(data); refreshing_.set(false); - refreshFuture_.complete(null); + newRefreshFuture.complete(null); Context.this.logEvent(ContextEventLogger.EventType.Refresh, data); } @@ -522,7 +574,7 @@ public void accept(ContextData data) { @Override public Void apply(Throwable exception) { refreshing_.set(false); - refreshFuture_.completeExceptionally(exception); + newRefreshFuture.completeExceptionally(exception); Context.this.logError(exception); return null; @@ -530,7 +582,7 @@ public Void apply(Throwable exception) { }); } - final CompletableFuture future = refreshFuture_; + final CompletableFuture future = refreshFuture_.get(); if (future != null) { return future; } @@ -548,39 +600,48 @@ public CompletableFuture closeAsync() { clearRefreshTimer(); if (pendingCount_.get() > 0) { - closingFuture_ = new CompletableFuture(); + final CompletableFuture newClosingFuture = new CompletableFuture(); + closingFuture_.set(newClosingFuture); flush().thenAccept(new Consumer() { @Override public void accept(Void x) { closed_.set(true); closing_.set(false); - closingFuture_.complete(null); + newClosingFuture.complete(null); Context.this.logEvent(ContextEventLogger.EventType.Close, null); } }).exceptionally(new Function() { @Override public Void apply(Throwable exception) { - closed_.set(true); + // If events were restored by flush's failure handler, leave the context + // open so a retry of closeAsync() can attempt to publish them. + if (pendingCount_.get() == 0) { + closed_.set(true); + } closing_.set(false); - closingFuture_.completeExceptionally(exception); - // event logger gets this error during publish + newClosingFuture.completeExceptionally(exception); return null; } }); - return closingFuture_; + return newClosingFuture; } else { closed_.set(true); closing_.set(false); Context.this.logEvent(ContextEventLogger.EventType.Close, null); + + // Nothing was pending here, so no closingFuture_ was published for this + // attempt; return directly to avoid picking up a stale future left behind + // by an earlier failed close attempt. + return CompletableFuture.completedFuture(null); } } - final CompletableFuture future = closingFuture_; + final CompletableFuture future = closingFuture_.get(); if (future != null) { return future; } @@ -594,6 +655,11 @@ public void close() { closeAsync().join(); } + @Deprecated + public CompletableFuture finalizeAsync() { + return closeAsync(); + } + private CompletableFuture flush() { clearTimeout(); @@ -602,6 +668,19 @@ private CompletableFuture flush() { Exposure[] exposures = null; GoalAchievement[] achievements = null; int eventCount; + final CompletableFuture result = new CompletableFuture(); + final CompletableFuture callerResult = new CompletableFuture(); + result.handle(new BiFunction() { + @Override + public Void apply(Void ignoredResult, Throwable exception) { + if (exception != null) { + callerResult.completeExceptionally(exception); + } else { + callerResult.complete(null); + } + return null; + } + }); try { eventLock_.lock(); @@ -628,38 +707,85 @@ private CompletableFuture flush() { final PublishEvent event = new PublishEvent(); event.hashed = true; event.publishedAt = clock_.millis(); - event.units = Algorithm.mapSetToArray(units_.entrySet(), new Unit[0], - new Function, Unit>() { - @Override - public Unit apply(Map.Entry entry) { - return new Unit(entry.getKey(), - new String(getUnitHash(entry.getKey(), entry.getValue()), - StandardCharsets.US_ASCII)); - } - }); - event.attributes = attributes_.isEmpty() ? null : attributes_.toArray(new Attribute[0]); + + try { + contextLock_.writeLock().lock(); + event.units = Algorithm.mapSetToArray(units_.entrySet(), new Unit[0], + new Function, Unit>() { + @Override + public Unit apply(Map.Entry entry) { + return new Unit(entry.getKey(), + new String(getUnitHash(entry.getKey(), entry.getValue()), + StandardCharsets.US_ASCII)); + } + }); + event.attributes = attributes_.isEmpty() ? null : attributes_.toArray(new Attribute[0]); + } finally { + contextLock_.writeLock().unlock(); + } event.exposures = exposures; event.goals = achievements; - final CompletableFuture result = new CompletableFuture(); + final Exposure[] finalExposures = exposures; + final GoalAchievement[] finalAchievements = achievements; + final int finalEventCount = eventCount; - eventHandler_.publish(this, event).thenRunAsync(new Runnable() { - @Override - public void run() { - Context.this.logEvent(ContextEventLogger.EventType.Publish, event); - result.complete(null); - } - }).exceptionally(new Function() { + final Function onPublishFailure = new Function() { @Override public Void apply(Throwable throwable) { - Context.this.logError(throwable); + try { + eventLock_.lock(); + if (finalExposures != null) { + for (int i = finalExposures.length - 1; i >= 0; i--) { + exposures_.add(0, finalExposures[i]); + } + } + if (finalAchievements != null) { + for (int i = finalAchievements.length - 1; i >= 0; i--) { + achievements_.add(0, finalAchievements[i]); + } + } + pendingCount_.addAndGet(finalEventCount); + } finally { + eventLock_.unlock(); + } + try { + Context.this.logError(throwable); + } catch (final Throwable ignored) { + // diagnostic logger failures must not affect publish accounting + } result.completeExceptionally(throwable); return null; } + }; + + final CompletableFuture publishResult; + try { + publishResult = eventHandler_.publish(this, event); + } catch (final Throwable throwable) { + onPublishFailure.apply(throwable); + return callerResult; + } + + // The Publish log event runs in its own stage so a logger exception cannot be + // mistaken for a publisher failure and trigger event restoration. + publishResult.thenRunAsync(new Runnable() { + @Override + public void run() { + try { + Context.this.logEvent(ContextEventLogger.EventType.Publish, event); + } catch (final Throwable ignored) { + // diagnostic logger failures must not affect publish accounting + } finally { + result.complete(null); + } + } }); - return result; + publishResult.exceptionally(onPublishFailure); + + return callerResult; } } } else { @@ -678,15 +804,15 @@ public Void apply(Throwable throwable) { private void checkNotClosed() { if (closed_.get()) { - throw new IllegalStateException("ABSmartly Context is closed"); + throw new IllegalStateException("ABSmartly Context is finalized."); } else if (closing_.get()) { - throw new IllegalStateException("ABSmartly Context is closing"); + throw new IllegalStateException("ABSmartly Context is closing."); } } private void checkReady(final boolean expectNotClosed) { if (!isReady()) { - throw new IllegalStateException("ABSmartly Context is not yet ready"); + throw new IllegalStateException("ABSmartly Context is not yet ready."); } else if (expectNotClosed) { checkNotClosed(); } @@ -694,12 +820,28 @@ private void checkReady(final boolean expectNotClosed) { private boolean experimentMatches(final Experiment experiment, final Assignment assignment) { return experiment.id == assignment.id && - experiment.unitType.equals(assignment.unitType) && + (experiment.unitType != null && experiment.unitType.equals(assignment.unitType)) && experiment.iteration == assignment.iteration && experiment.fullOnVariant == assignment.fullOnVariant && Arrays.equals(experiment.trafficSplit, assignment.trafficSplit); } + private boolean audienceMatches(final Experiment experiment, final Assignment assignment) { + if (experiment.audience != null && experiment.audience.length() > 0) { + if (attrsSeq_.get() > assignment.attrsSeq) { + final Map attrs = buildAttributesMap(); + + final AudienceMatcher.Result match = audienceMatcher_.evaluate(experiment.audience, attrs); + final boolean newAudienceMismatch = (match != null) ? !match.get() : false; + + if (newAudienceMismatch != assignment.audienceMismatch) { + return false; + } + } + } + return true; + } + private static class Assignment { int id; int iteration; @@ -715,7 +857,8 @@ private static class Assignment { boolean custom; boolean audienceMismatch; - Map variables = Collections.emptyMap(); + Map variables = null; + int attrsSeq; final AtomicBoolean exposed = new AtomicBoolean(false); } @@ -743,7 +886,8 @@ private Assignment getAssignment(final String experimentName) { return assignment; } } else if ((custom == null) || custom == assignment.variant) { - if (experimentMatches(experiment.data, assignment)) { + if (experimentMatches(experiment.data, assignment) + && audienceMatches(experiment.data, assignment)) { // assignment up-to-date return assignment; } @@ -779,10 +923,7 @@ private Assignment getAssignment(final String experimentName) { final String unitType = experiment.data.unitType; if (experiment.data.audience != null && experiment.data.audience.length() > 0) { - final Map attrs = new HashMap(attributes_.size()); - for (final Attribute attr : attributes_) { - attrs.put(attr.name, attr.value); - } + final Map attrs = buildAttributesMap(); final AudienceMatcher.Result match = audienceMatcher_ .evaluate(experiment.data.audience, attrs); @@ -829,10 +970,12 @@ private Assignment getAssignment(final String experimentName) { assignment.iteration = experiment.data.iteration; assignment.trafficSplit = experiment.data.trafficSplit; assignment.fullOnVariant = experiment.data.fullOnVariant; + assignment.attrsSeq = attrsSeq_.get(); } } - if ((experiment != null) && (assignment.variant < experiment.data.variants.length)) { + if ((experiment != null) && experiment.data.variants != null && assignment.variant >= 0 + && (assignment.variant < experiment.data.variants.length)) { assignment.variables = experiment.variables.get(assignment.variant); } @@ -891,7 +1034,7 @@ public VariantAssigner apply(String key) { } private void setTimeout() { - if (isReady()) { + if (isReady() && publishDelay_ >= 0) { if (timeout_ == null) { try { timeoutLock_.lock(); @@ -899,7 +1042,13 @@ private void setTimeout() { timeout_ = scheduler_.schedule(new Runnable() { @Override public void run() { - Context.this.flush(); + Context.this.flush().exceptionally(new Function() { + @Override + public Void apply(Throwable exception) { + Context.this.logError(exception); + return null; + } + }); } }, publishDelay_, TimeUnit.MILLISECONDS); } @@ -954,44 +1103,61 @@ private static class ContextCustomFieldValue { } private void setData(final ContextData data) { + if (data == null) { + throw new IllegalArgumentException("Context data cannot be null"); + } + final Map index = new HashMap(); final Map> indexVariables = new HashMap>(); for (final Experiment experiment : data.experiments) { final ContextExperiment contextExperiment = new ContextExperiment(); contextExperiment.data = experiment; - contextExperiment.variables = new ArrayList>(experiment.variants.length); - - for (final ExperimentVariant variant : experiment.variants) { - if ((variant.config != null) && !variant.config.isEmpty()) { - final Map variables = variableParser_.parse(this, experiment.name, variant.name, - variant.config); - for (final String key : variables.keySet()) { - List keyExperimentVariables = indexVariables.get(key); - if (keyExperimentVariables == null) { - keyExperimentVariables = new ArrayList(); - indexVariables.put(key, keyExperimentVariables); - } + contextExperiment.variables = new ArrayList>( + experiment.variants != null ? experiment.variants.length : 0); + + if (experiment.variants != null) + for (final ExperimentVariant variant : experiment.variants) { + if ((variant.config != null) && !variant.config.isEmpty()) { + try { + final Map variables = variableParser_.parse(this, experiment.name, + variant.name, + variant.config); + if (variables != null) { + for (final String key : variables.keySet()) { + List keyExperimentVariables = indexVariables.get(key); + if (keyExperimentVariables == null) { + keyExperimentVariables = new ArrayList(); + indexVariables.put(key, keyExperimentVariables); + } - int at = Collections.binarySearch(keyExperimentVariables, contextExperiment, - new Comparator() { - @Override - public int compare(ContextExperiment a, ContextExperiment b) { - return Integer.valueOf(a.data.id).compareTo(b.data.id); + int at = Collections.binarySearch(keyExperimentVariables, contextExperiment, + new Comparator() { + @Override + public int compare(ContextExperiment a, ContextExperiment b) { + return Integer.valueOf(a.data.id).compareTo(b.data.id); + } + }); + + if (at < 0) { + at = -at - 1; + keyExperimentVariables.add(at, contextExperiment); } - }); + } - if (at < 0) { - at = -at - 1; - keyExperimentVariables.add(at, contextExperiment); + contextExperiment.variables.add(variables); + } else { + contextExperiment.variables.add(Collections. emptyMap()); + } + } catch (Exception e) { + log.error("Failed to parse variant config for experiment '{}', variant '{}': {}", + experiment.name, variant.name, e.getMessage()); + contextExperiment.variables.add(Collections. emptyMap()); } + } else { + contextExperiment.variables.add(Collections. emptyMap()); } - - contextExperiment.variables.add(variables); - } else { - contextExperiment.variables.add(Collections. emptyMap()); } - } contextExperiment.customFieldValues = new HashMap(); if (experiment.customFieldValues != null) { @@ -1001,13 +1167,24 @@ public int compare(ContextExperiment a, ContextExperiment b) { value.type = customFieldValue.getType(); if (customFieldValue.getValue() != null) { - if (customFieldValue.getType().startsWith("json")) { - value.value = variableParser_.parse(this, experiment.name, customFieldValue.getValue()); - } else if (customFieldValue.getType().equals("boolean")) { - value.value = Boolean.parseBoolean(customFieldValue.getValue()); - } else if (customFieldValue.getType().equals("number")) { - value.value = Double.parseDouble(customFieldValue.getValue()); - } else { + try { + final String type = customFieldValue.getType(); + if (type != null && type.startsWith("json")) { + value.value = variableParser_.parse(this, experiment.name, customFieldValue.getValue()); + } else if (type != null && type.equals("boolean")) { + value.value = Boolean.parseBoolean(customFieldValue.getValue()); + } else if (type != null && type.equals("number")) { + value.value = Double.parseDouble(customFieldValue.getValue()); + } else { + value.value = customFieldValue.getValue(); + } + } catch (NumberFormatException e) { + log.warn("Failed to parse custom field number value for experiment '{}': {}", + experiment.name, e.getMessage()); + value.value = customFieldValue.getValue(); + } catch (Exception e) { + log.warn("Failed to parse custom field value for experiment '{}': {}", experiment.name, + e.getMessage()); value.value = customFieldValue.getValue(); } } @@ -1030,6 +1207,10 @@ public int compare(ContextExperiment a, ContextExperiment b) { } } + public Throwable readyError() { + return readyError_; + } + private void setDataFailed(final Throwable exception) { try { dataLock_.writeLock().lock(); @@ -1037,6 +1218,7 @@ private void setDataFailed(final Throwable exception) { indexVariables_ = new HashMap>(); data_ = new ContextData(); failed_ = true; + readyError_ = exception; } finally { dataLock_.writeLock().unlock(); } @@ -1057,6 +1239,14 @@ private void logError(Throwable error) { } } + private Map buildAttributesMap() { + final Map attrs = new HashMap(attributes_.size()); + for (final Attribute attr : attributes_) { + attrs.put(attr.name, attr.value); + } + return attrs; + } + private final Clock clock_; private final long publishDelay_; private final long refreshInterval_; @@ -1067,10 +1257,11 @@ private void logError(Throwable error) { private final AudienceMatcher audienceMatcher_; private final ScheduledExecutorService scheduler_; private final Map units_; - private boolean failed_; + private volatile boolean failed_; + private volatile Throwable readyError_; private final ReentrantReadWriteLock dataLock_ = new ReentrantReadWriteLock(); - private ContextData data_; + private volatile ContextData data_; private Map index_; private Map> indexVariables_; private final ReentrantReadWriteLock contextLock_ = new ReentrantReadWriteLock(); @@ -1086,15 +1277,17 @@ private void logError(Throwable error) { private final List attributes_ = new ArrayList(); private final Map overrides_; private final Map cassignments_; + private final AtomicInteger attrsSeq_ = new AtomicInteger(0); private final AtomicInteger pendingCount_ = new AtomicInteger(0); private final AtomicBoolean closing_ = new AtomicBoolean(false); private final AtomicBoolean closed_ = new AtomicBoolean(false); private final AtomicBoolean refreshing_ = new AtomicBoolean(false); - private volatile CompletableFuture readyFuture_; - private volatile CompletableFuture closingFuture_; - private volatile CompletableFuture refreshFuture_; + private static final CompletableFuture COMPLETED_VOID_FUTURE = CompletableFuture.completedFuture(null); + private final AtomicReference> readyFuture_ = new AtomicReference>(); + private final AtomicReference> closingFuture_ = new AtomicReference>(); + private final AtomicReference> refreshFuture_ = new AtomicReference>(); private final ReentrantLock timeoutLock_ = new ReentrantLock(); private volatile ScheduledFuture timeout_ = null; diff --git a/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java b/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java index 9bdd6ab..a6ecbfc 100644 --- a/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java +++ b/core-api/src/main/java/com/absmartly/sdk/ContextConfig.java @@ -29,7 +29,7 @@ public ContextConfig setUnits(@Nonnull final Map units) { } public String getUnit(@Nonnull final String unitType) { - return units_.get(unitType); + return units_ != null ? units_.get(unitType) : null; } public Map getUnits() { @@ -53,7 +53,7 @@ public ContextConfig setAttributes(@Nonnull final Map attributes } public Object getAttribute(@Nonnull final String name) { - return this.attributes_.get(name); + return this.attributes_ != null ? this.attributes_.get(name) : null; } public Map getAttributes() { @@ -77,7 +77,7 @@ public ContextConfig setOverrides(@Nonnull final Map overrides) } public Object getOverride(@Nonnull final String experimentName) { - return this.overrides_.get(experimentName); + return this.overrides_ != null ? this.overrides_.get(experimentName) : null; } public Map getOverrides() { @@ -85,27 +85,27 @@ public Map getOverrides() { } public ContextConfig setCustomAssignment(@Nonnull final String experimentName, int variant) { - if (cassigmnents_ == null) { - cassigmnents_ = new HashMap(); + if (cassignments_ == null) { + cassignments_ = new HashMap(); } - cassigmnents_.put(experimentName, variant); + cassignments_.put(experimentName, variant); return this; } public ContextConfig setCustomAssignments(@Nonnull final Map customAssignments) { - if (cassigmnents_ == null) { - cassigmnents_ = new HashMap(customAssignments.size()); + if (cassignments_ == null) { + cassignments_ = new HashMap(customAssignments.size()); } - cassigmnents_.putAll(customAssignments); + cassignments_.putAll(customAssignments); return this; } public Object getCustomAssignment(@Nonnull final String experimentName) { - return this.cassigmnents_.get(experimentName); + return this.cassignments_ != null ? this.cassignments_.get(experimentName) : null; } public Map getCustomAssignments() { - return this.cassigmnents_; + return this.cassignments_; } public ContextEventLogger getEventLogger() { @@ -138,7 +138,7 @@ public long getRefreshInterval() { private Map units_; private Map attributes_; private Map overrides_; - private Map cassigmnents_; + private Map cassignments_; private ContextEventLogger eventLogger_; diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java index a93c0b6..465f6cb 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultAudienceDeserializer.java @@ -8,19 +8,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import com.absmartly.sdk.internal.JsonMapperUtils; + public class DefaultAudienceDeserializer implements AudienceDeserializer { private static final Logger log = LoggerFactory.getLogger(DefaultAudienceDeserializer.class); private final ObjectReader reader_; public DefaultAudienceDeserializer() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); - this.reader_ = objectMapper.readerForMapOf(Object.class); + this.reader_ = JsonMapperUtils.createStandardObjectMapper().readerForMapOf(Object.class); } @Override @@ -28,7 +26,7 @@ public Map deserialize(@Nonnull byte[] bytes, int offset, int le try { return reader_.readValue(bytes, offset, length); } catch (IOException e) { - log.error("", e); + log.error("Failed to deserialize audience data: {}", e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java index b28eb79..0d49e49 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextDataDeserializer.java @@ -7,26 +7,23 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import com.absmartly.sdk.internal.JsonMapperUtils; import com.absmartly.sdk.json.ContextData; public class DefaultContextDataDeserializer implements ContextDataDeserializer { private static final Logger log = LoggerFactory.getLogger(DefaultContextDataDeserializer.class); public DefaultContextDataDeserializer() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); - this.reader_ = objectMapper.readerFor(ContextData.class); + this.reader_ = JsonMapperUtils.createStandardObjectMapper().readerFor(ContextData.class); } public ContextData deserialize(@Nonnull final byte[] bytes, final int offset, final int length) { try { return reader_.readValue(bytes, offset, length); } catch (IOException e) { - log.error("", e); + log.error("Failed to deserialize context data: {}", e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java index 6aba4fb..f729520 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultContextEventSerializer.java @@ -28,7 +28,7 @@ public byte[] serialize(@Nonnull final PublishEvent event) { try { return writer_.writeValueAsBytes(event); } catch (JsonProcessingException e) { - log.error("", e); + log.error("Failed to serialize publish event: {}", e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java b/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java index 16375d8..9b6f67f 100644 --- a/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java +++ b/core-api/src/main/java/com/absmartly/sdk/DefaultVariableParser.java @@ -9,17 +9,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.type.TypeFactory; +import com.absmartly.sdk.internal.JsonMapperUtils; + public class DefaultVariableParser implements VariableParser { private static final Logger log = LoggerFactory.getLogger(DefaultVariableParser.class); public DefaultVariableParser() { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.enable(MapperFeature.USE_STATIC_TYPING); + final ObjectMapper objectMapper = JsonMapperUtils.createStandardObjectMapper(); + this.reader_ = objectMapper .readerFor(TypeFactory.defaultInstance().constructMapType(HashMap.class, String.class, Object.class)); this.readerGeneric_ = objectMapper.readerFor(Object.class); @@ -30,7 +31,8 @@ public Map parse(@Nonnull final Context context, @Nonnull final try { return reader_.readValue(variableValues); } catch (IOException e) { - log.error("", e); + log.error("Failed to parse variable values for experiment '{}', variant '{}': {}", experimentName, + variantName, e.getMessage(), e); return null; } } @@ -40,7 +42,7 @@ public Object parse(@Nonnull final Context context, @Nonnull final String experi try { return readerGeneric_.readValue(variableValue); } catch (IOException e) { - log.error("", e); + log.error("Failed to parse custom field value for experiment '{}': {}", experimentName, e.getMessage(), e); return null; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java b/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java index cf91a5b..33d3241 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Algorithm.java @@ -4,6 +4,7 @@ import java8.util.function.Function; public class Algorithm { + @SuppressWarnings("unchecked") public static R[] mapSetToArray(Set set, R[] array, Function mapper) { final int size = set.size(); if (array.length < size) { diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java index efab910..319a507 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/Buffers.java @@ -28,22 +28,32 @@ static public int getUInt8(byte[] buf, int offset) { } static public int encodeUTF8(byte[] buf, int offset, CharSequence value) { - final int n = value.length(); - - int out = offset; - for (int i = 0; i < n; ++i) { + final int start = offset; + final int length = value.length(); + for (int i = 0; i < length; ++i) { final char c = value.charAt(i); if (c < 0x80) { - buf[out++] = (byte) c; + buf[offset++] = (byte) c; } else if (c < 0x800) { - buf[out++] = (byte) ((c >> 6) | 192); - buf[out++] = (byte) ((c & 63) | 128); + buf[offset++] = (byte) (0xc0 | (c >> 6)); + buf[offset++] = (byte) (0x80 | (c & 0x3f)); } else { - buf[out++] = (byte) ((c >> 12) | 224); - buf[out++] = (byte) (((c >> 6) & 63) | 128); - buf[out++] = (byte) ((c & 63) | 128); + final char low = Character.isHighSurrogate(c) && i + 1 < length ? value.charAt(i + 1) : 0; + if (Character.isLowSurrogate(low)) { + // A surrogate pair is one code point and requires one four-byte sequence. + final int codePoint = Character.toCodePoint(c, low); + ++i; + buf[offset++] = (byte) (0xf0 | (codePoint >> 18)); + buf[offset++] = (byte) (0x80 | ((codePoint >> 12) & 0x3f)); + buf[offset++] = (byte) (0x80 | ((codePoint >> 6) & 0x3f)); + buf[offset++] = (byte) (0x80 | (codePoint & 0x3f)); + } else { + buf[offset++] = (byte) (0xe0 | (c >> 12)); + buf[offset++] = (byte) (0x80 | ((c >> 6) & 0x3f)); + buf[offset++] = (byte) (0x80 | (c & 0x3f)); + } } } - return out - offset; + return offset - start; } } diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java b/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java new file mode 100644 index 0000000..11e4a70 --- /dev/null +++ b/core-api/src/main/java/com/absmartly/sdk/internal/JsonMapperUtils.java @@ -0,0 +1,15 @@ +package com.absmartly.sdk.internal; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +public class JsonMapperUtils { + public static ObjectMapper createStandardObjectMapper() { + return JsonMapper.builder() + .enable(MapperFeature.USE_STATIC_TYPING) + .enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY) + .build(); + } +} diff --git a/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java b/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java index 5332e07..49823a8 100644 --- a/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java +++ b/core-api/src/main/java/com/absmartly/sdk/internal/hashing/Hashing.java @@ -14,7 +14,9 @@ public byte[] initialValue() { public static byte[] hashUnit(CharSequence unit) { final int n = unit.length(); - final int bufferLen = n << 1; + // Up to 4 UTF-8 bytes per UTF-16 code unit (3-byte BMP chars, and 4-byte + // astral chars span two code units). n << 1 underflowed for 3-byte chars. + final int bufferLen = n * 4; byte[] buffer = threadBuffer.get(); if (buffer.length < bufferLen) { diff --git a/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java b/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java index aea0a45..2c9a972 100644 --- a/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java +++ b/core-api/src/main/java/com/absmartly/sdk/java/time/Clock.java @@ -11,12 +11,8 @@ static public Clock fixed(long millis) { } static public Clock systemUTC() { - if (utc_ != null) { - return utc_; - } - - return utc_ = new SystemClockUTC(); + return utc_; } - static SystemClockUTC utc_; + static final SystemClockUTC utc_ = new SystemClockUTC(); } diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java index 119a7e9..975e400 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/ExprEvaluator.java @@ -6,7 +6,12 @@ import java.util.List; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public class ExprEvaluator implements Evaluator { + private static final Logger log = LoggerFactory.getLogger(ExprEvaluator.class); + final static ThreadLocal formatter = new ThreadLocal() { @Override public DecimalFormat initialValue() { @@ -34,6 +39,10 @@ public Object evaluate(Object expr) { final Operator op = operators.get(entry.getKey()); if (op != null) { return op.evaluate(this, entry.getValue()); + } else { + log.warn( + "Unknown operator in audience expression: '{}'. This may be a forward compatibility issue with newer server versions.", + entry.getKey()); } break; } @@ -63,7 +72,7 @@ public Double numberConvert(Object x) { } else if (x instanceof String) { try { return Double.parseDouble((String) x); // use javascript semantics: numbers are doubles - } catch (Throwable ignored) {} + } catch (NumberFormatException ignored) {} } return null; @@ -93,7 +102,7 @@ public Object extractVar(String path) { final List list = (List) target; try { value = list.get(Integer.parseInt(frag)); - } catch (Throwable ignored) {} + } catch (NumberFormatException ignored) {} catch (IndexOutOfBoundsException ignored) {} } else if (target instanceof Map) { final Map map = (Map) target; value = map.get(frag); diff --git a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java index 4f05858..aa61b97 100644 --- a/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java +++ b/core-api/src/main/java/com/absmartly/sdk/jsonexpr/operators/MatchOperator.java @@ -2,10 +2,16 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.absmartly.sdk.jsonexpr.Evaluator; public class MatchOperator extends BinaryOperator { + private static final Logger log = LoggerFactory.getLogger(MatchOperator.class); + @Override public Object binary(Evaluator evaluator, Object lhs, Object rhs) { final String text = evaluator.stringConvert(lhs); @@ -16,6 +22,8 @@ public Object binary(Evaluator evaluator, Object lhs, Object rhs) { final Pattern compiled = Pattern.compile(pattern); final Matcher matcher = compiled.matcher(text); return matcher.find(); + } catch (PatternSyntaxException e) { + log.warn("Invalid regex pattern: {}", e.getMessage()); } catch (Throwable ignored) {} } } diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java new file mode 100644 index 0000000..7e244c0 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyCompatTest.java @@ -0,0 +1,54 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.json.ContextData; + +class ABSmartlyCompatTest { + + @Test + @SuppressWarnings("deprecation") + void oldApiFluentChainAndLifecycleWork() throws IOException { + final ContextDataProvider provider = mock(ContextDataProvider.class); + final ContextEventHandler handler = mock(ContextEventHandler.class); + final VariableParser parser = mock(VariableParser.class); + final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + final ContextEventLogger logger = mock(ContextEventLogger.class); + final AudienceDeserializer deserializer = mock(AudienceDeserializer.class); + final Client client = mock(Client.class); + + // Separate assignments verify each setter's covariant return type at compile time. + ABSmartlyConfig step0 = ABSmartlyConfig.create(); + ABSmartlyConfig step1 = step0.setClient(client); + ABSmartlyConfig step2 = step1.setContextDataProvider(provider); + ABSmartlyConfig step3 = step2.setContextEventHandler(handler); + ABSmartlyConfig step4 = step3.setVariableParser(parser); + ABSmartlyConfig step5 = step4.setScheduler(scheduler); + ABSmartlyConfig step6 = step5.setContextEventLogger(logger); + ABSmartlyConfig config = step6.setAudienceDeserializer(deserializer); + + assertSame(step0, step1); + assertSame(step0, config); + assertSame(handler, config.getContextEventHandler()); + + final ContextData data = new ContextData(); + when(provider.getContextData()).thenReturn(CompletableFuture.completedFuture(data)); + + final ABSmartly absmartly = ABSmartly.create(config); + assertNotNull(absmartly); + + final ContextConfig contextConfig = ContextConfig.create(); + final Context context = absmartly.createContext(contextConfig); + assertNotNull(context); + verify(provider, times(1)).getContextData(); + + absmartly.close(); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyFixTest.java new file mode 100644 index 0000000..abe483f --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyFixTest.java @@ -0,0 +1,78 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.json.ContextData; + +class ABSmartlyFixTest extends TestUtils { + Client client; + + @BeforeEach + void setUp() { + client = mock(Client.class); + } + + @Test + void createContextThrowsAfterClose() throws IOException { + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(client); + + final ABSmartly absmartly = ABSmartly.create(config); + absmartly.close(); + + assertThrows(IllegalStateException.class, () -> { + absmartly.createContext(ContextConfig.create().setUnit("user_id", "123")); + }); + } + + @Test + void createContextWithThrowsAfterClose() throws IOException { + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(client); + + final ABSmartly absmartly = ABSmartly.create(config); + absmartly.close(); + + assertThrows(IllegalStateException.class, () -> { + absmartly.createContextWith(ContextConfig.create().setUnit("user_id", "123"), new ContextData()); + }); + } + + @Test + void getContextDataThrowsAfterClose() throws IOException { + final ContextDataProvider dataProvider = mock(ContextDataProvider.class); + when(dataProvider.getContextData()).thenReturn(mock(CompletableFuture.class)); + + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(client) + .setContextDataProvider(dataProvider); + + final ABSmartly absmartly = ABSmartly.create(config); + absmartly.close(); + + assertThrows(IllegalStateException.class, absmartly::getContextData); + } + + @Test + void closeIsIdempotent() throws IOException { + final ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(client) + .setScheduler(scheduler); + + final ABSmartly absmartly = ABSmartly.create(config); + absmartly.close(); + absmartly.close(); + + verify(client, times(1)).close(); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java index 7b5e7a2..53d1ced 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ABSmartlyTest.java @@ -6,10 +6,12 @@ import java.io.IOException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java8.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentCaptor; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; @@ -44,6 +46,76 @@ void createThrowsWithInvalidConfig() { }, "Missing Client instance configuration"); } + @Test + void builderCreatesWithConnectionParams() { + try (final MockedStatic clientStatic = mockStatic(Client.class); + final MockedConstruction dataProviderCtor = mockConstruction( + DefaultContextDataProvider.class)) { + clientStatic.when(() -> Client.create(any(ClientConfig.class))).thenReturn(client); + + final ABSmartly absmartly = ABSmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .apiKey("test-api-key") + .application("website") + .environment("production") + .build(); + assertNotNull(absmartly); + + final ArgumentCaptor clientConfigCaptor = ArgumentCaptor.forClass(ClientConfig.class); + clientStatic.verify(() -> Client.create(clientConfigCaptor.capture()), Mockito.times(1)); + + final ClientConfig capturedClientConfig = clientConfigCaptor.getValue(); + assertEquals("https://test.absmartly.io/v1", capturedClientConfig.getEndpoint()); + assertEquals("test-api-key", capturedClientConfig.getAPIKey()); + assertEquals("website", capturedClientConfig.getApplication()); + assertEquals("production", capturedClientConfig.getEnvironment()); + } + } + + @Test + void builderThrowsWithMissingEndpoint() { + assertThrows(IllegalArgumentException.class, () -> { + ABSmartly.builder() + .apiKey("test-api-key") + .application("website") + .environment("production") + .build(); + }); + } + + @Test + void builderThrowsWithMissingApiKey() { + assertThrows(IllegalArgumentException.class, () -> { + ABSmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .application("website") + .environment("production") + .build(); + }); + } + + @Test + void builderThrowsWithMissingApplication() { + assertThrows(IllegalArgumentException.class, () -> { + ABSmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .apiKey("test-api-key") + .environment("production") + .build(); + }); + } + + @Test + void builderThrowsWithMissingEnvironment() { + assertThrows(IllegalArgumentException.class, () -> { + ABSmartly.builder() + .endpoint("https://test.absmartly.io/v1") + .apiKey("test-api-key") + .application("website") + .build(); + }); + } + @Test void createContext() { final ABSmartlyConfig config = ABSmartlyConfig.create() @@ -60,7 +132,10 @@ void createContext() { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), + any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -84,13 +159,16 @@ void createContext() { final ArgumentCaptor audienceMatcherCaptor = ArgumentCaptor .forClass(AudienceMatcher.class); - contextStatic.verify(Mockito.timeout(5000).times(1), - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())); - contextStatic.verify(Mockito.timeout(5000).times(1), + contextStatic.verify( + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), + any(), any()), + Mockito.times(1)); + contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), dataFutureCaptor.capture(), dataProviderCaptor.capture(), eventHandlerCaptor.capture(), eventLoggerCaptor.capture(), variableParserCaptor.capture(), - audienceMatcherCaptor.capture())); + audienceMatcherCaptor.capture()), + Mockito.times(1)); assertEquals(Clock.systemUTC(), clockCaptor.getValue()); assertSame(contextConfig, configCaptor.getValue()); @@ -118,7 +196,10 @@ void createContextWith() { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), + any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -144,13 +225,16 @@ void createContextWith() { final ArgumentCaptor audienceMatcherCaptor = ArgumentCaptor .forClass(AudienceMatcher.class); - contextStatic.verify(Mockito.timeout(5000).times(1), - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())); - contextStatic.verify(Mockito.timeout(5000).times(1), + contextStatic.verify( + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), + any(), any()), + Mockito.times(1)); + contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), dataFutureCaptor.capture(), dataProviderCaptor.capture(), eventHandlerCaptor.capture(), eventLoggerCaptor.capture(), variableParserCaptor.capture(), - audienceMatcherCaptor.capture())); + audienceMatcherCaptor.capture()), + Mockito.times(1)); assertEquals(Clock.systemUTC(), clockCaptor.getValue()); assertSame(contextConfig, configCaptor.getValue()); @@ -213,7 +297,9 @@ void createContextWithCustomImpls() { assertSame(audienceDeserializer, context.arguments().get(0)); })) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -235,12 +321,16 @@ void createContextWithCustomImpls() { final ArgumentCaptor variableParserCaptor = ArgumentCaptor.forClass(VariableParser.class); final ArgumentCaptor audienceMatcher = ArgumentCaptor.forClass(AudienceMatcher.class); - contextStatic.verify(Mockito.timeout(5000).times(1), - () -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())); - contextStatic.verify(Mockito.timeout(5000).times(1), + contextStatic.verify( + () -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), + any(), + any()), + Mockito.times(1)); + contextStatic.verify( () -> Context.create(clockCaptor.capture(), configCaptor.capture(), schedulerCaptor.capture(), dataFutureCaptor.capture(), dataProviderCaptor.capture(), eventHandlerCaptor.capture(), - eventLoggerCaptor.capture(), variableParserCaptor.capture(), audienceMatcher.capture())); + eventLoggerCaptor.capture(), variableParserCaptor.capture(), audienceMatcher.capture()), + Mockito.times(1)); assertEquals(Clock.systemUTC(), clockCaptor.getValue()); assertSame(contextConfig, configCaptor.getValue()); @@ -266,7 +356,9 @@ void close() throws IOException, InterruptedException { try (final MockedStatic contextStatic = mockStatic(Context.class)) { final Context contextMock = mock(Context.class); - contextStatic.when(() -> Context.create(any(), any(), any(), any(), any(), any(), any(), any(), any())) + contextStatic + .when(() -> Context.create(any(), any(), any(), any(), any(), any(ContextEventHandler.class), any(), + any(), any())) .thenReturn(contextMock); final ContextConfig contextConfig = ContextConfig.create().setUnit("user_id", "1234567"); @@ -275,7 +367,50 @@ void close() throws IOException, InterruptedException { absmartly.close(); - verify(scheduler, Mockito.timeout(5000).times(1)).awaitTermination(anyLong(), any()); + // scheduler was injected by the caller, so close() must leave it under caller ownership + verify(scheduler, Mockito.times(0)).shutdown(); + verify(scheduler, Mockito.times(0)).awaitTermination(anyLong(), any()); + verify(scheduler, Mockito.times(0)).shutdownNow(); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeLeavesInjectedSchedulerRunning() throws IOException { + final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1); + try { + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(client) + .setScheduler(scheduler); + + final ABSmartly absmartly = ABSmartly.create(config); + absmartly.close(); + + assertFalse(scheduler.isShutdown()); + } finally { + scheduler.shutdownNow(); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeShutsDownSelfCreatedScheduler() throws IOException, InterruptedException { + try (final MockedConstruction schedulerCtor = mockConstruction( + ScheduledThreadPoolExecutor.class, (mock, context) -> { + when(mock.awaitTermination(anyLong(), any())).thenReturn(true); + })) { + final ABSmartlyConfig config = ABSmartlyConfig.create() + .setClient(client); + + final ABSmartly absmartly = ABSmartly.create(config); + assertEquals(1, schedulerCtor.constructed().size()); + + final ScheduledExecutorService createdScheduler = schedulerCtor.constructed().get(0); + + absmartly.close(); + + verify(createdScheduler, Mockito.times(1)).shutdown(); + verify(createdScheduler, Mockito.times(1)).awaitTermination(anyLong(), any()); } } } diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java index d571c01..729dba2 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ClientConfigTest.java @@ -1,7 +1,6 @@ package com.absmartly.sdk; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import java.util.Properties; @@ -103,4 +102,80 @@ void createFromProperties() { assertSame(serializer, config.getContextEventSerializer()); assertSame(executor, config.getExecutor()); } + + @Test + void testEmptyEndpointUrl() { + final ClientConfig config = ClientConfig.create() + .setEndpoint("") + .setAPIKey("api-key-test") + .setEnvironment("test") + .setApplication("website"); + + assertEquals("", config.getEndpoint()); + } + + @Test + void testNullEndpointUrl() { + final ClientConfig config = ClientConfig.create() + .setAPIKey("api-key-test") + .setEnvironment("test") + .setApplication("website"); + + assertNull(config.getEndpoint()); + } + + @Test + void testEmptyApiKey() { + final ClientConfig config = ClientConfig.create() + .setEndpoint("https://test.endpoint.com") + .setAPIKey("") + .setEnvironment("test") + .setApplication("website"); + + assertEquals("", config.getAPIKey()); + } + + @Test + void testNullApiKey() { + final ClientConfig config = ClientConfig.create() + .setEndpoint("https://test.endpoint.com") + .setEnvironment("test") + .setApplication("website"); + + assertNull(config.getAPIKey()); + } + + @Test + void testMissingPropertiesPrefix() { + final Properties props = new Properties(); + props.putAll(TestUtils.mapOf( + "endpoint", "https://test.endpoint.com", + "environment", "test", + "apikey", "api-key-test", + "application", "website")); + + final ClientConfig config = ClientConfig.createFromProperties(props, "absmartly."); + + assertNull(config.getEndpoint()); + assertNull(config.getAPIKey()); + assertNull(config.getEnvironment()); + assertNull(config.getApplication()); + } + + @Test + void testCreateFromPropertiesWithEmptyPrefix() { + final Properties props = new Properties(); + props.putAll(TestUtils.mapOf( + "endpoint", "https://test.endpoint.com", + "environment", "test", + "apikey", "api-key-test", + "application", "website")); + + final ClientConfig config = ClientConfig.createFromProperties(props); + + assertEquals("https://test.endpoint.com", config.getEndpoint()); + assertEquals("api-key-test", config.getAPIKey()); + assertEquals("test", config.getEnvironment()); + assertEquals("website", config.getApplication()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java new file mode 100644 index 0000000..3b5b475 --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ClientFixTest.java @@ -0,0 +1,152 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java8.util.concurrent.CompletableFuture; +import java8.util.concurrent.CompletionException; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.absmartly.sdk.java.nio.charset.StandardCharsets; +import com.absmartly.sdk.json.ContextData; + +class ClientFixTest extends TestUtils { + + @Test + void endpointTrailingSlashNormalized() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1/") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final byte[] bytes = "{}".getBytes(StandardCharsets.UTF_8); + final ContextData expected = new ContextData(); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", bytes))); + when(deser.deserialize(bytes, 0, bytes.length)).thenReturn(expected); + + final CompletableFuture dataFuture = client.getContextData(); + final ContextData actual = dataFuture.join(); + + assertSame(expected, actual); + verify(httpClient, Mockito.timeout(5000).times(1)).get("https://localhost/v1/context", expectedQuery, null); + } + + @Test + void endpointWithoutTrailingSlashUnchanged() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final byte[] bytes = "{}".getBytes(StandardCharsets.UTF_8); + final ContextData expected = new ContextData(); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", bytes))); + when(deser.deserialize(bytes, 0, bytes.length)).thenReturn(expected); + + final CompletableFuture dataFuture = client.getContextData(); + final ContextData actual = dataFuture.join(); + + assertSame(expected, actual); + } + + @Test + void httpEndpointIsAccepted() { + final HTTPClient httpClient = mock(HTTPClient.class); + assertDoesNotThrow(() -> { + Client.create(ClientConfig.create() + .setEndpoint("http://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev"), httpClient); + }); + } + + @Test + void invalidProtocolThrows() { + final HTTPClient httpClient = mock(HTTPClient.class); + assertThrows(IllegalArgumentException.class, () -> { + Client.create(ClientConfig.create() + .setEndpoint("ftp://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev"), httpClient); + }); + } + + @Test + void getContextDataNullDeserializationThrowsExceptionally() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final byte[] bytes = "invalid".getBytes(StandardCharsets.UTF_8); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", bytes))); + when(deser.deserialize(bytes, 0, bytes.length)).thenReturn(null); + + final CompletableFuture dataFuture = client.getContextData(); + final CompletionException actual = assertThrows(CompletionException.class, dataFuture::join); + assertTrue(actual.getCause() instanceof IllegalStateException); + assertEquals("Failed to deserialize context data response", actual.getCause().getMessage()); + } + + @Test + void getContextDataEmptyResponseThrowsExceptionally() { + final HTTPClient httpClient = mock(HTTPClient.class); + final ContextDataDeserializer deser = mock(ContextDataDeserializer.class); + final Client client = Client.create(ClientConfig.create() + .setEndpoint("https://localhost/v1") + .setAPIKey("test-api-key") + .setApplication("website") + .setEnvironment("dev") + .setContextDataDeserializer(deser), httpClient); + + final Map expectedQuery = mapOf( + "application", "website", + "environment", "dev"); + + when(httpClient.get("https://localhost/v1/context", expectedQuery, null)) + .thenReturn(CompletableFuture.completedFuture(new DefaultHTTPClient.DefaultResponse(200, "OK", + "application/json", new byte[0]))); + + final CompletableFuture dataFuture = client.getContextData(); + final CompletionException actual = assertThrows(CompletionException.class, dataFuture::join); + assertTrue(actual.getCause() instanceof IllegalStateException); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java new file mode 100644 index 0000000..719fb8f --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/ContextFixTest.java @@ -0,0 +1,89 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.ScheduledExecutorService; +import java8.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.absmartly.sdk.java.time.Clock; +import com.absmartly.sdk.json.ContextData; +import com.absmartly.sdk.json.Experiment; + +class ContextFixTest extends TestUtils { + + ContextDataProvider dataProvider; + ContextEventLogger eventLogger; + ContextEventHandler eventHandler; + VariableParser variableParser; + AudienceMatcher audienceMatcher; + ScheduledExecutorService scheduler; + Clock clock = Clock.fixed(1_620_000_000_000L); + + @BeforeEach + void setUp() { + dataProvider = mock(ContextDataProvider.class); + eventHandler = mock(ContextEventHandler.class); + eventLogger = mock(ContextEventLogger.class); + variableParser = new DefaultVariableParser(); + audienceMatcher = new AudienceMatcher(new DefaultAudienceDeserializer()); + scheduler = mock(ScheduledExecutorService.class); + } + + Context createReadyContext(ContextData data) { + final ContextConfig config = ContextConfig.create() + .setUnit("session_id", "e791e240fcd3df7d238cfc285f475e8152fcc0ec"); + + return Context.create(clock, config, scheduler, CompletableFuture.completedFuture(data), dataProvider, + eventHandler, eventLogger, variableParser, audienceMatcher); + } + + @Test + void setDataWithNullVariantsDoesNotThrowNPE() { + final ContextData data = new ContextData(); + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_test"; + experiment.unitType = "session_id"; + experiment.variants = null; + data.experiments = new Experiment[]{experiment}; + + assertDoesNotThrow(() -> createReadyContext(data)); + } + + @Test + void setDataWithNullCustomFieldValuesDoesNotThrowNPE() { + final ContextData data = new ContextData(); + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_test"; + experiment.unitType = "session_id"; + experiment.variants = new com.absmartly.sdk.json.ExperimentVariant[0]; + experiment.customFieldValues = null; + data.experiments = new Experiment[]{experiment}; + + assertDoesNotThrow(() -> createReadyContext(data)); + } + + @Test + void setDataWithNullExperimentVariantsReturnsDefaultTreatment() { + final ContextData data = new ContextData(); + final Experiment experiment = new Experiment(); + experiment.id = 1; + experiment.name = "exp_test"; + experiment.unitType = "session_id"; + experiment.variants = null; + experiment.trafficSplit = new double[]{1.0}; + experiment.split = new double[]{1.0}; + data.experiments = new Experiment[]{experiment}; + + final Context context = createReadyContext(data); + assertTrue(context.isReady()); + + int treatment = context.getTreatment("exp_test"); + assertEquals(0, treatment); + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java index 0abe1c0..311540c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/ContextTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/ContextTest.java @@ -1,9 +1,11 @@ package com.absmartly.sdk; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -21,6 +23,8 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -31,6 +35,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentMatchers; import org.mockito.Mockito; @@ -229,6 +234,29 @@ void becomesReadyAndFailedWithException() { assertTrue(context.isFailed()); } + @Test + void readyErrorReturnsNullOnSuccess() { + final Context context = createReadyContext(); + assertNull(context.readyError()); + } + + @Test + void readyErrorReturnsExceptionOnFailedFuture() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isFailed()); + assertNotNull(context.readyError()); + } + + @Test + void readyErrorReturnsExceptionOnAsyncFailure() { + final Context context = createContext(dataFuture); + final Exception error = new Exception("FAILED"); + dataFuture.completeExceptionally(error); + context.waitUntilReady(); + assertTrue(context.isFailed()); + assertNotNull(context.readyError()); + } + @Test void callsEventLoggerWhenReady() { final Context context = createContext(dataFuture); @@ -321,21 +349,34 @@ void throwsWhenNotReady() { assertFalse(context.isReady()); assertFalse(context.isFailed()); - final String notReadyMessage = "ABSmartly Context is not yet ready"; - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.getTreatment("exp_test_ab")).getMessage()); + final String notReadyMessage = "ABSmartly Context is not yet ready."; assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); - assertEquals(notReadyMessage, assertThrows(IllegalStateException.class, context::getExperiments).getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.getVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(notReadyMessage, - assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertEquals(new HashMap<>(), context.getVariableKeys()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); + } + + @Test + void returnsDefaultsWhenNotReady() { + final Context context = createContext(dataFuture); + assertFalse(context.isReady()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals("default", context.getVariableValue("banner.border", "default")); + assertEquals("default", context.peekVariableValue("banner.border", "default")); + assertTrue(context.getVariableKeys().isEmpty()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); } @Test @@ -354,17 +395,12 @@ void throwsWhenClosing() { assertTrue(context.isClosing()); assertFalse(context.isClosed()); - final String closingMessage = "ABSmartly Context is closing"; + final String closingMessage = "ABSmartly Context is closing."; assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setAttributes(mapOf("attr1", "value1"))) - .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.setOverride("exp_test_ab", 2)).getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.setOverrides(mapOf("exp_test_ab", 2))) .getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setUnit("test", "test")) @@ -375,24 +411,21 @@ void throwsWhenClosing() { assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.setCustomAssignments(mapOf("exp_test_ab", 2))) - .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.getTreatment("exp_test_ab")).getMessage()); + .getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, () -> context.track("goal1", null)).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::publish).getMessage()); assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); - assertEquals(closingMessage, assertThrows(IllegalStateException.class, context::getExperiments).getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.getVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closingMessage, - assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertEquals(new HashMap<>(), context.getVariableKeys()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); } @Test @@ -410,17 +443,12 @@ void throwsWhenClosed() { assertFalse(context.isClosing()); assertTrue(context.isClosed()); - final String closedMessage = "ABSmartly Context is closed"; + final String closedMessage = "ABSmartly Context is finalized."; assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttribute("attr1", "value1")).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setAttributes(mapOf("attr1", "value1"))) - .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.setOverride("exp_test_ab", 2)).getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.setOverrides(mapOf("exp_test_ab", 2))) .getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setUnit("test", "test")) @@ -431,24 +459,56 @@ void throwsWhenClosed() { assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.setCustomAssignments(mapOf("exp_test_ab", 2))) - .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.peekTreatment("exp_test_ab")).getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.getTreatment("exp_test_ab")).getMessage()); + .getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, () -> context.track("goal1", null)).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::publish).getMessage()); assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::getData).getMessage()); - assertEquals(closedMessage, assertThrows(IllegalStateException.class, context::getExperiments).getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.getVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, () -> context.peekVariableValue("banner.border", 17)) - .getMessage()); - assertEquals(closedMessage, - assertThrows(IllegalStateException.class, context::getVariableKeys).getMessage()); + + assertEquals(0, context.peekTreatment("exp_test_ab")); + assertEquals(0, context.getTreatment("exp_test_ab")); + assertArrayEquals(new String[0], context.getExperiments()); + assertEquals(17, context.getVariableValue("banner.border", 17)); + assertEquals(17, context.peekVariableValue("banner.border", 17)); + assertEquals(new HashMap<>(), context.getVariableKeys()); + assertArrayEquals(new String[0], context.getCustomFieldKeys()); + assertNull(context.getCustomFieldValue("exp_test_ab", "key")); + assertNull(context.getCustomFieldValueType("exp_test_ab", "key")); + } + + @Test + void isFinalizedAliasesIsClosedAfterClose() { + final Context context = createReadyContext(); + assertFalse(context.isFinalized()); + assertFalse(context.isFinalizing()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.close(); + + assertTrue(context.isFinalized()); + assertTrue(context.isClosed()); + } + + @Test + void finalizeAsyncIsAliasForCloseAsync() { + final Context context = createReadyContext(); + assertFalse(context.isFinalized()); + + context.track("goal1", mapOf("amount", 125, "hours", 245)); + + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture finalizeFuture = context.finalizeAsync(); + assertTrue(context.isFinalizing()); + assertFalse(context.isFinalized()); + + publishFuture.complete(null); + finalizeFuture.join(); + + assertTrue(context.isFinalized()); + assertFalse(context.isFinalizing()); } @Test @@ -473,10 +533,10 @@ void startsRefreshTimerWhenReady() { final AtomicReference runnable = new AtomicReference<>(null); when(scheduler.scheduleWithFixedDelay(any(), eq(config.getRefreshInterval()), eq(config.getRefreshInterval()), eq(TimeUnit.MILLISECONDS))) - .thenAnswer(invokation -> { - runnable.set(invokation.getArgument(0)); - return mock(ScheduledFuture.class); - }); + .thenAnswer(invokation -> { + runnable.set(invokation.getArgument(0)); + return mock(ScheduledFuture.class); + }); dataFuture.complete(data); context.waitUntilReady(); @@ -1814,7 +1874,7 @@ void closeStopsRefreshTimer() { final ScheduledFuture refreshTimer = mock(ScheduledFuture.class); when(scheduler.scheduleWithFixedDelay(any(), eq(config.getRefreshInterval()), eq(config.getRefreshInterval()), eq(TimeUnit.MILLISECONDS))) - .thenReturn(refreshTimer); + .thenReturn(refreshTimer); final Context context = createContext(config, dataFutureReady); assertTrue(context.isReady()); @@ -2203,4 +2263,755 @@ void getCustomFieldValue() { assertNull(context.getCustomFieldValue("exp_test_no_custom_fields", "languages")); assertNull(context.getCustomFieldValueType("exp_test_no_custom_fields", "languages")); } + + @Test + void getTreatmentQueuesExposureAfterPeek() { + final Context context = createReadyContext(); + + Arrays.stream(data.experiments).forEach(experiment -> context.peekTreatment(experiment.name)); + context.peekTreatment("not_found"); + + assertEquals(0, context.getPendingCount()); + + Arrays.stream(data.experiments).forEach(experiment -> context.getTreatment(experiment.name)); + context.getTreatment("not_found"); + + assertEquals(1 + data.experiments.length, context.getPendingCount()); + } + + @Test + void getTreatmentQueuesExposureWithBaseVariantOnUnknownExperiment() { + final Context context = createReadyContext(); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + + expected.exposures = new Exposure[]{ + new Exposure(0, "not_found", null, 0, clock.millis(), false, true, false, false, false, false), + }; + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void getTreatmentDoesNotReQueueExposureOnUnknownExperiment() { + final Context context = createReadyContext(); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(1, context.getPendingCount()); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + assertEquals(0, context.getTreatment("not_found")); + assertEquals(0, context.getPendingCount()); + } + + @Test + void getTreatmentQueuesExposureWithCustomAssignmentVariant() { + final Context context = createReadyContext(); + + context.setCustomAssignment("exp_test_ab", 2); + + assertEquals(2, context.getTreatment("exp_test_ab")); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + + expected.exposures = new Exposure[]{ + new Exposure(1, "exp_test_ab", "session_id", 2, clock.millis(), true, true, false, false, true, false), + }; + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void getVariableValueReturnsDefaultValueWhenUnassigned() { + final Context context = createReadyContext(); + + assertEquals(17, context.getVariableValue("card.width", 17)); + } + + @Test + void getVariableValueReturnsDefaultValueOnUnknownVariable() { + final Context context = createReadyContext(); + + assertEquals("default", context.getVariableValue("unknown_variable", "default")); + assertEquals(0, context.getPendingCount()); + } + + @Test + void getVariableValueReturnsVariableValuesWhenOverridden() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 0); + + assertEquals(17, context.getVariableValue("banner.border", 17)); + } + + @Test + void getVariableValueQueuesExposureAfterPeekVariableValue() { + final Context context = createReadyContext(); + + context.peekVariableValue("banner.border", 17); + context.peekVariableValue("banner.size", 17); + + assertEquals(0, context.getPendingCount()); + + context.getVariableValue("banner.border", 17); + context.getVariableValue("banner.size", 17); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void getVariableValueQueuesExposureOnce() { + final Context context = createReadyContext(); + + context.getVariableValue("banner.border", 17); + context.getVariableValue("banner.size", 17); + + assertEquals(1, context.getPendingCount()); + + context.getVariableValue("banner.border", 17); + context.getVariableValue("banner.size", 17); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void peekVariableValueReturnsDefaultValueWhenUnassigned() { + final Context context = createReadyContext(); + + assertEquals(17, context.peekVariableValue("card.width", 17)); + } + + @Test + void peekVariableValueReturnsVariableValuesWhenOverridden() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 0); + + assertEquals(17, context.peekVariableValue("banner.border", 17)); + } + + @Test + void peekVariableValueReturnsDefaultValueOnUnknownOverrideVariant() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 15); + + assertEquals(17, context.peekVariableValue("banner.border", 17)); + } + + @Test + void refreshKeepsOverrides() { + final Context context = createReadyContext(); + + context.setOverride("exp_test_ab", 5); + assertEquals(5, context.getOverride("exp_test_ab")); + + when(dataProvider.getContextData()).thenReturn(refreshDataFutureReady); + + context.refresh(); + + assertEquals(5, context.getOverride("exp_test_ab")); + assertEquals(5, context.getTreatment("exp_test_ab")); + } + + @Test + void refreshKeepsCustomAssignments() { + final Context context = createReadyContext(); + + context.setCustomAssignment("exp_test_ab", 2); + assertEquals(2, context.getCustomAssignment("exp_test_ab")); + + when(dataProvider.getContextData()).thenReturn(refreshDataFutureReady); + + context.refresh(); + + assertEquals(2, context.getCustomAssignment("exp_test_ab")); + assertEquals(2, context.getTreatment("exp_test_ab")); + } + + @Test + void refreshDoesNotCallPublishWhenFailed() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isReady()); + assertTrue(context.isFailed()); + + when(dataProvider.getContextData()).thenReturn(refreshDataFutureReady); + + context.refresh(); + + verify(dataProvider, Mockito.timeout(5000).times(1)).getContextData(); + } + + @Test + void publishIncludesExposureData() { + final Context context = createReadyContext(); + + context.getTreatment("exp_test_ab"); + + assertEquals(1, context.getPendingCount()); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.exposures = new Exposure[]{ + new Exposure(1, "exp_test_ab", "session_id", 1, clock.millis(), true, true, false, false, false, false), + }; + + when(eventHandler.publish(context, expected)).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void publishIncludesGoalData() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125, "hours", 245)); + + assertEquals(1, context.getPendingCount()); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.goals = new GoalAchievement[]{ + new GoalAchievement("goal1", clock.millis(), new TreeMap<>(mapOf("amount", 125, "hours", 245))), + }; + + when(eventHandler.publish(context, expected)).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void publishIncludesAttributeData() { + final ContextConfig config = ContextConfig.create() + .setUnits(units) + .setAttributes(mapOf("attr1", "value1")); + + final Context context = createContext(config, dataFutureReady); + + context.track("goal1", null); + + final PublishEvent expected = new PublishEvent(); + expected.hashed = true; + expected.publishedAt = clock.millis(); + expected.units = publishUnits; + expected.goals = new GoalAchievement[]{ + new GoalAchievement("goal1", clock.millis(), null), + }; + expected.attributes = new Attribute[]{ + new Attribute("attr1", "value1", clock.millis()), + }; + + when(eventHandler.publish(context, expected)).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(context, expected); + } + + @Test + void publishClearsQueueOnSuccess() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.publish(); + + assertEquals(0, context.getPendingCount()); + } + + @Test + void publishPropagatesClientErrorOnFailure() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + final Exception failure = new Exception("publish error"); + when(eventHandler.publish(any(), any())).thenReturn(failedFuture(failure)); + + final CompletionException actual = assertThrows(CompletionException.class, context::publish); + assertSame(failure, actual.getCause()); + } + + @Test + void closeDoesNotCallEventHandlerWhenQueueIsEmpty() { + final Context context = createReadyContext(); + assertEquals(0, context.getPendingCount()); + + context.close(); + + assertTrue(context.isClosed()); + verify(eventHandler, Mockito.timeout(5000).times(0)).publish(any(), any()); + } + + @Test + void closeCallsEventHandlerWithPendingData() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + context.close(); + + assertTrue(context.isClosed()); + verify(eventHandler, Mockito.timeout(5000).times(1)).publish(any(), any()); + } + + @Test + void closeDoesNotCallEventHandlerWhenFailed() { + final Context context = createContext(dataFutureFailed); + assertTrue(context.isReady()); + assertTrue(context.isFailed()); + + context.getTreatment("exp_test_abc"); + context.track("goal1", mapOf("amount", 125)); + + context.close(); + + assertTrue(context.isClosed()); + verify(eventHandler, Mockito.timeout(5000).times(0)).publish(any(), any()); + } + + @Test + void closeAsyncReturnsSameFutureWhenCalledTwice() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + + final CompletableFuture publishFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture); + + final CompletableFuture closeFuture1 = context.closeAsync(); + final CompletableFuture closeFuture2 = context.closeAsync(); + + assertSame(closeFuture1, closeFuture2); + + publishFuture.complete(null); + closeFuture1.join(); + + assertTrue(context.isClosed()); + } + + @Test + void closeAsyncReturnsCompletedFutureWhenAlreadyClosed() { + final Context context = createReadyContext(); + + context.close(); + assertTrue(context.isClosed()); + + final CompletableFuture closeFuture = context.closeAsync(); + assertTrue(closeFuture.isDone()); + } + + @Test + void trackQueuesGoalWithProperties() { + final Context context = createReadyContext(); + + final Map properties = mapOf("amount", 125, "hours", 245); + context.track("goal1", properties); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void trackQueuesGoalWithNullProperties() { + final Context context = createReadyContext(); + + context.track("goal1", null); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void getVariableValueConflictingKeyOverlappingAudiences() { + for (final Experiment experiment : data.experiments) { + switch (experiment.name) { + case "exp_test_ab": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"arrow\"}"; + break; + case "exp_test_abc": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"circle\"}"; + break; + default: + break; + } + } + + final Context context = createReadyContext(data); + context.setAttribute("age", 25); + assertEquals("arrow", context.getVariableValue("icon", "square")); + assertEquals(1, context.getPendingCount()); + } + + @Test + void publishKeepsEventsPendingOnFailure() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + final Exception failure = new Exception("PUBLISH_FAILED"); + when(eventHandler.publish(any(), any())).thenReturn(failedFuture(failure)); + + assertThrows(CompletionException.class, context::publish); + + assertEquals(1, context.getPendingCount()); + } + + @Test + void setOverrideSucceedsAfterClose() { + final Context context = createReadyContext(); + + context.close(); + assertTrue(context.isClosed()); + + context.setOverride("exp_test_ab", 2); + assertEquals(2, context.getOverride("exp_test_ab")); + } + + @Test + void setOverridesSucceedsAfterClose() { + final Context context = createReadyContext(); + + context.close(); + assertTrue(context.isClosed()); + + context.setOverrides(mapOf("exp_test_ab", 2, "exp_test_abc", 1)); + assertEquals(2, context.getOverride("exp_test_ab")); + assertEquals(1, context.getOverride("exp_test_abc")); + } + + @Test + void peekVariableValueConflictingKeyOverlappingAudiences() { + for (final Experiment experiment : data.experiments) { + switch (experiment.name) { + case "exp_test_ab": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"arrow\"}"; + break; + case "exp_test_abc": + assert (expectedVariants.get(experiment.name) != 0); + experiment.audienceStrict = true; + experiment.audience = "{\"filter\":[{\"gte\":[{\"var\":\"age\"},{\"value\":20}]}]}"; + experiment.variants[expectedVariants.get(experiment.name)].config = "{\"icon\":\"circle\"}"; + break; + default: + break; + } + } + + final Context context = createReadyContext(data); + context.setAttribute("age", 25); + assertEquals("arrow", context.peekVariableValue("icon", "square")); + assertEquals(0, context.getPendingCount()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void concurrentProducerDuringFailedPublishRestoresBothEvents() { + final Context context = createReadyContext(); + + context.track("goal_a", mapOf("amount", 1)); + assertEquals(1, context.getPendingCount()); + + final CompletableFuture publishFuture1 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); + + final CompletableFuture asyncResult1 = context.publishAsync(); + assertEquals(0, context.getPendingCount()); + + context.track("goal_b", mapOf("amount", 2)); + assertEquals(1, context.getPendingCount()); + + final Exception failure = new Exception("publish failed"); + publishFuture1.completeExceptionally(failure); + + assertThrows(CompletionException.class, asyncResult1::join); + assertEquals(2, context.getPendingCount()); + + final CompletableFuture publishFuture2 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); + + final CompletableFuture asyncResult2 = context.publishAsync(); + assertEquals(0, context.getPendingCount()); + + publishFuture2.complete(null); + asyncResult2.join(); + + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void closeAsyncRetryAfterFailedPublishSucceeds() { + final Context context = createReadyContext(); + + context.track("goal_a", mapOf("amount", 1)); + assertEquals(1, context.getPendingCount()); + + final CompletableFuture publishFuture1 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture1); + + final CompletableFuture closeFuture1 = context.closeAsync(); + assertFalse(context.isClosed()); + + final Exception failure = new Exception("publish failed"); + publishFuture1.completeExceptionally(failure); + + final CompletionException actual = assertThrows(CompletionException.class, closeFuture1::join); + assertSame(failure, actual.getCause()); + + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture publishFuture2 = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publishFuture2); + + final CompletableFuture closeFuture2 = context.closeAsync(); + assertFalse(closeFuture2 == closeFuture1, "second closeAsync must return a new future"); + + publishFuture2.complete(null); + closeFuture2.join(); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + + verify(eventHandler, Mockito.times(2)).publish(any(), any()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCanCloseContextInline() throws Exception { + final Context context = createReadyContext(); + final AtomicReference> closeResult = new AtomicReference<>(); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + return result; + }); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publishResult = context.publishAsync(); + publishResult.get(2, TimeUnit.SECONDS); + closeResult.get().get(2, TimeUnit.SECONDS); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCanCloseContextFromWorkerThread() throws Exception { + final Context context = createReadyContext(); + final AtomicReference> closeResult = new AtomicReference<>(); + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + final CompletableFuture transportResult = new CompletableFuture<>(); + final Thread worker = new Thread(() -> { + try { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + result.get(2, TimeUnit.SECONDS); + transportResult.complete(null); + } catch (Exception exception) { + transportResult.completeExceptionally(exception); + } + }); + worker.setDaemon(true); + worker.start(); + return transportResult; + }); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publishResult = context.publishAsync(); + publishResult.get(2, TimeUnit.SECONDS); + closeResult.get().get(2, TimeUnit.SECONDS); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publisherCanCloseContextFromExecutorCallback() throws Exception { + final Context context = createReadyContext(); + final ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { + final Thread thread = new Thread(runnable); + thread.setDaemon(true); + return thread; + }); + final AtomicReference> closeResult = new AtomicReference<>(); + try { + when(eventHandler.publish(any(), any())).thenAnswer(invocation -> { + final CompletableFuture transportResult = new CompletableFuture<>(); + executor.execute(() -> { + try { + final CompletableFuture result = context.closeAsync(); + closeResult.set(result); + result.get(2, TimeUnit.SECONDS); + transportResult.complete(null); + } catch (Exception exception) { + transportResult.completeExceptionally(exception); + } + }); + return transportResult; + }); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publishResult = context.publishAsync(); + publishResult.get(2, TimeUnit.SECONDS); + closeResult.get().get(2, TimeUnit.SECONDS); + + assertTrue(context.isClosed()); + assertEquals(0, context.getPendingCount()); + } finally { + executor.shutdownNow(); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void publishSucceedsWhenLoggerThrowsOnPublishEvent() { + final Context context = createReadyContext(); + + context.track("goal1", mapOf("amount", 125)); + assertEquals(1, context.getPendingCount()); + + when(eventHandler.publish(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + Mockito.doThrow(new RuntimeException("logger failure")) + .when(eventLogger).handleEvent(any(), eq(ContextEventLogger.EventType.Publish), any()); + + assertDoesNotThrow(context::publish); + assertEquals(0, context.getPendingCount()); + + // no new events were queued, so a retry must not re-deliver the same batch + context.publishAsync().join(); + assertEquals(0, context.getPendingCount()); + + verify(eventHandler, Mockito.times(1)).publish(any(), any()); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void synchronousPublisherFailureSettlesWhenErrorLoggerThrows() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException publisherFailure = new RuntimeException("publisher threw"); + when(eventHandler.publish(any(), any())).thenThrow(publisherFailure); + Mockito.doThrow(new RuntimeException("logger threw")) + .when(eventLogger).handleEvent(any(), eq(ContextEventLogger.EventType.Error), any()); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(publisherFailure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture closeResult = assertDoesNotThrow(context::closeAsync); + assertTrue(closeResult.isDone()); + assertThrows(CompletionException.class, closeResult::join); + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void synchronousPublisherFailureRestoresEventsAndDoesNotHangClose() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final RuntimeException failure = new RuntimeException("publisher threw"); + when(eventHandler.publish(any(), any())).thenThrow(failure); + + final CompletableFuture publishResult = assertDoesNotThrow(context::publishAsync); + final CompletionException actual = assertThrows(CompletionException.class, publishResult::join); + assertSame(failure, actual.getCause()); + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture closeResult = assertDoesNotThrow(context::closeAsync); + assertTrue(closeResult.isDone()); + assertThrows(CompletionException.class, closeResult::join); + assertFalse(context.isClosed()); + assertTrue(context.getPendingCount() > 0); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void cancellingPublishResultDoesNotDisturbPublishAccounting() { + final Context context = createReadyContext(); + context.track("goal", mapOf("amount", 1)); + + final CompletableFuture publisherFuture = new CompletableFuture<>(); + when(eventHandler.publish(any(), any())).thenReturn(publisherFuture) + .thenReturn(CompletableFuture.completedFuture(null)); + + final CompletableFuture publishResult = context.publishAsync(); + assertTrue(publishResult.cancel(false)); + + final RuntimeException failure = new RuntimeException("publish failed"); + publisherFuture.completeExceptionally(failure); + + assertTrue(context.getPendingCount() > 0); + + final CompletableFuture retryPublish = assertDoesNotThrow(context::publishAsync); + retryPublish.join(); + assertEquals(0, context.getPendingCount()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java index d1966d8..ea4141e 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataDeserializerTest.java @@ -123,4 +123,115 @@ void deserializeDoesNotThrow() { assertNull(data); }); } + + @Test + void testMalformedJsonResponse() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] malformedJson = "{\"experiments\": [".getBytes(); + final ContextData result = deser.deserialize(malformedJson, 0, malformedJson.length); + assertNull(result); + + final byte[] invalidJson = "not a json at all".getBytes(); + final ContextData result2 = deser.deserialize(invalidJson, 0, invalidJson.length); + assertNull(result2); + + final byte[] emptyBraces = "{}".getBytes(); + final ContextData result3 = deser.deserialize(emptyBraces, 0, emptyBraces.length); + assertNotNull(result3); + + final byte[] emptyArray = "[]".getBytes(); + final ContextData result4 = deser.deserialize(emptyArray, 0, emptyArray.length); + assertNull(result4); + } + + @Test + void testEmptyExperimentsArray() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] emptyExperiments = "{\"experiments\": []}".getBytes(); + final ContextData result = deser.deserialize(emptyExperiments, 0, emptyExperiments.length); + assertNotNull(result); + assertNotNull(result.experiments); + assertEquals(0, result.experiments.length); + } + + @Test + void testPartialResponseHandling() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] partialExperiment = "{\"experiments\": [{\"id\": 1, \"name\": \"test\"}]}".getBytes(); + final ContextData result = deser.deserialize(partialExperiment, 0, partialExperiment.length); + assertNotNull(result); + assertNotNull(result.experiments); + assertEquals(1, result.experiments.length); + assertEquals(1, result.experiments[0].id); + assertEquals("test", result.experiments[0].name); + assertNull(result.experiments[0].unitType); + assertNull(result.experiments[0].variants); + assertNull(result.experiments[0].split); + + final byte[] missingVariants = ("{\"experiments\": [{" + + "\"id\": 1, " + + "\"name\": \"exp_test\", " + + "\"unitType\": \"session_id\", " + + "\"iteration\": 1, " + + "\"seedHi\": 100, " + + "\"seedLo\": 200" + + "}]}").getBytes(); + final ContextData result2 = deser.deserialize(missingVariants, 0, missingVariants.length); + assertNotNull(result2); + assertNotNull(result2.experiments); + assertEquals(1, result2.experiments.length); + assertNull(result2.experiments[0].variants); + } + + @Test + void testNullFieldsInExperiment() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] withNulls = ("{\"experiments\": [{" + + "\"id\": 1, " + + "\"name\": \"exp_test\", " + + "\"unitType\": \"session_id\", " + + "\"iteration\": 1, " + + "\"seedHi\": 100, " + + "\"seedLo\": 200, " + + "\"split\": null, " + + "\"trafficSplit\": null, " + + "\"variants\": null, " + + "\"audience\": null" + + "}]}").getBytes(); + final ContextData result = deser.deserialize(withNulls, 0, withNulls.length); + assertNotNull(result); + assertNotNull(result.experiments); + assertEquals(1, result.experiments.length); + assertNull(result.experiments[0].split); + assertNull(result.experiments[0].trafficSplit); + assertNull(result.experiments[0].variants); + assertNull(result.experiments[0].audience); + } + + @Test + void testEmptyByteArray() { + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final byte[] empty = new byte[0]; + final ContextData result = deser.deserialize(empty, 0, 0); + assertNull(result); + } + + @Test + void testOffsetAndLength() { + final byte[] bytes = getResourceBytes("context.json"); + final ContextDataDeserializer deser = new DefaultContextDataDeserializer(); + + final ContextData partialResult = deser.deserialize(bytes, 0, 10); + assertNull(partialResult); + + final ContextData fullResult = deser.deserialize(bytes, 0, bytes.length); + assertNotNull(fullResult); + assertNotNull(fullResult.experiments); + assertTrue(fullResult.experiments.length > 0); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java index 487acb8..9ff8365 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultContextDataProviderTest.java @@ -43,4 +43,60 @@ void getContextDataExceptionally() { verify(client, Mockito.timeout(5000).times(1)).getContextData(); } + + @Test + void getContextDataWithEmptyExperiments() throws ExecutionException, InterruptedException { + final Client client = mock(Client.class); + final ContextDataProvider provider = new DefaultContextDataProvider(client); + + final ContextData emptyData = new ContextData(); + emptyData.experiments = new com.absmartly.sdk.json.Experiment[0]; + when(client.getContextData()).thenReturn(CompletableFuture.completedFuture(emptyData)); + + final CompletableFuture dataFuture = provider.getContextData(); + final ContextData actual = dataFuture.get(); + + assertNotNull(actual); + assertNotNull(actual.experiments); + assertEquals(0, actual.experiments.length); + } + + @Test + void getContextDataMultipleCalls() throws ExecutionException, InterruptedException { + final Client client = mock(Client.class); + final ContextDataProvider provider = new DefaultContextDataProvider(client); + + final ContextData firstData = new ContextData(); + final ContextData secondData = new ContextData(); + when(client.getContextData()) + .thenReturn(CompletableFuture.completedFuture(firstData)) + .thenReturn(CompletableFuture.completedFuture(secondData)); + + final CompletableFuture firstFuture = provider.getContextData(); + final ContextData firstActual = firstFuture.get(); + assertSame(firstData, firstActual); + + final CompletableFuture secondFuture = provider.getContextData(); + final ContextData secondActual = secondFuture.get(); + assertSame(secondData, secondActual); + + verify(client, Mockito.timeout(5000).times(2)).getContextData(); + } + + @Test + void getContextDataWithTimeoutException() { + final Client client = mock(Client.class); + final ContextDataProvider provider = new DefaultContextDataProvider(client); + + final java.util.concurrent.TimeoutException timeoutException = new java.util.concurrent.TimeoutException( + "Request timed out"); + final CompletableFuture failedFuture = failedFuture(timeoutException); + when(client.getContextData()).thenReturn(failedFuture); + + final CompletableFuture dataFuture = provider.getContextData(); + final CompletionException actual = assertThrows(CompletionException.class, dataFuture::join); + assertTrue(actual.getCause() instanceof java.util.concurrent.TimeoutException); + + verify(client, Mockito.timeout(5000).times(1)).getContextData(); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java index bb42d66..4af971b 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientConfigTest.java @@ -59,4 +59,57 @@ void setHttpVersionPolicy() { .setHTTPVersionPolicy(HTTPVersionPolicy.FORCE_HTTP_1); assertEquals(HTTPVersionPolicy.FORCE_HTTP_1, config.getHTTPVersionPolicy()); } + + @Test + void testNegativeConnectTimeout() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setConnectTimeout(-1); + assertEquals(-1, config.getConnectTimeout()); + } + + @Test + void testNegativeConnectionKeepAlive() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setConnectionKeepAlive(-1); + assertEquals(-1, config.getConnectionKeepAlive()); + } + + @Test + void testNegativeConnectionRequestTimeout() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setConnectionRequestTimeout(-1); + assertEquals(-1, config.getConnectionRequestTimeout()); + } + + @Test + void testNegativeRetryInterval() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setRetryInterval(-1); + assertEquals(-1, config.getRetryInterval()); + } + + @Test + void testZeroMaxRetries() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setMaxRetries(0); + assertEquals(0, config.getMaxRetries()); + } + + @Test + void testNegativeMaxRetries() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create() + .setMaxRetries(-1); + assertEquals(-1, config.getMaxRetries()); + } + + @Test + void testDefaultValues() { + final DefaultHTTPClientConfig config = DefaultHTTPClientConfig.create(); + assertEquals(3000, config.getConnectTimeout()); + assertEquals(30000, config.getConnectionKeepAlive()); + assertEquals(1000, config.getConnectionRequestTimeout()); + assertEquals(5, config.getMaxRetries()); + assertEquals(333, config.getRetryInterval()); + assertEquals(HTTPVersionPolicy.NEGOTIATE, config.getHTTPVersionPolicy()); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java index b868892..3162025 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientRetryStrategyTest.java @@ -64,4 +64,24 @@ void getRetryInterval() { assertTrue(Math.abs(maxIntervalMs - previous) <= 1); } + + @Test + void doesNotRetryNonRetryableCodes() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(7, 1_000); + final HttpContext context = new BasicHttpContext(); + + for (int code : setOf(200, 400, 404, 500, 504)) { + assertFalse(strategy.retryRequest(new SimpleHttpResponse(code), 1, context)); + } + } + + @Test + void zeroMaxRetriesDisablesRetrying() { + final DefaultHTTPClientRetryStrategy strategy = new DefaultHTTPClientRetryStrategy(0, 1_000); + final HttpRequest request = SimpleRequestBuilder.get("http://localhost/v1/context").build(); + final HttpContext context = new BasicHttpContext(); + + assertFalse(strategy.retryRequest(new SimpleHttpResponse(503), 1, context)); + assertFalse(strategy.retryRequest(request, new ConnectTimeoutException("timeout"), 1, context)); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java index 0a3a4fd..150110b 100644 --- a/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/DefaultHTTPClientTest.java @@ -4,12 +4,15 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import java.net.SocketTimeoutException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java8.util.concurrent.CompletableFuture; import java8.util.concurrent.CompletionException; +import org.apache.hc.client5.http.ConnectTimeoutException; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; @@ -22,6 +25,7 @@ import org.apache.hc.core5.util.TimeValue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -286,4 +290,130 @@ void post() throws ExecutionException, InterruptedException { verify(asyncHTTPClient, Mockito.timeout(5000).times(1)).execute(any(), any()); } } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void testConnectionTimeout() { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create( + DefaultHTTPClientConfig.create().setConnectTimeout(100)); + + final ConnectTimeoutException timeoutException = new ConnectTimeoutException("Connection timed out"); + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.failed(timeoutException); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + + final CompletionException thrown = assertThrows(CompletionException.class, responseFuture::join); + assertSame(timeoutException, thrown.getCause()); + assertTrue(thrown.getCause() instanceof ConnectTimeoutException); + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + void testReadTimeout() { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); + + final SocketTimeoutException readTimeoutException = new SocketTimeoutException("Read timed out"); + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.failed(readTimeoutException); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + + final CompletionException thrown = assertThrows(CompletionException.class, responseFuture::join); + assertSame(readTimeoutException, thrown.getCause()); + assertTrue(thrown.getCause() instanceof SocketTimeoutException); + } + } + + @Test + void testRateLimiting429Response() throws ExecutionException, InterruptedException { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); + + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.completed(SimpleHttpResponse.create(429, "Too Many Requests".getBytes(), + ContentType.TEXT_PLAIN)); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + final HTTPClient.Response response = responseFuture.get(); + + assertEquals(429, response.getStatusCode()); + assertEquals("text/plain", response.getContentType()); + } + } + + @Test + void testRetryOnTransientError503Response() throws ExecutionException, InterruptedException { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create( + DefaultHTTPClientConfig.create().setMaxRetries(3).setRetryInterval(100)); + + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.completed(SimpleHttpResponse.create(503, "Service Unavailable".getBytes(), + ContentType.TEXT_PLAIN)); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + final HTTPClient.Response response = responseFuture.get(); + + assertEquals(503, response.getStatusCode()); + + verify(asyncHTTPClient, Mockito.timeout(5000).times(1)).execute(any(), any()); + } + } + + @Test + void testSSLCertificateValidation() { + try (final MockedStatic builderStatic = Mockito + .mockStatic(HttpAsyncClientBuilder.class)) { + builderStatic.when(HttpAsyncClientBuilder::create).thenReturn(asyncHTTPClientBuilder); + + final DefaultHTTPClient httpClient = DefaultHTTPClient.create(DefaultHTTPClientConfig.create()); + + final javax.net.ssl.SSLHandshakeException sslException = new javax.net.ssl.SSLHandshakeException( + "Certificate validation failed"); + when(asyncHTTPClient.execute(any(), any())).thenAnswer(invocation -> { + final FutureCallback callback = invocation.getArgument(1); + callback.failed(sslException); + return null; + }); + + final CompletableFuture responseFuture = httpClient + .get("https://api.absmartly.com/v1/context", null, null); + + final CompletionException thrown = assertThrows(CompletionException.class, responseFuture::join); + assertSame(sslException, thrown.getCause()); + assertTrue(thrown.getCause() instanceof javax.net.ssl.SSLHandshakeException); + } + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java b/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java new file mode 100644 index 0000000..8093e4e --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/LocalHttpServerIntegrationTest.java @@ -0,0 +1,221 @@ +package com.absmartly.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +/** + * Hermetic end-to-end integration test that drives the public SDK API against a real local + * HTTP server (JDK built-in {@link HttpServer}) on an ephemeral port. This exercises the + * SDK's real Apache HttpAsyncClient transport (no Java-level mocking) and asserts the + * on-the-wire request shape documented in the ABSmartly SDK ↔ Collector wire contract: + *
    + *
  • GET /context with {@code application}/{@code environment} query params and NO auth + * headers (Java authenticates the fetch via query params).
  • + *
  • PUT /context publish with the full auth header set and a JSON body containing + * {@code hashed}, {@code units}, {@code publishedAt}, plus {@code exposures}/{@code goals} + * when present.
  • + *
+ */ +class LocalHttpServerIntegrationTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static class RecordedRequest { + String method; + String path; + String rawQuery; + Map headers = new HashMap(); + byte[] body; + } + + private HttpServer server; + private String endpoint; + private final LinkedBlockingQueue requests = new LinkedBlockingQueue(); + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/context", exchange -> { + final RecordedRequest recorded = record(exchange); + requests.add(recorded); + + final byte[] responseBody; + if ("GET".equals(recorded.method)) { + // Minimal valid ContextData so the context reaches "ready". + responseBody = "{\"experiments\":[]}".getBytes(StandardCharsets.UTF_8); + } else { + responseBody = "{}".getBytes(StandardCharsets.UTF_8); + } + + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, responseBody.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(responseBody); + } + }); + server.start(); + + final int port = server.getAddress().getPort(); + endpoint = "http://127.0.0.1:" + port; + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + private static RecordedRequest record(final HttpExchange exchange) throws IOException { + final RecordedRequest recorded = new RecordedRequest(); + recorded.method = exchange.getRequestMethod(); + recorded.path = exchange.getRequestURI().getPath(); + recorded.rawQuery = exchange.getRequestURI().getRawQuery(); + for (final Map.Entry> header : exchange.getRequestHeaders().entrySet()) { + recorded.headers.put(header.getKey().toLowerCase(), String.join(",", header.getValue())); + } + recorded.body = readAll(exchange); + return recorded; + } + + private static byte[] readAll(final HttpExchange exchange) throws IOException { + final java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + final byte[] chunk = new byte[4096]; + int read; + while ((read = exchange.getRequestBody().read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + return buffer.toByteArray(); + } + + private static Map parseQuery(final String rawQuery) { + final Map params = new HashMap(); + if (rawQuery == null) { + return params; + } + for (final String pair : rawQuery.split("&")) { + final int eq = pair.indexOf('='); + if (eq < 0) { + continue; + } + try { + final String key = URLDecoder.decode(pair.substring(0, eq), "UTF-8"); + final String value = URLDecoder.decode(pair.substring(eq + 1), "UTF-8"); + params.put(key, value); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + return params; + } + + @Test + void drivesRealHttpForFetchAndPublish() throws Exception { + final String application = "www"; + final String environment = "test-env"; + + final ABSmartly sdk = ABSmartly.builder() + .endpoint(endpoint) + .apiKey("test-api-key") + .application(application) + .environment(environment) + .build(); + + try { + final ContextConfig contextConfig = ContextConfig.create() + .setUnit("user_id", "123456"); + + final Context context = sdk.createContext(contextConfig); + context.waitUntilReady(); + assertTrue(context.isReady()); + + // --- Assert the real GET /context fetch --- + final RecordedRequest get = requests.poll(5, TimeUnit.SECONDS); + assertNotNull(get, "expected a GET /context request"); + assertEquals("GET", get.method); + assertEquals("/context", get.path); + + final Map query = parseQuery(get.rawQuery); + assertEquals(application, query.get("application")); + assertEquals(environment, query.get("environment")); + + // Per the wire contract: Java sends NO auth headers on GET (auth via query params). + assertFalse(get.headers.containsKey("x-api-key"), "GET must not carry X-API-Key"); + assertFalse(get.headers.containsKey("x-application"), "GET must not carry X-Application"); + assertFalse(get.headers.containsKey("x-environment"), "GET must not carry X-Environment"); + + // --- Queue an exposure + a goal, then publish --- + context.getTreatment("an_experiment"); + + final Map properties = new HashMap(); + properties.put("value", 125); + context.track("a_goal", properties); + + assertTrue(context.getPendingCount() > 0, "expected pending events before publish"); + + context.publish(); + + // --- Assert the real PUT /context publish --- + final RecordedRequest put = requests.poll(5, TimeUnit.SECONDS); + assertNotNull(put, "expected a PUT /context request"); + assertEquals("PUT", put.method); + assertEquals("/context", put.path); + assertNull(put.rawQuery, "PUT must not carry query params"); + + assertEquals("test-api-key", put.headers.get("x-api-key")); + assertEquals(application, put.headers.get("x-application")); + assertEquals(environment, put.headers.get("x-environment")); + assertEquals("0", put.headers.get("x-application-version")); + assertNotNull(put.headers.get("x-agent"), "X-Agent must be present"); + assertFalse(put.headers.get("x-agent").isEmpty(), "X-Agent must be non-empty"); + assertTrue(put.headers.containsKey("content-type"), "Content-Type must be present"); + assertTrue(put.headers.get("content-type").contains("application/json"), + "Content-Type must be application/json, was: " + put.headers.get("content-type")); + + final JsonNode body = MAPPER.readTree(put.body); + assertTrue(body.has("hashed"), "body must contain 'hashed'"); + assertTrue(body.get("hashed").isBoolean()); + assertTrue(body.has("publishedAt"), "body must contain 'publishedAt'"); + assertTrue(body.get("publishedAt").canConvertToLong()); + + assertTrue(body.has("units"), "body must contain 'units'"); + assertTrue(body.get("units").isArray()); + assertTrue(body.get("units").size() > 0, "units must be non-empty"); + final JsonNode unit = body.get("units").get(0); + assertTrue(unit.has("type")); + assertTrue(unit.has("uid")); + assertEquals("user_id", unit.get("type").asText()); + + // We tracked a goal, so 'goals' must be present and non-empty. + assertTrue(body.has("goals"), "body must contain 'goals' after track()"); + assertTrue(body.get("goals").isArray()); + assertTrue(body.get("goals").size() > 0, "goals must be non-empty"); + assertEquals("a_goal", body.get("goals").get(0).get("name").asText()); + + // getTreatment queued an exposure, so 'exposures' must be present and non-empty. + assertTrue(body.has("exposures"), "body must contain 'exposures' after getTreatment()"); + assertTrue(body.get("exposures").isArray()); + assertTrue(body.get("exposures").size() > 0, "exposures must be non-empty"); + assertEquals("an_experiment", body.get("exposures").get(0).get("name").asText()); + } finally { + sdk.close(); + } + } +} diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java index b92c0a6..4757ed0 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/BuffersTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -101,4 +102,61 @@ void encodeUTF8() { assertEquals(expected.length, encodeLengthOffset); } } + + @Test + void encodeUTF8ByteWidths() { + assertEncoding("A", new byte[]{0x41}); + assertEncoding("é", new byte[]{(byte) 0xc3, (byte) 0xa9}); + assertEncoding("世", new byte[]{(byte) 0xe4, (byte) 0xb8, (byte) 0x96}); + } + + @Test + void encodeUTF8SurrogatePair() { + assertEncoding("😀", new byte[]{(byte) 0xf0, (byte) 0x9f, (byte) 0x98, (byte) 0x80}); + } + + @Test + void encodeUTF8MixedContentMatchesPlatformEncoder() { + final String value = "Aé世😀"; + assertEncoding(value, value.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void encodeUTF8UnpairedSurrogatesMatchLegacyEncoding() { + assertEncoding("\ud83d", new byte[]{(byte) 0xed, (byte) 0xa0, (byte) 0xbd}); + assertEncoding("\ude00", new byte[]{(byte) 0xed, (byte) 0xb8, (byte) 0x80}); + } + + @Test + void encodeUTF8MatchesLegacyEncodingExceptForSurrogatePairs() { + final List values = listOf("ASCII", "é", "世", "\ud83d", "\ude00", "Aé世\ud83dB\ude00"); + for (final String value : values) { + assertEncoding(value, encodeUTF8ByCodeUnit(value)); + } + } + + private void assertEncoding(String value, byte[] expected) { + final byte[] actual = new byte[expected.length]; + assertEquals(expected.length, Buffers.encodeUTF8(actual, 0, value)); + assertArrayEquals(expected, actual); + } + + private byte[] encodeUTF8ByCodeUnit(String value) { + final byte[] encoded = new byte[value.length() * 3]; + int offset = 0; + for (int i = 0; i < value.length(); ++i) { + final char c = value.charAt(i); + if (c < 0x80) { + encoded[offset++] = (byte) c; + } else if (c < 0x800) { + encoded[offset++] = (byte) (0xc0 | (c >> 6)); + encoded[offset++] = (byte) (0x80 | (c & 0x3f)); + } else { + encoded[offset++] = (byte) (0xe0 | (c >> 12)); + encoded[offset++] = (byte) (0x80 | ((c >> 6) & 0x3f)); + encoded[offset++] = (byte) (0x80 | (c & 0x3f)); + } + } + return Arrays.copyOf(encoded, offset); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java index ba1d46c..7ebed6c 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/ConcurrencyTest.java @@ -1,10 +1,10 @@ package com.absmartly.sdk.internal; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import java.util.Map; +import java.util.*; +import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantReadWriteLock; import java8.util.function.Function; @@ -14,6 +14,7 @@ import org.mockito.stubbing.Answer; import com.absmartly.sdk.TestUtils; + import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") diff --git a/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java b/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java index 697d3ff..238f044 100644 --- a/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/internal/hashing/HashingTest.java @@ -32,4 +32,17 @@ void testHashUnitLarge() { assertEquals("Rxnq-eM9eE1SEoMnkEMOIw", new String(Hashing.hashUnit(sb.toString()), StandardCharsets.US_ASCII)); } + + @Test + void testHashUnitAstralAndMultibyte() { + // These vectors pin canonical UTF-8 encoding: surrogate pairs must produce 4-byte sequences. + assertEquals("KgLqw51xanDs83V5GFkntg", + new String(Hashing.hashUnit("😀"), StandardCharsets.US_ASCII)); + assertEquals("ZJuDalvUWRJnVtkspj-2bQ", + new String(Hashing.hashUnit("😀😁"), StandardCharsets.US_ASCII)); + assertEquals("v2CJG7YcjjWncKOSCzF2GA", + new String(Hashing.hashUnit("世界你好"), StandardCharsets.US_ASCII)); + assertEquals("SCgk4OzXlFMvo1UMsP88fA", + new String(Hashing.hashUnit("user_世界_123"), StandardCharsets.US_ASCII)); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java index bcd27be..6e1e9c2 100644 --- a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/ExprEvaluatorTest.java @@ -333,4 +333,124 @@ void testCompareStrings() { assertEquals(8, evaluator.compare("9", "100")); assertEquals(-8, evaluator.compare("100", "9")); } + + @Test + void testNumberConvertNaN() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + final Double nanResult = evaluator.numberConvert(Double.NaN); + assertNotNull(nanResult); + assertTrue(Double.isNaN(nanResult)); + + final Double nanFromString = evaluator.numberConvert("NaN"); + assertNotNull(nanFromString); + assertTrue(Double.isNaN(nanFromString)); + } + + @Test + void testNumberConvertInfinity() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + final Double posInfResult = evaluator.numberConvert(Double.POSITIVE_INFINITY); + assertNotNull(posInfResult); + assertTrue(Double.isInfinite(posInfResult)); + assertTrue(posInfResult > 0); + + final Double negInfResult = evaluator.numberConvert(Double.NEGATIVE_INFINITY); + assertNotNull(negInfResult); + assertTrue(Double.isInfinite(negInfResult)); + assertTrue(negInfResult < 0); + + final Double infFromString = evaluator.numberConvert("Infinity"); + assertNotNull(infFromString); + assertTrue(Double.isInfinite(infFromString)); + + final Double negInfFromString = evaluator.numberConvert("-Infinity"); + assertNotNull(negInfFromString); + assertTrue(Double.isInfinite(negInfFromString)); + assertTrue(negInfFromString < 0); + } + + @Test + void testLargeNumberPrecision() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + assertEquals(Double.MAX_VALUE, evaluator.numberConvert(Double.MAX_VALUE)); + assertEquals(Double.MIN_VALUE, evaluator.numberConvert(Double.MIN_VALUE)); + assertEquals(-Double.MAX_VALUE, evaluator.numberConvert(-Double.MAX_VALUE)); + + assertEquals(Long.MAX_VALUE, (long) evaluator.numberConvert(Long.MAX_VALUE).doubleValue()); + + final String largeNumberString = "12345678901234567890"; + final Double largeNumber = evaluator.numberConvert(largeNumberString); + assertNotNull(largeNumber); + assertEquals(1.2345678901234568E19, largeNumber, 1e4); + + assertEquals(1E308, evaluator.numberConvert(1E308)); + assertEquals(-1E308, evaluator.numberConvert(-1E308)); + } + + @Test + void testUnicodeStringComparison() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + assertEquals(0, evaluator.compare("\u00E9", "\u00E9")); + assertEquals(0, evaluator.compare("\u4E2D\u6587", "\u4E2D\u6587")); + assertEquals(0, evaluator.compare("\uD83D\uDE00", "\uD83D\uDE00")); + + assertTrue(evaluator.compare("a", "\u00E1") < 0); + assertTrue(evaluator.compare("\u00E1", "a") > 0); + + assertTrue(evaluator.compare("\u4E00", "\u4E01") < 0); + assertTrue(evaluator.compare("\u4E01", "\u4E00") > 0); + + assertEquals(0, evaluator.compare("", "")); + assertTrue(evaluator.compare("", "\u4E2D") < 0); + assertTrue(evaluator.compare("\u4E2D", "") > 0); + + assertEquals(0, evaluator.compare("\u0041\u0042\u0043", "ABC")); + } + + @Test + void testStringConvertSpecialNumbers() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + final String nanString = evaluator.stringConvert(Double.NaN); + assertNotNull(nanString); + assertEquals("NaN", nanString); + + final String posInfString = evaluator.stringConvert(Double.POSITIVE_INFINITY); + assertNotNull(posInfString); + assertEquals("\u221E", posInfString); + + final String negInfString = evaluator.stringConvert(Double.NEGATIVE_INFINITY); + assertNotNull(negInfString); + assertEquals("-\u221E", negInfString); + } + + @Test + void testBooleanConvertEdgeCases() { + final ExprEvaluator evaluator = new ExprEvaluator(EMPTY_MAP, EMPTY_MAP); + + assertEquals(true, evaluator.booleanConvert(" ")); + assertEquals(true, evaluator.booleanConvert(" ")); + assertEquals(true, evaluator.booleanConvert("\t")); + assertEquals(true, evaluator.booleanConvert("\n")); + + assertEquals(true, evaluator.booleanConvert("FALSE")); + assertEquals(true, evaluator.booleanConvert("False")); + assertEquals(true, evaluator.booleanConvert("TRUE")); + assertEquals(true, evaluator.booleanConvert("True")); + + assertEquals(false, evaluator.booleanConvert(0.1)); + assertEquals(false, evaluator.booleanConvert(-0.1)); + assertEquals(false, evaluator.booleanConvert(0.9)); + assertEquals(true, evaluator.booleanConvert(1.0)); + assertEquals(true, evaluator.booleanConvert(1.5)); + assertEquals(true, evaluator.booleanConvert(Long.MAX_VALUE)); + assertEquals(true, evaluator.booleanConvert(Long.MIN_VALUE)); + + assertEquals(false, evaluator.booleanConvert(0.0)); + assertEquals(false, evaluator.booleanConvert(-0.0)); + } } diff --git a/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java new file mode 100644 index 0000000..3e34efa --- /dev/null +++ b/core-api/src/test/java/com/absmartly/sdk/jsonexpr/operators/MatchOperatorFixTest.java @@ -0,0 +1,44 @@ +package com.absmartly.sdk.jsonexpr.operators; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class MatchOperatorFixTest extends OperatorTest { + final MatchOperator operator = new MatchOperator(); + + @Test + void testNormalMatchingStillWorks() { + assertTrue((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "abc"))); + assertFalse((Boolean) operator.evaluate(evaluator, listOf("abcdefghijk", "xyz"))); + } + + @Test + void testInvalidRegexReturnsNull() { + assertNull(operator.evaluate(evaluator, listOf("test", "["))); + } + + @Test + void testNullArgumentsReturnNull() { + assertNull(operator.evaluate(evaluator, listOf(null, "abc"))); + assertNull(operator.evaluate(evaluator, listOf("abc", null))); + } + + @Test + void longPatternIsNotRejected() { + StringBuilder pattern = new StringBuilder(); + for (int i = 0; i < 1001; i++) { + pattern.append("a"); + } + assertEquals(Boolean.TRUE, operator.evaluate(evaluator, listOf(pattern.toString(), pattern.toString()))); + } + + @Test + void longTextIsNotRejected() { + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 10001; i++) { + text.append("a"); + } + assertEquals(Boolean.TRUE, operator.evaluate(evaluator, listOf(text.toString(), "aaa"))); + } +} diff --git a/example/build.gradle b/example/build.gradle index 097b54d..835b879 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -8,7 +8,5 @@ description = """ABSmartly Java SDK Example""" dependencies { - implementation group: "com.absmartly.sdk", name: "core-api", version: "1.1.1" - - //implementation project(":core-api") + implementation project(":core-api") } diff --git a/gradle/compatibility.gradle b/gradle/compatibility.gradle new file mode 100644 index 0000000..2474049 --- /dev/null +++ b/gradle/compatibility.gradle @@ -0,0 +1,15 @@ +// Enforces Java 1.6 API floor on production source of core-api using Animal Sniffer. +// Test sources are excluded: their dependencies (JUnit 5, Mockito 5) require Java 11+ APIs. +// java16:1.1 is the Java 1.6 signature despite the artifact name. +if (project.name == "core-api") { + apply plugin: "ru.vyarus.animalsniffer" + + animalsniffer { + sourceSets = [sourceSets.main] + } + + dependencies { + // java16 1.1 contains the set of classes and members present in Java 1.6. + signature "org.codehaus.mojo.signature:java16:1.1@signature" + } +} diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index db9a55a..4601f05 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -2,7 +2,7 @@ apply plugin: "jacoco" jacoco { - toolVersion = "0.8.6" + toolVersion = "0.8.12" } @@ -10,7 +10,7 @@ project.tasks.withType(JacocoReport) { group "Verification" reports { - xml.enabled = true - html.enabled = true + xml.required = true + html.required = true } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 790bdbf..c84565a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,4 @@ -#Thu May 28 18:38:14 CEST 2020 -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists