diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..30117ed02 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# 4 space indentation +[*.{py,java}] +indent_style = space +indent_size = 4 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 000000000..9295c6a34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,88 @@ +name: 🐞 Bug +description: File a bug/issue +title: "[BUG] " +labels: ["bug", "needs-triage"] +body: +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: SDK Version + description: Version of the SDK in use? + validations: + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 1. With this config... + 1. Run '...' + 1. See error... + validations: + required: true +- type: textarea + attributes: + label: Java Version + description: What version of Java are you using? + validations: + required: false +- type: textarea + attributes: + label: Link + description: Link to code demonstrating the problem. + validations: + required: false +- type: textarea + attributes: + label: Logs + description: Logs/stack traces related to the problem (⚠do not include sensitive information). + validations: + required: false +- type: dropdown + attributes: + label: Severity + description: What is the severity of the problem? + multiple: true + options: + - Blocking development + - Affecting users + - Minor issue + validations: + required: false +- type: textarea + attributes: + label: Workaround/Solution + description: Do you have any workaround or solution in mind for the problem? + validations: + required: false +- type: textarea + attributes: + label: "Recent Change" + description: Has this issue started happening after an update or experiment change? + validations: + required: false +- type: textarea + attributes: + label: Conflicts + description: Are there other libraries/dependencies potentially in conflict? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml new file mode 100644 index 000000000..2b315c010 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml @@ -0,0 +1,45 @@ +name: ✹Enhancement +description: Create a new ticket for a Enhancement/Tech-initiative for the benefit of the SDK which would be considered for a minor version update. +title: "[ENHANCEMENT] <title>" +labels: ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: "Description" + description: Briefly describe the enhancement in a few sentences. + placeholder: Short description... + validations: + required: true + - type: textarea + id: benefits + attributes: + label: "Benefits" + description: How would the enhancement benefit to your product or usage? + placeholder: Benefits... + validations: + required: true + - type: textarea + id: detail + attributes: + label: "Detail" + description: How would you like the enhancement to work? Please provide as much detail as possible + placeholder: Detailed description... + validations: + required: false + - type: textarea + id: examples + attributes: + label: "Examples" + description: Are there any examples of this enhancement in other products/services? If so, please provide links or references. + placeholder: Links/References... + validations: + required: false + - type: textarea + id: risks + attributes: + label: "Risks/Downsides" + description: Do you think this enhancement could have any potential downsides or risks? + placeholder: Risks/Downsides... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md new file mode 100644 index 000000000..5aa42ce83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md @@ -0,0 +1,4 @@ +<!-- + Thanks for filing in issue! Are you requesting a new feature? If so, please share your feedback with us on the following link. +--> +## Feedback requesting a new feature can be shared [here.](https://feedback.optimizely.com/) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..dc7735bc9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 💡Feature Requests + url: https://feedback.optimizely.com/ + about: Feedback requesting a new feature can be shared here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..09d70d2c2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## Summary +- The "what"; a concise description of each logical change +- Another change + +The "why", or other context. + +## Test plan + +## Issues +- "THING-1234" or "Fixes #123" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..1cb2193c8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: Reusable action of building snapshot and publish + +on: + workflow_call: + inputs: + action: + required: true + type: string + github_tag: + required: true + type: string + secrets: + MAVEN_SIGNING_KEY_BASE64: + required: true + MAVEN_SIGNING_PASSPHRASE: + required: true + MAVEN_CENTRAL_USERNAME: + required: true + MAVEN_CENTRAL_PASSWORD: + required: true +jobs: + run_build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: set up JDK 8 + uses: actions/setup-java@v2 + with: + java-version: '8' + distribution: 'temurin' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: ${{ inputs.action }} + env: + MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} + MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + run: GITHUB_TAG=${{ inputs.github_tag }} ./gradlew ${{ inputs.action }} diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml new file mode 100644 index 000000000..76fef5ad3 --- /dev/null +++ b/.github/workflows/integration_test.yml @@ -0,0 +1,53 @@ +name: Reusable action of running integration of production suite + +on: + workflow_call: + inputs: + FULLSTACK_TEST_REPO: + required: false + type: string + secrets: + CI_USER_TOKEN: + required: true +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # You should create a personal access token and store it in your repository + token: ${{ secrets.CI_USER_TOKEN }} + repository: 'optimizely/ci-helper-tools' + path: 'home/runner/ci-helper-tools' + ref: 'master' + - name: set SDK Branch if PR + env: + HEAD_REF: ${{ github.head_ref }} + if: ${{ github.event_name == 'pull_request' }} + run: | + echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV + - name: set SDK Branch if not pull request + env: + REF_NAME: ${{ github.ref_name }} + if: ${{ github.event_name != 'pull_request' }} + run: | + echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV + - name: Trigger build + env: + SDK: java + FULLSTACK_TEST_REPO: ${{ inputs.FULLSTACK_TEST_REPO }} + BUILD_NUMBER: ${{ github.run_id }} + TESTAPP_BRANCH: master + GITHUB_TOKEN: ${{ secrets.CI_USER_TOKEN }} + EVENT_TYPE: ${{ github.event_name }} + GITHUB_CONTEXT: ${{ toJson(github) }} + PULL_REQUEST_SLUG: ${{ github.repository }} + UPSTREAM_REPO: ${{ github.repository }} + PULL_REQUEST_SHA: ${{ github.event.pull_request.head.sha }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + UPSTREAM_SHA: ${{ github.sha }} + EVENT_MESSAGE: ${{ github.event.message }} + HOME: 'home/runner' + run: | + echo "$GITHUB_CONTEXT" + home/runner/ci-helper-tools/trigger-script-with-status-update.sh diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 000000000..95e8ccf8d --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,115 @@ +name: Java CI with Gradle + +on: + push: + branches: [ master ] + tags: + - '*' + pull_request: + branches: [ master ] + workflow_dispatch: + inputs: + SNAPSHOT: + type: boolean + description: Set SNAPSHOT true to publish + +jobs: + lint_markdown_files: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.6' + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Install gem + run: | + gem install awesome_bot + - name: Run tests + run: find . -type f -name '*.md' -exec awesome_bot {} \; + + integration_tests: + if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} + uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master + secrets: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + + fullstack_production_suite: + if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} + uses: optimizely/java-sdk/.github/workflows/integration_test.yml@master + with: + FULLSTACK_TEST_REPO: ProdTesting + secrets: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + + test: + if: ${{ startsWith(github.ref, 'refs/tags/') != true && github.event.inputs.SNAPSHOT != 'true' }} + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + jdk: [8, 9] + optimizely_default_parser: [GSON_CONFIG_PARSER, JACKSON_CONFIG_PARSER, JSON_CONFIG_PARSER, JSON_SIMPLE_CONFIG_PARSER] + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: set up JDK ${{ matrix.jdk }} + uses: AdoptOpenJDK/install-jdk@v1 + with: + version: ${{ matrix.jdk }} + architecture: x64 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + + - name: run tests + id: unit_tests + env: + optimizely_default_parser: ${{ matrix.optimizely_default_parser }} + run: | + ./gradlew clean + ./gradlew exhaustiveTest + ./gradlew build + - name: Check on failures + if: always() && steps.unit_tests.outcome != 'success' + run: | + cat /Users/runner/work/java-sdk/core-api/build/reports/spotbugs/main.html + cat /Users/runner/work/java-sdk/core-api/build/reports/spotbugs/test.html + - name: Check on success + if: always() && steps.unit_tests.outcome == 'success' + run: | + ./gradlew coveralls --console plain + + publish: + if: startsWith(github.ref, 'refs/tags/') + uses: optimizely/java-sdk/.github/workflows/build.yml@master + with: + action: ship + github_tag: ${GITHUB_REF#refs/*/} + secrets: + MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} + MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + + snapshot: + if: ${{ github.event.inputs.SNAPSHOT == 'true' && github.event_name == 'workflow_dispatch' }} + uses: optimizely/java-sdk/.github/workflows/build.yml@master + with: + action: ship + github_tag: BB-SNAPSHOT + secrets: + MAVEN_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_BASE64 }} + MAVEN_SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} diff --git a/.github/workflows/source_clear_cron.yml b/.github/workflows/source_clear_cron.yml new file mode 100644 index 000000000..54eca5358 --- /dev/null +++ b/.github/workflows/source_clear_cron.yml @@ -0,0 +1,16 @@ +name: Source clear + +on: + schedule: + # Runs "weekly" + - cron: '0 0 * * 0' + +jobs: + source_clear: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Source clear scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | bash -s – scan diff --git a/.github/workflows/ticket_reference_check.yml b/.github/workflows/ticket_reference_check.yml new file mode 100644 index 000000000..b7d52780f --- /dev/null +++ b/.github/workflows/ticket_reference_check.yml @@ -0,0 +1,16 @@ +name: Jira ticket reference check + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + + jira_ticket_reference_check: + runs-on: ubuntu-latest + + steps: + - name: Check for Jira ticket reference + uses: optimizely/github-action-ticket-reference-checker-public@master + with: + bodyRegex: 'FSSDK-(?<ticketNumber>\d+)' diff --git a/.gitignore b/.gitignore index bacbde254..aefc53cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ local.properties **/build +bin out classes diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 74d2f7d2d..000000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: java -dist: trusty -jdk: - - openjdk8 - - oraclejdk8 - - oraclejdk9 -install: true -addons: - srcclr: true -script: - - "./gradlew clean" - - "./gradlew exhaustiveTest" - - "if [[ -n $TRAVIS_TAG ]]; then - ./gradlew ship; - else - ./gradlew build; - fi" -cache: - gradle: true - directories: - - "$HOME/.gradle/caches" - - "$HOME/.gradle/wrapper" -branches: - only: - - master - - /^\d+\.\d+\.\d+(-SNAPSHOT|-alpha|-beta)?\d*$/ # trigger builds on tags which are semantically versioned to ship the SDK. -after_success: - - ./gradlew coveralls uploadArchives --console plain diff --git a/CHANGELOG.md b/CHANGELOG.md index 9953b3252..565bfcd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,593 @@ # Optimizely Java X SDK Changelog +## [4.2.2] +May 28th, 2025 + +### Fixes +- Added experimentId and variationId to decision notification ([#569](https://github.com/optimizely/java-sdk/pull/569)). + +## [4.2.1] +Feb 19th, 2025 + +### Fixes +- Fix big integer conversion ([#556](https://github.com/optimizely/java-sdk/pull/556)). + +## [4.2.0] +November 6th, 2024 + +### New Features +* Batch UPS lookup and save calls in decideAll and decideForKeys methods ([#549](https://github.com/optimizely/java-sdk/pull/549)). + + +## [4.1.1] +May 8th, 2024 + +### Fixes +- Fix logx events discarded for staled connections with httpclient connection pooling ([#545](https://github.com/optimizely/java-sdk/pull/545)). + + +## [4.1.0] +April 12th, 2024 + +### New Features +* OptimizelyFactory method for injecting customHttpClient is fixed to share the customHttpClient for all modules using httpClient (HttpProjectConfigManager, AsyncEventHander, ODPManager) ([#542](https://github.com/optimizely/java-sdk/pull/542)). +* A custom ThreadFactory can be injected to support virtual threads (Loom) ([#540](https://github.com/optimizely/java-sdk/pull/540)). + + +## [4.0.0] +January 16th, 2024 + +### New Features +The 4.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ( +[#474](https://github.com/optimizely/java-sdk/pull/474), +[#481](https://github.com/optimizely/java-sdk/pull/481), +[#482](https://github.com/optimizely/java-sdk/pull/482), +[#483](https://github.com/optimizely/java-sdk/pull/483), +[#484](https://github.com/optimizely/java-sdk/pull/484), +[#485](https://github.com/optimizely/java-sdk/pull/485), +[#487](https://github.com/optimizely/java-sdk/pull/487), +[#489](https://github.com/optimizely/java-sdk/pull/489), +[#490](https://github.com/optimizely/java-sdk/pull/490), +[#494](https://github.com/optimizely/java-sdk/pull/494) +). + +You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex +real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important +for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can +be used as a single source of truth for these segments in any Optimizely or 3rd party tool. + +With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and +make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager. + +This version includes the following changes: +- New API added to `OptimizelyUserContext`: + - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. + - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. +- New APIs added to `OptimizelyClient`: + - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. + +For details, refer to our documentation pages: +- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +- [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks) +- [Initialize Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-java) +- [OptimizelyUserContext Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-java) +- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-java) +- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-java) + +### Breaking Changes +- `OdpManager` in the SDK is enabled by default, if initialized using OptimizelyFactory. Unless an ODP account is integrated into the Optimizely projects, most `OdpManager` functions will be ignored. If needed, ODP features can be disabled by initializing `OptimizelyClient` without passing `OdpManager`. +- `ProjectConfigManager` interface has been changed to add 2 more methods `getCachedConfig()` and `getSDKKey()`. Custom ProjectConfigManager should implement these new methods. See `PollingProjectConfigManager` for reference. This change is required to support ODPManager updated on datafile download ([#501](https://github.com/optimizely/java-sdk/pull/501)). + +### Fixes +- Fix thread leak from httpClient in HttpProjectConfigManager ([#530](https://github.com/optimizely/java-sdk/pull/530)). +- Fix issue when vuid is passed as userid for `AsyncGetQualifiedSegments` ([#527](https://github.com/optimizely/java-sdk/pull/527)). +- Fix to support arbitrary client names to be included in logx and odp events ([#524](https://github.com/optimizely/java-sdk/pull/524)). +- Add evict timeout to logx connections ([#518](https://github.com/optimizely/java-sdk/pull/518)). + +### Functionality Enhancements +- Update Github Issue Templates ([#531](https://github.com/optimizely/java-sdk/pull/531)) + + + +## [4.0.0-beta2] +August 28th, 2023 + +### Fixes +- Fix thread leak from httpClient in HttpProjectConfigManager ([#530](https://github.com/optimizely/java-sdk/pull/530)). +- Fix issue when vuid is passed as userid for `AsyncGetQualifiedSegments` ([#527](https://github.com/optimizely/java-sdk/pull/527)). +- Fix to support arbitrary client names to be included in logx and odp events ([#524](https://github.com/optimizely/java-sdk/pull/524)). + +### Functionality Enhancements +- Update Github Issue Templates ([#531](https://github.com/optimizely/java-sdk/pull/531)) + + +## [3.10.4] +June 8th, 2023 + +### Fixes +- Fix intermittent logx event dispatch failures possibly caused by reusing stale connections. Add `evictIdleConnections` (1min) to `OptimizelyHttpClient` in `AsyncEventHandler` to force close persistent connections after 1min idle time ([#518](https://github.com/optimizely/java-sdk/pull/518)). + + +## [4.0.0-beta] +May 5th, 2023 + +### New Features +The 4.0.0-beta release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) ( +[#474](https://github.com/optimizely/java-sdk/pull/474), +[#481](https://github.com/optimizely/java-sdk/pull/481), +[#482](https://github.com/optimizely/java-sdk/pull/482), +[#483](https://github.com/optimizely/java-sdk/pull/483), +[#484](https://github.com/optimizely/java-sdk/pull/484), +[#485](https://github.com/optimizely/java-sdk/pull/485), +[#487](https://github.com/optimizely/java-sdk/pull/487), +[#489](https://github.com/optimizely/java-sdk/pull/489), +[#490](https://github.com/optimizely/java-sdk/pull/490), +[#494](https://github.com/optimizely/java-sdk/pull/494) +). + +You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex +real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important +for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can +be used as a single source of truth for these segments in any Optimizely or 3rd party tool. + +With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and +make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager. + +This version includes the following changes: +- New API added to `OptimizelyUserContext`: + - `fetchQualifiedSegments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. + - When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. +- New APIs added to `OptimizelyClient`: + - `sendOdpEvent()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. + +For details, refer to our documentation pages: +- [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) +- [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks) +- [Initialize Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-java) +- [OptimizelyUserContext Java SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-java) +- [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-java) +- [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-java) + +### Breaking Changes +- `OdpManager` in the SDK is enabled by default, if initialized using OptimizelyFactory. Unless an ODP account is integrated into the Optimizely projects, most `OdpManager` functions will be ignored. If needed, `OdpManager` to be disabled initialize `OptimizelyClient` without passing `OdpManager`. +- `ProjectConfigManager` interface has been changed to add 2 more methods `getCachedConfig()` and `getSDKKey()`. Custom ProjectConfigManager should implement these new methods. See `PollingProjectConfigManager` for reference. This change is required to support ODPManager updated on datafile download ([#501](https://github.com/optimizely/java-sdk/pull/501)). + +## [3.10.3] +March 13th, 2023 + +### Fixes +We updated our README.md and other non-functional code to reflect that this SDK supports both Optimizely Feature Experimentation and Optimizely Full Stack ([#506](https://github.com/optimizely/java-sdk/pull/506)). + +## [3.10.2] +March 17th, 2022 + +### Fixes + +- For some audience condition matchers (semantic-version, le, or ge), SDK logs WARNING messages when the attribute value is missing. This is fixed down to the DEBUG level to be consistent with other condition matchers ([#463](https://github.com/optimizely/java-sdk/pull/463)). +- Add an option to specify the client-engine version (android-sdk, etc) in the Optimizely builder ([#466](https://github.com/optimizely/java-sdk/pull/466)). + + +## [3.10.1] +February 3rd, 2022 + +### Fixes +- Fix NotificationManager to be thread-safe (add-handler and send-notifications can happen concurrently) ([#460](https://github.com/optimizely/java-sdk/pull/460)). + +## [3.10.0] +January 10th, 2022 + +### New Features +* Add a set of new APIs for overriding and managing user-level flag, experiment and delivery rule decisions. These methods can be used for QA and automated testing purposes. They are an extension of the OptimizelyUserContext interface ([#451](https://github.com/optimizely/java-sdk/pull/451), [#454](https://github.com/optimizely/java-sdk/pull/454), [#455](https://github.com/optimizely/java-sdk/pull/455), [#457](https://github.com/optimizely/java-sdk/pull/457)) + - setForcedDecision + - getForcedDecision + - removeForcedDecision + - removeAllForcedDecisions + +- For details, refer to our documentation pages: [OptimizelyUserContext](https://docs.developers.optimizely.com/full-stack/v4.0/docs/optimizelyusercontext-java) and [Forced Decision methods](https://docs.developers.optimizely.com/full-stack/v4.0/docs/forced-decision-methods-java). + +## [3.9.0] +September 16th, 2021 + +### New Features +* Added new public properties to OptimizelyConfig. [#434] (https://github.com/optimizely/java-sdk/pull/434), [#438] (https://github.com/optimizely/java-sdk/pull/438) + - sdkKey + - environmentKey + - attributes + - events + - audiences and audiences in OptimizelyExperiment + - experimentRules + - deliveryRules +* OptimizelyFeature.experimentsMap of OptimizelyConfig is now deprecated. Please use OptimizelyFeature.experiment_rules and OptimizelyFeature.delivery_rules. [#440] (https://github.com/optimizely/java-sdk/pull/440) +* For more information please refer to Optimizely documentation: [https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-java] + +* Added custom closeableHttpClient for custom proxy support. [#441] (https://github.com/optimizely/java-sdk/pull/441) + +## [3.8.2] +March 8th, 2021 + +### Fixes +- Fix intermittent SocketTimeout exceptions while downloading datafiles. Add configurable `evictIdleConnections` to `HttpProjectConfigManager` to force close persistent connections after the idle time (evict after 1min idle time by default) ([#431](https://github.com/optimizely/java-sdk/pull/431)). + +## [3.8.1] +March 2nd, 2021 + +Switch publish repository to MavenCentral (bintray/jcenter sunset) + +### Fixes +- Fix app crashing when the rollout length is zero ([#423](https://github.com/optimizely/java-sdk/pull/423)). +- Fix javadoc warnings ([#426](https://github.com/optimizely/java-sdk/pull/426)). + + +## [3.8.0] +February 3rd, 2021 + +### New Features + +- Introducing a new primary interface for retrieving feature flag status, configuration and associated experiment decisions for users ([#406](https://github.com/optimizely/java-sdk/pull/406), [#415](https://github.com/optimizely/java-sdk/pull/415), [#417](https://github.com/optimizely/java-sdk/pull/417)). The new `OptimizelyUserContext` class is instantiated with `createUserContext` and exposes the following APIs to get `OptimizelyDecision`: + + - setAttribute + - decide + - decideAll + - decideForKeys + - trackEvent + +- For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk). + +### Fixes +- Close the closable response from apache httpclient ([#419](https://github.com/optimizely/java-sdk/pull/419)) + + +## [3.8.0-beta2] +January 14th, 2021 + +### Fixes: +- Clone user-context before calling Optimizely decide ([#417](https://github.com/optimizely/java-sdk/pull/417)) +- Return reasons as a part of tuple in decision hierarchy ([#415](https://github.com/optimizely/java-sdk/pull/415)) + +## [3.8.0-beta] +December 14th, 2020 + +### New Features + +- Introducing a new primary interface for retrieving feature flag status, configuration and associated experiment decisions for users. The new `OptimizelyUserContext` class is instantiated with `createUserContext` and exposes the following APIs ([#406](https://github.com/optimizely/java-sdk/pull/406)): + + - setAttribute + - decide + - decideAll + - decideForKeys + - trackEvent + +- For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk](https://docs.developers.optimizely.com/full-stack/v4.0/docs/java-sdk). + +## [3.7.0] +November 20th, 2020 + +### New Features +- Add support for upcoming application-controlled introduction of tracking for non-experiment Flag decisions. ([#405](https://github.com/optimizely/java-sdk/pull/405), [#409](https://github.com/optimizely/java-sdk/pull/409), [#410](https://github.com/optimizely/java-sdk/pull/410)) + +### Fixes: +- Upgrade httpclient to 4.5.13 + +## [3.6.0] +September 30th, 2020 + +### New Features +- Add support for version audience condition which follows the semantic version (http://semver.org)[#386](https://github.com/optimizely/java-sdk/pull/386). + +- Add support for datafile accessor [#392](https://github.com/optimizely/java-sdk/pull/392). + +- Audience logging refactor (move from info to debug) [#380](https://github.com/optimizely/java-sdk/pull/380). + +- Added SetDatafileAccessToken method in OptimizelyFactory [#384](https://github.com/optimizely/java-sdk/pull/384). + +- Add MatchRegistry for custom match implementations. [#390] (https://github.com/optimizely/java-sdk/pull/390). + +### Fixes: +- logging issue in quick-start application [#402] (https://github.com/optimizely/java-sdk/pull/402). + +- Update libraries to latest to avoid vulnerability issues [#397](https://github.com/optimizely/java-sdk/pull/397). + +## [3.5.0] +July 7th, 2020 + +### New Features +- Add support for JSON feature variables ([#372](https://github.com/optimizely/java-sdk/pull/372), [#371](https://github.com/optimizely/java-sdk/pull/371), [#375](https://github.com/optimizely/java-sdk/pull/375)) +- Add support for authenticated datafile access ([#378](https://github.com/optimizely/java-sdk/pull/378)) + +### Bug Fixes: +- Adjust log level on audience evaluation logs ([#377](https://github.com/optimizely/java-sdk/pull/377)) + +## [3.5.0-beta] +July 2nd, 2020 + +### New Features +- Add support for JSON feature variables ([#372](https://github.com/optimizely/java-sdk/pull/372), [#371](https://github.com/optimizely/java-sdk/pull/371), [#375](https://github.com/optimizely/java-sdk/pull/375)) +- Add support for authenticated datafile access ([#378](https://github.com/optimizely/java-sdk/pull/378)) + +### Bug Fixes: +- Adjust log level on audience evaluation logs ([#377](https://github.com/optimizely/java-sdk/pull/377)) + +## [3.4.3] +April 28th, 2020 + +### Bug Fixes: +- Change FeatureVariable type from enum to string for forward compatibility. ([#370](https://github.com/optimizely/java-sdk/pull/370)) + +## [3.4.2] +March 30th, 2020 + +### Bug Fixes: +- Change log level to debug for "Fetching datafile from" in HttpProjectConfigManager. ([#362](https://github.com/optimizely/java-sdk/pull/362)) +- Change log level to warn when experiments are not in datafile. ([#361](https://github.com/optimizely/java-sdk/pull/361)) + +## [3.4.1] +January 30th, 2020 + +### Bug Fixes: +- Remove usage of stream to support Android API 21 and 22. ([#357](https://github.com/optimizely/java-sdk/pull/357)) + + +## [3.4.0] +January 27th, 2020 + +### New Features +- Added a new API to get project configuration static data. + - Call `getOptimizelyConfig()` to get a snapshot of project configuration static data. + - It returns an `OptimizelyConfig` instance which includes a datafile revision number, all experiments, and feature flags mapped by their key values. + - Added caching for `getOptimizelyConfig` - `OptimizelyConfig` object will be cached and reused for the lifetime of the datafile. + - For details, refer to our documentation page: [https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-java](https://docs.developers.optimizely.com/full-stack/docs/optimizelyconfig-java). + +## 3.3.4 +December 16th, 2019 + +### New Features: +- Accept http client parameters via system properties. ([#349](https://github.com/optimizely/java-sdk/pull/349)) + +## 3.2.2 +December 16th, 2019 + +### New Features: +- Accept http client parameters via system properties. ([#349](https://github.com/optimizely/java-sdk/pull/349)) + +## 3.3.3 +November 14th, 2019 + +### New Features: +- Require EventHandler in BatchEventProcessor builder. ([#333](https://github.com/optimizely/java-sdk/pull/333)) +- Defend against invalid BatchEventProcessor configuration overrides. i([#331](https://github.com/optimizely/java-sdk/pull/331)) + +## 3.3.2 +October 23rd, 2019 + +### New Features: +- The BatchEventProcessor was refactored for performance so that it ends up hanging on the blocking queue if there is nothing to processes. ([#343](https://github.com/optimizely/java-sdk/pull/343)) + +## 3.3.1 +October 11th, 2019 + +### New Features: +- java.util.Supplier is not supported below Android API 24. In order to support Android 22 which still has more than 10% market share, we have changed our implementation to use our own config supplier interface. + +## 3.3.0 +October 1st, 2019 + +### New Features: +- Introduced `EventProcessor` interface with `BatchEventProcessor` implementation. +- Introduced `LogEvent` notification. +- Added `BatchEventProcessor` as the default implementation within the `OptimizelyFactory` class. + +### Deprecated +- `LogEvent` was deprecated from `TrackNotification` and `ActivateNotification` notifications in favor of explicit `LogEvent` notification. + +## 3.2.1 +August 19th, 2019 + +### New Features: +- Add clear deprecation path from factory builder method. +- Make Optimizely#withDatafile public. + +## 3.4.0-beta +July 24th, 2019 + +### New Features: +- Introduced `EventProcessor` interface with `BatchEventProcessor` implementation. +- Introduced `LogEvent` notification. +- Added `BatchEventProcessor` as the default implementation within the `OptimizelyFactory` class. + +### Deprecated +- `LogEvent` was deprecated from `TrackNotification` and `ActivateNotification` notifications in favor of explicit `LogEvent` notification. + +## 3.2.0 +June 26th, 2019 + +### New Features: +- Added support for automatic datafile management via `HttpProjectConfigManager`: + - The [`HttpProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java) + is part of the `core-httpclient-impl` package and is an implementation of the abstract + [`PollingProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java) class. + - Users must first build the `HttpProjectConfigManager` with an SDK key and then and provide that instance to the Optimizely.Builder. + - An initial datafile can be provided to the `HttpProjectConfigManager` to bootstrap before making http requests for the hosted datafile. + - Requests for the datafile are made in a separate thread and are scheduled with fixed delay. + - Configuration updates can be subscribed to via the `Optimizely#addUpdateConfigNotificationHandler` or by subscribing to + the NotificationCenter built with the `HttpProjectConfigManager`. +- Added `AsyncEventHandler.Builder` to be consistent with other Optimizely resources. +- The [`OptimizelyFactory`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java) + was included in the `core-httpclient-impl` package and provides basic methods for instantiating the Optimizely SDK with a minimal number of parameters. +- Default configuration options for `HttpProjectConfigManager` and `AsyncEventHandler` can be overwritten using Java system properties, environment variables or via an `optimizely.properties` file + to avoid hard coding the configuration options. + +### Deprecated +- `Optimizely.builder(String, EventHandler)` was deprecated in favor of pure builder methods `withConfigManager` and `withEventHandler`. + +## 3.2.0-alpha +May 23rd, 2019 + +### New Features: +- Added support for automatic datafile management via `HttpProjectConfigManager`: + - The [`HttpProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java) + is part of the `core-httpclient-impl` package and is an implementation of the abstract + [`PollingProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java) class. + - Users must first build the `HttpProjectConfigManager` with an SDK key and then and provide that instance to the Optimizely.Builder. + - An initial datafile can be provided to the `HttpProjectConfigManager` to bootstrap before making http requests for the hosted datafile. + - Requests for the datafile are made in a separate thread and are scheduled with fixed delay. + - Configuration updates can be subscribed to via the `Optimizely#addUpdateConfigNotificationHandler` or by subscribing to + the NotificationCenter built with the `HttpProjectConfigManager`. +- Added `AsyncEventHandler.Builder` to be consistent with other Optimizely resources. +- The [`OptimizelyFactory`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java) + was included in the `core-httpclient-impl` package and provides basic methods for instantiating the Optimizely SDK with a minimal number of parameters. +- Default configuration options for `HttpProjectConfigManager` and `AsyncEventHandler` can be overwritten using Java system properties, environment variables or via an `optimizely.properties` file + to avoid hard coding the configuration options. + +### Deprecated +- `Optimizely.builder(String, EventHandler)` was deprecated in favor of pure builder methods `withConfigManager` and `withEventHandler`. + +## 3.1.0 +May 6th, 2019 + +### New Features: +- Introduced Decision notification listener to be able to record: + - Variation assignments for users activated in an experiment. + - Feature access for users. + - Feature variable value for users. +- Added APIs to be able to conveniently add Decision notification handler (`addDecisionNotificationHandler`) and Track notification handler (`addTrackNotificationHandler`). + +### Bug Fixes: +- Feature variable APIs return default variable value when featureEnabled property is false. ([#274](https://github.com/optimizely/java-sdk/pull/274)) + +### Deprecated +- Activate notification listener is deprecated as of this release. Recommendation is to use the new Decision notification listener. Activate notification listener will be removed in the next major release. +- `addActivateNotificationListener`, `addTrackNotificationListener` and `addNotificationListener` APIs on `NotificationCenter`. + +## 3.0.1 + +April 23, 2019 + +This is a simple fix so that older versions of org.json can still parse the datafile. + +### Bug Fix +We use org.json.JSONArray. Older versions do not support the iterator. In order to ensure that the datafile is still parsable if you use a older version, we changed to use the get method instead of the iterator. +([#283](https://github.com/optimizely/java-sdk/pull/283)) + +## 3.0.0 + +February 13, 2019 + +The 3.0 release improves event tracking and supports additional audience targeting functionality. + +### New Features: +* Event tracking: + * The `track` method now dispatches its conversion event _unconditionally_, without first determining whether the user is targeted by a known experiment that uses the event. This may increase outbound network traffic. + * In Optimizely results, conversion events sent by 3.0 SDKs don't explicitly name the experiments and variations that are currently targeted to the user. Instead, conversions are automatically attributed to variations that the user has previously seen, as long as those variations were served via 3.0 SDKs or by other clients capable of automatic attribution, and as long as our backend actually received the impression events for those variations. + * Altogether, this allows you to track conversion events and attribute them to variations even when you don't know all of a user's attribute values, and even if the user's attribute values or the experiment's configuration have changed such that the user is no longer affected by the experiment. As a result, **you may observe an increase in the conversion rate for previously-instrumented events.** If that is undesirable, you can reset the results of previously-running experiments after upgrading to the 3.0 SDK. + * This will also allow you to attribute events to variations from other Optimizely projects in your account, even though those experiments don't appear in the same datafile. + * Note that for results segmentation in Optimizely results, the user attribute values from one event are automatically applied to all other events in the same session, as long as the events in question were actually received by our backend. This behavior was already in place and is not affected by the 3.0 release. +* Support for all types of attribute values, not just strings: + * All values are passed through to notification listeners. + * Strings, booleans, and valid numbers are passed to the event dispatcher and can be used for Optimizely results segmentation. A valid number is a finite float, double, integer, or long in the inclusive range [-2⁔³, 2⁔³]. + * Strings, booleans, and valid numbers are relevant for audience conditions. +* Support for additional matchers in audience conditions: + * An `exists` matcher that passes if the user has a non-null value for the targeted user attribute and fails otherwise. + * A `substring` matcher that resolves if the user has a string value for the targeted attribute. + * `gt` (greater than) and `lt` (less than) matchers that resolve if the user has a valid number value for the targeted attribute. A valid number is a finite float, double, integer, or long in the inclusive range [-2⁔³, 2⁔³]. + * The original (`exact`) matcher can now be used to target booleans and valid numbers, not just strings. +* Support for A/B tests, feature tests, and feature rollouts whose audiences are combined using `"and"` and `"not"` operators, not just the `"or"` operator. +* Datafile-version compatibility check: The SDK will remain uninitialized (i.e., will gracefully fail to activate experiments and features) if given a datafile version greater than 4. +* Updated Pull Request template and commit message guidelines. +* When given an invalid datafile, the Optimizely client object now instantiates into a no-op state instead of throwing a `ConfigParseException`. This matches the behavior of the other Optimizely SDKs. +* Support for graceful shutdown in the default, async event dispatcher. + +### Breaking Changes: +* Java 7 is no longer supported. +* Conversion events sent by 3.0 SDKs don't explicitly name the experiments and variations that are currently targeted to the user, so these events are unattributed in raw events data export. You must use the new _results_ export to determine the variations to which events have been attributed. +* Previously, notification listeners were only given string-valued user attributes because only strings could be passed into various method calls. That is no longer the case. The `ActivateNotificationListener` and `TrackNotificationListener` interfaces now receive user attributes as `Map<String, ?>` instead of `Map<String, String>`. + +### Bug Fixes: +* Experiments and features can no longer activate when a negatively targeted attribute has a missing, null, or malformed value. + * Audience conditions (except for the new `exists` matcher) no longer resolve to `false` when they fail to find an legitimate value for the targeted user attribute. The result remains `null` (unknown). Therefore, an audience that negates such a condition (using the `"not"` operator) can no longer resolve to `true` unless there is an unrelated branch in the condition tree that itself resolves to `true`. +* Support for empty user IDs. ([#220](https://github.com/optimizely/java-sdk/pull/220)) +* Sourceclear flagged jackson-databind 2.9.4 fixed in 2.9.8 ([#260](https://github.com/optimizely/java-sdk/pull/260)) +* Fix the quick-start app to create a unique user for every impression/conversion in a run. ([#257](https://github.com/optimizely/java-sdk/pull/257)) + +## 2.1.4 + +December 6th, 2018 + +### Bug Fixes +* fix/wrap in try catch for getting build version in static init which might crash ([#241](https://github.com/optimizely/java-sdk/pull/241)) + +## 3.0.0-RC2 + +November 20th, 2018 + +This is the release candidate for the 3.0 SDK, which includes a number of improvements to audience targeting along with a few bug fixes. + +### New Features +* Support for number-valued and boolean-valued attributes. ([#213](https://github.com/optimizely/java-sdk/pull/213)) +* Support for audiences with new match conditions for attribute values, including “substring” and “exists” matches for strings; “greater than”, “less than”, exact, and “exists” matches for numbers; and “exact”, and “exists” matches for booleans. +* Built-in datafile version compatibility checks so that SDKs will not initialize with a newer datafile it is not compatible with. ([#209](https://github.com/optimizely/java-sdk/pull/209)) +* Audience combinations within an experiment are unofficially supported in this release. +* Refactor EventDispatcher to handle graceful shutdown via a call to AsyncEventHandler.shutdownAndAwaitTermination. + +### Breaking Changes +* Previously, notification listeners filtered non-string attribute values from the data passed to registered listeners. To support our growing list of supported attribute values, we’ve changed this behavior. Notification listeners will now post any value type passed as an attribute. Therefore, the interface of the notification listeners has changed to accept a `Map<String, ?>`. +* Update to use Java 1.7 ([#208](https://github.com/optimizely/java-sdk/pull/208)) + +### Bug Fixes +* refactor: Performance improvements for JacksonConfigParser ([#209](https://github.com/optimizely/java-sdk/pull/209)) +* refactor: typeAudience.combinations will not be string encoded like audience.combinations. To handle this we created a new parsing type TypedAudience. +* fix for exact match when dealing with integers and doubles. Created a new Numeric match type. +* make a copy of attributes passed in to avoid any concurrency problems. Addresses GitHub issue in Optimizely Andriod SDK. +* allow single root node for audience.conditions, typedAudience.conditions, and Experiment.audienceCombinations. + +## 3.0.0-RC + +November 7th, 2018 + +This is the release candidate for the 3.0 SDK, which includes a number of improvements to audience targeting along with a few bug fixes. + +### New Features +* Support for number-valued and boolean-valued attributes. ([#213](https://github.com/optimizely/java-sdk/pull/213)) +* Support for audiences with new match conditions for attribute values, including “substring” and “exists” matches for strings; “greater than”, “less than”, exact, and “exists” matches for numbers; and “exact”, and “exists” matches for booleans. +* Built-in datafile version compatibility checks so that SDKs will not initialize with a newer datafile it is not compatible with. ([#209](https://github.com/optimizely/java-sdk/pull/209)) +* Audience combinations within an experiment are unofficially supported in this release. + +### Breaking Changes +* Previously, notification listeners filtered non-string attribute values from the data passed to registered listeners. To support our growing list of supported attribute values, we’ve changed this behavior. Notification listeners will now post any value type passed as an attribute. Therefore, the interface of the notification listeners has changed to accept a `Map<String, ?>`. +* Update to use Java 1.7 ([#208](https://github.com/optimizely/java-sdk/pull/208)) + +### Bug Fixes +* refactor: Performance improvements for JacksonConfigParser ([#209](https://github.com/optimizely/java-sdk/pull/209)) +* refactor: typeAudience.combinations will not be string encoded like audience.combinations. To handle this we created a new parsing type TypedAudience. +* fix for exact match when dealing with integers and doubles. Created a new Numeric match type. +* make a copy of attributes passed in to avoid any concurrency problems. Addresses GitHub issue in Optimizely Andriod SDK. + +## 3.0.0-alpha + +October 10th, 2018 + +This is the alpha release of the 3.0 SDK, which includes a number of improvements to audience targeting along with a few bug fixes. + +### New Features +* Support for number-valued and boolean-valued attributes. ([#213](https://github.com/optimizely/java-sdk/pull/213)) +* Support for audiences with new match conditions for attribute values, including “substring” and “exists” matches for strings; “greater than”, “less than”, exact, and “exists” matches for numbers; and “exact”, and “exists” matches for booleans. +* Built-in datafile version compatibility checks so that SDKs will not initialize with a newer datafile it is not compatible with. ([#209](https://github.com/optimizely/java-sdk/pull/209)) + +### Breaking Changes +* Previously, notification listeners filtered non-string attribute values from the data passed to registered listeners. To support our growing list of supported attribute values, we’ve changed this behavior. Notification listeners will now post any value type passed as an attribute. Therefore, the interface of the notification listeners has changed to accept a `Map<String, ?>`. +* Update to use Java 1.7 ([#208](https://github.com/optimizely/java-sdk/pull/208)) + +### Bug Fixes +* refactor: Performance improvements for JacksonConfigParser ([#209](https://github.com/optimizely/java-sdk/pull/209)) + +## 2.1.3 + +September 21st, 2018 + +### Bug Fixes +* fix(attributes): Filters out attributes with null values from the event payload ([#204](https://github.com/optimizely/java-sdk/pull/204)) + ## 2.1.2 August 1st, 2018 @@ -120,7 +708,7 @@ January 30, 2018 This release adds support for bucketing id (By passing in `$opt_bucketing_id` in the attribute map to override the user id as the bucketing variable. This is useful when wanting a set of users to share the same experience such as two players in a game). -This release also depricates the old notification broadcaster in favor of a notification center that supports a wide range of notifications. The notification listener is now registered for the specific notification type such as ACTIVATE and TRACK. This is accomplished by allowing for a variable argument call to notify (a new var arg method added to the NotificationListener). Specific abstract classes exist for the associated notification type (ActivateNotification and TrackNotification). These abstract classes enforce the strong typing that exists in Java. You may also add custom notification types and fire them through the notification center. The notification center is implemented using this var arg approach in all Optimizely SDKs. +This release also deprecates the old notification broadcaster in favor of a notification center that supports a wide range of notifications. The notification listener is now registered for the specific notification type such as ACTIVATE and TRACK. This is accomplished by allowing for a variable argument call to notify (a new var arg method added to the NotificationListener). Specific abstract classes exist for the associated notification type (ActivateNotification and TrackNotification). These abstract classes enforce the strong typing that exists in Java. You may also add custom notification types and fire them through the notification center. The notification center is implemented using this var arg approach in all Optimizely SDKs. ### New Features diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..15a35bcf9 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,16 @@ +# This is a comment. +# Each line is a file pattern followed by one or more owners. + + # These owners will be the default owners for everything in the repo. +# Unless a later match takes precedence, @global-owner1 and @global-owner2 +# will be requested for review when someone opens a pull request. +* @optimizely/fullstack-devs + + # Order is important; the last matching pattern takes the most precedence. +# When someone opens a pull request that only modifies JS files, only @js-owner +# and not the global owner(s) will be requested for a review. +#*.js @js-owner + + # You can also use email addresses if you prefer. They'll be used to look up +# users just like we do for commit author emails. +#docs/* docs@example.com diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76c114583..640239efa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,13 +4,15 @@ We welcome contributions and feedback! All contributors must sign our [Contribut ## Development process -1. Create a branch off of `master`: `git checkout -b YOUR_NAME/branch_name`. -2. Commit your changes. Make sure to add tests! -3. Run `./gradlew clean check` to automatically catch potential bugs. -4. `git push` your changes to GitHub. -5. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`. -6. Open a pull request from `YOUR_NAME/branch_name` to `master`. -7. A repository maintainer will review your pull request and, if all goes well, squash and merge it! +1. Fork the repository and create your branch from master. +2. Please follow the [commit message guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) for each commit message. +3. Make sure to add tests! +4. Run `./gradlew clean check` to automatically catch potential bugs. +5. `git push` your changes to GitHub. +6. Open a PR from your fork into the master branch of the original repo. +7. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`. +8. Open a pull request from `YOUR_NAME/branch_name` to `master`. +9. A repository maintainer will review your pull request and, if all goes well, squash and merge it! ## Pull request acceptance criteria @@ -25,7 +27,11 @@ Refer to the [Google Java Style Guide](https://google.github.io/styleguide/javag ## License -All contributions are under the CLA mentioned above. For this project, Optimizely uses the Apache 2.0 license, and so asks that by contributing your code, you agree to license your contribution under the terms of the [Apache License v2.0](http://www.apache.org/licenses/LICENSE-2.0). Your contributions should also include the following header: +All contributions are under the CLA mentioned above. + +For this project, Optimizely uses the Apache 2.0 license, and so asks that by contributing your code, you agree to license your contribution under the terms of the [Apache License v2.0](http://www.apache.org/licenses/LICENSE-2.0). + +Your contributions should also include the following header: ``` /**************************************************************************** @@ -47,6 +53,10 @@ All contributions are under the CLA mentioned above. For this project, Optimizel The YEAR above should be the year of the contribution. If work on the file has been done over multiple years, list each year in the section above. Example: Optimizely writes the file and releases it in 2014. No changes are made in 2015. Change made in 2016. YEAR should be “2014, 2016”. +This project contains Gradle tasks that check the correct license header exists +in each file. Contributors can use the `./gradlew licenseFormat` command to +automatically insert missing license headers. + ## Contact If you have questions, please contact developers@optimizely.com. diff --git a/LICENSE b/LICENSE index afc550977..c9f7279d1 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2016, Optimizely + Copyright 2016-2024, Optimizely, Inc. and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 0250d4f37..1a7370c43 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,81 @@ -Optimizely Java SDK -=================== -[![Build Status](https://travis-ci.org/optimizely/java-sdk.svg?branch=master)](https://travis-ci.org/optimizely/java-sdk) +# Optimizely Java SDK + [![Apache 2.0](https://img.shields.io/badge/license-APACHE%202.0-blue.svg)](http://www.apache.org/licenses/LICENSE-2.0) -This repository houses the Java SDK for Optimizely's Full Stack product. +This repository houses the Java SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy). + +Optimizely Feature Experimentation is an A/B testing and feature management tool for product development teams that enables you to experiment at every step. Using Optimizely Feature Experimentation allows for every feature on your roadmap to be an opportunity to discover hidden insights. Learn more at [Optimizely.com](https://www.optimizely.com/products/experiment/feature-experimentation/), or see the [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome). + +Optimizely Rollouts is [free feature flags](https://www.optimizely.com/free-feature-flagging/) for development teams. You can easily roll out and roll back features in any application without code deploys, mitigating risk for every feature on your roadmap. + +## Get started + +Refer to the [Java SDK's developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/java-sdk) for detailed instructions on getting started with using the SDK. + +### Requirements + +Java 8 or higher versions. + +### Install the SDK -## Getting Started +The Java SDK is distributed through Maven Central and is created with source and target compatibility of Java 1.8. The `core-api` and `httpclient` packages are [optimizely-sdk-core-api](https://mvnrepository.com/artifact/com.optimizely.ab/core-api) and [optimizely-sdk-httpclient](https://mvnrepository.com/artifact/com.optimizely.ab/core-httpclient-impl), respectively. -### Installing the SDK -#### Gradle +`core-api` requires [org.slf4j:slf4j-api:1.7.16](https://mvnrepository.com/artifact/org.slf4j/slf4j-api/1.7.16) and a supported JSON parser. +We currently integrate with [Jackson](https://github.com/FasterXML/jackson), [GSON](https://github.com/google/gson), [json.org](http://www.json.org), and [json-simple](https://code.google.com/archive/p/json-simple); if any of those packages are available at runtime, they will be used by `core-api`. If none of those packages are already provided in your project's classpath, one will need to be added. -The SDK is available through Bintray. The core-api and httpclient Bintray packages are [optimizely-sdk-core-api](https://bintray.com/optimizely/optimizely/optimizely-sdk-core-api) -and [optimizely-sdk-httpclient](https://bintray.com/optimizely/optimizely/optimizely-sdk-httpclient) respectively. To install, place the -following in your `build.gradle` and substitute `VERSION` for the latest SDK version available via Bintray: +`core-httpclient-impl` is an optional dependency that implements the event dispatcher and requires [org.apache.httpcomponents:httpclient:4.5.2](https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient/4.5.2). + +--- + +**NOTE** + +Optimizely previously distributed the Java SDK through Bintray/JCenter. But, as of April 27, 2021, [Bintray/JCenter will become a read-only repository indefinitely](https://jfrog.com/blog/into-the-sunset-bintray-jcenter-gocenter-and-chartcenter/). The publish repository has been migrated to [MavenCentral](https://mvnrepository.com/artifact/com.optimizely.ab) for the SDK version 3.8.1 or later. + +--- ``` repositories { + mavenCentral() jcenter() } dependencies { compile 'com.optimizely.ab:core-api:{VERSION}' compile 'com.optimizely.ab:core-httpclient-impl:{VERSION}' - // The SDK integrates with multiple JSON parsers, here we use - // Jackson. + // The SDK integrates with multiple JSON parsers, here we use Jackson. compile 'com.fasterxml.jackson.core:jackson-core:2.7.1' compile 'com.fasterxml.jackson.core:jackson-annotations:2.7.1' compile 'com.fasterxml.jackson.core:jackson-databind:2.7.1' } -``` - -#### Dependencies - -`core-api` requires [org.slf4j:slf4j-api:1.7.16](https://mvnrepository.com/artifact/org.slf4j/slf4j-api/1.7.16) and a supported JSON parser. -We currently integrate with [Jackson](https://github.com/FasterXML/jackson), [GSON](https://github.com/google/gson), [json.org](http://www.json.org), -and [json-simple](https://code.google.com/archive/p/json-simple); if any of those packages are available at runtime, they will be used by `core-api`. -If none of those packages are already provided in your project's classpath, one will need to be added. `core-httpclient-impl` is an optional -dependency that implements the event dispatcher and requires [org.apache.httpcomponents:httpclient:4.5.2](https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient/4.5.2). -The supplied `pom` files on Bintray define module dependencies. - -### Feature Management Access -To access the Feature Management configuration in the Optimizely dashboard, please contact your Optimizely account executive. - -### Using the SDK +``` -See the Optimizely Full Stack [developer documentation](http://developers.optimizely.com/server/reference/index.html) to learn how to set -up your first Java project and use the SDK. -## Development +## Use the Java SDK -### Building the SDK +See the Optimizely Feature Experimentation [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0-full-stack/docs/java-sdk) to learn how to set up your first Java project and use the SDK. -To build local jars which are outputted into the respective modules' `build/lib` directories: -``` -./gradlew build -``` +## SDK Development ### Unit tests -#### Running all tests - You can run all unit tests with: ``` + ./gradlew test + ``` ### Checking for bugs -We utilize [FindBugs](http://findbugs.sourceforge.net/) to identify possible bugs in the SDK. To run the check: +We utilize [SpotBugs](https://spotbugs.github.io/) to identify possible bugs in the SDK. To run the check: ``` + ./gradlew check + ``` ### Benchmarking @@ -81,7 +83,9 @@ We utilize [FindBugs](http://findbugs.sourceforge.net/) to identify possible bug [JMH](http://openjdk.java.net/projects/code-tools/jmh/) benchmarks can be run through gradle: ``` + ./gradlew core-api:jmh + ``` Results are generated in `$buildDir/reports/jmh`. @@ -90,26 +94,85 @@ Results are generated in `$buildDir/reports/jmh`. Please see [CONTRIBUTING](CONTRIBUTING.md). -## License -``` - Copyright 2016, Optimizely +### Credits - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +First-party code (under core-api/ and core-httpclient-impl) is copyright Optimizely, Inc. and contributors, licensed under Apache 2.0. - http://www.apache.org/licenses/LICENSE-2.0 +### Additional Code - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -``` +This software incorporates code from the following open source projects: + +#### core-api module + +**SLF4J** [https://www.slf4j.org ](https://www.slf4j.org) + +Copyright © 2004-2017 QOS.ch + +License (MIT): [https://www.slf4j.org/license.html](https://www.slf4j.org/license.html) + +**Jackson Annotations** [https://github.com/FasterXML/jackson-annotations](https://github.com/FasterXML/jackson-annotations) + +License (Apache 2.0): [https://github.com/FasterXML/jackson-annotations/blob/master/src/main/resources/META-INF/LICENSE](https://github.com/FasterXML/jackson-annotations/blob/master/src/main/resources/META-INF/LICENSE) + +**Gson** [https://github.com/google/gson ](https://github.com/google/gson) + +Copyright © 2008 Google Inc. + +License (Apache 2.0): [https://github.com/google/gson/blob/master/LICENSE](https://github.com/google/gson/blob/master/LICENSE) + +**JSON-java** [https://github.com/stleary/JSON-java](https://github.com/stleary/JSON-java) + +Copyright © 2002 JSON.org + +License (The JSON License): [https://github.com/stleary/JSON-java/blob/master/LICENSE](https://github.com/stleary/JSON-java/blob/master/LICENSE) + +**JSON.simple** [https://code.google.com/archive/p/json-simple/](https://code.google.com/archive/p/json-simple/) + +Copyright © January 2004 + +License (Apache 2.0): [https://github.com/fangyidong/json-simple/blob/master/LICENSE.txt](https://github.com/fangyidong/json-simple/blob/master/LICENSE.txt) + +**Jackson Databind** [https://github.com/FasterXML/jackson-databind](https://github.com/FasterXML/jackson-databind) + +License (Apache 2.0): [https://github.com/FasterXML/jackson-databind/blob/master/src/main/resources/META-INF/LICENSE](https://github.com/FasterXML/jackson-databind/blob/master/src/main/resources/META-INF/LICENSE) + +#### core-httpclient-impl module + +**Gson** [https://github.com/google/gson ](https://github.com/google/gson) + +Copyright © 2008 Google Inc. + +License (Apache 2.0): [https://github.com/google/gson/blob/master/LICENSE](https://github.com/google/gson/blob/master/LICENSE) + +**Apache HttpClient** [https://hc.apache.org/httpcomponents-client-ga/index.html ](https://hc.apache.org/httpcomponents-client-ga/index.html) + +Copyright © January 2004 + +License (Apache 2.0): [https://github.com/apache/httpcomponents-client/blob/master/LICENSE.txt](https://github.com/apache/httpcomponents-client/blob/master/LICENSE.txt) + +### Other Optimzely SDKs + +- Agent - https://github.com/optimizely/agent + +- Android - https://github.com/optimizely/android-sdk + +- C# - https://github.com/optimizely/csharp-sdk + +- Flutter - https://github.com/optimizely/optimizely-flutter-sdk + +- Go - https://github.com/optimizely/go-sdk + +- Java - https://github.com/optimizely/java-sdk + +- JavaScript - https://github.com/optimizely/javascript-sdk + +- PHP - https://github.com/optimizely/php-sdk + +- Python - https://github.com/optimizely/python-sdk + +- React - https://github.com/optimizely/react-sdk + +- Ruby - https://github.com/optimizely/ruby-sdk -[JAR hell]: https://en.wikipedia.org/wiki/Java_Classloader#JAR_hell -[developer_docs]: http://developers.optimizely.com/server/index.html -[project_json]: http://developers.optimizely.com/server/reference/index.html#json -[Optimizely]: core-api/src/main/java/com/optimizely/ab/Optimizely.java -[Project Watcher]: core-api/src/main/java/com/optimizely/ab/config/ProjectWatcher.java -[Event Handler]: core-api/src/main/java/com/optimizely/ab/event/EventHandler.java +- Swift - https://github.com/optimizely/swift-sdk + diff --git a/build.gradle b/build.gradle index 0d6ebc9f0..54426f6e7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,25 +1,14 @@ -buildscript { - repositories { - jcenter() - maven { - url "https://oss.sonatype.org/content/repositories/snapshots/" - } - } - - dependencies { - classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.6' - } -} - plugins { - id 'com.github.kt3k.coveralls' version '2.8.2' + id 'com.github.kt3k.coveralls' version '2.12.2' id 'jacoco' - id 'me.champeau.gradle.jmh' version '0.3.1' + id 'me.champeau.gradle.jmh' version '0.5.3' id 'nebula.optional-base' version '3.2.0' + id 'com.github.hierynomus.license' version '0.16.1' + id 'com.github.spotbugs' version "6.0.14" + id 'maven-publish' } allprojects { - group = 'com.optimizely.ab' apply plugin: 'idea' apply plugin: 'jacoco' @@ -28,73 +17,71 @@ allprojects { } jacoco { - toolVersion = '0.8.0' + toolVersion = '0.8.7' } } -apply from: 'gradle/publish.gradle' - allprojects { - def travis_defined_version = System.getenv('TRAVIS_TAG') + group = 'com.optimizely.ab' + + def travis_defined_version = System.getenv('GITHUB_TAG') if (travis_defined_version != null) { version = travis_defined_version } + + ext.isReleaseVersion = !version.endsWith("SNAPSHOT") } -subprojects { - apply plugin: 'com.jfrog.bintray' - apply plugin: 'findbugs' +def publishedProjects = subprojects.findAll { it.name != 'java-quickstart' } + +configure(publishedProjects) { + apply plugin: 'com.github.spotbugs' apply plugin: 'jacoco' apply plugin: 'java' apply plugin: 'maven-publish' + apply plugin: 'signing' apply plugin: 'me.champeau.gradle.jmh' apply plugin: 'nebula.optional-base' + apply plugin: 'com.github.hierynomus.license' - sourceCompatibility = 1.6 - targetCompatibility = 1.6 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 repositories { jcenter() + maven { + url 'https://plugins.gradle.org/m2/' + } } task sourcesJar(type: Jar, dependsOn: classes) { - classifier = 'sources' + archiveClassifier.set('sources') from sourceSets.main.allSource } task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' + archiveClassifier.set('javadoc') from javadoc.destinationDir } - artifacts { - archives sourcesJar - archives javadocJar - } - - tasks.withType(FindBugs) { + spotbugsMain { reports { xml.enabled = false html.enabled = true } } - test { - useJUnit { - excludeCategories 'com.optimizely.ab.categories.ExhaustiveTest' - } + spotbugs { + spotbugsJmh.enabled = false + reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') + } + test { testLogging { showStandardStreams = false } } - task exhaustiveTest(type: Test) { - useJUnit { - includeCategories 'com.optimizely.ab.categories.ExhaustiveTest' - } - } - jmh { duplicateClassesStrategy = 'warn' } @@ -104,93 +91,100 @@ subprojects { } dependencies { - afterEvaluate { - jmh configurations.testCompile.allDependencies - } + jmh 'org.openjdk.jmh:jmh-core:1.12' + jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.12' } dependencies { - testCompile group: 'junit', name: 'junit', version: junitVersion - testCompile group: 'org.mockito', name: 'mockito-core', version: mockitoVersion - testCompile group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion - testCompile group: 'com.google.guava', name: 'guava', version: guavaVersion + implementation group: 'commons-codec', name: 'commons-codec', version: commonCodecVersion + + testImplementation group: 'junit', name: 'junit', version: junitVersion + testImplementation group: 'org.mockito', name: 'mockito-core', version: mockitoVersion + testImplementation group: 'org.hamcrest', name: 'hamcrest-all', version: hamcrestVersion + testImplementation group: 'com.google.guava', name: 'guava', version: guavaVersion // logging dependencies (logback) - testCompile group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion - testCompile group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion - - testCompile group: 'com.google.code.gson', name: 'gson', version: gsonVersion - testCompile group: 'org.json', name: 'json', version: jsonVersion - testCompile group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion - testCompile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion - } - - publishing { - publications { - mavenJava(MavenPublication) { - from components.java - artifact sourcesJar - artifact javadocJar - pom.withXml { - asNode().children().last() + { - resolveStrategy = Closure.DELEGATE_FIRST - url 'https://github.com/optimizely/java-sdk' - licenses { - license { - name 'The Apache Software License, Version 2.0' - url 'http://www.apache.org/license/LICENSE-2.0.txt' - distribution 'repo' - } - } - developers { - developer { - id 'optimizely' - name 'Optimizely' - email 'developers@optimizely.com' - } - } - } - } - } + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion + testImplementation group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion + + testImplementation group: 'com.google.code.gson', name: 'gson', version: gsonVersion + testImplementation group: 'org.json', name: 'json', version: jsonVersion + testImplementation group: 'com.googlecode.json-simple', name: 'json-simple', version: jsonSimpleVersion + testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion + } + + configurations.all { + resolutionStrategy { + force "junit:junit:${junitVersion}" } } - def bintrayName = 'core-api'; + + def docTitle = "Optimizely Java SDK" if (name.equals('core-httpclient-impl')) { - bintrayName = 'httpclient' - } - - bintray { - user = System.getenv('BINTRAY_USER') - key = System.getenv('BINTRAY_KEY') - pkg { - repo = 'optimizely' - name = "optimizely-sdk-${bintrayName}" - userOrg = 'optimizely' - version { - name = rootProject.version + docTitle = "Optimizely Java SDK: Httpclient" + } + + afterEvaluate { + publishing { + publications { + release(MavenPublication) { + customizePom(pom, docTitle) + + from components.java + artifact sourcesJar + artifact javadocJar + } + } + repositories { + maven { + def releaseUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2" + def snapshotUrl = "https://oss.sonatype.org/content/repositories/snapshots" + url = isReleaseVersion ? releaseUrl : snapshotUrl + credentials { + username System.getenv('MAVEN_CENTRAL_USERNAME') + password System.getenv('MAVEN_CENTRAL_PASSWORD') + } + } } - publications = ['mavenJava'] } - } - build.dependsOn('generatePomFileForMavenJavaPublication') + signing { + // base64 for workaround travis escape chars issue + def signingKeyBase64 = System.getenv('MAVEN_SIGNING_KEY_BASE64') + // skip signing for "local" version into MavenLocal for test-app + if (!signingKeyBase64?.trim()) return + byte[] decoded = signingKeyBase64.decodeBase64() + def signingKey = new String(decoded) + + def signingPassword = System.getenv('MAVEN_SIGNING_PASSPHRASE') + useInMemoryPgpKeys(signingKey, signingPassword) + sign publishing.publications.release + } + } - bintrayUpload.dependsOn 'build' + license { + header = rootProject.file("resources/HEADER") + skipExistingHeaders = true + include "**/*.java" + ext.author = "Optimizely" + ext.year = Calendar.getInstance().get(Calendar.YEAR) + } task ship() { - dependsOn('bintrayUpload') + dependsOn('publish') } + // concurrent publishing (maven-publish) causes an issue with maven-central repository + // - a single module splits into multiple staging repos, so validation fails. + // - adding this ordering requirement forces sequential publishing processes. + project(':core-api').javadocJar.mustRunAfter = [':core-httpclient-impl:ship'] } task ship() { dependsOn(':core-api:ship', ':core-httpclient-impl:ship') } -// Only report code coverage for projects that are distributed -def publishedProjects = subprojects.findAll { it.path != ':simulator' } - task jacocoMerge(type: JacocoMerge) { publishedProjects.each { subproject -> executionData subproject.tasks.withType(Test) @@ -204,9 +198,9 @@ task jacocoRootReport(type: JacocoReport, group: 'Coverage reports') { description = 'Generates an aggregate report from all subprojects' dependsOn publishedProjects.test, jacocoMerge - additionalSourceDirs = files(publishedProjects.sourceSets.main.allSource.srcDirs) - sourceDirectories = files(publishedProjects.sourceSets.main.allSource.srcDirs) - classDirectories = files(publishedProjects.sourceSets.main.output) + getAdditionalSourceDirs().setFrom(files(publishedProjects.sourceSets.main.allSource.srcDirs)) + getSourceDirectories().setFrom(files(publishedProjects.sourceSets.main.allSource.srcDirs)) + getAdditionalClassDirs().setFrom(files(publishedProjects.sourceSets.main.output)) executionData jacocoMerge.destinationFile reports { @@ -227,3 +221,37 @@ tasks.coveralls { dependsOn jacocoRootReport onlyIf { System.env.'CI' && !JavaVersion.current().isJava9Compatible() } } + +// standard POM format required by MavenCentral + +def customizePom(pom, title) { + pom.withXml { + asNode().children().last() + { + // keep this - otherwise some properties are not made into pom properly + resolveStrategy = Closure.DELEGATE_FIRST + + name title + url 'https://github.com/optimizely/java-sdk' + description 'The Java SDK for Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts' + licenses { + license { + name 'The Apache Software License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution 'repo' + } + } + developers { + developer { + id 'optimizely' + name 'Optimizely' + email 'optimizely-fullstack@optimizely.com' + } + } + scm { + connection 'scm:git:git://github.com/optimizely/java-sdk.git' + developerConnection 'scm:git:ssh:github.com/optimizely/java-sdk.git' + url 'https://github.com/optimizely/java-sdk.git' + } + } + } +} diff --git a/core-api/README.md b/core-api/README.md new file mode 100644 index 000000000..91d439ec7 --- /dev/null +++ b/core-api/README.md @@ -0,0 +1,105 @@ +# Java SDK Core API +This package contains the core APIs and interfaces for the Optimizely Feature Experimentation API in Java. + +Full product documentation is in the [Optimizely developers documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome). + +## Installation + +### Gradle +```groovy +compile 'com.optimizely.ab:core-api:{VERSION}' +``` + +### Maven +```xml +<dependency> + <groupId>com.optimizely.ab</groupId> + <artifactId>core-api</artifactId> + <version>{VERSION}</version> +</dependency> + +``` + +## Optimizely +[`Optimizely`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/Optimizely.java) +provides top level API access to the Feature Experimentation project. + +### Usage +```Java +Optimizely optimizely = Optimizely.builder() + .withConfigManager(configManager) + .withEventProcessor(eventProcessor) + .build(); + +Variation variation = optimizely.activate("ad-test"); +optimizely.track("conversion"); +``` + +## ErrorHandler +The [`ErrorHandler`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/error/ErrorHandler.java) +interface is available for handling errors from the SDK without interfering with the host application. + +### NoOpErrorHandler +The [`NoOpErrorHandler`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/error/NoOpErrorHandler.java) +is the default `ErrorHandler` implementation that silently consumes all errors raised from the SDK. + +### RaiseExceptionErrorHandler +The [`RaiseExceptionErrorHandler`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/error/RaiseExceptionErrorHandler.java) +is an implementation of `ErrorHandler` best suited for testing and development where **all** errors are raised, potentially crashing +the hosting application. + +## EventProcessor +The [`EventProcessor`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/EventProcessor.java) +interface is used to provide an intermediary processing stage within event production. +It's assumed that the `EventProcessor` dispatches events via a provided `EventHandler`. + +### BatchEventProcessor +[`BatchEventProcessor`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java) +is an implementation of `EventProcessor` where events are batched. The class maintains a single consumer thread that pulls +events off of the `BlockingQueue` and buffers them for either a +configured batch size or a maximum duration before the resulting `LogEvent` is sent to the `EventDispatcher` and `NotificationCenter`. + +### ForwardingEventProcessor +The [`ForwardingEventProcessor`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/ForwardingEventProcessor.java) +implements `EventProcessor` for backwards compatibility. Each event processed is converted into a [`LogEvent`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/ForwardingEventProcessor.java) +message before it is sent synchronously to the supplied `EventHandler`. + +## EventHandler +The [`EventHandler`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/EventHandler.java) +interface is used for dispatching events to the Optimizely event endpoint. Implementations of `EventHandler#dispatchEvent(LogEvent)` are expected +to make an HTTP request of type `getRequestMethod()` to the `LogEvent#getEndpointUrl()` location. The corresponding request parameters and body +are available via `LogEvent#getRequestParams()` and `LogEvent#getBody()` respectively. + +### NoopEventHandler +The [`NoopEventHandler`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/NoopEventHandler.java) +implements `EventHandler` with no side-effects. `NoopEventHandler` is useful for testing or non-production environments. + +## NotificationCenter +The [`NotificationCenter`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java) +is the centralized component for subscribing to notifications from the SDK. Subscribers must implement the `NotificationHandler<T>` interface +and are registered via `NotificationCenterxaddNotificationHandler`. Note that notifications are called synchronously and have the potential to +block the main thread. + +## ProjectConfig +The [`ProjectConfig`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java) +represents the current state of the Optimizely project as configured through [optimizely.com](https://www.optimizely.com/). +The interface is currently unstable and only used internally. All public access to this implementation is subject to change +with each subsequent version. + +### DatafileProjectConfig +The [`DatafileProjectConfig`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java) +is an implementation of `ProjectConfig` backed by a file, typically sourced from the Optimizely CDN. + +## ProjectConfigManager +The [`ProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java) +is a factory class that provides `ProjectConfig`. Implementations of this class provide a consistent representation +of a `ProjectConfig` that can be references between service calls. + +### AtomicProjectConfigManager +The [`AtomicProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java) +is a static provider that can be updated atomically to provide a consistent view of a `ProjectConfig`. + +### PollingProjectConfigManager +The [`PollingProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java) +is an abstract class that provides the framework for a dynamic factory that updates asynchronously within a background thread. +Implementations of this class can be used to poll from an externalized sourced without blocking the main application thread. diff --git a/core-api/build.gradle b/core-api/build.gradle index cd1d1fa9e..602131cd3 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -1,9 +1,10 @@ dependencies { - compile group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion - compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion - - compile group: 'com.google.code.findbugs', name: 'annotations', version: findbugsVersion - compile group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsVersion + implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: jacksonVersion + implementation group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + testImplementation group: 'junit', name: 'junit', version: junitVersion + testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion // an assortment of json parsers compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion, optional @@ -12,6 +13,24 @@ dependencies { compileOnly group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: jacksonVersion, optional } +tasks.named('processJmhResources') { + duplicatesStrategy = DuplicatesStrategy.WARN +} + + +test { + useJUnit { + excludeCategories 'com.optimizely.ab.categories.ExhaustiveTest' + } +} + +task exhaustiveTest(type: Test) { + useJUnit { + includeCategories 'com.optimizely.ab.categories.ExhaustiveTest' + } +} + + task generateVersionFile { // add the build version information into a file that'll go into the distribution ext.buildVersion = new File(projectDir, "src/main/resources/optimizely-build-version") diff --git a/core-api/src/jmh/java/com/optimizely/ab/config/parser/JacksonConfigParserBenchmark.java b/core-api/src/jmh/java/com/optimizely/ab/config/parser/JacksonConfigParserBenchmark.java new file mode 100644 index 000000000..8751da4b1 --- /dev/null +++ b/core-api/src/jmh/java/com/optimizely/ab/config/parser/JacksonConfigParserBenchmark.java @@ -0,0 +1,60 @@ +/** + * + * Copyright 2018-2019 Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.DatafileProjectConfigTestUtils; +import org.openjdk.jmh.annotations.*; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Fork(2) +@Warmup(iterations = 10) +@Measurement(iterations = 20) +@State(Scope.Benchmark) +public class JacksonConfigParserBenchmark { + JacksonConfigParser parser; + String jsonV2; + String jsonV3; + String jsonV4; + + @Setup + public void setUp() throws IOException { + parser = new JacksonConfigParser(); + jsonV2 = DatafileProjectConfigTestUtils.validConfigJsonV2(); + jsonV3 = DatafileProjectConfigTestUtils.validConfigJsonV3(); + jsonV4 = DatafileProjectConfigTestUtils.validConfigJsonV4(); + } + + @Benchmark + public ProjectConfig parseV2() throws ConfigParseException { + return parser.parseProjectConfig(jsonV2); + } + + @Benchmark + public ProjectConfig parseV3() throws ConfigParseException { + return parser.parseProjectConfig(jsonV3); + } + + @Benchmark + public ProjectConfig parseV4() throws ConfigParseException { + return parser.parseProjectConfig(jsonV4); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 51f5dadbd..6eead11c6 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2018, Optimizely, Inc. and contributors * + * Copyright 2016-2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -20,45 +20,80 @@ import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; import com.optimizely.ab.bucketing.UserProfileService; -import com.optimizely.ab.config.Attribute; +import com.optimizely.ab.config.AtomicProjectConfigManager; +import com.optimizely.ab.config.DatafileProjectConfig; import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariableUsageInstance; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.FeatureVariableUsageInstance; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; import com.optimizely.ab.config.Variation; import com.optimizely.ab.config.parser.ConfigParseException; -import com.optimizely.ab.config.parser.DefaultConfigParser; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; +import com.optimizely.ab.event.ForwardingEventProcessor; import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.NoopEventHandler; import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; import com.optimizely.ab.event.internal.EventFactory; -import com.optimizely.ab.event.internal.payload.EventBatch.ClientEngine; +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.event.internal.UserEventFactory; +import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.internal.NotificationRegistry; +import com.optimizely.ab.notification.ActivateNotification; +import com.optimizely.ab.notification.DecisionNotification; +import com.optimizely.ab.notification.FeatureTestSourceInfo; import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.notification.NotificationHandler; +import com.optimizely.ab.notification.RolloutSourceInfo; +import com.optimizely.ab.notification.SourceInfo; +import com.optimizely.ab.notification.TrackNotification; +import com.optimizely.ab.notification.UpdateConfigNotification; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.odp.ODPSegmentManager; +import com.optimizely.ab.odp.ODPSegmentOption; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; +import java.io.Closeable; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; + +import static com.optimizely.ab.internal.SafetyUtils.tryClose; /** * Top-level container class for Optimizely functionality. * Thread-safe, so can be created as a singleton and safely passed around. - * + * <p> * Example instantiation: * <pre> * Optimizely optimizely = Optimizely.builder(projectWatcher, eventHandler).build(); * </pre> - * + * <p> * To activate an experiment and perform variation specific processing: * <pre> * Variation variation = optimizely.activate(experimentKey, userId, attributes); @@ -76,51 +111,116 @@ * to be logged, and for the "control" variation to be returned. */ @ThreadSafe -public class Optimizely { +public class Optimizely implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(Optimizely.class); - @VisibleForTesting final DecisionService decisionService; - @VisibleForTesting final EventFactory eventFactory; - @VisibleForTesting final ProjectConfig projectConfig; - @VisibleForTesting final EventHandler eventHandler; - @VisibleForTesting final ErrorHandler errorHandler; - public final NotificationCenter notificationCenter = new NotificationCenter(); + final DecisionService decisionService; + @Deprecated + final EventHandler eventHandler; + @VisibleForTesting + final EventProcessor eventProcessor; + @VisibleForTesting + final ErrorHandler errorHandler; + + public final List<OptimizelyDecideOption> defaultDecideOptions; - @Nullable private final UserProfileService userProfileService; + @VisibleForTesting + final ProjectConfigManager projectConfigManager; - private Optimizely(@Nonnull ProjectConfig projectConfig, - @Nonnull DecisionService decisionService, - @Nonnull EventHandler eventHandler, - @Nonnull EventFactory eventFactory, + @Nullable + private final OptimizelyConfigManager optimizelyConfigManager; + + // TODO should be private + public final NotificationCenter notificationCenter; + + @Nullable + private final UserProfileService userProfileService; + + @Nullable + private final ODPManager odpManager; + + private final ReentrantLock lock = new ReentrantLock(); + + private Optimizely(@Nonnull EventHandler eventHandler, + @Nonnull EventProcessor eventProcessor, @Nonnull ErrorHandler errorHandler, - @Nullable UserProfileService userProfileService) { - this.projectConfig = projectConfig; - this.decisionService = decisionService; + @Nonnull DecisionService decisionService, + @Nullable UserProfileService userProfileService, + @Nonnull ProjectConfigManager projectConfigManager, + @Nullable OptimizelyConfigManager optimizelyConfigManager, + @Nonnull NotificationCenter notificationCenter, + @Nonnull List<OptimizelyDecideOption> defaultDecideOptions, + @Nullable ODPManager odpManager + ) { this.eventHandler = eventHandler; - this.eventFactory = eventFactory; + this.eventProcessor = eventProcessor; this.errorHandler = errorHandler; + this.decisionService = decisionService; this.userProfileService = userProfileService; + this.projectConfigManager = projectConfigManager; + this.optimizelyConfigManager = optimizelyConfigManager; + this.notificationCenter = notificationCenter; + this.defaultDecideOptions = defaultDecideOptions; + this.odpManager = odpManager; + + if (odpManager != null) { + odpManager.getEventManager().start(); + if (projectConfigManager.getCachedConfig() != null) { + updateODPSettings(); + } + if (projectConfigManager.getSDKKey() != null) { + NotificationRegistry.getInternalNotificationCenter(projectConfigManager.getSDKKey()). + addNotificationHandler(UpdateConfigNotification.class, + configNotification -> { + updateODPSettings(); + }); + } + + } } - // Do work here that should be done once per Optimizely lifecycle - @VisibleForTesting - void initialize() { + /** + * Determine if the instance of the Optimizely client is valid. An instance can be deemed invalid if it was not + * initialized properly due to an invalid datafile being passed in. + * + * @return True if the Optimizely instance is valid. + * False if the Optimizely instance is not valid. + */ + public boolean isValid() { + return getProjectConfig() != null; + } + /** + * Checks if eventHandler {@link EventHandler} and projectConfigManager {@link ProjectConfigManager} + * are Closeable {@link Closeable} and calls close on them. + * + * <b>NOTE:</b> There is a chance that this could be long running if the implementations of close are long running. + */ + @Override + public void close() { + tryClose(eventProcessor); + tryClose(eventHandler); + tryClose(projectConfigManager); + notificationCenter.clearAllNotificationListeners(); + NotificationRegistry.clearNotificationCenterRegistry(projectConfigManager.getSDKKey()); + if (odpManager != null) { + tryClose(odpManager); + } } //======== activate calls ========// - public @Nullable - Variation activate(@Nonnull String experimentKey, - @Nonnull String userId) throws UnknownExperimentException { + @Nullable + public Variation activate(@Nonnull String experimentKey, + @Nonnull String userId) throws UnknownExperimentException { return activate(experimentKey, userId, Collections.<String, String>emptyMap()); } - public @Nullable - Variation activate(@Nonnull String experimentKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) throws UnknownExperimentException { + @Nullable + public Variation activate(@Nonnull String experimentKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) throws UnknownExperimentException { if (experimentKey == null) { logger.error("The experimentKey parameter must be nonnull."); @@ -132,91 +232,128 @@ Variation activate(@Nonnull String experimentKey, return null; } - ProjectConfig currentConfig = getProjectConfig(); + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing activate call."); + return null; + } - Experiment experiment = currentConfig.getExperimentForKey(experimentKey, errorHandler); + Experiment experiment = projectConfig.getExperimentForKey(experimentKey, errorHandler); if (experiment == null) { // if we're unable to retrieve the associated experiment, return null logger.info("Not activating user \"{}\" for experiment \"{}\".", userId, experimentKey); return null; } - return activate(currentConfig, experiment, userId, attributes); + return activate(projectConfig, experiment, userId, attributes); } - public @Nullable - Variation activate(@Nonnull Experiment experiment, - @Nonnull String userId) { + @Nullable + public Variation activate(@Nonnull Experiment experiment, + @Nonnull String userId) { return activate(experiment, userId, Collections.<String, String>emptyMap()); } - public @Nullable - Variation activate(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { - - ProjectConfig currentConfig = getProjectConfig(); - - return activate(currentConfig, experiment, userId, attributes); + @Nullable + public Variation activate(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + return activate(getProjectConfig(), experiment, userId, attributes); } - private @Nullable - Variation activate(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { + @Nullable + private Variation activate(@Nullable ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing activate call."); + return null; + } - if (!validateUserId(userId)){ + if (!validateUserId(userId)) { logger.info("Not activating user \"{}\" for experiment \"{}\".", userId, experiment.getKey()); return null; } - // determine whether all the given attributes are present in the project config. If not, filter out the unknown - // attributes. - Map<String, String> filteredAttributes = filterAttributes(projectConfig, attributes); - + Map<String, ?> copiedAttributes = copyAttributes(attributes); // bucket the user to the given experiment and dispatch an impression event - Variation variation = decisionService.getVariation(experiment, userId, filteredAttributes); + Variation variation = getVariation(projectConfig, experiment, userId, copiedAttributes); if (variation == null) { logger.info("Not activating user \"{}\" for experiment \"{}\".", userId, experiment.getKey()); return null; } - sendImpression(projectConfig, experiment, userId, filteredAttributes, variation); + sendImpression(projectConfig, experiment, userId, copiedAttributes, variation, "experiment"); return variation; } + /** + * Creates and sends impression event. + * + * @param projectConfig the current projectConfig + * @param experiment the experiment user bucketed into and dispatch an impression event + * @param userId the ID of the user + * @param filteredAttributes the attributes of the user + * @param variation the variation that was returned from activate. + * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + */ private void sendImpression(@Nonnull ProjectConfig projectConfig, @Nonnull Experiment experiment, @Nonnull String userId, - @Nonnull Map<String, String> filteredAttributes, - @Nonnull Variation variation) { - if (experiment.isRunning()) { - LogEvent impressionEvent = eventFactory.createImpressionEvent( - projectConfig, - experiment, - variation, - userId, - filteredAttributes); - logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); - - if (logger.isDebugEnabled()) { - logger.debug( - "Dispatching impression event to URL {} with params {} and payload \"{}\".", - impressionEvent.getEndpointUrl(), impressionEvent.getRequestParams(), impressionEvent.getBody()); - } - - try { - eventHandler.dispatchEvent(impressionEvent); - } catch (Exception e) { - logger.error("Unexpected exception in event dispatcher", e); - } + @Nonnull Map<String, ?> filteredAttributes, + @Nonnull Variation variation, + @Nonnull String ruleType) { + sendImpression(projectConfig, experiment, userId, filteredAttributes, variation, "", ruleType, true); + } - notificationCenter.sendNotifications(NotificationCenter.NotificationType.Activate, experiment, userId, - filteredAttributes, variation, impressionEvent); - } else { - logger.info("Experiment has \"Launched\" status so not dispatching event during activation."); + /** + * Creates and sends impression event. + * + * @param projectConfig the current projectConfig + * @param experiment the experiment user bucketed into and dispatch an impression event + * @param userId the ID of the user + * @param filteredAttributes the attributes of the user + * @param variation the variation that was returned from activate. + * @param flagKey It can either be empty if ruleType is experiment or it's feature key in case ruleType is feature-test or rollout + * @param ruleType It can either be experiment in case impression event is sent from activate or it's feature-test or rollout + */ + private boolean sendImpression(@Nonnull ProjectConfig projectConfig, + @Nullable Experiment experiment, + @Nonnull String userId, + @Nonnull Map<String, ?> filteredAttributes, + @Nullable Variation variation, + @Nonnull String flagKey, + @Nonnull String ruleType, + @Nonnull boolean enabled) { + + UserEvent userEvent = UserEventFactory.createImpressionEvent( + projectConfig, + experiment, + variation, + userId, + filteredAttributes, + flagKey, + ruleType, + enabled); + + if (userEvent == null) { + return false; } + eventProcessor.process(userEvent); + if (experiment != null) { + logger.info("Activating user \"{}\" in experiment \"{}\".", userId, experiment.getKey()); + } + // Kept For backwards compatibility. + // This notification is deprecated and the new DecisionNotifications + // are sent via their respective method calls. + if (notificationCenter.getNotificationManager(ActivateNotification.class).size() > 0) { + LogEvent impressionEvent = EventFactory.createLogEvent(userEvent); + ActivateNotification activateNotification = new ActivateNotification( + experiment, userId, filteredAttributes, variation, impressionEvent); + notificationCenter.send(activateNotification); + } + return true; } //======== track calls ========// @@ -228,90 +365,63 @@ public void track(@Nonnull String eventName, public void track(@Nonnull String eventName, @Nonnull String userId, - @Nonnull Map<String, String> attributes) throws UnknownEventTypeException { + @Nonnull Map<String, ?> attributes) throws UnknownEventTypeException { track(eventName, userId, attributes, Collections.<String, String>emptyMap()); } public void track(@Nonnull String eventName, @Nonnull String userId, - @Nonnull Map<String, String> attributes, + @Nonnull Map<String, ?> attributes, @Nonnull Map<String, ?> eventTags) throws UnknownEventTypeException { - if (!validateUserId(userId)) { logger.info("Not tracking event \"{}\".", eventName); return; } - if (eventName == null || eventName.trim().isEmpty()){ + if (eventName == null || eventName.trim().isEmpty()) { logger.error("Event Key is null or empty when non-null and non-empty String was expected."); logger.info("Not tracking event for user \"{}\".", userId); return; } - ProjectConfig currentConfig = getProjectConfig(); + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return; + } + + Map<String, ?> copiedAttributes = copyAttributes(attributes); - EventType eventType = currentConfig.getEventTypeForName(eventName, errorHandler); + EventType eventType = projectConfig.getEventTypeForName(eventName, errorHandler); if (eventType == null) { // if no matching event type could be found, do not dispatch an event logger.info("Not tracking event \"{}\" for user \"{}\".", eventName, userId); return; } - // determine whether all the given attributes are present in the project config. If not, filter out the unknown - // attributes. - Map<String, String> filteredAttributes = filterAttributes(currentConfig, attributes); - if (eventTags == null) { logger.warn("Event tags is null when non-null was expected. Defaulting to an empty event tags map."); - eventTags = Collections.<String, String>emptyMap(); - } - - List<Experiment> experimentsForEvent = projectConfig.getExperimentsForEventKey(eventName); - Map<Experiment, Variation> experimentVariationMap = new HashMap<Experiment, Variation>(experimentsForEvent.size()); - for (Experiment experiment : experimentsForEvent) { - if (experiment.isRunning()) { - Variation variation = decisionService.getVariation(experiment, userId, filteredAttributes); - if (variation != null) { - experimentVariationMap.put(experiment, variation); - } - } else { - logger.info( - "Not tracking event \"{}\" for experiment \"{}\" because experiment has status \"Launched\".", - eventType.getKey(), experiment.getKey()); - } } - // create the conversion event request parameters, then dispatch - LogEvent conversionEvent = eventFactory.createConversionEvent( - projectConfig, - experimentVariationMap, - userId, - eventType.getId(), - eventType.getKey(), - filteredAttributes, - eventTags); - - if (conversionEvent == null) { - logger.info("There are no valid experiments for event \"{}\" to track.", eventName); - logger.info("Not tracking event \"{}\" for user \"{}\".", eventName, userId); - return; - } + UserEvent userEvent = UserEventFactory.createConversionEvent( + projectConfig, + userId, + eventType.getId(), + eventType.getKey(), + copiedAttributes, + eventTags); + eventProcessor.process(userEvent); logger.info("Tracking event \"{}\" for user \"{}\".", eventName, userId); - if (logger.isDebugEnabled()) { - logger.debug("Dispatching conversion event to URL {} with params {} and payload \"{}\".", - conversionEvent.getEndpointUrl(), conversionEvent.getRequestParams(), conversionEvent.getBody()); - } + if (notificationCenter.getNotificationManager(TrackNotification.class).size() > 0) { + // create the conversion event request parameters, then dispatch + LogEvent conversionEvent = EventFactory.createLogEvent(userEvent); + TrackNotification notification = new TrackNotification(eventName, userId, + copiedAttributes, eventTags, conversionEvent); - try { - eventHandler.dispatchEvent(conversionEvent); - } catch (Exception e) { - logger.error("Unexpected exception in event dispatcher", e); + notificationCenter.send(notification); } - - notificationCenter.sendNotifications(NotificationCenter.NotificationType.Track, eventName, userId, - filteredAttributes, eventTags, conversionEvent); } //======== FeatureFlag APIs ========// @@ -321,14 +431,15 @@ public void track(@Nonnull String eventName, * Send an impression event if the user is bucketed into an experiment using the feature. * * @param featureKey The unique key of the feature. - * @param userId The ID of the user. + * @param userId The ID of the user. * @return True if the feature is enabled. - * False if the feature is disabled. - * False if the feature is not found. + * False if the feature is disabled. + * False if the feature is not found. */ - public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey, - @Nonnull String userId) { - return isFeatureEnabled(featureKey, userId, Collections.<String, String>emptyMap()); + @Nonnull + public Boolean isFeatureEnabled(@Nonnull String featureKey, + @Nonnull String userId) { + return isFeatureEnabled(featureKey, userId, Collections.emptyMap()); } /** @@ -336,350 +447,694 @@ public void track(@Nonnull String eventName, * Send an impression event if the user is bucketed into an experiment using the feature. * * @param featureKey The unique key of the feature. - * @param userId The ID of the user. + * @param userId The ID of the user. * @param attributes The user's attributes. * @return True if the feature is enabled. - * False if the feature is disabled. - * False if the feature is not found. + * False if the feature is disabled. + * False if the feature is not found. */ - public @Nonnull Boolean isFeatureEnabled(@Nonnull String featureKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { + @Nonnull + public Boolean isFeatureEnabled(@Nonnull String featureKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return false; + } + + return isFeatureEnabled(projectConfig, featureKey, userId, attributes); + } + + @Nonnull + private Boolean isFeatureEnabled(@Nonnull ProjectConfig projectConfig, + @Nonnull String featureKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { if (featureKey == null) { logger.warn("The featureKey parameter must be nonnull."); return false; - } - else if (userId == null) { + } else if (userId == null) { logger.warn("The userId parameter must be nonnull."); return false; } + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); if (featureFlag == null) { logger.info("No feature flag was found for key \"{}\".", featureKey); return false; } - Map<String, String> filteredAttributes = filterAttributes(projectConfig, attributes); + Map<String, ?> copiedAttributes = copyAttributes(attributes); + FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult(); + Boolean featureEnabled = false; + SourceInfo sourceInfo = new RolloutSourceInfo(); + if (featureDecision.decisionSource != null) { + decisionSource = featureDecision.decisionSource; + } - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, filteredAttributes); if (featureDecision.variation != null) { - if (featureDecision.decisionSource.equals(FeatureDecision.DecisionSource.EXPERIMENT)) { - sendImpression( - projectConfig, - featureDecision.experiment, - userId, - filteredAttributes, - featureDecision.variation); + // This information is only necessary for feature tests. + // For rollouts experiments and variations are an implementation detail only. + if (featureDecision.decisionSource.equals(FeatureDecision.DecisionSource.FEATURE_TEST)) { + sourceInfo = new FeatureTestSourceInfo(featureDecision.experiment.getKey(), featureDecision.variation.getKey()); } else { logger.info("The user \"{}\" is not included in an experiment for feature \"{}\".", - userId, featureKey); + userId, featureKey); } if (featureDecision.variation.getFeatureEnabled()) { - logger.info("Feature \"{}\" is enabled for user \"{}\".", featureKey, userId); - return true; + featureEnabled = true; } } - - logger.info("Feature \"{}\" is not enabled for user \"{}\".", featureKey, userId); - return false; + sendImpression( + projectConfig, + featureDecision.experiment, + userId, + copiedAttributes, + featureDecision.variation, + featureKey, + decisionSource.toString(), + featureEnabled); + + DecisionNotification decisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFeatureKey(featureKey) + .withFeatureEnabled(featureEnabled) + .withSource(decisionSource) + .withSourceInfo(sourceInfo) + .build(); + + notificationCenter.send(decisionNotification); + + logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", featureKey, userId, featureEnabled); + return featureEnabled; } /** * Get the Boolean value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. + * @param userId The ID of the user. * @return The Boolean value of the boolean single variable feature. - * Null if the feature could not be found. + * Null if the feature could not be found. */ - public @Nullable Boolean getFeatureVariableBoolean(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId) { + @Nullable + public Boolean getFeatureVariableBoolean(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { return getFeatureVariableBoolean(featureKey, variableKey, userId, Collections.<String, String>emptyMap()); } /** * Get the Boolean value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. - * @param attributes The user's attributes. + * @param userId The ID of the user. + * @param attributes The user's attributes. * @return The Boolean value of the boolean single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable Boolean getFeatureVariableBoolean(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { - String variableValue = getFeatureVariableValueForType( - featureKey, - variableKey, - userId, - attributes, - LiveVariable.VariableType.BOOLEAN + @Nullable + public Boolean getFeatureVariableBoolean(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + + return getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + FeatureVariable.BOOLEAN_TYPE ); - if (variableValue != null) { - return Boolean.parseBoolean(variableValue); - } - return null; } /** * Get the Double value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. + * @param userId The ID of the user. * @return The Double value of the double single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable Double getFeatureVariableDouble(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId) { + @Nullable + public Double getFeatureVariableDouble(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { return getFeatureVariableDouble(featureKey, variableKey, userId, Collections.<String, String>emptyMap()); } /** * Get the Double value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. - * @param attributes The user's attributes. + * @param userId The ID of the user. + * @param attributes The user's attributes. * @return The Double value of the double single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable Double getFeatureVariableDouble(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { - String variableValue = getFeatureVariableValueForType( + @Nullable + public Double getFeatureVariableDouble(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + + Double variableValue = null; + try { + variableValue = getFeatureVariableValueForType( featureKey, variableKey, userId, attributes, - LiveVariable.VariableType.DOUBLE - ); - if (variableValue != null) { - try { - return Double.parseDouble(variableValue); - } catch (NumberFormatException exception) { - logger.error("NumberFormatException while trying to parse \"" + variableValue + - "\" as Double. " + exception); - } + FeatureVariable.DOUBLE_TYPE + ); + } catch (Exception exception) { + logger.error("NumberFormatException while trying to parse \"" + variableValue + + "\" as Double. " + exception); } - return null; + + return variableValue; } /** * Get the Integer value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. + * @param userId The ID of the user. * @return The Integer value of the integer single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable Integer getFeatureVariableInteger(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId) { + @Nullable + public Integer getFeatureVariableInteger(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { return getFeatureVariableInteger(featureKey, variableKey, userId, Collections.<String, String>emptyMap()); } /** * Get the Integer value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. - * @param attributes The user's attributes. + * @param userId The ID of the user. + * @param attributes The user's attributes. * @return The Integer value of the integer single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable Integer getFeatureVariableInteger(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { - String variableValue = getFeatureVariableValueForType( + @Nullable + public Integer getFeatureVariableInteger(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + + Integer variableValue = null; + + try { + variableValue = getFeatureVariableValueForType( featureKey, variableKey, userId, attributes, - LiveVariable.VariableType.INTEGER - ); - if (variableValue != null) { - try { - return Integer.parseInt(variableValue); - } catch (NumberFormatException exception) { - logger.error("NumberFormatException while trying to parse \"" + variableValue + - "\" as Integer. " + exception.toString()); - } + FeatureVariable.INTEGER_TYPE + ); + + } catch (Exception exception) { + logger.error("NumberFormatException while trying to parse value as Integer. " + exception.toString()); + } + + return variableValue; + } + + /** + * Get the Long value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return The Integer value of the integer single variable feature. + * Null if the feature or variable could not be found. + */ + @Nullable + public Long getFeatureVariableLong(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableLong(featureKey, variableKey, userId, Collections.emptyMap()); + } + + /** + * Get the Integer value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return The Integer value of the integer single variable feature. + * Null if the feature or variable could not be found. + */ + @Nullable + public Long getFeatureVariableLong(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + try { + return getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + FeatureVariable.INTEGER_TYPE + ); + + } catch (Exception exception) { + logger.error("NumberFormatException while trying to parse value as Long. {}", String.valueOf(exception)); } + return null; } /** * Get the String value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. + * @param userId The ID of the user. * @return The String value of the string single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable String getFeatureVariableString(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId) { + @Nullable + public String getFeatureVariableString(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { return getFeatureVariableString(featureKey, variableKey, userId, Collections.<String, String>emptyMap()); } /** * Get the String value of the specified variable in the feature. - * @param featureKey The unique key of the feature. + * + * @param featureKey The unique key of the feature. * @param variableKey The unique key of the variable. - * @param userId The ID of the user. - * @param attributes The user's attributes. + * @param userId The ID of the user. + * @param attributes The user's attributes. * @return The String value of the string single variable feature. - * Null if the feature or variable could not be found. + * Null if the feature or variable could not be found. */ - public @Nullable String getFeatureVariableString(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { + @Nullable + public String getFeatureVariableString(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + return getFeatureVariableValueForType( - featureKey, - variableKey, - userId, - attributes, - LiveVariable.VariableType.STRING); + featureKey, + variableKey, + userId, + attributes, + FeatureVariable.STRING_TYPE); + } + + /** + * Get the JSON value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @return An OptimizelyJSON instance for the JSON variable value. + * Null if the feature or variable could not be found. + */ + @Nullable + public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId) { + return getFeatureVariableJSON(featureKey, variableKey, userId, Collections.<String, String>emptyMap()); + } + + /** + * Get the JSON value of the specified variable in the feature. + * + * @param featureKey The unique key of the feature. + * @param variableKey The unique key of the variable. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return An OptimizelyJSON instance for the JSON variable value. + * Null if the feature or variable could not be found. + */ + @Nullable + public OptimizelyJSON getFeatureVariableJSON(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + + return getFeatureVariableValueForType( + featureKey, + variableKey, + userId, + attributes, + FeatureVariable.JSON_TYPE); } @VisibleForTesting - String getFeatureVariableValueForType(@Nonnull String featureKey, - @Nonnull String variableKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes, - @Nonnull LiveVariable.VariableType variableType) { + <T> T getFeatureVariableValueForType(@Nonnull String featureKey, + @Nonnull String variableKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nonnull String variableType) { if (featureKey == null) { logger.warn("The featureKey parameter must be nonnull."); return null; - } - else if (variableKey == null) { + } else if (variableKey == null) { logger.warn("The variableKey parameter must be nonnull."); return null; - } - else if (userId == null) { + } else if (userId == null) { logger.warn("The userId parameter must be nonnull."); return null; } + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing getFeatureVariableValueForType call. type: {}", variableType); + return null; + } + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); if (featureFlag == null) { logger.info("No feature flag was found for key \"{}\".", featureKey); return null; } - LiveVariable variable = featureFlag.getVariableKeyToLiveVariableMap().get(variableKey); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get(variableKey); if (variable == null) { logger.info("No feature variable was found for key \"{}\" in feature flag \"{}\".", - variableKey, featureKey); + variableKey, featureKey); return null; } else if (!variable.getType().equals(variableType)) { logger.info("The feature variable \"" + variableKey + - "\" is actually of type \"" + variable.getType().toString() + - "\" type. You tried to access it as type \"" + variableType.toString() + - "\". Please use the appropriate feature variable accessor."); + "\" is actually of type \"" + variable.getType().toString() + + "\" type. You tried to access it as type \"" + variableType.toString() + + "\". Please use the appropriate feature variable accessor."); return null; } String variableValue = variable.getDefaultValue(); - - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes); + Map<String, ?> copiedAttributes = copyAttributes(attributes); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult(); + Boolean featureEnabled = false; if (featureDecision.variation != null) { - LiveVariableUsageInstance liveVariableUsageInstance = - featureDecision.variation.getVariableIdToLiveVariableUsageInstanceMap().get(variable.getId()); - if (liveVariableUsageInstance != null) { - variableValue = liveVariableUsageInstance.getValue(); + if (featureDecision.variation.getFeatureEnabled()) { + FeatureVariableUsageInstance featureVariableUsageInstance = + featureDecision.variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); + if (featureVariableUsageInstance != null) { + variableValue = featureVariableUsageInstance.getValue(); + logger.info("Got variable value \"{}\" for variable \"{}\" of feature flag \"{}\".", variableValue, variableKey, featureKey); + } else { + variableValue = variable.getDefaultValue(); + logger.info("Value is not defined for variable \"{}\". Returning default value \"{}\".", variableKey, variableValue); + } } else { - variableValue = variable.getDefaultValue(); + logger.info("Feature \"{}\" is not enabled for user \"{}\". " + + "Returning the default variable value \"{}\".", + featureKey, userId, variableValue + ); } + featureEnabled = featureDecision.variation.getFeatureEnabled(); } else { logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " + - "The default value \"{}\" for \"{}\" is being returned.", - userId, featureKey, variableValue, variableKey + "The default value \"{}\" for \"{}\" is being returned.", + userId, featureKey, variableValue, variableKey ); } - return variableValue; - } - - /** - * Get the list of features that are enabled for the user. - * @param userId The ID of the user. - * @param attributes The user's attributes. - * @return List of the feature keys that are enabled for the user if the userId is empty it will - * return Empty List. - */ - public List<String> getEnabledFeatures(@Nonnull String userId, @Nonnull Map<String, String> attributes) { - List<String> enabledFeaturesList = new ArrayList<String>(); - - if (!validateUserId(userId)){ - return enabledFeaturesList; - } - - for (FeatureFlag featureFlag : projectConfig.getFeatureFlags()){ - String featureKey = featureFlag.getKey(); - if(isFeatureEnabled(featureKey, userId, attributes)) - enabledFeaturesList.add(featureKey); + Object convertedValue = convertStringToType(variableValue, variableType); + Object notificationValue = convertedValue; + if (convertedValue instanceof OptimizelyJSON) { + notificationValue = ((OptimizelyJSON) convertedValue).toMap(); } - return enabledFeaturesList; - } + DecisionNotification decisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFeatureKey(featureKey) + .withFeatureEnabled(featureEnabled) + .withVariableKey(variableKey) + .withVariableType(variableType) + .withVariableValue(notificationValue) + .withFeatureDecision(featureDecision) + .build(); - //======== getVariation calls ========// - public @Nullable - Variation getVariation(@Nonnull Experiment experiment, - @Nonnull String userId) throws UnknownExperimentException { + notificationCenter.send(decisionNotification); - return getVariation(experiment, userId, Collections.<String, String>emptyMap()); + return (T) convertedValue; } - public @Nullable - Variation getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) throws UnknownExperimentException { - - Map<String, String> filteredAttributes = filterAttributes(projectConfig, attributes); + // Helper method which takes type and variable value and convert it to object to use in Listener DecisionInfo object variable value + Object convertStringToType(String variableValue, String type) { + if (variableValue != null) { + switch (type) { + case FeatureVariable.DOUBLE_TYPE: + try { + return Double.parseDouble(variableValue); + } catch (NumberFormatException exception) { + logger.error("NumberFormatException while trying to parse \"" + variableValue + + "\" as Double. " + exception); + } + break; + case FeatureVariable.STRING_TYPE: + return variableValue; + case FeatureVariable.BOOLEAN_TYPE: + return Boolean.parseBoolean(variableValue); + case FeatureVariable.INTEGER_TYPE: + try { + return Integer.parseInt(variableValue); + } catch (NumberFormatException exception) { + try { + return Long.parseLong(variableValue); + } catch (NumberFormatException longException) { + logger.error("NumberFormatException while trying to parse \"{}\" as Integer. {}", + variableValue, + exception.toString()); + } + } + break; + case FeatureVariable.JSON_TYPE: + return new OptimizelyJSON(variableValue); + default: + return variableValue; + } + } - return decisionService.getVariation(experiment, userId, filteredAttributes); + return null; } - public @Nullable - Variation getVariation(@Nonnull String experimentKey, - @Nonnull String userId) throws UnknownExperimentException { - - return getVariation(experimentKey, userId, Collections.<String, String>emptyMap()); + /** + * Get the values of all variables in the feature. + * + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @return An OptimizelyJSON instance for all variable values. + * Null if the feature could not be found. + */ + @Nullable + public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, + @Nonnull String userId) { + return getAllFeatureVariables(featureKey, userId, Collections.<String, String>emptyMap()); } - public @Nullable - Variation getVariation(@Nonnull String experimentKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { - if (!validateUserId(userId)) { + /** + * Get the values of all variables in the feature. + * + * @param featureKey The unique key of the feature. + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return An OptimizelyJSON instance for all variable values. + * Null if the feature could not be found. + */ + @Nullable + public OptimizelyJSON getAllFeatureVariables(@Nonnull String featureKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + + if (featureKey == null) { + logger.warn("The featureKey parameter must be nonnull."); + return null; + } else if (userId == null) { + logger.warn("The userId parameter must be nonnull."); return null; } - if (experimentKey == null || experimentKey.trim().isEmpty()){ - logger.error("The experimentKey parameter must be nonnull."); + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing getAllFeatureVariableValues call. type"); return null; } - ProjectConfig currentConfig = getProjectConfig(); - - Experiment experiment = currentConfig.getExperimentForKey(experimentKey, errorHandler); - if (experiment == null) { - // if we're unable to retrieve the associated experiment, return null + FeatureFlag featureFlag = projectConfig.getFeatureKeyMapping().get(featureKey); + if (featureFlag == null) { + logger.info("No feature flag was found for key \"{}\".", featureKey); return null; } - Map<String, String> filteredAttributes = filterAttributes(projectConfig, attributes); + Map<String, ?> copiedAttributes = copyAttributes(attributes); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, createUserContextCopy(userId, copiedAttributes), projectConfig, Collections.emptyList()).getResult(); + Boolean featureEnabled = false; + Variation variation = featureDecision.variation; - return decisionService.getVariation(experiment,userId,filteredAttributes); + if (variation != null) { + featureEnabled = variation.getFeatureEnabled(); + if (featureEnabled) { + logger.info("Feature \"{}\" is enabled for user \"{}\".", featureKey, userId); + } else { + logger.info("Feature \"{}\" is not enabled for user \"{}\".", featureKey, userId); + } + } else { + logger.info("User \"{}\" was not bucketed into any variation for feature flag \"{}\". " + + "The default values are being returned.", userId, featureKey); + } + + Map<String, Object> valuesMap = new HashMap<String, Object>(); + for (FeatureVariable variable : featureFlag.getVariables()) { + String value = variable.getDefaultValue(); + if (featureEnabled) { + FeatureVariableUsageInstance instance = variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); + if (instance != null) { + value = instance.getValue(); + } + } + + Object convertedValue = convertStringToType(value, variable.getType()); + if (convertedValue instanceof OptimizelyJSON) { + convertedValue = ((OptimizelyJSON) convertedValue).toMap(); + } + + valuesMap.put(variable.getKey(), convertedValue); + } + + DecisionNotification decisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFeatureKey(featureKey) + .withFeatureEnabled(featureEnabled) + .withVariableValues(valuesMap) + .withFeatureDecision(featureDecision) + .build(); + + notificationCenter.send(decisionNotification); + + return new OptimizelyJSON(valuesMap); + } + + /** + * Get the list of features that are enabled for the user. + * TODO revisit this method. Calling this as-is can dramatically increase visitor impression counts. + * + * @param userId The ID of the user. + * @param attributes The user's attributes. + * @return List of the feature keys that are enabled for the user if the userId is empty it will + * return Empty List. + */ + public List<String> getEnabledFeatures(@Nonnull String userId, @Nonnull Map<String, ?> attributes) { + List<String> enabledFeaturesList = new ArrayList(); + if (!validateUserId(userId)) { + return enabledFeaturesList; + } + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return enabledFeaturesList; + } + + Map<String, ?> copiedAttributes = copyAttributes(attributes); + for (FeatureFlag featureFlag : projectConfig.getFeatureFlags()) { + String featureKey = featureFlag.getKey(); + if (isFeatureEnabled(projectConfig, featureKey, userId, copiedAttributes)) + enabledFeaturesList.add(featureKey); + } + + return enabledFeaturesList; + } + + //======== getVariation calls ========// + + @Nullable + public Variation getVariation(@Nonnull Experiment experiment, + @Nonnull String userId) throws UnknownExperimentException { + + return getVariation(experiment, userId, Collections.emptyMap()); + } + + @Nullable + public Variation getVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) throws UnknownExperimentException { + return getVariation(getProjectConfig(), experiment, userId, attributes); + } + + @Nullable + private Variation getVariation(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) throws UnknownExperimentException { + Map<String, ?> copiedAttributes = copyAttributes(attributes); + Variation variation = decisionService.getVariation(experiment, createUserContextCopy(userId, copiedAttributes), projectConfig).getResult(); + String notificationType = NotificationCenter.DecisionNotificationType.AB_TEST.toString(); + + if (projectConfig.getExperimentFeatureKeyMapping().get(experiment.getId()) != null) { + notificationType = NotificationCenter.DecisionNotificationType.FEATURE_TEST.toString(); + } + + DecisionNotification decisionNotification = DecisionNotification.newExperimentDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withExperimentKey(experiment.getKey()) + .withVariation(variation) + .withType(notificationType) + .build(); + + notificationCenter.send(decisionNotification); + + return variation; + } + + @Nullable + public Variation getVariation(@Nonnull String experimentKey, + @Nonnull String userId) throws UnknownExperimentException { + + return getVariation(experimentKey, userId, Collections.<String, String>emptyMap()); + } + + @Nullable + public Variation getVariation(@Nonnull String experimentKey, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + if (!validateUserId(userId)) { + return null; + } + + if (experimentKey == null || experimentKey.trim().isEmpty()) { + logger.error("The experimentKey parameter must be nonnull."); + return null; + } + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return null; + } + + Experiment experiment = projectConfig.getExperimentForKey(experimentKey, errorHandler); + if (experiment == null) { + // if we're unable to retrieve the associated experiment, return null + return null; + } + + return getVariation(projectConfig, experiment, userId, attributes); } /** @@ -687,140 +1142,567 @@ Variation getVariation(@Nonnull String experimentKey, * The forced variation value does not persist across application launches. * If the experiment key is not in the project file, this call fails and returns false. * If the variationKey is not in the experiment, this call fails. - * @param experimentKey The key for the experiment. - * @param userId The user ID to be used for bucketing. - * @param variationKey The variation key to force the user into. If the variation key is null - * then the forcedVariation for that experiment is removed. * + * @param experimentKey The key for the experiment. + * @param userId The user ID to be used for bucketing. + * @param variationKey The variation key to force the user into. If the variation key is null + * then the forcedVariation for that experiment is removed. * @return boolean A boolean value that indicates if the set completed successfully. */ public boolean setForcedVariation(@Nonnull String experimentKey, @Nonnull String userId, @Nullable String variationKey) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return false; + } + // if the experiment is not a valid experiment key, don't set it. + Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey); + if (experiment == null) { + logger.error("Experiment {} does not exist in ProjectConfig for project {}", experimentKey, projectConfig.getProjectId()); + return false; + } - return projectConfig.setForcedVariation(experimentKey, userId, variationKey); + // TODO this is problematic if swapping out ProjectConfigs. + // This state should be represented elsewhere like in a ephemeral UserProfileService. + return decisionService.setForcedVariation(experiment, userId, variationKey); } /** * Gets the forced variation for a given user and experiment. - * This method just calls into the {@link com.optimizely.ab.config.ProjectConfig#getForcedVariation(String, String)} + * This method just calls into the {@link DecisionService#getForcedVariation(Experiment, String)} * method of the same signature. * * @param experimentKey The key for the experiment. - * @param userId The user ID to be used for bucketing. - * + * @param userId The user ID to be used for bucketing. * @return The variation the user was bucketed into. This value can be null if the * forced variation fails. */ - public @Nullable Variation getForcedVariation(@Nonnull String experimentKey, + @Nullable + public Variation getForcedVariation(@Nonnull String experimentKey, @Nonnull String userId) { - return projectConfig.getForcedVariation(experimentKey, userId); + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing getForcedVariation call."); + return null; + } + + Experiment experiment = projectConfig.getExperimentKeyMapping().get(experimentKey); + if (experiment == null) { + logger.debug("No experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId); + return null; + } + + return decisionService.getForcedVariation(experiment, userId).getResult(); } /** * @return the current {@link ProjectConfig} instance. */ - public @Nonnull ProjectConfig getProjectConfig() { - return projectConfig; + @Nullable + public ProjectConfig getProjectConfig() { + return projectConfigManager.getConfig(); + } + + @Nullable + public UserProfileService getUserProfileService() { + return userProfileService; + } + + //======== Helper methods ========// + + /** + * Helper function to check that the provided userId is valid + * + * @param userId the userId being validated + * @return whether the user ID is valid + */ + private boolean validateUserId(String userId) { + if (userId == null) { + logger.error("The user ID parameter must be nonnull."); + return false; + } + + return true; } /** - * @return a {@link ProjectConfig} instance given a json string + * Get {@link OptimizelyConfig} containing experiments and features map + * + * @return {@link OptimizelyConfig} */ - private static ProjectConfig getProjectConfig(String datafile) throws ConfigParseException { - if (datafile == null) { - throw new ConfigParseException("Unable to parse null datafile."); + public OptimizelyConfig getOptimizelyConfig() { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing getOptimizelyConfig call."); + return null; } - if (datafile.length() == 0) { - throw new ConfigParseException("Unable to parse empty datafile."); + if (optimizelyConfigManager != null) { + return optimizelyConfigManager.getOptimizelyConfig(); } + // Generate and return a new OptimizelyConfig object as a fallback when consumer implements their own ProjectConfigManager without implementing OptimizelyConfigManager. + logger.debug("optimizelyConfigManager is null, generating new OptimizelyConfigObject as a fallback"); + return new OptimizelyConfigService(projectConfig).getConfig(); + } - ProjectConfig projectConfig = DefaultConfigParser.getInstance().parseProjectConfig(datafile); + //============ decide ============// - if (projectConfig.getVersion().equals("1")) { - throw new ConfigParseException("This version of the Java SDK does not support version 1 datafiles. " + - "Please use a version 2 or 3 datafile with this SDK."); + /** + * Create a context of the user for which decision APIs will be called. + * <p> + * A user context will be created successfully even when the SDK is not fully configured yet. + * + * @param userId The user ID to be used for bucketing. + * @param attributes: A map of attribute names to current user attribute values. + * @return An OptimizelyUserContext associated with this OptimizelyClient. + */ + public OptimizelyUserContext createUserContext(@Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; } - return projectConfig; + return new OptimizelyUserContext(this, userId, attributes); } - @Nullable - public UserProfileService getUserProfileService() { - return userProfileService; + public OptimizelyUserContext createUserContext(@Nonnull String userId) { + return new OptimizelyUserContext(this, userId); } - //======== Helper methods ========// + private OptimizelyUserContext createUserContextCopy(@Nonnull String userId, @Nonnull Map<String, ?> attributes) { + if (userId == null) { + logger.warn("The userId parameter must be nonnull."); + return null; + } + return new OptimizelyUserContext(this, userId, attributes, Collections.EMPTY_MAP, null, false); + } + + OptimizelyDecision decide(@Nonnull OptimizelyUserContext user, + @Nonnull String key, + @Nonnull List<OptimizelyDecideOption> options) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + return OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.SDK_NOT_READY.reason()); + } + + List<OptimizelyDecideOption> allOptions = getAllOptions(options); + allOptions.remove(OptimizelyDecideOption.ENABLED_FLAGS_ONLY); + + return decideForKeys(user, Arrays.asList(key), allOptions, true).get(key); + } + + private OptimizelyDecision createOptimizelyDecision( + OptimizelyUserContext user, + String flagKey, + FeatureDecision flagDecision, + DecisionReasons decisionReasons, + List<OptimizelyDecideOption> allOptions, + ProjectConfig projectConfig + ) { + String userId = user.getUserId(); + String experimentId = null; + String variationId = null; + + Boolean flagEnabled = false; + if (flagDecision.variation != null) { + if (flagDecision.variation.getFeatureEnabled()) { + flagEnabled = true; + } + } + logger.info("Feature \"{}\" is enabled for user \"{}\"? {}", flagKey, userId, flagEnabled); + + Map<String, Object> variableMap = new HashMap<>(); + if (!allOptions.contains(OptimizelyDecideOption.EXCLUDE_VARIABLES)) { + DecisionResponse<Map<String, Object>> decisionVariables = getDecisionVariableMap( + projectConfig.getFeatureKeyMapping().get(flagKey), + flagDecision.variation, + flagEnabled); + variableMap = decisionVariables.getResult(); + decisionReasons.merge(decisionVariables.getReasons()); + } + OptimizelyJSON optimizelyJSON = new OptimizelyJSON(variableMap); + + FeatureDecision.DecisionSource decisionSource = FeatureDecision.DecisionSource.ROLLOUT; + if (flagDecision.decisionSource != null) { + decisionSource = flagDecision.decisionSource; + } + + List<String> reasonsToReport = decisionReasons.toReport(); + String variationKey = flagDecision.variation != null ? flagDecision.variation.getKey() : null; + // TODO: add ruleKey values when available later. use a copy of experimentKey until then. + // add to event metadata as well (currently set to experimentKey) + String ruleKey = flagDecision.experiment != null ? flagDecision.experiment.getKey() : null; + + + Boolean decisionEventDispatched = false; + experimentId = flagDecision.experiment != null ? flagDecision.experiment.getId() : null; + variationId = flagDecision.variation != null ? flagDecision.variation.getId() : null; + + Map<String, Object> attributes = user.getAttributes(); + Map<String, ?> copiedAttributes = new HashMap<>(attributes); + + if (!allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) { + decisionEventDispatched = sendImpression( + projectConfig, + flagDecision.experiment, + userId, + copiedAttributes, + flagDecision.variation, + flagKey, + decisionSource.toString(), + flagEnabled); + } + + DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder() + .withUserId(userId) + .withAttributes(copiedAttributes) + .withFlagKey(flagKey) + .withEnabled(flagEnabled) + .withVariables(variableMap) + .withVariationKey(variationKey) + .withRuleKey(ruleKey) + .withReasons(reasonsToReport) + .withDecisionEventDispatched(decisionEventDispatched) + .withExperimentId(experimentId) + .withVariationId(variationId) + .build(); + notificationCenter.send(decisionNotification); + + return new OptimizelyDecision( + variationKey, + flagEnabled, + optimizelyJSON, + ruleKey, + flagKey, + user, + reasonsToReport); + } + + Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, + @Nonnull List<String> keys, + @Nonnull List<OptimizelyDecideOption> options) { + return decideForKeys(user, keys, options, false); + } + + private Map<String, OptimizelyDecision> decideForKeys(@Nonnull OptimizelyUserContext user, + @Nonnull List<String> keys, + @Nonnull List<OptimizelyDecideOption> options, + boolean ignoreDefaultOptions) { + Map<String, OptimizelyDecision> decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing decideForKeys call."); + return decisionMap; + } + + if (keys.isEmpty()) return decisionMap; + + List<OptimizelyDecideOption> allOptions = ignoreDefaultOptions ? options : getAllOptions(options); + + Map<String, FeatureDecision> flagDecisions = new HashMap<>(); + Map<String, DecisionReasons> decisionReasonsMap = new HashMap<>(); + + List<FeatureFlag> flagsWithoutForcedDecision = new ArrayList<>(); + + List<String> validKeys = new ArrayList<>(); + + for (String key : keys) { + FeatureFlag flag = projectConfig.getFeatureKeyMapping().get(key); + if (flag == null) { + decisionMap.put(key, OptimizelyDecision.newErrorDecision(key, user, DecisionMessage.FLAG_KEY_INVALID.reason(key))); + continue; + } + + validKeys.add(key); + + DecisionReasons decisionReasons = DefaultDecisionReasons.newInstance(allOptions); + decisionReasonsMap.put(key, decisionReasons); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(key, null); + DecisionResponse<Variation> forcedDecisionVariation = decisionService.validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + decisionReasons.merge(forcedDecisionVariation.getReasons()); + + if (forcedDecisionVariation.getResult() != null) { + flagDecisions.put(key, + new FeatureDecision(null, forcedDecisionVariation.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST)); + } else { + flagsWithoutForcedDecision.add(flag); + } + } + + List<DecisionResponse<FeatureDecision>> decisionList = + decisionService.getVariationsForFeatureList(flagsWithoutForcedDecision, user, projectConfig, allOptions); + + for (int i = 0; i < flagsWithoutForcedDecision.size(); i++) { + DecisionResponse<FeatureDecision> decision = decisionList.get(i); + String flagKey = flagsWithoutForcedDecision.get(i).getKey(); + flagDecisions.put(flagKey, decision.getResult()); + decisionReasonsMap.get(flagKey).merge(decision.getReasons()); + } + + for (String key : validKeys) { + FeatureDecision flagDecision = flagDecisions.get(key); + DecisionReasons decisionReasons = decisionReasonsMap.get((key)); + + OptimizelyDecision optimizelyDecision = createOptimizelyDecision( + user, key, flagDecision, decisionReasons, allOptions, projectConfig + ); + + if (!allOptions.contains(OptimizelyDecideOption.ENABLED_FLAGS_ONLY) || optimizelyDecision.getEnabled()) { + decisionMap.put(key, optimizelyDecision); + } + } + + return decisionMap; + } + + Map<String, OptimizelyDecision> decideAll(@Nonnull OptimizelyUserContext user, + @Nonnull List<OptimizelyDecideOption> options) { + Map<String, OptimizelyDecision> decisionMap = new HashMap<>(); + + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing isFeatureEnabled call."); + return decisionMap; + } + + List<FeatureFlag> allFlags = projectConfig.getFeatureFlags(); + List<String> allFlagKeys = new ArrayList<>(); + for (int i = 0; i < allFlags.size(); i++) allFlagKeys.add(allFlags.get(i).getKey()); + + return decideForKeys(user, allFlagKeys, options); + } + + private List<OptimizelyDecideOption> getAllOptions(List<OptimizelyDecideOption> options) { + List<OptimizelyDecideOption> copiedOptions = new ArrayList(defaultDecideOptions); + if (options != null) { + copiedOptions.addAll(options); + } + return copiedOptions; + } + + @Nonnull + private DecisionResponse<Map<String, Object>> getDecisionVariableMap(@Nonnull FeatureFlag flag, + @Nonnull Variation variation, + @Nonnull Boolean featureEnabled) { + DecisionReasons reasons = new DecisionReasons(); + + Map<String, Object> valuesMap = new HashMap<String, Object>(); + for (FeatureVariable variable : flag.getVariables()) { + String value = variable.getDefaultValue(); + if (featureEnabled) { + FeatureVariableUsageInstance instance = variation.getVariableIdToFeatureVariableUsageInstanceMap().get(variable.getId()); + if (instance != null) { + value = instance.getValue(); + } + } + + Object convertedValue = convertStringToType(value, variable.getType()); + if (convertedValue == null) { + reasons.addError(DecisionMessage.VARIABLE_VALUE_INVALID.reason(variable.getKey())); + } else if (convertedValue instanceof OptimizelyJSON) { + convertedValue = ((OptimizelyJSON) convertedValue).toMap(); + } + + valuesMap.put(variable.getKey(), convertedValue); + } + + return new DecisionResponse(valuesMap, reasons); + } /** - * Helper method to verify that the given attributes map contains only keys that are present in the - * {@link ProjectConfig}. + * Helper method which makes separate copy of attributesMap variable and returns it * - * @param projectConfig the current project config - * @param attributes the attributes map to validate and potentially filter. Attributes which starts with reserved key - * {@link ProjectConfig#RESERVED_ATTRIBUTE_PREFIX} are kept. - * @return the filtered attributes map (containing only attributes that are present in the project config) or an - * empty map if a null attributes object is passed in + * @param attributes map to copy + * @return copy of attributes */ - private Map<String, String> filterAttributes(@Nonnull ProjectConfig projectConfig, - @Nonnull Map<String, String> attributes) { - if (attributes == null) { - logger.warn("Attributes is null when non-null was expected. Defaulting to an empty attributes map."); - return Collections.<String, String>emptyMap(); + private Map<String, ?> copyAttributes(Map<String, ?> attributes) { + Map<String, ?> copiedAttributes = null; + if (attributes != null) { + copiedAttributes = new HashMap<>(attributes); } + return copiedAttributes; + } - List<String> unknownAttributes = null; + //======== Notification APIs ========// - Map<String, Attribute> attributeKeyMapping = projectConfig.getAttributeKeyMapping(); - for (Map.Entry<String, String> attribute : attributes.entrySet()) { - if (!attributeKeyMapping.containsKey(attribute.getKey()) && - !attribute.getKey().startsWith(ProjectConfig.RESERVED_ATTRIBUTE_PREFIX)) { - if (unknownAttributes == null) { - unknownAttributes = new ArrayList<String>(); - } - unknownAttributes.add(attribute.getKey()); + public NotificationCenter getNotificationCenter() { + return notificationCenter; + } + + /** + * Convenience method for adding DecisionNotification Handlers + * + * @param handler DicisionNotification handler + * @return A handler Id (greater than 0 if succeeded) + */ + public int addDecisionNotificationHandler(NotificationHandler<DecisionNotification> handler) { + return addNotificationHandler(DecisionNotification.class, handler); + } + + /** + * Convenience method for adding TrackNotification Handlers + * + * @param handler TrackNotification handler + * @return A handler Id (greater than 0 if succeeded) + */ + public int addTrackNotificationHandler(NotificationHandler<TrackNotification> handler) { + return addNotificationHandler(TrackNotification.class, handler); + } + + /** + * Convenience method for adding UpdateConfigNotification Handlers + * + * @param handler UpdateConfigNotification handler + * @return A handler Id (greater than 0 if succeeded) + */ + public int addUpdateConfigNotificationHandler(NotificationHandler<UpdateConfigNotification> handler) { + return addNotificationHandler(UpdateConfigNotification.class, handler); + } + + /** + * Convenience method for adding LogEvent Notification Handlers + * + * @param handler NotificationHandler handler + * @return A handler Id (greater than 0 if succeeded) + */ + public int addLogEventNotificationHandler(NotificationHandler<LogEvent> handler) { + return addNotificationHandler(LogEvent.class, handler); + } + + /** + * Convenience method for adding NotificationHandlers + * + * @param clazz The class of NotificationHandler + * @param handler NotificationHandler handler + * @param <T> This is the type parameter + * @return A handler Id (greater than 0 if succeeded) + */ + public <T> int addNotificationHandler(Class<T> clazz, NotificationHandler<T> handler) { + return notificationCenter.addNotificationHandler(clazz, handler); + } + + public List<String> fetchQualifiedSegments(String userId, @Nonnull List<ODPSegmentOption> segmentOptions) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing fetchQualifiedSegments call."); + return null; + } + if (odpManager != null) { + lock.lock(); + try { + return odpManager.getSegmentManager().getQualifiedSegments(userId, segmentOptions); + } finally { + lock.unlock(); } } + logger.error("Audience segments fetch failed (ODP is not enabled)."); + return null; + } - if (unknownAttributes != null) { - logger.warn("Attribute(s) {} not in the datafile.", unknownAttributes); - // make a copy of the passed through attributes, then remove the unknown list - attributes = new HashMap<String, String>(attributes); - for (String unknownAttribute : unknownAttributes) { - attributes.remove(unknownAttribute); - } + public void fetchQualifiedSegments(String userId, ODPSegmentManager.ODPSegmentFetchCallback callback, List<ODPSegmentOption> segmentOptions) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing fetchQualifiedSegments call."); + callback.onCompleted(null); + return; } + if (odpManager == null) { + logger.error("Audience segments fetch failed (ODP is not enabled)."); + callback.onCompleted(null); + } else { + odpManager.getSegmentManager().getQualifiedSegments(userId, callback, segmentOptions); + } + } - return attributes; + @Nullable + public ODPManager getODPManager() { + return odpManager; } + /** - * Helper function to check that the provided userId is valid + * Send an event to the ODP server. * - * @param userId the userId being validated - * @return whether the user ID is valid + * @param type the event type (default = "fullstack"). + * @param action the event action name. + * @param identifiers a dictionary for identifiers. The caller must provide at least one key-value pair unless non-empty common identifiers have been set already with {@link ODPManager.Builder#withUserCommonIdentifiers(Map) }. + * @param data a dictionary for associated data. The default event data will be added to this data before sending to the ODP server. */ - private boolean validateUserId(String userId) { - if (userId == null) { - logger.error("The user ID parameter must be nonnull."); - return false; + public void sendODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map<String, String> identifiers, @Nullable Map<String, Object> data) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing sendODPEvent call."); + return; } - if (userId.trim().isEmpty()) { - logger.error("Non-empty user ID required"); - return false; + if (odpManager != null) { + if (action == null || action.trim().isEmpty()) { + logger.error("ODP action is not valid (cannot be empty)."); + return; + } + + ODPEvent event = new ODPEvent(type, action, identifiers, data); + odpManager.getEventManager().sendEvent(event); + } else { + logger.error("ODP event send failed (ODP is not enabled)"); } + } - return true; + public void identifyUser(@Nonnull String userId) { + ProjectConfig projectConfig = getProjectConfig(); + if (projectConfig == null) { + logger.error("Optimizely instance is not valid, failing identifyUser call."); + return; + } + ODPManager odpManager = getODPManager(); + if (odpManager != null) { + odpManager.getEventManager().identifyUser(userId); + } + } + + private void updateODPSettings() { + ProjectConfig projectConfig = projectConfigManager.getCachedConfig(); + if (odpManager != null && projectConfig != null) { + odpManager.updateSettings(projectConfig.getHostForODP(), projectConfig.getPublicKeyForODP(), projectConfig.getAllSegments()); + } } //======== Builder ========// + /** + * This overloaded factory method is deprecated in favor of pure builder methods. + * Please use {@link com.optimizely.ab.Optimizely#builder()} along with + * {@link Builder#withDatafile(java.lang.String)} and + * {@link Builder#withEventHandler(com.optimizely.ab.event.EventHandler)} + * respectively. + * <p> + * Example: + * <pre> + * Optimizely optimizely = Optimizely.builder() + * .withDatafile(datafile) + * .withEventHandler(eventHandler) + * .build(); + * </pre> + * + * @param datafile A datafile + * @param eventHandler An EventHandler + * @return An Optimizely builder + */ + @Deprecated public static Builder builder(@Nonnull String datafile, @Nonnull EventHandler eventHandler) { + return new Builder(datafile, eventHandler); } + public static Builder builder() { + return new Builder(); + } + /** * {@link Optimizely} instance builder. * <p> @@ -836,30 +1718,56 @@ public static class Builder { private DecisionService decisionService; private ErrorHandler errorHandler; private EventHandler eventHandler; - private EventFactory eventFactory; - private ClientEngine clientEngine; - private String clientVersion; + private EventProcessor eventProcessor; private ProjectConfig projectConfig; + private ProjectConfigManager projectConfigManager; + private OptimizelyConfigManager optimizelyConfigManager; private UserProfileService userProfileService; + private NotificationCenter notificationCenter; + private List<OptimizelyDecideOption> defaultDecideOptions; + private ODPManager odpManager; + // For backwards compatibility + private AtomicProjectConfigManager fallbackConfigManager = new AtomicProjectConfigManager(); + + @Deprecated public Builder(@Nonnull String datafile, @Nonnull EventHandler eventHandler) { - this.datafile = datafile; this.eventHandler = eventHandler; + this.datafile = datafile; } - protected Builder withBucketing(Bucketer bucketer) { - this.bucketer = bucketer; + public Builder() { + } + + public Builder withErrorHandler(ErrorHandler errorHandler) { + this.errorHandler = errorHandler; return this; } - protected Builder withDecisionService(DecisionService decisionService) { - this.decisionService = decisionService; + /** + * The withEventHandler has been moved to the EventProcessor which takes a EventHandler in it's builder + * method. + * {@link com.optimizely.ab.event.BatchEventProcessor.Builder#withEventHandler(com.optimizely.ab.event.EventHandler)} label} + * Please use that builder method instead. + * + * @param eventHandler An EventHandler + * @return An Optimizely builder + */ + @Deprecated + public Builder withEventHandler(EventHandler eventHandler) { + this.eventHandler = eventHandler; return this; } - public Builder withErrorHandler(ErrorHandler errorHandler) { - this.errorHandler = errorHandler; + /** + * You can instantiate a BatchEventProcessor or a ForwardingEventProcessor or supply your own. + * + * @param eventProcessor An EventProcessor + * @return An Optimizely builder + */ + public Builder withEventProcessor(EventProcessor eventProcessor) { + this.eventProcessor = eventProcessor; return this; } @@ -868,60 +1776,142 @@ public Builder withUserProfileService(UserProfileService userProfileService) { return this; } - public Builder withClientEngine(ClientEngine clientEngine) { - this.clientEngine = clientEngine; + /** + * Override the SDK name and version (for client SDKs like android-sdk wrapping the core java-sdk) to be included in events. + * + * @param clientEngineName the client engine name ("java-sdk", "android-sdk", "flutter-sdk", etc.). + * @param clientVersion the client SDK version. + * @return An Optimizely builder + */ + public Builder withClientInfo(String clientEngineName, String clientVersion) { + ClientEngineInfo.setClientEngineName(clientEngineName); + BuildVersionInfo.setClientVersion(clientVersion); return this; } + /** + * @deprecated in favor of {@link withClientInfo(String, String)} which can set with arbitrary client names. + */ + @Deprecated + public Builder withClientInfo(EventBatch.ClientEngine clientEngine, String clientVersion) { + ClientEngineInfo.setClientEngine(clientEngine); + BuildVersionInfo.setClientVersion(clientVersion); + return this; + } + + @Deprecated + public Builder withClientEngine(EventBatch.ClientEngine clientEngine) { + logger.info("Deprecated. In the future, set ClientEngine via ClientEngineInfo#setClientEngine."); + ClientEngineInfo.setClientEngine(clientEngine); + return this; + } + + @Deprecated public Builder withClientVersion(String clientVersion) { - this.clientVersion = clientVersion; + logger.info("Explicitly setting the ClientVersion is no longer supported."); + return this; + } + + public Builder withConfigManager(ProjectConfigManager projectConfigManager) { + this.projectConfigManager = projectConfigManager; return this; } - protected Builder withEventBuilder(EventFactory eventFactory) { - this.eventFactory = eventFactory; + public Builder withNotificationCenter(NotificationCenter notificationCenter) { + this.notificationCenter = notificationCenter; + return this; + } + + public Builder withDatafile(String datafile) { + this.datafile = datafile; + return this; + } + + public Builder withDefaultDecideOptions(List<OptimizelyDecideOption> defaultDecideOtions) { + this.defaultDecideOptions = defaultDecideOtions; + return this; + } + + public Builder withODPManager(ODPManager odpManager) { + this.odpManager = odpManager; + return this; + } + + // Helper functions for making testing easier + protected Builder withBucketing(Bucketer bucketer) { + this.bucketer = bucketer; return this; } - // Helper function for making testing easier protected Builder withConfig(ProjectConfig projectConfig) { this.projectConfig = projectConfig; return this; } - public Optimizely build() throws ConfigParseException { - if (projectConfig == null) { - projectConfig = Optimizely.getProjectConfig(datafile); + protected Builder withDecisionService(DecisionService decisionService) { + this.decisionService = decisionService; + return this; + } + + public Optimizely build() { + + if (errorHandler == null) { + errorHandler = new NoOpErrorHandler(); + } + + if (eventHandler == null) { + eventHandler = new NoopEventHandler(); } if (bucketer == null) { - bucketer = new Bucketer(projectConfig); + bucketer = new Bucketer(); + } + + if (decisionService == null) { + decisionService = new DecisionService(bucketer, errorHandler, userProfileService); } - if (clientEngine == null) { - clientEngine = ClientEngine.JAVA_SDK; + if (projectConfig == null && datafile != null && !datafile.isEmpty()) { + try { + projectConfig = new DatafileProjectConfig.Builder().withDatafile(datafile).build(); + logger.info("Datafile successfully loaded with revision: {}", projectConfig.getRevision()); + } catch (ConfigParseException ex) { + logger.error("Unable to parse the datafile", ex); + logger.info("Datafile is invalid"); + errorHandler.handleError(new OptimizelyRuntimeException(ex)); + } } - if (clientVersion == null) { - clientVersion = BuildVersionInfo.VERSION; + if (projectConfig != null) { + fallbackConfigManager.setConfig(projectConfig); } + if (projectConfigManager == null) { + projectConfigManager = fallbackConfigManager; + } - if (eventFactory == null) { - eventFactory = new EventFactory(clientEngine, clientVersion); + // PollingProjectConfigManager now also implements OptimizelyConfigManager interface to support OptimizelyConfig API. + // This check is needed in case a consumer provides their own ProjectConfigManager which does nt implement OptimizelyConfigManager interface + if (projectConfigManager instanceof OptimizelyConfigManager) { + optimizelyConfigManager = (OptimizelyConfigManager) projectConfigManager; } - if (errorHandler == null) { - errorHandler = new NoOpErrorHandler(); + if (notificationCenter == null) { + notificationCenter = new NotificationCenter(); } - if (decisionService == null) { - decisionService = new DecisionService(bucketer, errorHandler, projectConfig, userProfileService); + // For backwards compatibility + if (eventProcessor == null) { + eventProcessor = new ForwardingEventProcessor(eventHandler, notificationCenter); + } + + if (defaultDecideOptions != null) { + defaultDecideOptions = Collections.unmodifiableList(defaultDecideOptions); + } else { + defaultDecideOptions = Collections.emptyList(); } - Optimizely optimizely = new Optimizely(projectConfig, decisionService, eventHandler, eventFactory, errorHandler, userProfileService); - optimizely.initialize(); - return optimizely; + return new Optimizely(eventHandler, eventProcessor, errorHandler, decisionService, userProfileService, projectConfigManager, optimizelyConfigManager, notificationCenter, defaultDecideOptions, odpManager); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java new file mode 100644 index 000000000..3663f769d --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyDecisionContext.java @@ -0,0 +1,50 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class OptimizelyDecisionContext { + public static final String OPTI_NULL_RULE_KEY = "$opt-null-rule-key"; + public static final String OPTI_KEY_DIVIDER = "-$opt$-"; + + private String flagKey; + private String ruleKey; + + public OptimizelyDecisionContext(@Nonnull String flagKey, @Nullable String ruleKey) { + if (flagKey == null) throw new NullPointerException("FlagKey must not be null, please provide a valid input."); + this.flagKey = flagKey; + this.ruleKey = ruleKey; + } + + public String getFlagKey() { + return flagKey; + } + + public String getRuleKey() { + return ruleKey != null ? ruleKey : OPTI_NULL_RULE_KEY; + } + + public String getKey() { + StringBuilder keyBuilder = new StringBuilder(); + keyBuilder.append(flagKey); + keyBuilder.append(OPTI_KEY_DIVIDER); + keyBuilder.append(getRuleKey()); + return keyBuilder.toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java new file mode 100644 index 000000000..d73a86c83 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyForcedDecision.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import javax.annotation.Nonnull; + +public class OptimizelyForcedDecision { + private String variationKey; + + public OptimizelyForcedDecision(@Nonnull String variationKey) { + this.variationKey = variationKey; + } + + public String getVariationKey() { + return variationKey; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyRuntimeException.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyRuntimeException.java index eb535daf5..fe6f793b7 100644 --- a/core-api/src/main/java/com/optimizely/ab/OptimizelyRuntimeException.java +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyRuntimeException.java @@ -21,7 +21,8 @@ */ public class OptimizelyRuntimeException extends RuntimeException { - public OptimizelyRuntimeException() { } + public OptimizelyRuntimeException() { + } public OptimizelyRuntimeException(Exception exception) { super(exception); diff --git a/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java new file mode 100644 index 000000000..e2c03b147 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/OptimizelyUserContext.java @@ -0,0 +1,393 @@ +/** + * + * Copyright 2020-2023, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.odp.ODPSegmentCallback; +import com.optimizely.ab.odp.ODPSegmentOption; +import com.optimizely.ab.optimizelydecision.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class OptimizelyUserContext { + // OptimizelyForcedDecisionsKey mapped to variationKeys + Map<String, OptimizelyForcedDecision> forcedDecisionsMap; + + @Nonnull + private final String userId; + + @Nonnull + private final Map<String, Object> attributes; + + private List<String> qualifiedSegments; + + @Nonnull + private final Optimizely optimizely; + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyUserContext.class); + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes) { + this(optimizely, userId, attributes, Collections.EMPTY_MAP, null); + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nullable Map<String, OptimizelyForcedDecision> forcedDecisionsMap, + @Nullable List<String> qualifiedSegments) { + this(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments, true); + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nullable Map<String, OptimizelyForcedDecision> forcedDecisionsMap, + @Nullable List<String> qualifiedSegments, + @Nullable Boolean shouldIdentifyUser) { + this.optimizely = optimizely; + this.userId = userId; + if (attributes != null) { + this.attributes = Collections.synchronizedMap(new HashMap<>(attributes)); + } else { + this.attributes = Collections.synchronizedMap(new HashMap<>()); + } + if (forcedDecisionsMap != null) { + this.forcedDecisionsMap = new ConcurrentHashMap<>(forcedDecisionsMap); + } + + if (qualifiedSegments != null) { + this.qualifiedSegments = Collections.synchronizedList(new LinkedList<>(qualifiedSegments)); + } + + if (shouldIdentifyUser == null || shouldIdentifyUser) { + optimizely.identifyUser(userId); + } + } + + public OptimizelyUserContext(@Nonnull Optimizely optimizely, @Nonnull String userId) { + this(optimizely, userId, Collections.EMPTY_MAP); + } + + public String getUserId() { + return userId; + } + + public Map<String, Object> getAttributes() { + return attributes; + } + + public Optimizely getOptimizely() { + return optimizely; + } + + public OptimizelyUserContext copy() { + return new OptimizelyUserContext(optimizely, userId, attributes, forcedDecisionsMap, qualifiedSegments, false); + } + + /** + * Returns true if the user is qualified for the given segment name + * @param segment A String segment key which will be checked in the qualified segments list that if it exists then user is qualified. + * @return boolean Is user qualified for a segment. + */ + public boolean isQualifiedFor(@Nonnull String segment) { + if (qualifiedSegments == null) { + return false; + } + + return qualifiedSegments.contains(segment); + } + + /** + * Set an attribute for a given key. + * + * @param key An attribute key + * @param value An attribute value + */ + public void setAttribute(@Nonnull String key, @Nullable Object value) { + attributes.put(key, value); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + * <ul> + * <li>If the SDK finds an error, it’ll return a decision with <b>null</b> for <b>variationKey</b>. The decision will include an error message in <b>reasons</b>. + * </ul> + * @param key A flag key for which a decision will be made. + * @param options A list of options for decision-making. + * @return A decision result. + */ + public OptimizelyDecision decide(@Nonnull String key, + @Nonnull List<OptimizelyDecideOption> options) { + return optimizely.decide(copy(), key, options); + } + + /** + * Returns a decision result ({@link OptimizelyDecision}) for a given flag key and a user context, which contains all data required to deliver the flag. + * + * @param key A flag key for which a decision will be made. + * @return A decision result. + */ + public OptimizelyDecision decide(@Nonnull String key) { + return decide(key, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for multiple flag keys and a user context. + * <ul> + * <li>If the SDK finds an error for a key, the response will include a decision for the key showing <b>reasons</b> for the error. + * <li>The SDK will always return key-mapped decisions. When it can not process requests, it’ll return an empty map after logging the errors. + * </ul> + * @param keys A list of flag keys for which decisions will be made. + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map<String, OptimizelyDecision> decideForKeys(@Nonnull List<String> keys, + @Nonnull List<OptimizelyDecideOption> options) { + return optimizely.decideForKeys(copy(), keys, options); + } + + /** + * Returns a key-map of decision results for multiple flag keys and a user context. + * + * @param keys A list of flag keys for which decisions will be made. + * @return All decision results mapped by flag keys. + */ + public Map<String, OptimizelyDecision> decideForKeys(@Nonnull List<String> keys) { + return decideForKeys(keys, Collections.emptyList()); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @param options A list of options for decision-making. + * @return All decision results mapped by flag keys. + */ + public Map<String, OptimizelyDecision> decideAll(@Nonnull List<OptimizelyDecideOption> options) { + return optimizely.decideAll(copy(), options); + } + + /** + * Returns a key-map of decision results ({@link OptimizelyDecision}) for all active flag keys. + * + * @return A dictionary of all decision results, mapped by flag keys. + */ + public Map<String, OptimizelyDecision> decideAll() { + return decideAll(Collections.emptyList()); + } + + /** + * Track an event. + * + * @param eventName The event name. + * @param eventTags A map of event tag names to event tag values. + * @throws UnknownEventTypeException when event type is unknown + */ + public void trackEvent(@Nonnull String eventName, + @Nonnull Map<String, ?> eventTags) throws UnknownEventTypeException { + optimizely.track(eventName, userId, attributes, eventTags); + } + + /** + * Track an event. + * + * @param eventName The event name. + * @throws UnknownEventTypeException when event type is unknown + */ + public void trackEvent(@Nonnull String eventName) throws UnknownEventTypeException { + trackEvent(eventName, Collections.emptyMap()); + } + + /** + * Set a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @param optimizelyForcedDecision The OptimizelyForcedDecision containing the variationKey + * @return Returns a boolean, Ture if successfully set, otherwise false + */ + public Boolean setForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext, + @Nonnull OptimizelyForcedDecision optimizelyForcedDecision) { + // Check if the forcedDecisionsMap has been initialized yet or not + if (forcedDecisionsMap == null ){ + // Thread-safe implementation of HashMap + forcedDecisionsMap = new ConcurrentHashMap<>(); + } + forcedDecisionsMap.put(optimizelyDecisionContext.getKey(), optimizelyForcedDecision); + return true; + } + + /** + * Get a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a variationKey for a given forced decision + */ + @Nullable + public OptimizelyForcedDecision getForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + return findForcedDecision(optimizelyDecisionContext); + } + + /** + * Finds a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a variationKey relating to the found forced decision, otherwise null + */ + @Nullable + public OptimizelyForcedDecision findForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + if (forcedDecisionsMap != null) { + return forcedDecisionsMap.get(optimizelyDecisionContext.getKey()); + } + return null; + } + + /** + * Remove a forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @return Returns a boolean, true if successfully removed, otherwise false + */ + public boolean removeForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext) { + try { + if (forcedDecisionsMap != null) { + if (forcedDecisionsMap.remove(optimizelyDecisionContext.getKey()) != null) { + return true; + } + } + } catch (Exception e) { + logger.error("Unable to remove forced-decision - " + e); + } + + return false; + } + + /** + * Remove all forced decisions + * + * @return Returns a boolean, True if successfully, otherwise false + */ + public boolean removeAllForcedDecisions() { + // Clear both maps for with and without ruleKey + if (forcedDecisionsMap != null) { + forcedDecisionsMap.clear(); + } + return true; + } + + public List<String> getQualifiedSegments() { + return qualifiedSegments; + } + + public void setQualifiedSegments(List<String> qualifiedSegments) { + if (qualifiedSegments == null) { + this.qualifiedSegments = null; + } else if (this.qualifiedSegments == null) { + this.qualifiedSegments = Collections.synchronizedList(new LinkedList<>(qualifiedSegments)); + } else { + this.qualifiedSegments.clear(); + this.qualifiedSegments.addAll(qualifiedSegments); + } + } + + /** + * Fetch all qualified segments for the user context. + * <p> + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @return a boolean value for fetch success or failure. + */ + public Boolean fetchQualifiedSegments() { + return fetchQualifiedSegments(Collections.emptyList()); + } + + /** + * Fetch all qualified segments for the user context. + * <p> + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @param segmentOptions A set of options for fetching qualified segments. + * @return a boolean value for fetch success or failure. + */ + public Boolean fetchQualifiedSegments(@Nonnull List<ODPSegmentOption> segmentOptions) { + List<String> segments = optimizely.fetchQualifiedSegments(userId, segmentOptions); + setQualifiedSegments(segments); + return segments != null; + } + + /** + * Fetch all qualified segments for the user context in a non-blocking manner. This method will fetch segments + * in a separate thread and invoke the provided callback when results are available. + * <p> + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @param callback A callback to invoke when results are available. + * @param segmentOptions A set of options for fetching qualified segments. + */ + public void fetchQualifiedSegments(ODPSegmentCallback callback, List<ODPSegmentOption> segmentOptions) { + optimizely.fetchQualifiedSegments(userId, segments -> { + setQualifiedSegments(segments); + callback.onCompleted(segments != null); + }, segmentOptions); + } + + /** + * Fetch all qualified segments for the user context in a non-blocking manner. This method will fetch segments + * in a separate thread and invoke the provided callback when results are available. + * <p> + * The segments fetched will be saved and can be accessed at any time by calling {@link #getQualifiedSegments()}. + * + * @param callback A callback to invoke when results are available. + */ + public void fetchQualifiedSegments(ODPSegmentCallback callback) { + fetchQualifiedSegments(callback, Collections.emptyList()); + } + + // Utils + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyUserContext userContext = (OptimizelyUserContext) obj; + return userId.equals(userContext.getUserId()) && + attributes.equals(userContext.getAttributes()) && + optimizely.equals(userContext.getOptimizely()); + } + + @Override + public int hashCode() { + int hash = userId.hashCode(); + hash = 31 * hash + attributes.hashCode(); + hash = 31 * hash + optimizely.hashCode(); + return hash; + } + + @Override + public String toString() { + return "OptimizelyUserContext {" + + "userId='" + userId + '\'' + + ", attributes='" + attributes + '\'' + + '}'; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/UnknownEventTypeException.java b/core-api/src/main/java/com/optimizely/ab/UnknownEventTypeException.java index 9375b35e2..aaf701dd1 100644 --- a/core-api/src/main/java/com/optimizely/ab/UnknownEventTypeException.java +++ b/core-api/src/main/java/com/optimizely/ab/UnknownEventTypeException.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,8 @@ */ package com.optimizely.ab; -import com.optimizely.ab.config.EventType; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.EventType; /** * Exception thrown when attempting to use/refer to an {@link EventType} that isn't present in the current diff --git a/core-api/src/main/java/com/optimizely/ab/UnknownExperimentException.java b/core-api/src/main/java/com/optimizely/ab/UnknownExperimentException.java index 01a9aa774..637058b0a 100644 --- a/core-api/src/main/java/com/optimizely/ab/UnknownExperimentException.java +++ b/core-api/src/main/java/com/optimizely/ab/UnknownExperimentException.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,8 @@ */ package com.optimizely.ab; -import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Experiment; /** * Exception thrown when attempting to use/refer to an {@link Experiment} that isn't present in the current diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java index 56504d490..b92d2cf15 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019-2021 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,16 +18,14 @@ import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.bucketing.internal.MurmurHash3; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; +import com.optimizely.ab.config.*; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.List; @@ -36,15 +34,13 @@ * identifier. * <p> * The user identifier <i>must</i> be provided in the first data argument passed to - * {@link #bucket(Experiment, String)} and <i>must</i> be non-null and non-empty. + * {@link #bucket(Experiment, String, ProjectConfig)} and <i>must</i> be non-null and non-empty. * * @see <a href="https://en.wikipedia.org/wiki/MurmurHash">MurmurHash</a> */ @Immutable public class Bucketer { - private final ProjectConfig projectConfig; - private static final Logger logger = LoggerFactory.getLogger(Bucketer.class); private static final int MURMUR_HASH_SEED = 1; @@ -55,10 +51,6 @@ public class Bucketer { @VisibleForTesting static final int MAX_TRAFFIC_VALUE = 10000; - public Bucketer(ProjectConfig projectConfig) { - this.projectConfig = projectConfig; - } - private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAllocations) { int currentEndOfRange; for (TrafficAllocation currAllocation : trafficAllocations) { @@ -76,7 +68,8 @@ private String bucketToEntity(int bucketValue, List<TrafficAllocation> trafficAl } private Experiment bucketToExperiment(@Nonnull Group group, - @Nonnull String bucketingId) { + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { // "salt" the bucket id using the group id String bucketKey = bucketingId + group.getId(); @@ -95,8 +88,11 @@ private Experiment bucketToExperiment(@Nonnull Group group, return null; } - private Variation bucketToVariation(@Nonnull Experiment experiment, - @Nonnull String bucketingId) { + @Nonnull + private DecisionResponse<Variation> bucketToVariation(@Nonnull Experiment experiment, + @Nonnull String bucketingId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // "salt" the bucket id using the experiment id String experimentId = experiment.getId(); String experimentKey = experiment.getKey(); @@ -112,25 +108,33 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, if (bucketedVariationId != null) { Variation bucketedVariation = experiment.getVariationIdToVariationMap().get(bucketedVariationId); String variationKey = bucketedVariation.getKey(); - logger.info("User with bucketingId \"{}\" is in variation \"{}\" of experiment \"{}\".", bucketingId, variationKey, - experimentKey); + String message = reasons.addInfo("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", bucketingId, variationKey, + experimentKey); + logger.info(message); - return bucketedVariation; + return new DecisionResponse(bucketedVariation, reasons); } // user was not bucketed to a variation - logger.info("User with bucketingId \"{}\" is not in any variation of experiment \"{}\".", bucketingId, experimentKey); - return null; + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", bucketingId, experimentKey); + logger.info(message); + return new DecisionResponse(null, reasons); } /** * Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3. - * @param experiment The Experiment in which the user is to be bucketed. + * + * @param experiment The Experiment in which the user is to be bucketed. * @param bucketingId string A customer-assigned value used to create the key for the murmur hash. - * @return Variation the user is bucketed into or null. + * @param projectConfig The current projectConfig + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ - public @Nullable Variation bucket(@Nonnull Experiment experiment, - @Nonnull String bucketingId) { + @Nonnull + public DecisionResponse<Variation> bucket(@Nonnull Experiment experiment, + @Nonnull String bucketingId, + @Nonnull ProjectConfig projectConfig) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // ---------- Bucket User ---------- String groupId = experiment.getGroupId(); // check whether the experiment belongs to a group @@ -138,44 +142,47 @@ private Variation bucketToVariation(@Nonnull Experiment experiment, Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId); // bucket to an experiment only if group entities are to be mutually exclusive if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) { - Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId); + Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig); if (bucketedExperiment == null) { - logger.info("User with bucketingId \"{}\" is not in any experiment of group {}.", bucketingId, experimentGroup.getId()); - return null; - } - else { + String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId()); + logger.info(message); + return new DecisionResponse(null, reasons); + } else { } // if the experiment a user is bucketed in within a group isn't the same as the experiment provided, // don't perform further bucketing within the experiment if (!bucketedExperiment.getId().equals(experiment.getId())) { - logger.info("User with bucketingId \"{}\" is not in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(), - experimentGroup.getId()); - return null; + String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), + experimentGroup.getId()); + logger.info(message); + return new DecisionResponse(null, reasons); } - logger.info("User with bucketingId \"{}\" is in experiment \"{}\" of group {}.", bucketingId, experiment.getKey(), - experimentGroup.getId()); + String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(), + experimentGroup.getId()); + logger.info(message); } } - return bucketToVariation(experiment, bucketingId); + DecisionResponse<Variation> decisionResponse = bucketToVariation(experiment, bucketingId); + reasons.merge(decisionResponse.getReasons()); + return new DecisionResponse<>(decisionResponse.getResult(), reasons); } - //======== Helper methods ========// /** * Map the given 32-bit hashcode into the range [0, {@link #MAX_TRAFFIC_VALUE}). + * * @param hashCode the provided hashcode * @return a value in the range closed-open range, [0, {@link #MAX_TRAFFIC_VALUE}) */ @VisibleForTesting int generateBucketValue(int hashCode) { // map the hashCode into the range [0, BucketAlgorithm.MAX_TRAFFIC_VALUE) - double ratio = (double)(hashCode & 0xFFFFFFFFL) / Math.pow(2, 32); - return (int)Math.floor(MAX_TRAFFIC_VALUE * ratio); + double ratio = (double) (hashCode & 0xFFFFFFFFL) / Math.pow(2, 32); + return (int) Math.floor(MAX_TRAFFIC_VALUE * ratio); } - } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/Decision.java b/core-api/src/main/java/com/optimizely/ab/bucketing/Decision.java index 250aea05b..cfade243d 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/Decision.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/Decision.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017, Optimizely, Inc. and contributors * + * Copyright 2017, 2019, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -24,11 +24,15 @@ */ public class Decision { - /** The ID of the {@link com.optimizely.ab.config.Variation} the user was bucketed into. */ - @Nonnull public String variationId; + /** + * The ID of the {@link com.optimizely.ab.config.Variation} the user was bucketed into. + */ + @Nonnull + public String variationId; /** * Initialize a Decision object. + * * @param variationId The ID of the variation the user was bucketed into. */ public Decision(@Nonnull String variationId) { diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java index 0dc895feb..ff48ffb99 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2018, Optimizely, Inc. and contributors * + * Copyright 2017-2022, 2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -15,25 +15,27 @@ ***************************************************************************/ package com.optimizely.ab.bucketing; +import com.optimizely.ab.OptimizelyDecisionContext; +import com.optimizely.ab.OptimizelyForcedDecision; import com.optimizely.ab.OptimizelyRuntimeException; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.internal.ExperimentUtils; import com.optimizely.ab.internal.ControlAttribute; - +import com.optimizely.ab.internal.ExperimentUtils; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; - import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE; /** * Optimizely's decision service that determines which variation of an experiment the user will be allocated to. @@ -49,234 +51,392 @@ public class DecisionService { private final Bucketer bucketer; private final ErrorHandler errorHandler; - private final ProjectConfig projectConfig; private final UserProfileService userProfileService; private static final Logger logger = LoggerFactory.getLogger(DecisionService.class); + /** + * Forced variations supersede any other mappings. They are transient and are not persistent or part of + * the actual datafile. This contains all the forced variations + * set by the user by calling {@link DecisionService#setForcedVariation(Experiment, String, String)} (it is not the same as the + * whitelisting forcedVariations data structure in the Experiments class). + */ + private transient ConcurrentHashMap<String, ConcurrentHashMap<String, String>> forcedVariationMapping = new ConcurrentHashMap<String, ConcurrentHashMap<String, String>>(); + + /** * Initialize a decision service for the Optimizely client. - * @param bucketer Base bucketer to allocate new users to an experiment. - * @param errorHandler The error handler of the Optimizely client. - * @param projectConfig Optimizely Project Config representing the datafile. + * + * @param bucketer Base bucketer to allocate new users to an experiment. + * @param errorHandler The error handler of the Optimizely client. * @param userProfileService UserProfileService implementation for storing user info. */ public DecisionService(@Nonnull Bucketer bucketer, @Nonnull ErrorHandler errorHandler, - @Nonnull ProjectConfig projectConfig, @Nullable UserProfileService userProfileService) { this.bucketer = bucketer; this.errorHandler = errorHandler; - this.projectConfig = projectConfig; this.userProfileService = userProfileService; } /** * Get a {@link Variation} of an {@link Experiment} for a user to be allocated into. * - * @param experiment The Experiment the user will be bucketed into. - * @param userId The userId of the user. - * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. - * @return The {@link Variation} the user is allocated into. + * @param experiment The Experiment the user will be bucketed into. + * @param user The current OptimizelyUserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @param userProfileTracker tracker for reading and updating user profile of the user + * @param reasons Decision reasons + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons */ - public @Nullable Variation getVariation(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map<String, String> filteredAttributes) { + @Nonnull + public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List<OptimizelyDecideOption> options, + @Nullable UserProfileTracker userProfileTracker, + @Nullable DecisionReasons reasons) { + if (reasons == null) { + reasons = DefaultDecisionReasons.newInstance(); + } if (!ExperimentUtils.isExperimentActive(experiment)) { - return null; + String message = reasons.addInfo("Experiment \"%s\" is not running.", experiment.getKey()); + logger.info(message); + return new DecisionResponse(null, reasons); } // look for forced bucketing first. - Variation variation = projectConfig.getForcedVariation(experiment.getKey(), userId); + DecisionResponse<Variation> decisionVariation = getForcedVariation(experiment, user.getUserId()); + reasons.merge(decisionVariation.getReasons()); + Variation variation = decisionVariation.getResult(); // check for whitelisting if (variation == null) { - variation = getWhitelistedVariation(experiment, userId); + decisionVariation = getWhitelistedVariation(experiment, user.getUserId()); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); } if (variation != null) { - return variation; - } - - // fetch the user profile map from the user profile service - UserProfile userProfile = null; - - if (userProfileService != null) { - try { - Map<String, Object> userProfileMap = userProfileService.lookup(userId); - if (userProfileMap == null) { - logger.info("We were unable to get a user profile map from the UserProfileService."); - } else if (UserProfileUtils.isValidUserProfileMap(userProfileMap)) { - userProfile = UserProfileUtils.convertMapToUserProfile(userProfileMap); - } else { - logger.warn("The UserProfileService returned an invalid map."); - } - } catch (Exception exception) { - logger.error(exception.getMessage()); - errorHandler.handleError(new OptimizelyRuntimeException(exception)); - } + return new DecisionResponse(variation, reasons); } - // check if user exists in user profile - if (userProfile != null) { - variation = getStoredVariation(experiment, userProfile); + if (userProfileTracker != null) { + decisionVariation = getStoredVariation(experiment, userProfileTracker.getUserProfile(), projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); // return the stored variation if it exists if (variation != null) { - return variation; + return new DecisionResponse(variation, reasons); } - } else { // if we could not find a user profile, make a new one - userProfile = new UserProfile(userId, new HashMap<String, Decision>()); } - if (ExperimentUtils.isUserInExperiment(projectConfig, experiment, filteredAttributes)) { - String bucketingId = userId; - if (filteredAttributes.containsKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString())) { - bucketingId = filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()); - } - variation = bucketer.bucket(experiment, bucketingId); + DecisionResponse<Boolean> decisionMeetAudience = ExperimentUtils.doesUserMeetAudienceConditions(projectConfig, experiment, user, EXPERIMENT, experiment.getKey()); + reasons.merge(decisionMeetAudience.getReasons()); + if (decisionMeetAudience.getResult()) { + String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); + + decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig); + reasons.merge(decisionVariation.getReasons()); + variation = decisionVariation.getResult(); if (variation != null) { - if (userProfileService != null) { - saveVariation(experiment, variation, userProfile); + if (userProfileTracker != null) { + userProfileTracker.updateUserProfile(experiment, variation); } else { - logger.info("This decision will not be saved since the UserProfileService is null."); + logger.debug("This decision will not be saved since the UserProfileService is null."); } } - return variation; + return new DecisionResponse(variation, reasons); } - logger.info("User \"{}\" does not meet conditions to be in experiment \"{}\".", userId, experiment.getKey()); - return null; + String message = reasons.addInfo("User \"%s\" does not meet conditions to be in experiment \"%s\".", user.getUserId(), experiment.getKey()); + logger.info(message); + return new DecisionResponse(null, reasons); + } + + /** + * Get a {@link Variation} of an {@link Experiment} for a user to be allocated into. + * + * @param experiment The Experiment the user will be bucketed into. + * @param user The current OptimizelyUserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) and the decision reasons + */ + @Nonnull + public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List<OptimizelyDecideOption> options) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + // fetch the user profile map from the user profile service + boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + UserProfileTracker userProfileTracker = null; + + if (userProfileService != null && !ignoreUPS) { + userProfileTracker = new UserProfileTracker(user.getUserId(), userProfileService, logger); + userProfileTracker.loadUserProfile(reasons, errorHandler); + } + + DecisionResponse<Variation> response = getVariation(experiment, user, projectConfig, options, userProfileTracker, reasons); + + if(userProfileService != null && !ignoreUPS) { + userProfileTracker.saveUserProfile(errorHandler); + } + return response; + } + + @Nonnull + public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig) { + return getVariation(experiment, user, projectConfig, Collections.emptyList()); } /** * Get the variation the user is bucketed into for the FeatureFlag - * @param featureFlag The feature flag the user wants to access. - * @param userId User Identifier - * @param filteredAttributes A map of filtered attributes. - * @return {@link FeatureDecision} + * + * @param featureFlag The feature flag the user wants to access. + * @param user The current OptimizelyuserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + public DecisionResponse<FeatureDecision> getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List<OptimizelyDecideOption> options) { + return getVariationsForFeatureList(Arrays.asList(featureFlag), user, projectConfig, options).get(0); + } + + /** + * Get the variations the user is bucketed into for the list of feature flags + * + * @param featureFlags The feature flag list the user wants to access. + * @param user The current OptimizelyuserContext + * @param projectConfig The current projectConfig + * @param options An array of decision options + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ - public @Nonnull FeatureDecision getVariationForFeature(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map<String, String> filteredAttributes) { + @Nonnull + public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Nonnull List<FeatureFlag> featureFlags, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig, + @Nonnull List<OptimizelyDecideOption> options) { + DecisionReasons upsReasons = DefaultDecisionReasons.newInstance(); + + boolean ignoreUPS = options.contains(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE); + UserProfileTracker userProfileTracker = null; + + if (userProfileService != null && !ignoreUPS) { + userProfileTracker = new UserProfileTracker(user.getUserId(), userProfileService, logger); + userProfileTracker.loadUserProfile(upsReasons, errorHandler); + } + + List<DecisionResponse<FeatureDecision>> decisions = new ArrayList<>(); + + for (FeatureFlag featureFlag: featureFlags) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + reasons.merge(upsReasons); + + DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker); + reasons.merge(decisionVariationResponse.getReasons()); + + FeatureDecision decision = decisionVariationResponse.getResult(); + if (decision != null) { + decisions.add(new DecisionResponse(decision, reasons)); + continue; + } + + DecisionResponse<FeatureDecision> decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig); + reasons.merge(decisionFeatureResponse.getReasons()); + decision = decisionFeatureResponse.getResult(); + + String message; + if (decision.variation == null) { + message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } else { + message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", + user.getUserId(), featureFlag.getKey()); + } + logger.info(message); + + decisions.add(new DecisionResponse(decision, reasons)); + } + + if (userProfileService != null && !ignoreUPS) { + userProfileTracker.saveUserProfile(errorHandler); + } + + return decisions; + } + + @Nonnull + public DecisionResponse<FeatureDecision> getVariationForFeature(@Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig) { + return getVariationForFeature(featureFlag, user, projectConfig, Collections.emptyList()); + } + + /** + * + * @param projectConfig The ProjectConfig. + * @param featureFlag The feature flag the user wants to access. + * @param user The current OptimizelyUserContext. + * @param options An array of decision options + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons + */ + @Nonnull + DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectConfig projectConfig, + @Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull List<OptimizelyDecideOption> options, + @Nullable UserProfileTracker userProfileTracker) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); if (!featureFlag.getExperimentIds().isEmpty()) { for (String experimentId : featureFlag.getExperimentIds()) { Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId); - Variation variation = this.getVariation(experiment, userId, filteredAttributes); + + DecisionResponse<Variation> decisionVariation = + getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker); + reasons.merge(decisionVariation.getReasons()); + Variation variation = decisionVariation.getResult(); + if (variation != null) { - return new FeatureDecision(experiment, variation, - FeatureDecision.DecisionSource.EXPERIMENT); + return new DecisionResponse( + new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST), + reasons); } } } else { - logger.info("The feature flag \"{}\" is not used in any experiments.", featureFlag.getKey()); + String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", featureFlag.getKey()); + logger.info(message); } - FeatureDecision featureDecision = getVariationForFeatureInRollout(featureFlag, userId, filteredAttributes); - if (featureDecision.variation == null) { - logger.info("The user \"{}\" was not bucketed into a rollout for feature flag \"{}\".", - userId, featureFlag.getKey()); - } else { - logger.info("The user \"{}\" was bucketed into a rollout for feature flag \"{}\".", - userId, featureFlag.getKey()); - } - return featureDecision; + return new DecisionResponse(null, reasons); + } /** * Try to bucket the user into a rollout rule. * Evaluate the user for rules in priority order by seeing if the user satisfies the audience. * Fall back onto the everyone else rule if the user is ever excluded from a rule due to traffic allocation. - * @param featureFlag The feature flag the user wants to access. - * @param userId User Identifier - * @param filteredAttributes A map of filtered attributes. - * @return {@link FeatureDecision} + * + * @param featureFlag The feature flag the user wants to access. + * @param user The current OptimizelyUserContext + * @param projectConfig The current projectConfig + * @return A {@link DecisionResponse} including a {@link FeatureDecision} and the decision reasons */ - @Nonnull FeatureDecision getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, - @Nonnull String userId, - @Nonnull Map<String, String> filteredAttributes) { + @Nonnull + DecisionResponse<FeatureDecision> getVariationForFeatureInRollout(@Nonnull FeatureFlag featureFlag, + @Nonnull OptimizelyUserContext user, + @Nonnull ProjectConfig projectConfig) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // use rollout to get variation for feature if (featureFlag.getRolloutId().isEmpty()) { - logger.info("The feature flag \"{}\" is not used in a rollout.", featureFlag.getKey()); - return new FeatureDecision(null, null, null); + String message = reasons.addInfo("The feature flag \"%s\" is not used in a rollout.", featureFlag.getKey()); + logger.info(message); + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } Rollout rollout = projectConfig.getRolloutIdMapping().get(featureFlag.getRolloutId()); if (rollout == null) { - logger.error("The rollout with id \"{}\" was not found in the datafile for feature flag \"{}\".", - featureFlag.getRolloutId(), featureFlag.getKey()); - return new FeatureDecision(null, null, null); + String message = reasons.addInfo("The rollout with id \"%s\" was not found in the datafile for feature flag \"%s\".", + featureFlag.getRolloutId(), featureFlag.getKey()); + logger.error(message); + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } // for all rules before the everyone else rule int rolloutRulesLength = rollout.getExperiments().size(); - String bucketingId = userId; - if (filteredAttributes.containsKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString())) { - bucketingId = filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()); - } - Variation variation; - for (int i = 0; i < rolloutRulesLength - 1; i++) { - Experiment rolloutRule = rollout.getExperiments().get(i); - Audience audience = projectConfig.getAudienceIdMapping().get(rolloutRule.getAudienceIds().get(0)); - if (ExperimentUtils.isUserInExperiment(projectConfig, rolloutRule, filteredAttributes)) { - variation = bucketer.bucket(rolloutRule, bucketingId); - if (variation == null) { - break; - } - return new FeatureDecision(rolloutRule, variation, - FeatureDecision.DecisionSource.ROLLOUT); - } - else { - logger.debug("User \"{}\" did not meet the conditions to be in rollout rule for audience \"{}\".", - userId, audience.getName()); - } + if (rolloutRulesLength == 0) { + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } - // get last rule which is the fall back rule - Experiment finalRule = rollout.getExperiments().get(rolloutRulesLength - 1); - if (ExperimentUtils.isUserInExperiment(projectConfig, finalRule, filteredAttributes)) { - variation = bucketer.bucket(finalRule, bucketingId); + + int index = 0; + while (index < rolloutRulesLength) { + + DecisionResponse<AbstractMap.SimpleEntry> decisionVariationResponse = getVariationFromDeliveryRule( + projectConfig, + featureFlag.getKey(), + rollout.getExperiments(), + index, + user + ); + reasons.merge(decisionVariationResponse.getReasons()); + + AbstractMap.SimpleEntry<Variation, Boolean> response = decisionVariationResponse.getResult(); + Variation variation = response.getKey(); + Boolean skipToEveryoneElse = response.getValue(); if (variation != null) { - return new FeatureDecision(finalRule, variation, - FeatureDecision.DecisionSource.ROLLOUT); + Experiment rule = rollout.getExperiments().get(index); + FeatureDecision featureDecision = new FeatureDecision(rule, variation, FeatureDecision.DecisionSource.ROLLOUT); + return new DecisionResponse(featureDecision, reasons); } + + // The last rule is special for "Everyone Else" + index = skipToEveryoneElse ? (rolloutRulesLength - 1) : (index + 1); } - return new FeatureDecision(null, null, null); + + return new DecisionResponse(new FeatureDecision(null, null, null), reasons); } /** * Get the variation the user has been whitelisted into. + * * @param experiment {@link Experiment} in which user is to be bucketed. - * @param userId User Identifier - * @return null if the user is not whitelisted into any variation - * {@link Variation} the user is bucketed into if the user has a specified whitelisted variation. + * @param userId User Identifier + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) + * and the decision reasons. The variation can be null if the user is not whitelisted into any variation. */ - @Nullable Variation getWhitelistedVariation(@Nonnull Experiment experiment, @Nonnull String userId) { + @Nonnull + DecisionResponse<Variation> getWhitelistedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // if a user has a forced variation mapping, return the respective variation Map<String, String> userIdToVariationKeyMap = experiment.getUserIdToVariationKeyMap(); if (userIdToVariationKeyMap.containsKey(userId)) { String forcedVariationKey = userIdToVariationKeyMap.get(userId); Variation forcedVariation = experiment.getVariationKeyToVariationMap().get(forcedVariationKey); if (forcedVariation != null) { - logger.info("User \"{}\" is forced in variation \"{}\".", userId, forcedVariationKey); + String message = reasons.addInfo("User \"%s\" is forced in variation \"%s\".", userId, forcedVariationKey); + logger.info(message); } else { - logger.error("Variation \"{}\" is not in the datafile. Not activating user \"{}\".", - forcedVariationKey, userId); + String message = reasons.addInfo("Variation \"%s\" is not in the datafile. Not activating user \"%s\".", + forcedVariationKey, userId); + logger.error(message); } - return forcedVariation; + return new DecisionResponse(forcedVariation, reasons); } - return null; + return new DecisionResponse(null, reasons); } + + // TODO: Logically, it makes sense to move this method to UserProfileTracker. But some tests are also calling this + // method, requiring us to refactor those tests as well. We'll look to refactor this later. /** * Get the {@link Variation} that has been stored for the user in the {@link UserProfileService} implementation. - * @param experiment {@link Experiment} in which the user was bucketed. + * + * @param experiment {@link Experiment} in which the user was bucketed. * @param userProfile {@link UserProfile} of the user. - * @return null if the {@link UserProfileService} implementation is null or the user was not previously bucketed. - * else return the {@link Variation} the user was previously bucketed into. + * @param projectConfig The current projectConfig + * @return A {@link DecisionResponse} including the {@link Variation} that user was previously bucketed into (or null) + * and the decision reasons. The variation can be null if the {@link UserProfileService} implementation is null or the user was not previously bucketed. */ - @Nullable Variation getStoredVariation(@Nonnull Experiment experiment, - @Nonnull UserProfile userProfile) { + @Nonnull + DecisionResponse<Variation> getStoredVariation(@Nonnull Experiment experiment, + @Nonnull UserProfile userProfile, + @Nonnull ProjectConfig projectConfig) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + // ---------- Check User Profile for Sticky Bucketing ---------- // If a user profile instance is present then check it for a saved variation String experimentId = experiment.getId(); @@ -285,40 +445,41 @@ public DecisionService(@Nonnull Bucketer bucketer, if (decision != null) { String variationId = decision.variationId; Variation savedVariation = projectConfig - .getExperimentIdMapping() - .get(experimentId) - .getVariationIdToVariationMap() - .get(variationId); + .getExperimentIdMapping() + .get(experimentId) + .getVariationIdToVariationMap() + .get(variationId); if (savedVariation != null) { - logger.info("Returning previously activated variation \"{}\" of experiment \"{}\" " + - "for user \"{}\" from user profile.", - savedVariation.getKey(), experimentKey, userProfile.userId); + String message = reasons.addInfo("Returning previously activated variation \"%s\" of experiment \"%s\" for user \"%s\" from user profile.", + savedVariation.getKey(), experimentKey, userProfile.userId); + logger.info(message); // A variation is stored for this combined bucket id - return savedVariation; + return new DecisionResponse(savedVariation, reasons); } else { - logger.info("User \"{}\" was previously bucketed into variation with ID \"{}\" for experiment \"{}\", " + - "but no matching variation was found for that user. We will re-bucket the user.", - userProfile.userId, variationId, experimentKey); - return null; + String message = reasons.addInfo("User \"%s\" was previously bucketed into variation with ID \"%s\" for experiment \"%s\", but no matching variation was found for that user. We will re-bucket the user.", + userProfile.userId, variationId, experimentKey); + logger.info(message); + return new DecisionResponse(null, reasons); } } else { - logger.info("No previously activated variation of experiment \"{}\" " + - "for user \"{}\" found in user profile.", - experimentKey, userProfile.userId); - return null; + String message = reasons.addInfo("No previously activated variation of experiment \"%s\" for user \"%s\" found in user profile.", + experimentKey, userProfile.userId); + logger.info(message); + return new DecisionResponse(null, reasons); } } /** * Save a {@link Variation} of an {@link Experiment} for a user in the {@link UserProfileService}. * - * @param experiment The experiment the user was buck - * @param variation The Variation to save. + * @param experiment The experiment the user was buck + * @param variation The Variation to save. * @param userProfile A {@link UserProfile} instance of the user information. */ void saveVariation(@Nonnull Experiment experiment, @Nonnull Variation variation, @Nonnull UserProfile userProfile) { + // only save if the user has implemented a user profile service if (userProfileService != null) { String experimentId = experiment.getId(); @@ -338,9 +499,292 @@ void saveVariation(@Nonnull Experiment experiment, variationId, experimentId, userProfile.userId); } catch (Exception exception) { logger.warn("Failed to save variation \"{}\" of experiment \"{}\" for user \"{}\".", - variationId, experimentId, userProfile.userId); + variationId, experimentId, userProfile.userId); errorHandler.handleError(new OptimizelyRuntimeException(exception)); } } } + + /** + * Get the bucketingId of a user if a bucketingId exists in attributes, or else default to userId. + * + * @param userId The userId of the user. + * @param filteredAttributes The user's attributes. This should be filtered to just attributes in the Datafile. + * @return bucketingId if it is a String type in attributes. + * else return userId + */ + String getBucketingId(@Nonnull String userId, + @Nonnull Map<String, ?> filteredAttributes) { + String bucketingId = userId; + if (filteredAttributes != null && filteredAttributes.containsKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString())) { + if (String.class.isInstance(filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()))) { + bucketingId = (String) filteredAttributes.get(ControlAttribute.BUCKETING_ATTRIBUTE.toString()); + logger.debug("BucketingId is valid: \"{}\"", bucketingId); + } else { + logger.warn("BucketingID attribute is not a string. Defaulted to userId"); + } + } + return bucketingId; + } + + /** + * Find a validated forced decision + * + * @param optimizelyDecisionContext The OptimizelyDecisionContext containing flagKey and ruleKey + * @param projectConfig The Project config + * @param user The OptimizelyUserContext + * @return Returns a DecisionResponse structure of type Variation, otherwise null result with reasons + */ + public DecisionResponse<Variation> validatedForcedDecision(@Nonnull OptimizelyDecisionContext optimizelyDecisionContext, @Nonnull ProjectConfig projectConfig, @Nonnull OptimizelyUserContext user) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + String userId = user.getUserId(); + OptimizelyForcedDecision optimizelyForcedDecision = user.findForcedDecision(optimizelyDecisionContext); + String variationKey = optimizelyForcedDecision != null ? optimizelyForcedDecision.getVariationKey() : null; + if (projectConfig != null && variationKey != null) { + Variation variation = projectConfig.getFlagVariationByKey(optimizelyDecisionContext.getFlagKey(), variationKey); + String ruleKey = optimizelyDecisionContext.getRuleKey(); + String flagKey = optimizelyDecisionContext.getFlagKey(); + String info; + String target = ruleKey != OptimizelyDecisionContext.OPTI_NULL_RULE_KEY ? String.format("flag (%s), rule (%s)", flagKey, ruleKey) : String.format("flag (%s)", flagKey); + if (variation != null) { + info = String.format("Variation (%s) is mapped to %s and user (%s) in the forced decision map.", variationKey, target, userId); + logger.debug(info); + reasons.addInfo(info); + return new DecisionResponse(variation, reasons); + } else { + info = String.format("Invalid variation is mapped to %s and user (%s) in the forced decision map.", target, userId); + logger.debug(info); + reasons.addInfo(info); + } + } + return new DecisionResponse<>(null, reasons); + } + + public ConcurrentHashMap<String, ConcurrentHashMap<String, String>> getForcedVariationMapping() { + return forcedVariationMapping; + } + + /** + * Force a user into a variation for a given experiment. + * The forced variation value does not persist across application launches. + * If the experiment key is not in the project file, this call fails and returns false. + * + * @param experiment The experiment to override. + * @param userId The user ID to be used for bucketing. + * @param variationKey The variation key to force the user into. If the variation key is null + * then the forcedVariation for that experiment is removed. + * @return boolean A boolean value that indicates if the set completed successfully. + */ + public boolean setForcedVariation(@Nonnull Experiment experiment, + @Nonnull String userId, + @Nullable String variationKey) { + Variation variation = null; + + // keep in mind that you can pass in a variationKey that is null if you want to + // remove the variation. + if (variationKey != null) { + variation = experiment.getVariationKeyToVariationMap().get(variationKey); + // if the variation is not part of the experiment, return false. + if (variation == null) { + logger.error("Variation {} does not exist for experiment {}", variationKey, experiment.getKey()); + return false; + } + } + + // if the user id is invalid, return false. + if (!validateUserId(userId)) { + logger.error("User ID is invalid"); + return false; + } + + ConcurrentHashMap<String, String> experimentToVariation; + if (!forcedVariationMapping.containsKey(userId)) { + forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap()); + } + experimentToVariation = forcedVariationMapping.get(userId); + + boolean retVal = true; + // if it is null remove the variation if it exists. + if (variationKey == null) { + String removedVariationId = experimentToVariation.remove(experiment.getId()); + if (removedVariationId != null) { + Variation removedVariation = experiment.getVariationIdToVariationMap().get(removedVariationId); + if (removedVariation != null) { + logger.debug("Variation mapped to experiment \"{}\" has been removed for user \"{}\"", experiment.getKey(), userId); + } else { + logger.debug("Removed forced variation that did not exist in experiment"); + } + } else { + logger.debug("No variation for experiment {}", experiment.getKey()); + retVal = false; + } + } else { + String previous = experimentToVariation.put(experiment.getId(), variation.getId()); + logger.debug("Set variation \"{}\" for experiment \"{}\" and user \"{}\" in the forced variation map.", + variation.getKey(), experiment.getKey(), userId); + if (previous != null) { + Variation previousVariation = experiment.getVariationIdToVariationMap().get(previous); + if (previousVariation != null) { + logger.debug("forced variation {} replaced forced variation {} in forced variation map.", + variation.getKey(), previousVariation.getKey()); + } + } + } + + return retVal; + } + + /** + * Gets the forced variation for a given user and experiment. + * + * @param experiment The experiment forced. + * @param userId The user ID to be used for bucketing. + * @return A {@link DecisionResponse} including the {@link Variation} that user is bucketed into (or null) + * and the decision reasons. The variation can be null if the forced variation fails. + */ + @Nonnull + public DecisionResponse<Variation> getForcedVariation(@Nonnull Experiment experiment, + @Nonnull String userId) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + if (!validateUserId(userId)) { + String message = reasons.addInfo("User ID is invalid"); + logger.error(message); + return new DecisionResponse(null, reasons); + } + + Map<String, String> experimentToVariation = getForcedVariationMapping().get(userId); + if (experimentToVariation != null) { + String variationId = experimentToVariation.get(experiment.getId()); + if (variationId != null) { + Variation variation = experiment.getVariationIdToVariationMap().get(variationId); + if (variation != null) { + String message = reasons.addInfo("Variation \"%s\" is mapped to experiment \"%s\" and user \"%s\" in the forced variation map", + variation.getKey(), experiment.getKey(), userId); + logger.debug(message); + return new DecisionResponse(variation, reasons); + } + } else { + logger.debug("No variation for experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experiment.getKey(), userId); + } + } + return new DecisionResponse(null, reasons); + } + + + private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull ProjectConfig projectConfig, + @Nonnull String flagKey, + @Nonnull Experiment rule, + @Nonnull OptimizelyUserContext user, + @Nonnull List<OptimizelyDecideOption> options, + @Nullable UserProfileTracker userProfileTracker) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + String ruleKey = rule != null ? rule.getKey() : null; + // Check Forced-Decision + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + DecisionResponse<Variation> forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + + reasons.merge(forcedDecisionResponse.getReasons()); + + Variation variation = forcedDecisionResponse.getResult(); + if (variation != null) { + return new DecisionResponse(variation, reasons); + } + //regular decision + DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null); + reasons.merge(decisionResponse.getReasons()); + + variation = decisionResponse.getResult(); + + return new DecisionResponse(variation, reasons); + } + + /** + * Helper function to check that the provided userId is valid + * + * @param userId the userId being validated + * @return whether the user ID is valid + */ + private boolean validateUserId(String userId) { + return (userId != null); + } + + /** + * + * @param projectConfig The Project config + * @param flagKey The flag key for the feature flag + * @param rules The experiments belonging to a rollout + * @param ruleIndex The index of the rule + * @param user The OptimizelyUserContext + * @return Returns a DecisionResponse Object containing a AbstractMap.SimpleEntry<Variation, Boolean> + * where the Variation is the result and the Boolean is the skipToEveryoneElse. + */ + DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull ProjectConfig projectConfig, + @Nonnull String flagKey, + @Nonnull List<Experiment> rules, + @Nonnull int ruleIndex, + @Nonnull OptimizelyUserContext user) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + Boolean skipToEveryoneElse = false; + AbstractMap.SimpleEntry<Variation, Boolean> variationToSkipToEveryoneElsePair; + // Check forced-decisions first + Experiment rule = rules.get(ruleIndex); + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, rule.getKey()); + DecisionResponse<Variation> forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user); + reasons.merge(forcedDecisionResponse.getReasons()); + + Variation variation = forcedDecisionResponse.getResult(); + if (variation != null) { + variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(variation, false); + return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); + } + + // Handle a regular decision + String bucketingId = getBucketingId(user.getUserId(), user.getAttributes()); + Boolean everyoneElse = (ruleIndex == rules.size() - 1); + String loggingKey = everyoneElse ? "Everyone Else" : String.valueOf(ruleIndex + 1); + + Variation bucketedVariation = null; + + DecisionResponse<Boolean> audienceDecisionResponse = ExperimentUtils.doesUserMeetAudienceConditions( + projectConfig, + rule, + user, + RULE, + String.valueOf(ruleIndex + 1) + ); + + reasons.merge(audienceDecisionResponse.getReasons()); + String message; + if (audienceDecisionResponse.getResult()) { + message = reasons.addInfo("User \"%s\" meets conditions for targeting rule \"%s\".", user.getUserId(), loggingKey); + reasons.addInfo(message); + logger.debug(message); + + DecisionResponse<Variation> decisionResponse = bucketer.bucket(rule, bucketingId, projectConfig); + reasons.merge(decisionResponse.getReasons()); + bucketedVariation = decisionResponse.getResult(); + + if (bucketedVariation != null) { + message = reasons.addInfo("User \"%s\" bucketed for targeting rule \"%s\".", user.getUserId(), loggingKey); + logger.debug(message); + reasons.addInfo(message); + } else if (!everyoneElse) { + message = reasons.addInfo("User \"%s\" is not bucketed for targeting rule \"%s\".", user.getUserId(), loggingKey); + logger.debug(message); + reasons.addInfo(message); + // Skip the rest of rollout rules to the everyone-else rule if audience matches but not bucketed. + skipToEveryoneElse = true; + } + } else { + message = reasons.addInfo("User \"%s\" does not meet conditions for targeting rule \"%d\".", user.getUserId(), ruleIndex + 1); + reasons.addInfo(message); + logger.debug(message); + } + variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(bucketedVariation, skipToEveryoneElse); + return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java index 633a5ddd7..b0f0a11ed 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/FeatureDecision.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017, Optimizely, Inc. and contributors * + * Copyright 2017, 2019, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -21,24 +21,45 @@ import javax.annotation.Nullable; public class FeatureDecision { - /** The {@link Experiment} the Feature is associated with. */ - @Nullable public Experiment experiment; + /** + * The {@link Experiment} the Feature is associated with. + */ + @Nullable + public Experiment experiment; - /** The {@link Variation} the user was bucketed into. */ - @Nullable public Variation variation; + /** + * The {@link Variation} the user was bucketed into. + */ + @Nullable + public Variation variation; - /** The source of the {@link Variation}. */ - @Nullable public DecisionSource decisionSource; + /** + * The source of the {@link Variation}. + */ + @Nullable + public DecisionSource decisionSource; public enum DecisionSource { - EXPERIMENT, - ROLLOUT + FEATURE_TEST("feature-test"), + ROLLOUT("rollout"); + + private final String key; + + DecisionSource(String key) { + this.key = key; + } + + @Override + public String toString() { + return key; + } } /** * Initialize a FeatureDecision object. - * @param experiment The {@link Experiment} the Feature is associated with. - * @param variation The {@link Variation} the user was bucketed into. + * + * @param experiment The {@link Experiment} the Feature is associated with. + * @param variation The {@link Variation} the user was bucketed into. * @param decisionSource The source of the variation. */ public FeatureDecision(@Nullable Experiment experiment, @Nullable Variation variation, diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileService.java b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileService.java index 1b299709a..c4f2468a8 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileService.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileService.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,11 +25,17 @@ */ public interface UserProfileService { - /** The key for the user ID. Returns a String.*/ + /** + * The key for the user ID. Returns a String. + */ String userIdKey = "user_id"; - /** The key for the decisions Map. Returns a {@code Map<String, Map<String, String>>}.*/ + /** + * The key for the decisions Map. Returns a {@code Map<String, Map<String, String>>}. + */ String experimentBucketMapKey = "experiment_bucket_map"; - /** The key for the variation Id within a decision Map. */ + /** + * The key for the variation Id within a decision Map. + */ String variationIdKey = "variation_id"; /** diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java new file mode 100644 index 000000000..2dee3d171 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileTracker.java @@ -0,0 +1,109 @@ +/**************************************************************************** + * Copyright 2024, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.bucketing; + +import com.optimizely.ab.OptimizelyRuntimeException; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.optimizelydecision.DecisionReasons; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; + +class UserProfileTracker { + private UserProfileService userProfileService; + private Logger logger; + private UserProfile userProfile; + private boolean profileUpdated; + private String userId; + + UserProfileTracker( + @Nonnull String userId, + @Nonnull UserProfileService userProfileService, + @Nonnull Logger logger + ) { + this.userId = userId; + this.userProfileService = userProfileService; + this.logger = logger; + this.profileUpdated = false; + this.userProfile = null; + } + + public UserProfile getUserProfile() { + return userProfile; + } + + public void loadUserProfile(DecisionReasons reasons, ErrorHandler errorHandler) { + try { + Map<String, Object> userProfileMap = userProfileService.lookup(userId); + if (userProfileMap == null) { + String message = reasons.addInfo("We were unable to get a user profile map from the UserProfileService."); + logger.info(message); + } else if (UserProfileUtils.isValidUserProfileMap(userProfileMap)) { + userProfile = UserProfileUtils.convertMapToUserProfile(userProfileMap); + } else { + String message = reasons.addInfo("The UserProfileService returned an invalid map."); + logger.warn(message); + } + } catch (Exception exception) { + String message = reasons.addInfo(exception.getMessage()); + logger.error(message); + errorHandler.handleError(new OptimizelyRuntimeException(exception)); + } + + if (userProfile == null) { + userProfile = new UserProfile(userId, new HashMap<String, Decision>()); + } + } + + public void updateUserProfile(@Nonnull Experiment experiment, + @Nonnull Variation variation) { + String experimentId = experiment.getId(); + String variationId = variation.getId(); + Decision decision; + if (userProfile.experimentBucketMap.containsKey(experimentId)) { + decision = userProfile.experimentBucketMap.get(experimentId); + decision.variationId = variationId; + } else { + decision = new Decision(variationId); + } + userProfile.experimentBucketMap.put(experimentId, decision); + profileUpdated = true; + logger.info("Updated variation \"{}\" of experiment \"{}\" for user \"{}\".", + variationId, experimentId, userProfile.userId); + } + + public void saveUserProfile(ErrorHandler errorHandler) { + // if there were no updates, no need to save + if (!this.profileUpdated) { + return; + } + + try { + userProfileService.save(userProfile.toMap()); + logger.info("Saved user profile of user \"{}\".", + userProfile.userId); + } catch (Exception exception) { + logger.warn("Failed to save user profile of user \"{}\".", + userProfile.userId); + errorHandler.handleError(new OptimizelyRuntimeException(exception)); + } + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileUtils.java b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileUtils.java index afc54dee5..274935525 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/UserProfileUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,9 +29,10 @@ public class UserProfileUtils { /** * Validate whether a {@code Map<String, Object>} can be transformed into a {@link UserProfile}. + * * @param map The map to check. * @return True if the map can be converted into a {@link UserProfile}. - * False if the map cannot be converted. + * False if the map cannot be converted. */ public static boolean isValidUserProfileMap(@Nonnull Map<String, Object> map) { // The Map must contain a value for the user ID @@ -50,8 +51,7 @@ public static boolean isValidUserProfileMap(@Nonnull Map<String, Object> map) { Map<String, Map<String, String>> experimentBucketMap; try { experimentBucketMap = (Map<String, Map<String, String>>) map.get(UserProfileService.experimentBucketMapKey); - } - catch (ClassCastException classCastException) { + } catch (ClassCastException classCastException) { return false; } @@ -68,6 +68,7 @@ public static boolean isValidUserProfileMap(@Nonnull Map<String, Object> map) { /** * Convert a Map to a {@link UserProfile} instance. + * * @param map The map to construct the {@link UserProfile} from. * @return A {@link UserProfile} instance. */ diff --git a/core-api/src/main/java/com/optimizely/ab/bucketing/internal/MurmurHash3.java b/core-api/src/main/java/com/optimizely/ab/bucketing/internal/MurmurHash3.java index 93e706f3f..ac6764d5c 100644 --- a/core-api/src/main/java/com/optimizely/ab/bucketing/internal/MurmurHash3.java +++ b/core-api/src/main/java/com/optimizely/ab/bucketing/internal/MurmurHash3.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,18 +20,19 @@ public final class MurmurHash3 { - private MurmurHash3() { } + private MurmurHash3() { + } /** - * @param data the origin data + * @param data the origin data * @param offset the offset into the data array - * @param len the length of the data array to use - * @param seed the murmur hash seed + * @param len the length of the data array to use + * @param seed the murmur hash seed * @return the MurmurHash3_x86_32 hash */ @SuppressFBWarnings( - value={"SF_SWITCH_FALLTHROUGH","SF_SWITCH_NO_DEFAULT"}, - justification="deliberate") + value = {"SF_SWITCH_FALLTHROUGH", "SF_SWITCH_NO_DEFAULT"}, + justification = "deliberate") public static int murmurhash3_x86_32(byte[] data, int offset, int len, int seed) { final int c1 = 0xcc9e2d51; @@ -40,22 +41,22 @@ public static int murmurhash3_x86_32(byte[] data, int offset, int len, int seed) int h1 = seed; int roundedEnd = offset + (len & 0xfffffffc); // round down to 4 byte block - for (int i=offset; i<roundedEnd; i+=4) { + for (int i = offset; i < roundedEnd; i += 4) { // little endian load order - int k1 = (data[i] & 0xff) | ((data[i+1] & 0xff) << 8) | ((data[i+2] & 0xff) << 16) | (data[i+3] << 24); + int k1 = (data[i] & 0xff) | ((data[i + 1] & 0xff) << 8) | ((data[i + 2] & 0xff) << 16) | (data[i + 3] << 24); k1 *= c1; k1 = (k1 << 15) | (k1 >>> 17); // ROTL32(k1,15); k1 *= c2; h1 ^= k1; h1 = (h1 << 13) | (h1 >>> 19); // ROTL32(h1,13); - h1 = h1*5+0xe6546b64; + h1 = h1 * 5 + 0xe6546b64; } // tail int k1 = 0; - switch(len & 0x03) { + switch (len & 0x03) { case 3: k1 = (data[roundedEnd + 2] & 0xff) << 16; // fallthrough @@ -87,10 +88,10 @@ public static int murmurhash3_x86_32(byte[] data, int offset, int len, int seed) /** * This is more than 2x faster than hashing the result of String.getBytes(). * - * @param data the origin data + * @param data the origin data * @param offset the offset into the data array - * @param len the length of the data array to use - * @param seed the murmur hash seed + * @param len the length of the data array to use + * @param seed the murmur hash seed * @return the MurmurHash3_x86_32 hash of the UTF-8 bytes of the String without actually encoding * the string to a temporary buffer */ @@ -139,17 +140,15 @@ public static int murmurhash3_x86_32(CharSequence data, int offset, int len, int continue; ***/ - } - else if (code < 0x800) { + } else if (code < 0x800) { k2 = (0xC0 | (code >> 6)) - | ((0x80 | (code & 0x3F)) << 8); + | ((0x80 | (code & 0x3F)) << 8); bits = 16; - } - else if (code < 0xD800 || code > 0xDFFF || pos>=end) { + } else if (code < 0xD800 || code > 0xDFFF || pos >= end) { // we check for pos>=end to encode an unpaired surrogate as 3 bytes. k2 = (0xE0 | (code >> 12)) - | ((0x80 | ((code >> 6) & 0x3F)) << 8) - | ((0x80 | (code & 0x3F)) << 16); + | ((0x80 | ((code >> 6) & 0x3F)) << 8) + | ((0x80 | (code & 0x3F)) << 16); bits = 24; } else { // surrogate pair @@ -157,9 +156,9 @@ else if (code < 0xD800 || code > 0xDFFF || pos>=end) { int utf32 = (int) data.charAt(pos++); utf32 = ((code - 0xD7C0) << 10) + (utf32 & 0x3FF); k2 = (0xff & (0xF0 | (utf32 >> 18))) - | ((0x80 | ((utf32 >> 12) & 0x3F))) << 8 - | ((0x80 | ((utf32 >> 6) & 0x3F))) << 16 - | (0x80 | (utf32 & 0x3F)) << 24; + | ((0x80 | ((utf32 >> 12) & 0x3F))) << 8 + | ((0x80 | ((utf32 >> 6) & 0x3F))) << 16 + | (0x80 | (utf32 & 0x3F)) << 24; bits = 32; } @@ -179,12 +178,12 @@ else if (code < 0xD800 || code > 0xDFFF || pos>=end) { h1 ^= k1; h1 = (h1 << 13) | (h1 >>> 19); // ROTL32(h1,13); - h1 = h1*5+0xe6546b64; + h1 = h1 * 5 + 0xe6546b64; shift -= 32; // unfortunately, java won't let you shift 32 bits off, so we need to check for 0 if (shift != 0) { - k1 = k2 >>> (bits-shift); // bits used == bits - newshift + k1 = k2 >>> (bits - shift); // bits used == bits - newshift } else { k1 = 0; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java new file mode 100644 index 000000000..336d33c0e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/AtomicProjectConfigManager.java @@ -0,0 +1,48 @@ +/** + * + * Copyright 2019, 2023, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import java.util.concurrent.atomic.AtomicReference; + +public class AtomicProjectConfigManager implements ProjectConfigManager { + + private final AtomicReference<ProjectConfig> projectConfigReference = new AtomicReference<>(); + + @Override + public ProjectConfig getConfig() { + return projectConfigReference.get(); + } + + /** + * Access to current cached project configuration. + * + * @return {@link ProjectConfig} + */ + @Override + public ProjectConfig getCachedConfig() { + return projectConfigReference.get(); + } + + @Override + public String getSDKKey() { + return null; + } + + public void setConfig(ProjectConfig projectConfig) { + projectConfigReference.set(projectConfig); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/Attribute.java b/core-api/src/main/java/com/optimizely/ab/config/Attribute.java index 5bb6698bc..2aa94b533 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Attribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Attribute.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,9 +63,9 @@ public String getSegmentId() { @Override public String toString() { return "Attribute{" + - "id='" + id + '\'' + - ", key='" + key + '\'' + - ", segmentId='" + segmentId + '\'' + - '}'; + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", segmentId='" + segmentId + '\'' + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java new file mode 100644 index 000000000..28ad519a5 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -0,0 +1,642 @@ +/** + * + * Copyright 2016-2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.optimizely.ab.UnknownEventTypeException; +import com.optimizely.ab.UnknownExperimentException; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.config.parser.DefaultConfigParser; +import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.error.NoOpErrorHandler; +import com.optimizely.ab.error.RaiseExceptionErrorHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.*; + +/** + * DatafileProjectConfig is an implementation of ProjectConfig that is backed by a + * JSON data file. Optimizely automatically publishes new versions of the data file + * to it's CDN whenever changes are made within the Optimizely Application. + * + * Optimizely provides custom JSON parsers to extract objects from the JSON payload + * to populate the members of this class. {@link DefaultConfigParser} for details. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class DatafileProjectConfig implements ProjectConfig { + + private static final List<String> supportedVersions = Arrays.asList( + Version.V2.toString(), + Version.V3.toString(), + Version.V4.toString() + ); + + // logger + private static final Logger logger = LoggerFactory.getLogger(DatafileProjectConfig.class); + + // ProjectConfig properties + private final String accountId; + private final String projectId; + private final String revision; + private final String sdkKey; + private final String environmentKey; + private final String version; + private final boolean anonymizeIP; + private final boolean sendFlagDecisions; + private final Boolean botFiltering; + private final String hostForODP; + private final String publicKeyForODP; + private final List<Attribute> attributes; + private final List<Audience> audiences; + private final List<Audience> typedAudiences; + private final List<EventType> events; + private final List<Experiment> experiments; + private final List<FeatureFlag> featureFlags; + private final List<Group> groups; + private final List<Rollout> rollouts; + private final List<Integration> integrations; + private final Set<String> allSegments; + + // key to entity mappings + private final Map<String, Attribute> attributeKeyMapping; + private final Map<String, EventType> eventNameMapping; + private final Map<String, Experiment> experimentKeyMapping; + private final Map<String, FeatureFlag> featureKeyMapping; + + // Key to Entity mappings for Forced Decisions + private final Map<String, List<Variation>> flagVariationsMap; + + // id to entity mappings + private final Map<String, Audience> audienceIdMapping; + private final Map<String, Experiment> experimentIdMapping; + private final Map<String, Group> groupIdMapping; + private final Map<String, Rollout> rolloutIdMapping; + private final Map<String, List<String>> experimentFeatureKeyMapping; + + // other mappings + private final Map<String, Experiment> variationIdToExperimentMapping; + + private String datafile; + + // v2 constructor + public DatafileProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups, + List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType, + List<Audience> audiences) { + this(accountId, projectId, version, revision, groups, experiments, attributes, eventType, audiences, false); + } + + // v3 constructor + public DatafileProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups, + List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType, + List<Audience> audiences, boolean anonymizeIP) { + this( + accountId, + anonymizeIP, + false, + null, + projectId, + revision, + null, + null, + version, + attributes, + audiences, + null, + eventType, + experiments, + null, + groups, + null, + null + ); + } + + // v4 constructor + public DatafileProjectConfig(String accountId, + boolean anonymizeIP, + boolean sendFlagDecisions, + Boolean botFiltering, + String projectId, + String revision, + String sdkKey, + String environmentKey, + String version, + List<Attribute> attributes, + List<Audience> audiences, + List<Audience> typedAudiences, + List<EventType> events, + List<Experiment> experiments, + List<FeatureFlag> featureFlags, + List<Group> groups, + List<Rollout> rollouts, + List<Integration> integrations) { + this.accountId = accountId; + this.projectId = projectId; + this.version = version; + this.revision = revision; + this.sdkKey = sdkKey; + this.environmentKey = environmentKey; + this.anonymizeIP = anonymizeIP; + this.sendFlagDecisions = sendFlagDecisions; + this.botFiltering = botFiltering; + + this.attributes = Collections.unmodifiableList(attributes); + this.audiences = Collections.unmodifiableList(audiences); + + if (typedAudiences != null) { + this.typedAudiences = Collections.unmodifiableList(typedAudiences); + } else { + this.typedAudiences = Collections.emptyList(); + } + + this.events = Collections.unmodifiableList(events); + if (featureFlags == null) { + this.featureFlags = Collections.emptyList(); + } else { + this.featureFlags = Collections.unmodifiableList(featureFlags); + } + if (rollouts == null) { + this.rollouts = Collections.emptyList(); + } else { + this.rollouts = Collections.unmodifiableList(rollouts); + } + + this.groups = Collections.unmodifiableList(groups); + + List<Experiment> allExperiments = new ArrayList<Experiment>(); + allExperiments.addAll(experiments); + allExperiments.addAll(aggregateGroupExperiments(groups)); + this.experiments = Collections.unmodifiableList(allExperiments); + + String publicKeyForODP = ""; + String hostForODP = ""; + if (integrations == null) { + this.integrations = Collections.emptyList(); + } else { + this.integrations = Collections.unmodifiableList(integrations); + for (Integration integration: this.integrations) { + if (integration.getKey().equals("odp")) { + hostForODP = integration.getHost(); + publicKeyForODP = integration.getPublicKey(); + break; + } + } + } + + this.publicKeyForODP = publicKeyForODP; + this.hostForODP = hostForODP; + + Set<String> allSegments = new HashSet<>(); + if (typedAudiences != null) { + for(Audience audience: typedAudiences) { + allSegments.addAll(audience.getSegments()); + } + } + + this.allSegments = allSegments; + + Map<String, Experiment> variationIdToExperimentMap = new HashMap<String, Experiment>(); + for (Experiment experiment : this.experiments) { + for (Variation variation : experiment.getVariations()) { + variationIdToExperimentMap.put(variation.getId(), experiment); + } + } + this.variationIdToExperimentMapping = Collections.unmodifiableMap(variationIdToExperimentMap); + + // generate the name mappers + this.attributeKeyMapping = ProjectConfigUtils.generateNameMapping(attributes); + this.eventNameMapping = ProjectConfigUtils.generateNameMapping(this.events); + this.experimentKeyMapping = ProjectConfigUtils.generateNameMapping(this.experiments); + this.featureKeyMapping = ProjectConfigUtils.generateNameMapping(this.featureFlags); + + // generate audience id to audience mapping + if (typedAudiences == null) { + this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(audiences); + } else { + List<Audience> combinedList = new ArrayList<>(audiences); + combinedList.addAll(typedAudiences); + this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(combinedList); + } + this.experimentIdMapping = ProjectConfigUtils.generateIdMapping(this.experiments); + this.groupIdMapping = ProjectConfigUtils.generateIdMapping(groups); + this.rolloutIdMapping = ProjectConfigUtils.generateIdMapping(this.rollouts); + + // Generate experiment to featureFlag list mapping to identify if experiment is AB-Test experiment or Feature-Test Experiment. + this.experimentFeatureKeyMapping = ProjectConfigUtils.generateExperimentFeatureMapping(this.featureFlags); + + flagVariationsMap = new HashMap<>(); + if (featureFlags != null) { + for (FeatureFlag flag : featureFlags) { + Map<String, Variation> variationIdToVariationsMap = new HashMap<>(); + for (Experiment rule : getAllRulesForFlag(flag)) { + for (Variation variation : rule.getVariations()) { + if(!variationIdToVariationsMap.containsKey(variation.getId())) { + variationIdToVariationsMap.put(variation.getId(), variation); + } + } + } + // Grab all the variations from the flag experiments and rollouts and add to flagVariationsMap + flagVariationsMap.put(flag.getKey(), new ArrayList<>(variationIdToVariationsMap.values())); + } + } + } + + /** + * Helper method to grab all rules for a flag + * @param flag The flag to grab all the rules from + * @return Returns a list of Experiments as rules + */ + private List<Experiment> getAllRulesForFlag(FeatureFlag flag) { + List<Experiment> rules = new ArrayList<>(); + Rollout rollout = rolloutIdMapping.get(flag.getRolloutId()); + for (String experimentId : flag.getExperimentIds()) { + rules.add(experimentIdMapping.get(experimentId)); + } + if (rollout != null) { + rules.addAll(rollout.getExperiments()); + } + return rules; + } + + + /** + * Helper method to retrieve the {@link Experiment} for the given experiment key. + * If {@link RaiseExceptionErrorHandler} is provided, either an experiment is returned, + * or an exception is sent to the error handler + * if there are no experiments in the project config with the given experiment key. + * If {@link NoOpErrorHandler} is used, either an experiment or {@code null} is returned. + * + * @param experimentKey the experiment to retrieve from the current project config + * @param errorHandler the error handler to send exceptions to + * @return the experiment for given experiment key + */ + @Override + @CheckForNull + public Experiment getExperimentForKey(@Nonnull String experimentKey, + @Nonnull ErrorHandler errorHandler) { + + Experiment experiment = + getExperimentKeyMapping() + .get(experimentKey); + + // if the given experiment key isn't present in the config, log an exception to the error handler + if (experiment == null) { + String unknownExperimentError = String.format("Experiment \"%s\" is not in the datafile.", experimentKey); + logger.warn(unknownExperimentError); + errorHandler.handleError(new UnknownExperimentException(unknownExperimentError)); + } + + return experiment; + } + + /** + * Helper method to retrieve the {@link EventType} for the given event name. + * If {@link RaiseExceptionErrorHandler} is provided, either an event type is returned, + * or an exception is sent to the error handler if there are no event types in the project config with the given name. + * If {@link NoOpErrorHandler} is used, either an event type or {@code null} is returned. + * + * @param eventName the event type to retrieve from the current project config + * @param errorHandler the error handler to send exceptions to + * @return the event type for the given event name + */ + @Override + @CheckForNull + public EventType getEventTypeForName(String eventName, ErrorHandler errorHandler) { + + EventType eventType = getEventNameMapping().get(eventName); + + // if the given event name isn't present in the config, log an exception to the error handler + if (eventType == null) { + String unknownEventTypeError = String.format("Event \"%s\" is not in the datafile.", eventName); + logger.warn(unknownEventTypeError); + errorHandler.handleError(new UnknownEventTypeException(unknownEventTypeError)); + } + + return eventType; + } + + + @Override + @Nullable + public Experiment getExperimentForVariationId(String variationId) { + return this.variationIdToExperimentMapping.get(variationId); + } + + private List<Experiment> aggregateGroupExperiments(List<Group> groups) { + List<Experiment> groupExperiments = new ArrayList<Experiment>(); + for (Group group : groups) { + groupExperiments.addAll(group.getExperiments()); + } + + return groupExperiments; + } + + /** + * Checks is attributeKey is reserved or not and if it exist in attributeKeyMapping + * + * @param attributeKey The attribute key + * @return AttributeId corresponding to AttributeKeyMapping, AttributeKey when it's a reserved attribute and + * null when attributeKey is equal to BOT_FILTERING_ATTRIBUTE key. + */ + @Override + public String getAttributeId(ProjectConfig projectConfig, String attributeKey) { + String attributeIdOrKey = null; + com.optimizely.ab.config.Attribute attribute = projectConfig.getAttributeKeyMapping().get(attributeKey); + boolean hasReservedPrefix = attributeKey.startsWith(RESERVED_ATTRIBUTE_PREFIX); + if (attribute != null) { + if (hasReservedPrefix) { + logger.warn("Attribute {} unexpectedly has reserved prefix {}; using attribute ID instead of reserved attribute name.", + attributeKey, RESERVED_ATTRIBUTE_PREFIX); + } + attributeIdOrKey = attribute.getId(); + } else if (hasReservedPrefix) { + attributeIdOrKey = attributeKey; + } else { + logger.debug("Unrecognized Attribute \"{}\"", attributeKey); + } + return attributeIdOrKey; + } + + @Override + public String getAccountId() { + return accountId; + } + + @Override + public String toDatafile() { + return datafile; + } + + @Override + public String getProjectId() { + return projectId; + } + + @Override + public String getVersion() { + return version; + } + + @Override + public String getRevision() { + return revision; + } + + @Override + public String getSdkKey() { + return sdkKey; + } + + @Override + public String getEnvironmentKey() { + return environmentKey; + } + + @Override + public boolean getSendFlagDecisions() { return sendFlagDecisions; } + + @Override + public boolean getAnonymizeIP() { + return anonymizeIP; + } + + @Override + public Boolean getBotFiltering() { + return botFiltering; + } + + @Override + public List<Group> getGroups() { + return groups; + } + + @Override + public List<Experiment> getExperiments() { + return experiments; + } + + @Override + public Set<String> getAllSegments() { + return this.allSegments; + } + + @Override + public List<Experiment> getExperimentsForEventKey(String eventKey) { + EventType event = eventNameMapping.get(eventKey); + if (event != null) { + List<String> experimentIds = event.getExperimentIds(); + List<Experiment> experiments = new ArrayList<Experiment>(experimentIds.size()); + for (String experimentId : experimentIds) { + experiments.add(experimentIdMapping.get(experimentId)); + } + + return experiments; + } + + return Collections.emptyList(); + } + + @Override + public List<FeatureFlag> getFeatureFlags() { + return featureFlags; + } + + @Override + public List<Rollout> getRollouts() { + return rollouts; + } + + @Override + public List<Attribute> getAttributes() { + return attributes; + } + + @Override + public List<EventType> getEventTypes() { + return events; + } + + @Override + public List<Audience> getAudiences() { + return audiences; + } + + @Override + public List<Audience> getTypedAudiences() { + return typedAudiences; + } + + @Override + public List<Integration> getIntegrations() { + return integrations; + } + + @Override + public Audience getAudience(String audienceId) { + return audienceIdMapping.get(audienceId); + } + + @Override + public Map<String, Experiment> getExperimentKeyMapping() { + return experimentKeyMapping; + } + + @Override + public Map<String, Attribute> getAttributeKeyMapping() { + return attributeKeyMapping; + } + + @Override + public Map<String, EventType> getEventNameMapping() { + return eventNameMapping; + } + + @Override + public Map<String, Audience> getAudienceIdMapping() { + return audienceIdMapping; + } + + @Override + public Map<String, Experiment> getExperimentIdMapping() { + return experimentIdMapping; + } + + @Override + public Map<String, Group> getGroupIdMapping() { + return groupIdMapping; + } + + @Override + public Map<String, Rollout> getRolloutIdMapping() { + return rolloutIdMapping; + } + + @Override + public Map<String, FeatureFlag> getFeatureKeyMapping() { + return featureKeyMapping; + } + + @Override + public Map<String, List<String>> getExperimentFeatureKeyMapping() { + return experimentFeatureKeyMapping; + } + + @Override + public Map<String, List<Variation>> getFlagVariationsMap() { + return flagVariationsMap; + } + + /** + * Gets a variation based on flagKey and variationKey + * + * @param flagKey The flag key for the variation + * @param variationKey The variation key for the variation + * @return Returns a variation based on flagKey and variationKey, otherwise null + */ + @Override + public Variation getFlagVariationByKey(String flagKey, String variationKey) { + Map<String, List<Variation>> flagVariationsMap = getFlagVariationsMap(); + if (flagVariationsMap.containsKey(flagKey)) { + List<Variation> variations = flagVariationsMap.get(flagKey); + for (Variation variation : variations) { + if (variation.getKey().equals(variationKey)) { + return variation; + } + } + } + return null; + } + + @Override + public String getHostForODP() { + return hostForODP; + } + + @Override + public String getPublicKeyForODP() { + return publicKeyForODP; + } + + @Override + public String toString() { + return "ProjectConfig{" + + "accountId='" + accountId + '\'' + + ", projectId='" + projectId + '\'' + + ", revision='" + revision + '\'' + + ", sdkKey='" + sdkKey + '\'' + + ", environmentKey='" + environmentKey + '\'' + + ", version='" + version + '\'' + + ", anonymizeIP=" + anonymizeIP + + ", botFiltering=" + botFiltering + + ", attributes=" + attributes + + ", audiences=" + audiences + + ", typedAudiences=" + typedAudiences + + ", events=" + events + + ", experiments=" + experiments + + ", featureFlags=" + featureFlags + + ", groups=" + groups + + ", rollouts=" + rollouts + + ", attributeKeyMapping=" + attributeKeyMapping + + ", eventNameMapping=" + eventNameMapping + + ", experimentKeyMapping=" + experimentKeyMapping + + ", featureKeyMapping=" + featureKeyMapping + + ", audienceIdMapping=" + audienceIdMapping + + ", experimentIdMapping=" + experimentIdMapping + + ", groupIdMapping=" + groupIdMapping + + ", rolloutIdMapping=" + rolloutIdMapping + + ", variationIdToExperimentMapping=" + variationIdToExperimentMapping + + '}'; + } + + public static class Builder { + private String datafile; + + public Builder withDatafile(String datafile) { + this.datafile = datafile; + return this; + } + + /** + * @return a {@link DatafileProjectConfig} instance given a JSON string datafile + * @throws ConfigParseException when parsing datafile fails + */ + public ProjectConfig build() throws ConfigParseException { + if (datafile == null) { + throw new ConfigParseException("Unable to parse null datafile."); + } + if (datafile.isEmpty()) { + throw new ConfigParseException("Unable to parse empty datafile."); + } + + ProjectConfig projectConfig = DefaultConfigParser.getInstance().parseProjectConfig(datafile); + if (projectConfig instanceof DatafileProjectConfig) { + ((DatafileProjectConfig) projectConfig).datafile = datafile; + } + + if (!supportedVersions.contains(projectConfig.getVersion())) { + throw new ConfigParseException("This version of the Java SDK does not support the given datafile version: " + projectConfig.getVersion()); + } + + return projectConfig; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/EventType.java b/core-api/src/main/java/com/optimizely/ab/config/EventType.java index 961d15392..012302d9e 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/EventType.java +++ b/core-api/src/main/java/com/optimizely/ab/config/EventType.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -66,9 +66,9 @@ public List<String> getExperimentIds() { @Override public String toString() { return "EventType{" + - "id='" + id + '\'' + - ", key='" + key + '\'' + - ", experimentIds=" + experimentIds + - '}'; + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", experimentIds=" + experimentIds + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java index f82e78903..11530735c 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019, 2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,17 +16,16 @@ */ package com.optimizely.ab.config; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.Collections; -import java.util.List; -import java.util.Map; +import com.fasterxml.jackson.annotation.*; +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.config.audience.*; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; +import java.util.Collections; +import java.util.List; +import java.util.Map; /** * Represents the Optimizely Experiment configuration. @@ -43,7 +42,12 @@ public class Experiment implements IdKeyMapped { private final String layerId; private final String groupId; + private final String AND = "AND"; + private final String OR = "OR"; + private final String NOT = "NOT"; + private final List<String> audienceIds; + private final Condition<AudienceIdCondition> audienceConditions; private final List<Variation> variations; private final List<TrafficAllocation> trafficAllocation; @@ -52,11 +56,11 @@ public class Experiment implements IdKeyMapped { private final Map<String, String> userIdToVariationKeyMap; public enum ExperimentStatus { - RUNNING ("Running"), - LAUNCHED ("Launched"), - PAUSED ("Paused"), - NOT_STARTED ("Not started"), - ARCHIVED ("Archived"); + RUNNING("Running"), + LAUNCHED("Launched"), + PAUSED("Paused"), + NOT_STARTED("Not started"), + ARCHIVED("Archived"); private final String experimentStatus; @@ -69,16 +73,22 @@ public String toString() { } } + @VisibleForTesting + public Experiment(String id, String key, String layerId) { + this(id, key, null, layerId, Collections.emptyList(), null, Collections.emptyList(), Collections.emptyMap(), Collections.emptyList(), ""); + } + @JsonCreator public Experiment(@JsonProperty("id") String id, @JsonProperty("key") String key, @JsonProperty("status") String status, @JsonProperty("layerId") String layerId, @JsonProperty("audienceIds") List<String> audienceIds, + @JsonProperty("audienceConditions") Condition audienceConditions, @JsonProperty("variations") List<Variation> variations, @JsonProperty("forcedVariations") Map<String, String> userIdToVariationKeyMap, @JsonProperty("trafficAllocation") List<TrafficAllocation> trafficAllocation) { - this(id, key, status, layerId, audienceIds, variations, userIdToVariationKeyMap, trafficAllocation, ""); + this(id, key, status, layerId, audienceIds, audienceConditions, variations, userIdToVariationKeyMap, trafficAllocation, ""); } public Experiment(@Nonnull String id, @@ -86,6 +96,7 @@ public Experiment(@Nonnull String id, @Nullable String status, @Nullable String layerId, @Nonnull List<String> audienceIds, + @Nullable Condition audienceConditions, @Nonnull List<Variation> variations, @Nonnull Map<String, String> userIdToVariationKeyMap, @Nonnull List<TrafficAllocation> trafficAllocation, @@ -95,6 +106,7 @@ public Experiment(@Nonnull String id, this.status = status == null ? ExperimentStatus.NOT_STARTED.toString() : status; this.layerId = layerId; this.audienceIds = Collections.unmodifiableList(audienceIds); + this.audienceConditions = audienceConditions; this.variations = Collections.unmodifiableList(variations); this.trafficAllocation = Collections.unmodifiableList(trafficAllocation); this.groupId = groupId; @@ -123,6 +135,10 @@ public List<String> getAudienceIds() { return audienceIds; } + public Condition getAudienceConditions() { + return audienceConditions; + } + public List<Variation> getVariations() { return variations; } @@ -149,7 +165,7 @@ public String getGroupId() { public boolean isActive() { return status.equals(ExperimentStatus.RUNNING.toString()) || - status.equals(ExperimentStatus.LAUNCHED.toString()); + status.equals(ExperimentStatus.LAUNCHED.toString()); } public boolean isRunning() { @@ -160,18 +176,111 @@ public boolean isLaunched() { return status.equals(ExperimentStatus.LAUNCHED.toString()); } + public String serializeConditions(Map<String, String> audiencesMap) { + Condition condition = this.audienceConditions; + return condition instanceof EmptyCondition ? "" : this.serialize(condition, audiencesMap); + } + + private String getNameFromAudienceId(String audienceId, Map<String, String> audiencesMap) { + StringBuilder audienceName = new StringBuilder(); + if (audiencesMap != null && audiencesMap.get(audienceId) != null) { + audienceName.append("\"" + audiencesMap.get(audienceId) + "\""); + } else { + audienceName.append("\"" + audienceId + "\""); + } + return audienceName.toString(); + } + + private String getOperandOrAudienceId(Condition condition, Map<String, String> audiencesMap) { + if (condition != null) { + if (condition instanceof AudienceIdCondition) { + return this.getNameFromAudienceId(condition.getOperandOrId(), audiencesMap); + } else { + return condition.getOperandOrId(); + } + } else { + return ""; + } + } + + public String serialize(Condition condition, Map<String, String> audiencesMap) { + StringBuilder stringBuilder = new StringBuilder(); + List<Condition> conditions; + + String operand = this.getOperandOrAudienceId(condition, audiencesMap); + switch (operand){ + case (AND): + conditions = ((AndCondition<?>) condition).getConditions(); + stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); + break; + case (OR): + conditions = ((OrCondition<?>) condition).getConditions(); + stringBuilder.append(this.getNameOrNextCondition(operand, conditions, audiencesMap)); + break; + case (NOT): + stringBuilder.append(operand + " "); + Condition notCondition = ((NotCondition<?>) condition).getCondition(); + if (notCondition instanceof AudienceIdCondition) { + stringBuilder.append(serialize(notCondition, audiencesMap)); + } else { + stringBuilder.append("(" + serialize(notCondition, audiencesMap) + ")"); + } + break; + default: + stringBuilder.append(operand); + break; + } + + return stringBuilder.toString(); + } + + public String getNameOrNextCondition(String operand, List<Condition> conditions, Map<String, String> audiencesMap) { + StringBuilder stringBuilder = new StringBuilder(); + int index = 0; + if (conditions.isEmpty()) { + return ""; + } else if (conditions.size() == 1) { + return serialize(conditions.get(0), audiencesMap); + } else { + for (Condition con : conditions) { + index++; + if (index + 1 <= conditions.size()) { + if (con instanceof AudienceIdCondition) { + String audienceName = this.getNameFromAudienceId(((AudienceIdCondition<?>) con).getAudienceId(), + audiencesMap); + stringBuilder.append( audienceName + " "); + } else { + stringBuilder.append("(" + serialize(con, audiencesMap) + ") "); + } + stringBuilder.append(operand); + stringBuilder.append(" "); + } else { + if (con instanceof AudienceIdCondition) { + String audienceName = this.getNameFromAudienceId(((AudienceIdCondition<?>) con).getAudienceId(), + audiencesMap); + stringBuilder.append(audienceName); + } else { + stringBuilder.append("(" + serialize(con, audiencesMap) + ")"); + } + } + } + } + return stringBuilder.toString(); + } + @Override public String toString() { return "Experiment{" + - "id='" + id + '\'' + - ", key='" + key + '\'' + - ", groupId='" + groupId + '\'' + - ", status='" + status + '\'' + - ", audienceIds=" + audienceIds + - ", variations=" + variations + - ", variationKeyToVariationMap=" + variationKeyToVariationMap + - ", userIdToVariationKeyMap=" + userIdToVariationKeyMap + - ", trafficAllocation=" + trafficAllocation + - '}'; + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", groupId='" + groupId + '\'' + + ", status='" + status + '\'' + + ", audienceIds=" + audienceIds + + ", audienceConditions=" + audienceConditions + + ", variations=" + variations + + ", variationKeyToVariationMap=" + variationKeyToVariationMap + + ", userIdToVariationKeyMap=" + userIdToVariationKeyMap + + ", trafficAllocation=" + trafficAllocation + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java index bbe7a88ba..2b3bc4939 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureFlag.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,27 +27,27 @@ * Represents a FeatureFlag definition at the project level */ @JsonIgnoreProperties(ignoreUnknown = true) -public class FeatureFlag implements IdKeyMapped{ +public class FeatureFlag implements IdKeyMapped { private final String id; private final String key; private final String rolloutId; private final List<String> experimentIds; - private final List<LiveVariable> variables; - private final Map<String, LiveVariable> variableKeyToLiveVariableMap; + private final List<FeatureVariable> variables; + private final Map<String, FeatureVariable> variableKeyToFeatureVariableMap; @JsonCreator public FeatureFlag(@JsonProperty("id") String id, @JsonProperty("key") String key, @JsonProperty("rolloutId") String rolloutId, @JsonProperty("experimentIds") List<String> experimentIds, - @JsonProperty("variables") List<LiveVariable> variables) { + @JsonProperty("variables") List<FeatureVariable> variables) { this.id = id; this.key = key; this.rolloutId = rolloutId; this.experimentIds = experimentIds; this.variables = variables; - this.variableKeyToLiveVariableMap = ProjectConfigUtils.generateNameMapping(variables); + this.variableKeyToFeatureVariableMap = ProjectConfigUtils.generateNameMapping(variables); } public String getId() { @@ -66,24 +66,24 @@ public List<String> getExperimentIds() { return experimentIds; } - public List<LiveVariable> getVariables() { + public List<FeatureVariable> getVariables() { return variables; } - public Map<String, LiveVariable> getVariableKeyToLiveVariableMap() { - return variableKeyToLiveVariableMap; + public Map<String, FeatureVariable> getVariableKeyToFeatureVariableMap() { + return variableKeyToFeatureVariableMap; } @Override public String toString() { return "FeatureFlag{" + - "id='" + id + '\'' + - ", key='" + key + '\'' + - ", rolloutId='" + rolloutId + '\'' + - ", experimentIds=" + experimentIds + - ", variables=" + variables + - ", variableKeyToLiveVariableMap=" + variableKeyToLiveVariableMap + - '}'; + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", rolloutId='" + rolloutId + '\'' + + ", experimentIds=" + experimentIds + + ", variables=" + variables + + ", variableKeyToFeatureVariableMap=" + variableKeyToFeatureVariableMap + + '}'; } @Override @@ -98,7 +98,7 @@ public boolean equals(Object o) { if (!rolloutId.equals(that.rolloutId)) return false; if (!experimentIds.equals(that.experimentIds)) return false; if (!variables.equals(that.variables)) return false; - return variableKeyToLiveVariableMap.equals(that.variableKeyToLiveVariableMap); + return variableKeyToFeatureVariableMap.equals(that.variableKeyToFeatureVariableMap); } @Override @@ -108,7 +108,7 @@ public int hashCode() { result = 31 * result + rolloutId.hashCode(); result = 31 * result + experimentIds.hashCode(); result = 31 * result + variables.hashCode(); - result = 31 * result + variableKeyToLiveVariableMap.hashCode(); + result = 31 * result + variableKeyToFeatureVariableMap.hashCode(); return result; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java b/core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java similarity index 58% rename from core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java rename to core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java index 4ae910301..92d082a08 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/LiveVariable.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureVariable.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,17 +25,17 @@ import javax.annotation.Nullable; /** - * Represents a live variable definition at the project level + * Represents a feature variable definition at the project level */ @JsonIgnoreProperties(ignoreUnknown = true) -public class LiveVariable implements IdKeyMapped { +public class FeatureVariable implements IdKeyMapped { public enum VariableStatus { @SerializedName("active") - ACTIVE ("active"), + ACTIVE("active"), @SerializedName("archived") - ARCHIVED ("archived"); + ARCHIVED("archived"); private final String variableStatus; @@ -61,63 +61,38 @@ public static VariableStatus fromString(String variableStatusString) { } } - public enum VariableType { - @SerializedName("boolean") - BOOLEAN ("boolean"), - - @SerializedName("integer") - INTEGER ("integer"), - - @SerializedName("string") - STRING ("string"), - - @SerializedName("double") - DOUBLE ("double"); - - private final String variableType; - - VariableType(String variableType) { - this.variableType = variableType; - } - - @JsonValue - public String getVariableType() { - return variableType; - } - - public static VariableType fromString(String variableTypeString) { - if (variableTypeString != null) { - for (VariableType variableTypeEnum : VariableType.values()) { - if (variableTypeString.equals(variableTypeEnum.getVariableType())) { - return variableTypeEnum; - } - } - } - - return null; - } - } + public static final String STRING_TYPE = "string"; + public static final String INTEGER_TYPE = "integer"; + public static final String DOUBLE_TYPE = "double"; + public static final String BOOLEAN_TYPE = "boolean"; + public static final String JSON_TYPE = "json"; private final String id; private final String key; private final String defaultValue; - private final VariableType type; - @Nullable private final VariableStatus status; + private final String type; + @Nullable + private final String subType; // this is for backward-compatibility (json type) + @Nullable + private final VariableStatus status; @JsonCreator - public LiveVariable(@JsonProperty("id") String id, - @JsonProperty("key") String key, - @JsonProperty("defaultValue") String defaultValue, - @JsonProperty("status") VariableStatus status, - @JsonProperty("type") VariableType type) { + public FeatureVariable(@JsonProperty("id") String id, + @JsonProperty("key") String key, + @JsonProperty("defaultValue") String defaultValue, + @JsonProperty("status") VariableStatus status, + @JsonProperty("type") String type, + @JsonProperty("subType") String subType) { this.id = id; this.key = key; this.defaultValue = defaultValue; this.status = status; this.type = type; + this.subType = subType; } - public @Nullable VariableStatus getStatus() { + @Nullable + public VariableStatus getStatus() { return status; } @@ -133,19 +108,21 @@ public String getDefaultValue() { return defaultValue; } - public VariableType getType() { + public String getType() { + if (type.equals(STRING_TYPE) && subType != null && subType.equals(JSON_TYPE)) return JSON_TYPE; return type; } @Override public String toString() { - return "LiveVariable{" + - "id='" + id + '\'' + - ", key='" + key + '\'' + - ", defaultValue='" + defaultValue + '\'' + - ", type=" + type + - ", status=" + status + - '}'; + return "FeatureVariable{" + + "id='" + id + '\'' + + ", key='" + key + '\'' + + ", defaultValue='" + defaultValue + '\'' + + ", type=" + type + + ", subType=" + subType + + ", status=" + status + + '}'; } @Override @@ -153,12 +130,12 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - LiveVariable variable = (LiveVariable) o; + FeatureVariable variable = (FeatureVariable) o; if (!id.equals(variable.id)) return false; if (!key.equals(variable.key)) return false; if (!defaultValue.equals(variable.defaultValue)) return false; - if (type != variable.type) return false; + if (!type.equals(variable.type)) return false; return status == variable.status; } @@ -168,7 +145,8 @@ public int hashCode() { result = 31 * result + key.hashCode(); result = 31 * result + defaultValue.hashCode(); result = 31 * result + type.hashCode(); - result = 31 * result + status.hashCode(); + result = 31 * result + (subType != null ? subType.hashCode() : 0); + result = 31 * result + (status != null ? status.hashCode() : 0); return result; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java b/core-api/src/main/java/com/optimizely/ab/config/FeatureVariableUsageInstance.java similarity index 78% rename from core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java rename to core-api/src/main/java/com/optimizely/ab/config/FeatureVariableUsageInstance.java index 79cf05620..f6d3146d7 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/LiveVariableUsageInstance.java +++ b/core-api/src/main/java/com/optimizely/ab/config/FeatureVariableUsageInstance.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,17 +21,17 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** - * Represents the value of a live variable for a variation + * Represents the value of a feature variable for a variation */ @JsonIgnoreProperties(ignoreUnknown = true) -public class LiveVariableUsageInstance implements IdMapped { +public class FeatureVariableUsageInstance implements IdMapped { private final String id; private final String value; @JsonCreator - public LiveVariableUsageInstance(@JsonProperty("id") String id, - @JsonProperty("value") String value) { + public FeatureVariableUsageInstance(@JsonProperty("id") String id, + @JsonProperty("value") String value) { this.id = id; this.value = value; } @@ -49,7 +49,7 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - LiveVariableUsageInstance that = (LiveVariableUsageInstance) o; + FeatureVariableUsageInstance that = (FeatureVariableUsageInstance) o; return id.equals(that.id) && value.equals(that.value); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Group.java b/core-api/src/main/java/com/optimizely/ab/config/Group.java index cd41bc120..afb068be4 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Group.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Group.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; - import javax.annotation.concurrent.Immutable; +import java.util.ArrayList; +import java.util.List; /** * Represents a Optimizely Group configuration @@ -48,7 +48,25 @@ public Group(@JsonProperty("id") String id, this.id = id; this.policy = policy; this.trafficAllocation = trafficAllocation; - this.experiments = experiments; + // populate experiment's groupId + this.experiments = new ArrayList<>(experiments.size()); + for (Experiment experiment : experiments) { + if (id != null && !id.equals(experiment.getGroupId())) { + experiment = new Experiment( + experiment.getId(), + experiment.getKey(), + experiment.getStatus(), + experiment.getLayerId(), + experiment.getAudienceIds(), + experiment.getAudienceConditions(), + experiment.getVariations(), + experiment.getUserIdToVariationKeyMap(), + experiment.getTrafficAllocation(), + id + ); + } + this.experiments.add(experiment); + } } public String getId() { @@ -70,10 +88,10 @@ public List<Experiment> getExperiments() { @Override public String toString() { return "Group{" + - "id='" + id + '\'' + - ", policy='" + policy + '\'' + - ", experiments=" + experiments + - ", trafficAllocation=" + trafficAllocation + - '}'; + "id='" + id + '\'' + + ", policy='" + policy + '\'' + + ", experiments=" + experiments + + ", trafficAllocation=" + trafficAllocation + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Integration.java b/core-api/src/main/java/com/optimizely/ab/config/Integration.java new file mode 100644 index 000000000..ed24df625 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/Integration.java @@ -0,0 +1,67 @@ +/** + * + * Copyright 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * Represents the Optimizely Integration configuration. + * + * @see <a href="http://developers.optimizely.com/server/reference/index.html#json">Project JSON</a> + */ +@Immutable +@JsonIgnoreProperties(ignoreUnknown = true) +public class Integration { + private final String key; + private final String host; + private final String publicKey; + + @JsonCreator + public Integration(@JsonProperty("key") String key, + @JsonProperty("host") String host, + @JsonProperty("publicKey") String publicKey) { + this.key = key; + this.host = host; + this.publicKey = publicKey; + } + + @Nonnull + public String getKey() { + return key; + } + + @Nullable + public String getHost() { return host; } + + @Nullable + public String getPublicKey() { return publicKey; } + + @Override + public String toString() { + return "Integration{" + + "key='" + key + '\'' + + ((this.host != null) ? (", host='" + host + '\'') : "") + + ((this.publicKey != null) ? (", publicKey='" + publicKey + '\'') : "") + + '}'; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java new file mode 100644 index 000000000..6dd84470e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java @@ -0,0 +1,264 @@ +/** + * + * Copyright 2019-2020, 2023, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.internal.NotificationRegistry; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.notification.UpdateConfigNotification; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfig; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfigManager; +import com.optimizely.ab.optimizelyconfig.OptimizelyConfigService; +import java.util.concurrent.locks.ReentrantLock; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; + +/** + * PollingProjectConfigManager is an abstract class that provides basic scheduling and caching. + * + * Instances of this class, must implement the {@link PollingProjectConfigManager#poll()} method + * which is responsible for fetching a given ProjectConfig. + * + * If this class is never started then calls will be made directly to {@link PollingProjectConfigManager#poll()} + * since no scheduled execution is being performed. + * + * Calling {@link PollingProjectConfigManager#getConfig()} should block until the ProjectConfig + * is initially set. A default ProjectConfig can be provided to bootstrap the initial ProjectConfig + * return value and prevent blocking. + */ +public abstract class PollingProjectConfigManager implements ProjectConfigManager, AutoCloseable, OptimizelyConfigManager { + + private static final Logger logger = LoggerFactory.getLogger(PollingProjectConfigManager.class); + private static final UpdateConfigNotification SIGNAL = new UpdateConfigNotification(); + + private final AtomicReference<ProjectConfig> currentProjectConfig = new AtomicReference<>(); + private final AtomicReference<OptimizelyConfig> currentOptimizelyConfig = new AtomicReference<>(); + private final ScheduledExecutorService scheduledExecutorService; + private final long period; + private final TimeUnit timeUnit; + private final long blockingTimeoutPeriod; + private final TimeUnit blockingTimeoutUnit; + private final NotificationCenter notificationCenter; + + private final CountDownLatch countDownLatch = new CountDownLatch(1); + + private volatile String sdkKey; + private volatile boolean started; + private ScheduledFuture<?> scheduledFuture; + private ReentrantLock lock = new ReentrantLock(); + + public PollingProjectConfigManager(long period, TimeUnit timeUnit) { + this(period, timeUnit, Long.MAX_VALUE, TimeUnit.MILLISECONDS, new NotificationCenter()); + } + + public PollingProjectConfigManager(long period, TimeUnit timeUnit, NotificationCenter notificationCenter) { + this(period, timeUnit, Long.MAX_VALUE, TimeUnit.MILLISECONDS, notificationCenter); + } + + public PollingProjectConfigManager(long period, TimeUnit timeUnit, long blockingTimeoutPeriod, TimeUnit blockingTimeoutUnit, NotificationCenter notificationCenter) { + this(period, timeUnit, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter, null); + } + + public PollingProjectConfigManager(long period, + TimeUnit timeUnit, + long blockingTimeoutPeriod, + TimeUnit blockingTimeoutUnit, + NotificationCenter notificationCenter, + @Nullable ThreadFactory customThreadFactory) { + this.period = period; + this.timeUnit = timeUnit; + this.blockingTimeoutPeriod = blockingTimeoutPeriod; + this.blockingTimeoutUnit = blockingTimeoutUnit; + this.notificationCenter = notificationCenter; + if (TimeUnit.SECONDS.convert(period, this.timeUnit) < 30) { + logger.warn("Polling intervals below 30 seconds are not recommended."); + } + final ThreadFactory threadFactory = customThreadFactory != null ? customThreadFactory : Executors.defaultThreadFactory(); + this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = threadFactory.newThread(runnable); + thread.setDaemon(true); + return thread; + }); + } + + protected abstract ProjectConfig poll(); + + /** + * Access to current cached project configuration, This is to make sure that config returns without any wait, even if it is null. + * + * @return {@link ProjectConfig} + */ + @Override + public ProjectConfig getCachedConfig() { + return currentProjectConfig.get(); + } + + /** + * Only allow the ProjectConfig to be set to a non-null value, if and only if the value has not already been set. + * @param projectConfig + */ + void setConfig(ProjectConfig projectConfig) { + if (projectConfig == null) { + return; + } + + ProjectConfig oldProjectConfig = currentProjectConfig.get(); + String previousRevision = oldProjectConfig == null ? "null" : oldProjectConfig.getRevision(); + + if (projectConfig.getRevision().equals(previousRevision)) { + return; + } + + if (oldProjectConfig == null) { + logger.info("New datafile set with revision: {}.", projectConfig.getRevision()); + } else { + logger.info("New datafile set with revision: {}. Old revision: {}", projectConfig.getRevision(), previousRevision); + } + + currentProjectConfig.set(projectConfig); + currentOptimizelyConfig.set(new OptimizelyConfigService(projectConfig).getConfig()); + countDownLatch.countDown(); + + if (sdkKey == null) { + sdkKey = projectConfig.getSdkKey(); + } + if (sdkKey != null) { + NotificationRegistry.getInternalNotificationCenter(sdkKey).send(SIGNAL); + } + notificationCenter.send(SIGNAL); + } + + public NotificationCenter getNotificationCenter() { + return notificationCenter; + } + + /** + * If the instance was never started, then call getConfig() directly from the inner ProjectConfigManager. + * else, wait until the ProjectConfig is set or the timeout expires. + */ + @Override + public ProjectConfig getConfig() { + if (started) { + try { + boolean acquired = countDownLatch.await(blockingTimeoutPeriod, blockingTimeoutUnit); + if (!acquired) { + logger.warn("Timeout exceeded waiting for ProjectConfig to be set, returning null."); + countDownLatch.countDown(); + } + + } catch (InterruptedException e) { + logger.warn("Interrupted waiting for valid ProjectConfig, returning null."); + } + + return currentProjectConfig.get(); + } + + ProjectConfig projectConfig = poll(); + return projectConfig == null ? currentProjectConfig.get() : projectConfig; + } + + /** + * Returns the cached {@link OptimizelyConfig} + * @return {@link OptimizelyConfig} + */ + @Override + public OptimizelyConfig getOptimizelyConfig() { + return currentOptimizelyConfig.get(); + } + + @Override + public String getSDKKey() { + return this.sdkKey; + } + + public void start() { + lock.lock(); + try { + if (started) { + logger.warn("Manager already started."); + return; + } + + if (scheduledExecutorService.isShutdown()) { + logger.warn("Not starting. Already in shutdown."); + return; + } + + Runnable runnable = new ProjectConfigFetcher(); + scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(runnable, 0, period, timeUnit); + started = true; + } finally { + lock.unlock(); + } + } + + public void stop() { + lock.lock(); + try { + if (!started) { + logger.warn("Not pausing. Manager has not been started."); + return; + } + + if (scheduledExecutorService.isShutdown()) { + logger.warn("Not pausing. Already in shutdown."); + return; + } + + logger.info("pausing project watcher"); + scheduledFuture.cancel(true); + started = false; + } finally { + lock.unlock(); + } + } + + @Override + public void close() { + lock.lock(); + try { + stop(); + scheduledExecutorService.shutdownNow(); + started = false; + } finally { + lock.unlock(); + } + } + + protected void setSdkKey(String sdkKey) { + this.sdkKey = sdkKey; + } + + public boolean isRunning() { + return started; + } + + private class ProjectConfigFetcher implements Runnable { + @Override + public void run() { + try { + ProjectConfig projectConfig = poll(); + setConfig(projectConfig); + } catch (Exception e) { + logger.error("Uncaught exception polling for ProjectConfig.", e); + } + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java index 8a81af72f..2073be9ef 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,587 +16,123 @@ */ package com.optimizely.ab.config; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.optimizely.ab.UnknownEventTypeException; -import com.optimizely.ab.UnknownExperimentException; import com.optimizely.ab.config.audience.Audience; -import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.error.NoOpErrorHandler; -import com.optimizely.ab.error.RaiseExceptionErrorHandler; -import com.optimizely.ab.internal.ControlAttribute; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import javax.annotation.concurrent.Immutable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Set; /** - * Represents the Optimizely Project configuration. + * ProjectConfig is an interface capturing the experiment, variation and feature definitions. * - * @see <a href="http://developers.optimizely.com/server/reference/index.html#json">Project JSON</a> + * The default implementation of ProjectConfig can be found in {@link DatafileProjectConfig}. */ -@Immutable -@JsonIgnoreProperties(ignoreUnknown = true) -public class ProjectConfig { +public interface ProjectConfig { + String RESERVED_ATTRIBUTE_PREFIX = "$opt_"; - public enum Version { - V2 ("2"), - V3 ("3"), - V4 ("4"); - - private final String version; - - Version(String version) { - this.version = version; - } - - @Override - public String toString() { - return version; - } - } - - // logger - private static final Logger logger = LoggerFactory.getLogger(ProjectConfig.class); - - // ProjectConfig properties - private final String accountId; - private final String projectId; - private final String revision; - private final String version; - private final boolean anonymizeIP; - private final Boolean botFiltering; - private final List<Attribute> attributes; - private final List<Audience> audiences; - private final List<EventType> events; - private final List<Experiment> experiments; - private final List<FeatureFlag> featureFlags; - private final List<Group> groups; - private final List<LiveVariable> liveVariables; - private final List<Rollout> rollouts; - - // key to entity mappings - private final Map<String, Attribute> attributeKeyMapping; - private final Map<String, EventType> eventNameMapping; - private final Map<String, Experiment> experimentKeyMapping; - private final Map<String, FeatureFlag> featureKeyMapping; - private final Map<String, LiveVariable> liveVariableKeyMapping; - - // id to entity mappings - private final Map<String, Audience> audienceIdMapping; - private final Map<String, Experiment> experimentIdMapping; - private final Map<String, Group> groupIdMapping; - private final Map<String, Rollout> rolloutIdMapping; - - // other mappings - private final Map<String, List<Experiment>> liveVariableIdToExperimentsMapping; - private final Map<String, Map<String, LiveVariableUsageInstance>> variationToLiveVariableUsageInstanceMapping; - private final Map<String, Experiment> variationIdToExperimentMapping; - - public final static String RESERVED_ATTRIBUTE_PREFIX = "$opt_"; - - /** - * Forced variations supersede any other mappings. They are transient and are not persistent or part of - * the actual datafile. This contains all the forced variations - * set by the user by calling {@link ProjectConfig#setForcedVariation(String, String, String)} (it is not the same as the - * whitelisting forcedVariations data structure in the Experiments class). - */ - private transient ConcurrentHashMap<String, ConcurrentHashMap<String, String>> forcedVariationMapping = new ConcurrentHashMap<String, ConcurrentHashMap<String, String>>(); - - // v2 constructor - public ProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups, - List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType, - List<Audience> audiences) { - this(accountId, projectId, version, revision, groups, experiments, attributes, eventType, audiences, false, - null); - } - - // v3 constructor - public ProjectConfig(String accountId, String projectId, String version, String revision, List<Group> groups, - List<Experiment> experiments, List<Attribute> attributes, List<EventType> eventType, - List<Audience> audiences, boolean anonymizeIP, List<LiveVariable> liveVariables) { - this( - accountId, - anonymizeIP, - null, - projectId, - revision, - version, - attributes, - audiences, - eventType, - experiments, - null, - groups, - liveVariables, - null - ); - } - - // v4 constructor - public ProjectConfig(String accountId, - boolean anonymizeIP, - Boolean botFiltering, - String projectId, - String revision, - String version, - List<Attribute> attributes, - List<Audience> audiences, - List<EventType> events, - List<Experiment> experiments, - List<FeatureFlag> featureFlags, - List<Group> groups, - List<LiveVariable> liveVariables, - List<Rollout> rollouts) { - - this.accountId = accountId; - this.projectId = projectId; - this.version = version; - this.revision = revision; - this.anonymizeIP = anonymizeIP; - this.botFiltering = botFiltering; - - this.attributes = Collections.unmodifiableList(attributes); - this.audiences = Collections.unmodifiableList(audiences); - this.events = Collections.unmodifiableList(events); - if (featureFlags == null) { - this.featureFlags = Collections.emptyList(); - } - else { - this.featureFlags = Collections.unmodifiableList(featureFlags); - } - if (rollouts == null) { - this.rollouts = Collections.emptyList(); - } - else { - this.rollouts = Collections.unmodifiableList(rollouts); - } - - this.groups = Collections.unmodifiableList(groups); - - List<Experiment> allExperiments = new ArrayList<Experiment>(); - allExperiments.addAll(experiments); - allExperiments.addAll(aggregateGroupExperiments(groups)); - this.experiments = Collections.unmodifiableList(allExperiments); - - Map<String, Experiment> variationIdToExperimentMap = new HashMap<String, Experiment>(); - for (Experiment experiment : this.experiments) { - for (Variation variation: experiment.getVariations()) { - variationIdToExperimentMap.put(variation.getId(), experiment); - } - } - this.variationIdToExperimentMapping = Collections.unmodifiableMap(variationIdToExperimentMap); - - // generate the name mappers - this.attributeKeyMapping = ProjectConfigUtils.generateNameMapping(attributes); - this.eventNameMapping = ProjectConfigUtils.generateNameMapping(this.events); - this.experimentKeyMapping = ProjectConfigUtils.generateNameMapping(this.experiments); - this.featureKeyMapping = ProjectConfigUtils.generateNameMapping(this.featureFlags); - - // generate audience id to audience mapping - this.audienceIdMapping = ProjectConfigUtils.generateIdMapping(audiences); - this.experimentIdMapping = ProjectConfigUtils.generateIdMapping(this.experiments); - this.groupIdMapping = ProjectConfigUtils.generateIdMapping(groups); - this.rolloutIdMapping = ProjectConfigUtils.generateIdMapping(this.rollouts); - - if (liveVariables == null) { - this.liveVariables = null; - this.liveVariableKeyMapping = Collections.emptyMap(); - this.liveVariableIdToExperimentsMapping = Collections.emptyMap(); - this.variationToLiveVariableUsageInstanceMapping = Collections.emptyMap(); - } else { - this.liveVariables = Collections.unmodifiableList(liveVariables); - this.liveVariableKeyMapping = ProjectConfigUtils.generateNameMapping(this.liveVariables); - this.liveVariableIdToExperimentsMapping = - ProjectConfigUtils.generateLiveVariableIdToExperimentsMapping(this.experiments); - this.variationToLiveVariableUsageInstanceMapping = - ProjectConfigUtils.generateVariationToLiveVariableUsageInstancesMap(this.experiments); - } - } + @CheckForNull + Experiment getExperimentForKey(@Nonnull String experimentKey, + @Nonnull ErrorHandler errorHandler); - /** - * Helper method to retrieve the {@link Experiment} for the given experiment key. - * If {@link RaiseExceptionErrorHandler} is provided, either an experiment is returned, - * or an exception is sent to the error handler - * if there are no experiments in the project config with the given experiment key. - * If {@link NoOpErrorHandler} is used, either an experiment or {@code null} is returned. - * - * @param experimentKey the experiment to retrieve from the current project config - * @param errorHandler the error handler to send exceptions to - * @return the experiment for given experiment key - */ @CheckForNull - public Experiment getExperimentForKey(@Nonnull String experimentKey, - @Nonnull ErrorHandler errorHandler) { - - Experiment experiment = - getExperimentKeyMapping() - .get(experimentKey); - - // if the given experiment key isn't present in the config, log an exception to the error handler - if (experiment == null) { - String unknownExperimentError = String.format("Experiment \"%s\" is not in the datafile.", experimentKey); - logger.error(unknownExperimentError); - errorHandler.handleError(new UnknownExperimentException(unknownExperimentError)); - } + EventType getEventTypeForName(String eventName, ErrorHandler errorHandler); - return experiment; - } + @Nullable + Experiment getExperimentForVariationId(String variationId); - /** - * Helper method to retrieve the {@link EventType} for the given event name. - * If {@link RaiseExceptionErrorHandler} is provided, either an event type is returned, - * or an exception is sent to the error handler if there are no event types in the project config with the given name. - * If {@link NoOpErrorHandler} is used, either an event type or {@code null} is returned. - * - * @param eventName the event type to retrieve from the current project config - * @param errorHandler the error handler to send exceptions to - * @return the event type for the given event name - */ - public @CheckForNull EventType getEventTypeForName(String eventName, ErrorHandler errorHandler) { - - EventType eventType = - getEventNameMapping() - .get(eventName); - - // if the given event name isn't present in the config, log an exception to the error handler - if (eventType == null) { - String unknownEventTypeError = String.format("Event \"%s\" is not in the datafile.", eventName); - logger.error(unknownEventTypeError); - errorHandler.handleError(new UnknownEventTypeException(unknownEventTypeError)); - } + String getAttributeId(ProjectConfig projectConfig, String attributeKey); - return eventType; - } + String getAccountId(); + String toDatafile(); - public @Nullable Experiment getExperimentForVariationId(String variationId) { - return this.variationIdToExperimentMapping.get(variationId); - } + String getProjectId(); - private List<Experiment> aggregateGroupExperiments(List<Group> groups) { - List<Experiment> groupExperiments = new ArrayList<Experiment>(); - for (Group group : groups) { - groupExperiments.addAll(group.getExperiments()); - } + String getVersion(); - return groupExperiments; - } + String getRevision(); - /** - * Checks is attributeKey is reserved or not and if it exist in attributeKeyMapping - * @param attributeKey - * @return AttributeId corresponding to AttributeKeyMapping, AttributeKey when it's a reserved attribute and - * null when attributeKey is equal to BOT_FILTERING_ATTRIBUTE key. - */ - public String getAttributeId(ProjectConfig projectConfig, String attributeKey) { - String attributeIdOrKey = null; - com.optimizely.ab.config.Attribute attribute = projectConfig.getAttributeKeyMapping().get(attributeKey); - boolean hasReservedPrefix = attributeKey.startsWith(RESERVED_ATTRIBUTE_PREFIX); - if (attribute != null) { - if (hasReservedPrefix) { - logger.warn("Attribute {} unexpectedly has reserved prefix {}; using attribute ID instead of reserved attribute name.", - attributeKey, RESERVED_ATTRIBUTE_PREFIX); - } - attributeIdOrKey = attribute.getId(); - } else if (hasReservedPrefix) { - attributeIdOrKey = attributeKey; - } else { - logger.debug("Unrecognized Attribute \"{}\"", attributeKey); - } - return attributeIdOrKey; - } + String getSdkKey(); - public String getAccountId() { - return accountId; - } + String getEnvironmentKey(); - public String getProjectId() { - return projectId; - } + boolean getSendFlagDecisions(); - public String getVersion() { - return version; - } + boolean getAnonymizeIP(); - public String getRevision() { - return revision; - } + Boolean getBotFiltering(); - public boolean getAnonymizeIP() { - return anonymizeIP; - } + List<Group> getGroups(); - public Boolean getBotFiltering() { - return botFiltering; - } + List<Experiment> getExperiments(); - public List<Group> getGroups() { - return groups; - } + Set<String> getAllSegments(); - public List<Experiment> getExperiments() { - return experiments; - } - - public List<Experiment> getExperimentsForEventKey(String eventKey) { - EventType event = eventNameMapping.get(eventKey); - if (event != null) { - List<String> experimentIds = event.getExperimentIds(); - List<Experiment> experiments = new ArrayList<Experiment>(experimentIds.size()); - for (String experimentId : experimentIds) { - experiments.add(experimentIdMapping.get(experimentId)); - } - - return experiments; - } + List<Experiment> getExperimentsForEventKey(String eventKey); - return Collections.emptyList(); - } + List<FeatureFlag> getFeatureFlags(); - public List<FeatureFlag> getFeatureFlags() { - return featureFlags; - } + List<Rollout> getRollouts(); - public List<Rollout> getRollouts() { - return rollouts; - } - - public List<Attribute> getAttributes() { - return attributes; - } - - public List<EventType> getEventTypes() { - return events; - } + List<Attribute> getAttributes(); - public List<Audience> getAudiences() { - return audiences; - } + List<EventType> getEventTypes(); - public Condition getAudienceConditionsFromId(String audienceId) { - Audience audience = audienceIdMapping.get(audienceId); + List<Audience> getAudiences(); - return audience != null ? audience.getConditions() : null; - } + List<Audience> getTypedAudiences(); - public List<LiveVariable> getLiveVariables() { - return liveVariables; - } + List<Integration> getIntegrations(); - public Map<String, Experiment> getExperimentKeyMapping() { - return experimentKeyMapping; - } + Audience getAudience(String audienceId); - public Map<String, Attribute> getAttributeKeyMapping() { - return attributeKeyMapping; - } + Map<String, Experiment> getExperimentKeyMapping(); - public Map<String, EventType> getEventNameMapping() { - return eventNameMapping; - } + Map<String, Attribute> getAttributeKeyMapping(); - public Map<String, Audience> getAudienceIdMapping() { - return audienceIdMapping; - } + Map<String, EventType> getEventNameMapping(); - public Map<String, Experiment> getExperimentIdMapping() { - return experimentIdMapping; - } + Map<String, Audience> getAudienceIdMapping(); - public Map<String, Group> getGroupIdMapping() { - return groupIdMapping; - } + Map<String, Experiment> getExperimentIdMapping(); - public Map<String, Rollout> getRolloutIdMapping() { - return rolloutIdMapping; - } + Map<String, Group> getGroupIdMapping(); - public Map<String, LiveVariable> getLiveVariableKeyMapping() { - return liveVariableKeyMapping; - } + Map<String, Rollout> getRolloutIdMapping(); - public Map<String, List<Experiment>> getLiveVariableIdToExperimentsMapping() { - return liveVariableIdToExperimentsMapping; - } + Map<String, FeatureFlag> getFeatureKeyMapping(); - public Map<String, Map<String, LiveVariableUsageInstance>> getVariationToLiveVariableUsageInstanceMapping() { - return variationToLiveVariableUsageInstanceMapping; - } + Map<String, List<String>> getExperimentFeatureKeyMapping(); - public Map<String, FeatureFlag> getFeatureKeyMapping() { - return featureKeyMapping; - } + Map<String, List<Variation>> getFlagVariationsMap(); - public ConcurrentHashMap<String, ConcurrentHashMap<String, String>> getForcedVariationMapping() { return forcedVariationMapping; } - - /** - * Force a user into a variation for a given experiment. - * The forced variation value does not persist across application launches. - * If the experiment key is not in the project file, this call fails and returns false. - * - * @param experimentKey The key for the experiment. - * @param userId The user ID to be used for bucketing. - * @param variationKey The variation key to force the user into. If the variation key is null - * then the forcedVariation for that experiment is removed. - * - * @return boolean A boolean value that indicates if the set completed successfully. - */ - public boolean setForcedVariation(@Nonnull String experimentKey, - @Nonnull String userId, - @Nullable String variationKey) { - - // if the experiment is not a valid experiment key, don't set it. - Experiment experiment = getExperimentKeyMapping().get(experimentKey); - if (experiment == null){ - logger.error("Experiment {} does not exist in ProjectConfig for project {}", experimentKey, projectId); - return false; - } + Variation getFlagVariationByKey(String flagKey, String variationKey); - Variation variation = null; - - // keep in mind that you can pass in a variationKey that is null if you want to - // remove the variation. - if (variationKey != null) { - variation = experiment.getVariationKeyToVariationMap().get(variationKey); - // if the variation is not part of the experiment, return false. - if (variation == null) { - logger.error("Variation {} does not exist for experiment {}", variationKey, experimentKey); - return false; - } - } + String getHostForODP(); - // if the user id is invalid, return false. - if (userId == null || userId.trim().isEmpty()) { - logger.error("User ID is invalid"); - return false; - } + String getPublicKeyForODP(); - ConcurrentHashMap<String, String> experimentToVariation; - if (!forcedVariationMapping.containsKey(userId)) { - forcedVariationMapping.putIfAbsent(userId, new ConcurrentHashMap<String, String>()); - } - experimentToVariation = forcedVariationMapping.get(userId); - - boolean retVal = true; - // if it is null remove the variation if it exists. - if (variationKey == null) { - String removedVariationId = experimentToVariation.remove(experiment.getId()); - if (removedVariationId != null) { - Variation removedVariation = experiment.getVariationIdToVariationMap().get(removedVariationId); - if (removedVariation != null) { - logger.debug("Variation mapped to experiment \"{}\" has been removed for user \"{}\"", experiment.getKey(), userId); - } - else { - logger.debug("Removed forced variation that did not exist in experiment"); - } - } - else { - logger.debug("No variation for experiment {}", experimentKey); - retVal = false; - } - } - else { - String previous = experimentToVariation.put(experiment.getId(), variation.getId()); - logger.debug("Set variation \"{}\" for experiment \"{}\" and user \"{}\" in the forced variation map.", - variation.getKey(), experiment.getKey(), userId); - if (previous != null) { - Variation previousVariation = experiment.getVariationIdToVariationMap().get(previous); - if (previousVariation != null) { - logger.debug("forced variation {} replaced forced variation {} in forced variation map.", - variation.getKey(), previousVariation.getKey()); - } - } - } + @Override + String toString(); - return retVal; - } + public enum Version { + V2("2"), + V3("3"), + V4("4"); - /** - * Gets the forced variation for a given user and experiment. - * - * @param experimentKey The key for the experiment. - * @param userId The user ID to be used for bucketing. - * - * @return The variation the user was bucketed into. This value can be null if the - * forced variation fails. - */ - public @Nullable Variation getForcedVariation(@Nonnull String experimentKey, - @Nonnull String userId) { - - // if the user id is invalid, return false. - if (userId == null || userId.trim().isEmpty()) { - logger.error("User ID is invalid"); - return null; - } + private final String version; - if (experimentKey == null || experimentKey.isEmpty()) { - logger.error("experiment key is invalid"); - return null; + Version(String version) { + this.version = version; } - Map<String, String> experimentToVariation = getForcedVariationMapping().get(userId); - if (experimentToVariation != null) { - Experiment experiment = getExperimentKeyMapping().get(experimentKey); - if (experiment == null) { - logger.debug("No experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId); - return null; - } - String variationId = experimentToVariation.get(experiment.getId()); - if (variationId != null) { - Variation variation = experiment.getVariationIdToVariationMap().get(variationId); - if (variation != null) { - logger.debug("Variation \"{}\" is mapped to experiment \"{}\" and user \"{}\" in the forced variation map", - variation.getKey(), experimentKey, userId); - return variation; - } - } - else { - logger.debug("No variation for experiment \"{}\" mapped to user \"{}\" in the forced variation map ", experimentKey, userId); - } + @Override + public String toString() { + return version; } - return null; - } - - @Override - public String toString() { - return "ProjectConfig{" + - "accountId='" + accountId + '\'' + - ", projectId='" + projectId + '\'' + - ", revision='" + revision + '\'' + - ", version='" + version + '\'' + - ", anonymizeIP=" + anonymizeIP + - ", botFiltering=" + botFiltering + - ", attributes=" + attributes + - ", audiences=" + audiences + - ", events=" + events + - ", experiments=" + experiments + - ", featureFlags=" + featureFlags + - ", groups=" + groups + - ", liveVariables=" + liveVariables + - ", rollouts=" + rollouts + - ", attributeKeyMapping=" + attributeKeyMapping + - ", eventNameMapping=" + eventNameMapping + - ", experimentKeyMapping=" + experimentKeyMapping + - ", featureKeyMapping=" + featureKeyMapping + - ", liveVariableKeyMapping=" + liveVariableKeyMapping + - ", audienceIdMapping=" + audienceIdMapping + - ", experimentIdMapping=" + experimentIdMapping + - ", groupIdMapping=" + groupIdMapping + - ", rolloutIdMapping=" + rolloutIdMapping + - ", liveVariableIdToExperimentsMapping=" + liveVariableIdToExperimentsMapping + - ", variationToLiveVariableUsageInstanceMapping=" + variationToLiveVariableUsageInstanceMapping + - ", forcedVariationMapping=" + forcedVariationMapping + - ", variationIdToExperimentMapping=" + variationIdToExperimentMapping + - '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java new file mode 100644 index 000000000..002acae55 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigManager.java @@ -0,0 +1,48 @@ +/** + * + * Copyright 2019, 2023, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import javax.annotation.Nullable; + +public interface ProjectConfigManager { + /** + * Implementations of this method should block until a datafile is available. + * + * @return ProjectConfig + */ + ProjectConfig getConfig(); + + /** + * Implementations of this method should not block until a datafile is available, instead return current cached project configuration. + * return null if ProjectConfig is not ready at the moment. + * + * NOTE: To use ODP segments, implementation of this function is required to return current project configuration. + * @return ProjectConfig + */ + @Nullable + ProjectConfig getCachedConfig(); + + /** + * Implementations of this method should return SDK key. If there is no SDKKey then it should return null. + * + * NOTE: To update ODP segments configuration via polling, it is required to return sdkKey. + * @return String + */ + @Nullable + String getSDKKey(); +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java index 84cf6cab1..060f82a06 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/config/ProjectConfigUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017,2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,10 @@ public class ProjectConfigUtils { /** * Helper method for creating convenience mappings from key to entity + * + * @param nameables The list of IdMapped entities + * @param <T> This is the type parameter + * @return The map of key to entity */ public static <T extends IdKeyMapped> Map<String, T> generateNameMapping(List<T> nameables) { Map<String, T> nameMapping = new HashMap<String, T>(); @@ -38,6 +42,10 @@ public static <T extends IdKeyMapped> Map<String, T> generateNameMapping(List<T> /** * Helper method for creating convenience mappings from ID to entity + * + * @param nameables The list of IdMapped entities + * @param <T> This is the type parameter + * @return The map of ID to entity */ public static <T extends IdMapped> Map<String, T> generateIdMapping(List<T> nameables) { Map<String, T> nameMapping = new HashMap<String, T>(); @@ -49,60 +57,24 @@ public static <T extends IdMapped> Map<String, T> generateIdMapping(List<T> name } /** - * Helper method to create a map from a live variable to all the experiments using it + * Helper method for creating convenience mappings of ExperimentID to featureFlags it is included in. + * + * @param featureFlags The list of feture flags + * @return The mapping of ExperimentID to featureFlags */ - public static Map<String, List<Experiment>> generateLiveVariableIdToExperimentsMapping( - List<Experiment> experiments) { - - Map<String, List<Experiment>> variableIdToExperiments = - new HashMap<String, List<Experiment>>(); - for (Experiment experiment : experiments) { - if (!experiment.getVariations().isEmpty()) { - // if a live variable is used by an experiment, it will have instances in all variations so we can - // short-circuit after getting the live variables for the first variation - Variation variation = experiment.getVariations().get(0); - if (variation.getLiveVariableUsageInstances() != null) { - for (LiveVariableUsageInstance usageInstance : variation.getLiveVariableUsageInstances()) { - List<Experiment> experimentsUsingVariable = variableIdToExperiments.get(usageInstance.getId()); - if (experimentsUsingVariable == null) { - experimentsUsingVariable = new ArrayList<Experiment>(); - } - - experimentsUsingVariable.add(experiment); - variableIdToExperiments.put(usageInstance.getId(), experimentsUsingVariable); - } + public static Map<String, List<String>> generateExperimentFeatureMapping(List<FeatureFlag> featureFlags) { + Map<String, List<String>> experimentFeatureMap = new HashMap<>(); + for (FeatureFlag featureFlag : featureFlags) { + for (String experimentId : featureFlag.getExperimentIds()) { + if (experimentFeatureMap.containsKey(experimentId)) { + experimentFeatureMap.get(experimentId).add(featureFlag.getKey()); + } else { + ArrayList<String> featureFlagKeysList = new ArrayList<>(); + featureFlagKeysList.add(featureFlag.getKey()); + experimentFeatureMap.put(experimentId, featureFlagKeysList); } } } - - return variableIdToExperiments; - } - - /** - * Helper method to create a map from variation ID to variable ID to {@link LiveVariableUsageInstance} - */ - public static Map<String, Map<String, LiveVariableUsageInstance>> generateVariationToLiveVariableUsageInstancesMap( - List<Experiment> experiments) { - - Map<String, Map<String, LiveVariableUsageInstance>> liveVariableValueMap = - new HashMap<String, Map<String, LiveVariableUsageInstance>>(); - for (Experiment experiment : experiments) { - for (Variation variation : experiment.getVariations()) { - if (variation.getLiveVariableUsageInstances() != null) { - for (LiveVariableUsageInstance usageInstance : variation.getLiveVariableUsageInstances()) { - Map<String, LiveVariableUsageInstance> liveVariableIdToValueMap = - liveVariableValueMap.get(variation.getId()); - if (liveVariableIdToValueMap == null) { - liveVariableIdToValueMap = new HashMap<String, LiveVariableUsageInstance>(); - } - - liveVariableIdToValueMap.put(usageInstance.getId(), usageInstance); - liveVariableValueMap.put(variation.getId(), liveVariableIdToValueMap); - } - } - } - } - - return liveVariableValueMap; + return Collections.unmodifiableMap(experimentFeatureMap); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Rollout.java b/core-api/src/main/java/com/optimizely/ab/config/Rollout.java index b36f33838..777849c16 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Rollout.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Rollout.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -54,8 +54,8 @@ public List<Experiment> getExperiments() { @Override public String toString() { return "Rollout{" + - "id='" + id + '\'' + - ", experiments=" + experiments + - '}'; + "id='" + id + '\'' + + ", experiments=" + experiments + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/TrafficAllocation.java b/core-api/src/main/java/com/optimizely/ab/config/TrafficAllocation.java index a66cfb81a..4f85e8fee 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/TrafficAllocation.java +++ b/core-api/src/main/java/com/optimizely/ab/config/TrafficAllocation.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,9 +49,9 @@ public int getEndOfRange() { @Override public String toString() { return "TrafficAllocation{" + - "entityId='" + entityId + '\'' + - ", endOfRange=" + endOfRange + - '}'; + "entityId='" + entityId + '\'' + + ", endOfRange=" + endOfRange + + '}'; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Variation.java b/core-api/src/main/java/com/optimizely/ab/config/Variation.java index 21ab5d7e1..0bb1765c2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Variation.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Variation.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,8 +38,8 @@ public class Variation implements IdKeyMapped { private final String id; private final String key; private final Boolean featureEnabled; - private final List<LiveVariableUsageInstance> liveVariableUsageInstances; - private final Map<String, LiveVariableUsageInstance> variableIdToLiveVariableUsageInstanceMap; + private final List<FeatureVariableUsageInstance> featureVariableUsageInstances; + private final Map<String, FeatureVariableUsageInstance> variableIdToFeatureVariableUsageInstanceMap; public Variation(String id, String key) { this(id, key, null); @@ -47,48 +47,51 @@ public Variation(String id, String key) { public Variation(String id, String key, - List<LiveVariableUsageInstance> liveVariableUsageInstances) { - this(id, key,false, liveVariableUsageInstances); + List<FeatureVariableUsageInstance> featureVariableUsageInstances) { + this(id, key, false, featureVariableUsageInstances); } @JsonCreator public Variation(@JsonProperty("id") String id, @JsonProperty("key") String key, @JsonProperty("featureEnabled") Boolean featureEnabled, - @JsonProperty("variables") List<LiveVariableUsageInstance> liveVariableUsageInstances) { + @JsonProperty("variables") List<FeatureVariableUsageInstance> featureVariableUsageInstances) { this.id = id; this.key = key; - if(featureEnabled != null) + if (featureEnabled != null) this.featureEnabled = featureEnabled; else this.featureEnabled = false; - if (liveVariableUsageInstances == null) { - this.liveVariableUsageInstances = Collections.emptyList(); + if (featureVariableUsageInstances == null) { + this.featureVariableUsageInstances = Collections.emptyList(); + } else { + this.featureVariableUsageInstances = featureVariableUsageInstances; } - else { - this.liveVariableUsageInstances = liveVariableUsageInstances; - } - this.variableIdToLiveVariableUsageInstanceMap = ProjectConfigUtils.generateIdMapping(this.liveVariableUsageInstances); + this.variableIdToFeatureVariableUsageInstanceMap = ProjectConfigUtils.generateIdMapping(this.featureVariableUsageInstances); } - public @Nonnull String getId() { + @Nonnull + public String getId() { return id; } - public @Nonnull String getKey() { + @Nonnull + public String getKey() { return key; } - public @Nonnull Boolean getFeatureEnabled() { + @Nonnull + public Boolean getFeatureEnabled() { return featureEnabled; } - public @Nullable List<LiveVariableUsageInstance> getLiveVariableUsageInstances() { - return liveVariableUsageInstances; + @Nullable + public List<FeatureVariableUsageInstance> getFeatureVariableUsageInstances() { + return featureVariableUsageInstances; } - public Map<String, LiveVariableUsageInstance> getVariableIdToLiveVariableUsageInstanceMap() { - return variableIdToLiveVariableUsageInstanceMap; + public Map<String, FeatureVariableUsageInstance> getVariableIdToFeatureVariableUsageInstanceMap() { + return variableIdToFeatureVariableUsageInstanceMap; } public boolean is(String otherKey) { @@ -98,8 +101,8 @@ public boolean is(String otherKey) { @Override public String toString() { return "Variation{" + - "id='" + id + '\'' + - ", key='" + key + '\'' + - '}'; + "id='" + id + '\'' + + ", key='" + key + '\'' + + '}'; } -} \ No newline at end of file +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java index 2e485b281..7865eb2d2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AndCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,33 +16,77 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; + import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; +import java.util.StringJoiner; /** * Represents an 'And' conditions condition operation. */ -@Immutable -public class AndCondition implements Condition { +public class AndCondition<T> implements Condition<T> { private final List<Condition> conditions; + private static final String OPERAND = "AND"; + public AndCondition(@Nonnull List<Condition> conditions) { this.conditions = conditions; } + @Override public List<Condition> getConditions() { return conditions; } - public boolean evaluate(Map<String, String> attributes) { + @Nullable + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + if (conditions == null) return null; + boolean foundNull = false; + // According to the matrix where: + // false and true is false + // false and null is false + // true and null is null. + // true and false is false + // true and true is true + // null and null is null for (Condition condition : conditions) { - if (!condition.evaluate(attributes)) + Boolean conditionEval = condition.evaluate(config, user); + if (conditionEval == null) { + foundNull = true; + } else if (!conditionEval) { // false with nulls or trues is false. return false; + } + // true and nulls with no false will be null. + } + + if (foundNull) { // true and null or all null returns null + return null; } - return true; + return true; // otherwise, return true + } + + @Override + public String getOperandOrId() { + return OPERAND; + } + + @Override + public String toJson() { + StringBuilder s = new StringBuilder(); + s.append("[\"and\", "); + for (int i = 0; i < conditions.size(); i++) { + s.append(conditions.get(i).toJson()); + if (i < conditions.size() - 1) + s.append(", "); + } + s.append("]"); + return s.toString(); } @Override @@ -64,7 +108,7 @@ public boolean equals(Object other) { if (!(other instanceof AndCondition)) return false; - AndCondition otherAndCondition = (AndCondition)other; + AndCondition otherAndCondition = (AndCondition) other; return conditions.equals(otherAndCondition.getConditions()); } @@ -74,4 +118,3 @@ public int hashCode() { return conditions.hashCode(); } } - diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java new file mode 100644 index 000000000..2a1be3880 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AttributeType.java @@ -0,0 +1,33 @@ +/** + * + * Copyright 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +public enum AttributeType { + CUSTOM_ATTRIBUTE("custom_attribute"), + THIRD_PARTY_DIMENSION("third_party_dimension"); + + private final String key; + + AttributeType(String key) { + this.key = key; + } + + @Override + public String toString() { + return key; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java index 40bbbbe28..bfc1be85d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Audience.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,9 @@ import com.optimizely.ab.config.IdKeyMapped; import javax.annotation.concurrent.Immutable; +import java.util.HashSet; +import java.util.List; +import java.util.Set; /** * Represents the Optimizely Audience configuration. @@ -34,7 +37,7 @@ public class Audience implements IdKeyMapped { private final String id; private final String name; - private final Condition conditions; + private final Condition<UserAttribute> conditions; @JsonCreator public Audience(@JsonProperty("id") String id, @@ -64,9 +67,32 @@ public Condition getConditions() { @Override public String toString() { return "Audience{" + - "id='" + id + '\'' + - ", name='" + name + '\'' + - ", conditions=" + conditions + - '}'; + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", conditions=" + conditions + + '}'; + } + + public Set<String> getSegments() { + return getSegments(conditions); + } + + private static Set<String> getSegments(Condition conditions) { + List<Condition> nestedConditions = conditions.getConditions(); + Set<String> segments = new HashSet<>(); + if (nestedConditions != null) { + for (Condition nestedCondition : nestedConditions) { + Set<String> nestedSegments = getSegments(nestedCondition); + segments.addAll(nestedSegments); + } + } else { + if (conditions.getClass() == UserAttribute.class) { + UserAttribute userAttributeCondition = (UserAttribute) conditions; + if (UserAttribute.QUALIFIED.equals(userAttributeCondition.getMatch())) { + segments.add((String)userAttributeCondition.getValue()); + } + } + } + return segments; } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java new file mode 100644 index 000000000..9fc248522 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/AudienceIdCondition.java @@ -0,0 +1,119 @@ +/** + * + * Copyright 2018-2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.optimizely.ab.config.audience; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.internal.InvalidAudienceCondition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The AudienceIdCondition is a holder for the audience id in + * {@link com.optimizely.ab.config.Experiment#audienceConditions auienceConditions}. + * If the audienceId is not resolved at evaluation time, the + * condition will fail. AudienceIdConditions are resolved when the ProjectConfig is passed into evaluate. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AudienceIdCondition<T> implements Condition<T> { + private Audience audience; + final private String audienceId; + + final private static Logger logger = LoggerFactory.getLogger(AudienceIdCondition.class); + + /** + * Constructor used in json parsing to store the audienceId parsed from Experiment.audienceConditions. + * + * @param audienceId The audience id + */ + @JsonCreator + public AudienceIdCondition(String audienceId) { + this.audienceId = audienceId; + } + + public Audience getAudience() { + return audience; + } + + public void setAudience(Audience audience) { + this.audience = audience; + } + + public String getAudienceId() { + return audienceId; + } + + @Override + public String getOperandOrId() { + return audienceId; + } + + @Nullable + @Override + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + if (config != null) { + audience = config.getAudienceIdMapping().get(audienceId); + } + if (audience == null) { + logger.error("Audience {} could not be found.", audienceId); + return null; + } + logger.debug("Starting to evaluate audience \"{}\" with conditions: {}.", audience.getId(), audience.getConditions()); + Boolean result = audience.getConditions().evaluate(config, user); + logger.debug("Audience \"{}\" evaluated to {}.", audience.getId(), result); + return result; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AudienceIdCondition condition = (AudienceIdCondition) o; + return ((audience == null) ? (null == condition.audience) : + (audience.getId().equals(condition.audience != null ? condition.audience.getId() : null))) && + ((audienceId == null) ? (null == condition.audienceId) : + (audienceId.equals(condition.audienceId))); + } + + @Override + public List<Condition> getConditions() { + return null; + } + + @Override + public int hashCode() { + + return Objects.hash(audience, audienceId); + } + + @Override + public String toString() { + return audienceId; + } + + @Override + public String toJson() { return null; } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java index 3a47453e4..ab3fe99af 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/Condition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2018, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,12 +16,24 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; + +import javax.annotation.Nullable; +import java.util.List; import java.util.Map; /** * Interface implemented by all conditions condition objects to aid in condition evaluation. */ -public interface Condition { +public interface Condition<T> { + + @Nullable + Boolean evaluate(ProjectConfig config, OptimizelyUserContext user); + + String toJson(); + + String getOperandOrId(); - boolean evaluate(Map<String, String> attributes); -} \ No newline at end of file + List<Condition> getConditions(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java new file mode 100644 index 000000000..1f7a87b12 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/EmptyCondition.java @@ -0,0 +1,38 @@ +/** + * Copyright 2019, 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; + +import javax.annotation.Nullable; +import java.util.Map; + +public class EmptyCondition<T> extends LeafCondition<T> { + @Nullable + @Override + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + return true; + } + + @Override + public String toJson() { return null; } + + @Override + public String getOperandOrId() { + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java new file mode 100644 index 000000000..a61c1650e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/LeafCondition.java @@ -0,0 +1,26 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import java.util.List; + +public abstract class LeafCondition<T> implements Condition<T> { + + @Override + public List<Condition> getConditions() { + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java index 7e269c6f3..45dec6637 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NotCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,18 +16,24 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; + +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import javax.annotation.Nonnull; +import java.util.Arrays; +import java.util.List; -import java.util.Map; /** * Represents a 'Not' conditions condition operation. */ @Immutable -public class NotCondition implements Condition { +public class NotCondition<T> implements Condition<T> { private final Condition condition; + private static final String OPERAND = "NOT"; public NotCondition(@Nonnull Condition condition) { this.condition = condition; @@ -37,8 +43,29 @@ public Condition getCondition() { return condition; } - public boolean evaluate(Map<String, String> attributes) { - return !condition.evaluate(attributes); + @Override + public List<Condition> getConditions() { + return Arrays.asList(condition); + } + + @Nullable + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + Boolean conditionEval = condition == null ? null : condition.evaluate(config, user); + return (conditionEval == null ? null : !conditionEval); + } + + @Override + public String getOperandOrId() { + return OPERAND; + } + + @Override + public String toJson() { + StringBuilder s = new StringBuilder(); + s.append("[\"not\", "); + s.append(condition.toJson()); + s.append("]"); + return s.toString(); } @Override @@ -57,7 +84,7 @@ public boolean equals(Object other) { if (!(other instanceof NotCondition)) return false; - NotCondition otherNotCondition = (NotCondition)other; + NotCondition otherNotCondition = (NotCondition) other; return condition.equals(otherNotCondition.getCondition()); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java new file mode 100644 index 000000000..1e12b836e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/NullCondition.java @@ -0,0 +1,38 @@ +/** + * Copyright 2019, 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; + +import javax.annotation.Nullable; +import java.util.Map; + +public class NullCondition<T> extends LeafCondition<T> { + @Nullable + @Override + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + return null; + } + + @Override + public String toJson() { return null; } + + @Override + public String getOperandOrId() { + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java index 6e80a43e5..c0f3603eb 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/OrCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,35 +16,77 @@ */ package com.optimizely.ab.config.audience; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; + import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import java.util.List; import java.util.Map; +import java.util.StringJoiner; /** * Represents an 'Or' conditions condition operation. */ @Immutable -public class OrCondition implements Condition { +public class OrCondition<T> implements Condition<T> { private final List<Condition> conditions; + private static final String OPERAND = "OR"; public OrCondition(@Nonnull List<Condition> conditions) { this.conditions = conditions; } + @Override public List<Condition> getConditions() { return conditions; } - public boolean evaluate(Map<String, String> attributes) { + // According to the matrix: + // true returns true + // false or null is null + // false or false is false + // null or null is null + @Nullable + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + if (conditions == null) return null; + boolean foundNull = false; for (Condition condition : conditions) { - if (condition.evaluate(attributes)) + Boolean conditionEval = condition.evaluate(config, user); + if (conditionEval == null) { // true with falses and nulls is still true + foundNull = true; + } else if (conditionEval) { return true; + } + } + + // if found null and false return null. all false return false + if (foundNull) { + return null; } return false; } + @Override + public String getOperandOrId() { + return OPERAND; + } + + @Override + public String toJson() { + StringBuilder s = new StringBuilder(); + s.append("[\"or\", "); + for (int i = 0; i < conditions.size(); i++) { + s.append(conditions.get(i).toJson()); + if (i < conditions.size() - 1) + s.append(", "); + } + s.append("]"); + return s.toString(); + } + @Override public String toString() { StringBuilder s = new StringBuilder(); @@ -65,7 +107,7 @@ public boolean equals(Object other) { if (!(other instanceof OrCondition)) return false; - OrCondition otherOrCondition = (OrCondition)other; + OrCondition otherOrCondition = (OrCondition) other; return conditions.equals(otherOrCondition.getConditions()); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/TypedAudience.java b/core-api/src/main/java/com/optimizely/ab/config/audience/TypedAudience.java new file mode 100644 index 000000000..4180f5f35 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/TypedAudience.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2018, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class TypedAudience extends Audience { + @JsonCreator + public TypedAudience(@JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("conditions") Condition conditions) { + super(id, name, conditions); + } +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java index 3827291bd..c38b6c2a4 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/UserAttribute.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,24 +16,45 @@ */ package com.optimizely.ab.config.audience; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.match.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; -import java.util.Map; +import java.util.*; + +import static com.optimizely.ab.config.audience.AttributeType.CUSTOM_ATTRIBUTE; +import static com.optimizely.ab.config.audience.AttributeType.THIRD_PARTY_DIMENSION; /** * Represents a user attribute instance within an audience's conditions. */ @Immutable -public class UserAttribute implements Condition { +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserAttribute<T> extends LeafCondition<T> { + public static final String QUALIFIED = "qualified"; + private static final Logger logger = LoggerFactory.getLogger(UserAttribute.class); private final String name; private final String type; - private final String value; - - public UserAttribute(@Nonnull String name, @Nonnull String type, @Nullable String value) { + private final String match; + private final Object value; + private final static List ATTRIBUTE_TYPE = Arrays.asList(new String[]{CUSTOM_ATTRIBUTE.toString(), THIRD_PARTY_DIMENSION.toString()}); + @JsonCreator + public UserAttribute(@JsonProperty("name") @Nonnull String name, + @JsonProperty("type") @Nonnull String type, + @JsonProperty("match") @Nullable String match, + @JsonProperty("value") @Nullable Object value) { this.name = name; this.type = type; + this.match = match; this.value = value; } @@ -45,32 +66,110 @@ public String getType() { return type; } - public String getValue() { + public String getMatch() { + return match; + } + + public Object getValue() { return value; } - public boolean evaluate(Map<String, String> attributes) { - String userAttributeValue = attributes.get(name); + @Nullable + public Boolean evaluate(ProjectConfig config, OptimizelyUserContext user) { + Map<String,Object> attributes = user.getAttributes(); + // Valid for primitive types, but needs to change when a value is an object or an array + Object userAttributeValue = attributes.get(name); - if (value != null) { // if there is a value in the condition - // check user attribute value is equal - return value.equals(userAttributeValue); + if (!isValidType(type)) { + logger.warn("Audience condition \"{}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.", this); + return null; // unknown type } - else if (userAttributeValue != null) { // if the datafile value is null but user has a value for this attribute - // return false since null != nonnull - return false; + // check user attribute value is equal + try { + // Handle qualified segments + if (QUALIFIED.equals(match)) { + if (value instanceof String) { + return user.isQualifiedFor(value.toString()); + } + throw new UnknownValueTypeException(); + } + // Handle other conditions + Match matcher = MatchRegistry.getMatch(match); + Boolean result = matcher.eval(value, userAttributeValue); + if (result == null) { + throw new UnknownValueTypeException(); + } + + return result; + } catch(UnknownValueTypeException e) { + if (!attributes.containsKey(name)) { + //Missing attribute value + logger.debug("Audience condition \"{}\" evaluated to UNKNOWN because no value was passed for user attribute \"{}\"", this, name); + } else { + //if attribute value is not valid + if (userAttributeValue != null) { + logger.warn( + "Audience condition \"{}\" evaluated to UNKNOWN because a value of type \"{}\" was passed for user attribute \"{}\"", + this, + userAttributeValue.getClass().getCanonicalName(), + name); + } else { + logger.debug( + "Audience condition \"{}\" evaluated to UNKNOWN because a null value was passed for user attribute \"{}\"", + this, + name); + } + } + } catch (UnknownMatchTypeException | UnexpectedValueTypeException e) { + logger.warn("Audience condition \"{}\" " + e.getMessage(), this); + } catch (NullPointerException e) { + logger.error("attribute or value null for match {}", match != null ? match : "legacy condition", e); } - else { // both are null + return null; + } + + private boolean isValidType(String type) { + if (ATTRIBUTE_TYPE.contains(type)) { return true; } + return false; + } + + @Override + public String getOperandOrId() { + return null; + } + + public String getValueStr() { + final String valueStr; + if (value == null) { + valueStr = "null"; + } else if (value instanceof String) { + valueStr = String.format("%s", value); + } else { + valueStr = value.toString(); + } + return valueStr; + } + + @Override + public String toJson() { + StringBuilder attributes = new StringBuilder(); + if (name != null) attributes.append("{\"name\":\"" + name + "\""); + if (type != null) attributes.append(", \"type\":\"" + type + "\""); + if (match != null) attributes.append(", \"match\":\"" + match + "\""); + attributes.append(", \"value\":" + ((value instanceof String) ? ("\"" + getValueStr() + "\"") : getValueStr()) + "}"); + + return attributes.toString(); } @Override public String toString() { return "{name='" + name + "\'" + - ", type='" + type + "\'" + - ", value='" + value + "\'" + - "}"; + ", type='" + type + "\'" + + ", match='" + match + "\'" + + ", value=" + ((value instanceof String) ? ("'" + getValueStr() + "'") : getValueStr()) + + "}"; } @Override @@ -82,6 +181,7 @@ public boolean equals(Object o) { if (!name.equals(that.name)) return false; if (!type.equals(that.type)) return false; + if (match != null ? !match.equals(that.match) : that.match != null) return false; return value != null ? value.equals(that.value) : that.value == null; } @@ -89,6 +189,7 @@ public boolean equals(Object o) { public int hashCode() { int result = name.hashCode(); result = 31 * result + type.hashCode(); + result = 31 * result + (match != null ? match.hashCode() : 0); result = 31 * result + (value != null ? value.hashCode() : 0); return result; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java new file mode 100644 index 000000000..c3c970541 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/DefaultMatchForLegacyAttributes.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2018-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * This is a temporary class. It mimics the current behaviour for + * legacy custom attributes. This will be dropped for ExactMatch and the unit tests need to be fixed. + */ +class DefaultMatchForLegacyAttributes implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (!(conditionValue instanceof String)) { + throw new UnexpectedValueTypeException(); + } + if (attributeValue == null) { + return false; + } + return conditionValue.toString().equals(attributeValue.toString()); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java new file mode 100644 index 000000000..d39d00c83 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExactMatch.java @@ -0,0 +1,50 @@ +/** + * + * Copyright 2018-2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +/** + * ExactMatch supports matching Numbers, Strings and Booleans. Numbers are first converted to doubles + * before the comparison is evaluated. See {@link NumberComparator} Strings and Booleans are evaulated + * via the Object equals method. + */ +class ExactMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; + + if (isValidNumber(attributeValue)) { + if (isValidNumber(conditionValue)) { + return NumberComparator.compareUnsafe(attributeValue, conditionValue) == 0; + } + return null; + } + + if (!(conditionValue instanceof String || conditionValue instanceof Boolean)) { + throw new UnexpectedValueTypeException(); + } + + if (attributeValue.getClass() != conditionValue.getClass()) { + return null; + } + + return conditionValue.equals(attributeValue); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java new file mode 100644 index 000000000..38fb5a884 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/ExistsMatch.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2018-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * ExistsMatch checks that the attribute value is NOT null. + */ +class ExistsMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) { + return attributeValue != null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java new file mode 100644 index 000000000..e66012cba --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GEMatch.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * GEMatch performs a "greater than or equal to" number comparison via {@link NumberComparator}. + */ +class GEMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) >= 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java new file mode 100644 index 000000000..ba6689c9e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/GTMatch.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2018-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * GTMatch performs a "greater than" number comparison via {@link NumberComparator}. + */ +class GTMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) > 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java new file mode 100644 index 000000000..b222fa022 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LEMatch.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * GEMatch performs a "less than or equal to" number comparison via {@link NumberComparator}. + */ +class LEMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) <= 0; + } +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java new file mode 100644 index 000000000..3000aedff --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/LTMatch.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2018-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * GTMatch performs a "less than" number comparison via {@link NumberComparator}. + */ +class LTMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnknownValueTypeException { + return NumberComparator.compare(attributeValue, conditionValue) < 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java new file mode 100644 index 000000000..7bef74e6c --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/Match.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2018-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +public interface Match { + @Nullable + Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException, UnknownValueTypeException; +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java new file mode 100644 index 000000000..7563d2681 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/MatchRegistry.java @@ -0,0 +1,82 @@ +/** + * + * Copyright 2020-2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MatchRegistry maps a string match "type" to a match implementation. + * All supported Match implementations must be registed with this registry. + * Third-party {@link Match} implementations may also be registered to provide + * additional functionality. + */ +public class MatchRegistry { + + private static final Map<String, Match> registry = new ConcurrentHashMap<>(); + public static final String EXACT = "exact"; + public static final String EXISTS = "exists"; + public static final String GREATER_THAN = "gt"; + public static final String GREATER_THAN_EQ = "ge"; + public static final String LEGACY = "legacy"; + public static final String LESS_THAN = "lt"; + public static final String LESS_THAN_EQ = "le"; + public static final String SEMVER_EQ = "semver_eq"; + public static final String SEMVER_GE = "semver_ge"; + public static final String SEMVER_GT = "semver_gt"; + public static final String SEMVER_LE = "semver_le"; + public static final String SEMVER_LT = "semver_lt"; + public static final String SUBSTRING = "substring"; + + static { + register(EXACT, new ExactMatch()); + register(EXISTS, new ExistsMatch()); + register(GREATER_THAN, new GTMatch()); + register(GREATER_THAN_EQ, new GEMatch()); + register(LEGACY, new DefaultMatchForLegacyAttributes()); + register(LESS_THAN, new LTMatch()); + register(LESS_THAN_EQ, new LEMatch()); + register(SEMVER_EQ, new SemanticVersionEqualsMatch()); + register(SEMVER_GE, new SemanticVersionGEMatch()); + register(SEMVER_GT, new SemanticVersionGTMatch()); + register(SEMVER_LE, new SemanticVersionLEMatch()); + register(SEMVER_LT, new SemanticVersionLTMatch()); + register(SUBSTRING, new SubstringMatch()); + } + + // TODO rename Match to Matcher + public static Match getMatch(String name) throws UnknownMatchTypeException { + Match match = registry.get(name == null ? LEGACY : name); + if (match == null) { + throw new UnknownMatchTypeException(); + } + + return match; + } + + /** + * register registers a Match implementation with it's name. + * NOTE: This does not check for existence so default implementations can + * be overridden. + * @param name The match name + * @param match The match implementation + */ + public static void register(String name, Match match) { + registry.put(name, match); + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java new file mode 100644 index 000000000..49ce94eab --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/NumberComparator.java @@ -0,0 +1,41 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +/** + * NumberComparator performs a numeric comparison. The input values are assumed to be numbers else + * compare will throw an {@link UnknownValueTypeException}. + */ +public class NumberComparator { + public static int compare(Object o1, Object o2) throws UnknownValueTypeException { + if (!isValidNumber(o1) || !isValidNumber(o2)) { + throw new UnknownValueTypeException(); + } + + return compareUnsafe(o1, o2); + } + + /** + * compareUnsafe is provided to avoid checking the input values are numbers. It's assumed that the inputs + * are known to be Numbers. + */ + static int compareUnsafe(Object o1, Object o2) { + return Double.compare(((Number) o1).doubleValue(), ((Number) o2).doubleValue()); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java new file mode 100644 index 000000000..22fd56c4a --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersion.java @@ -0,0 +1,205 @@ +/** + * + * Copyright 2020-2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.optimizely.ab.internal.AttributesUtil.parseNumeric; +import static com.optimizely.ab.internal.AttributesUtil.stringIsNullOrEmpty; + +/** + * SemanticVersion implements the specification for the purpose of comparing two Versions. + */ +public final class SemanticVersion { + + private static final Logger logger = LoggerFactory.getLogger(SemanticVersion.class); + private static final String BUILD_SEPERATOR = "\\+"; + private static final String PRE_RELEASE_SEPERATOR = "-"; + + private final String version; + + public SemanticVersion(String version) { + this.version = version; + } + + /** + * compare takes object inputs and coerces them into SemanticVersion objects before performing the comparison. + * If the input values cannot be coerced then an {@link UnexpectedValueTypeException} is thrown. + * + * @param o1 The object to be compared + * @param o2 The object to be compared to + * @return The compare result + * @throws UnexpectedValueTypeException when an error is detected while comparing + */ + public static int compare(Object o1, Object o2) throws UnexpectedValueTypeException { + if (o1 instanceof String && o2 instanceof String) { + SemanticVersion v1 = new SemanticVersion((String) o1); + SemanticVersion v2 = new SemanticVersion((String) o2); + try { + return v1.compare(v2); + } catch (Exception e) { + logger.warn("Error comparing semantic versions", e); + } + } + + throw new UnexpectedValueTypeException(); + } + + public int compare(SemanticVersion targetedVersion) throws Exception { + + if (targetedVersion == null || stringIsNullOrEmpty(targetedVersion.version)) { + return 0; + } + + String[] targetedVersionParts = targetedVersion.splitSemanticVersion(); + String[] userVersionParts = splitSemanticVersion(); + + for (int index = 0; index < targetedVersionParts.length; index++) { + + if (userVersionParts.length <= index) { + return targetedVersion.isPreRelease() ? 1 : -1; + } + Integer targetVersionPartInt = parseNumeric(targetedVersionParts[index]); + Integer userVersionPartInt = parseNumeric(userVersionParts[index]); + + if (userVersionPartInt == null) { + // Compare strings + int result = userVersionParts[index].compareTo(targetedVersionParts[index]); + if (result < 0) { + return targetedVersion.isPreRelease() && !isPreRelease() ? 1 : -1; + } else if (result > 0) { + return !targetedVersion.isPreRelease() && isPreRelease() ? -1 : 1; + } + } else if (targetVersionPartInt != null) { + if (!userVersionPartInt.equals(targetVersionPartInt)) { + return userVersionPartInt < targetVersionPartInt ? -1 : 1; + } + } else { + return -1; + } + } + + if (!targetedVersion.isPreRelease() && + isPreRelease()) { + return -1; + } + + return 0; + } + + public boolean isPreRelease() { + int buildIndex = version.indexOf("+"); + int preReleaseIndex = version.indexOf("-"); + if (buildIndex < 0) { + return preReleaseIndex > 0; + } else if(preReleaseIndex < 0) { + return false; + } + return preReleaseIndex < buildIndex; + } + + public boolean isBuild() { + int buildIndex = version.indexOf("+"); + int preReleaseIndex = version.indexOf("-"); + if (preReleaseIndex < 0) { + return buildIndex > 0; + } else if(buildIndex < 0) { + return false; + } + return buildIndex < preReleaseIndex; + } + + private int dotCount(String prefixVersion) { + char[] vCharArray = prefixVersion.toCharArray(); + int count = 0; + for (char c : vCharArray) { + if (c == '.') { + count++; + } + } + return count; + } + + private boolean isValidBuildMetadata() { + char[] vCharArray = version.toCharArray(); + int count = 0; + for (char c : vCharArray) { + if (c == '+') { + count++; + } + } + return count > 1; + } + + public String[] splitSemanticVersion() throws Exception { + List<String> versionParts = new ArrayList<>(); + String versionPrefix = ""; + // pre-release or build. + String versionSuffix = ""; + // for example: beta.2.1 + String[] preVersionParts; + + // Contains white spaces + if (version.contains(" ") || isValidBuildMetadata()) { // log and throw error + throw new Exception("Invalid Semantic Version."); + } + + if (isBuild() || isPreRelease()) { + String[] partialVersionParts = version.split(isPreRelease() ? + PRE_RELEASE_SEPERATOR : BUILD_SEPERATOR, 2); + + if (partialVersionParts.length <= 1) { + // throw error + throw new Exception("Invalid Semantic Version."); + } + // major.minor.patch + versionPrefix = partialVersionParts[0]; + + versionSuffix = partialVersionParts[1]; + + } else { + versionPrefix = version; + } + + preVersionParts = versionPrefix.split("\\."); + + if (preVersionParts.length > 3 || + preVersionParts.length == 0 || + dotCount(versionPrefix) >= preVersionParts.length) { + // Throw error as pre version should only contain major.minor.patch version + throw new Exception("Invalid Semantic Version."); + } + + for (String preVersionPart : preVersionParts) { + if (parseNumeric(preVersionPart) == null) { + throw new Exception("Invalid Semantic Version."); + } + } + + Collections.addAll(versionParts, preVersionParts); + if (!stringIsNullOrEmpty(versionSuffix)) { + versionParts.add(versionSuffix); + } + + return versionParts.toArray(new String[0]); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java new file mode 100644 index 000000000..58ecb4202 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionEqualsMatch.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * SemanticVersionEqualsMatch performs a equality comparison via {@link SemanticVersion#compare(Object, Object)}. + */ +class SemanticVersionEqualsMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. + return SemanticVersion.compare(attributeValue, conditionValue) == 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java new file mode 100644 index 000000000..bad0b1e4f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGEMatch.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * SemanticVersionGEMatch performs a "greater than or equal to" comparison + * via {@link SemanticVersion#compare(Object, Object)}. + */ +class SemanticVersionGEMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. + return SemanticVersion.compare(attributeValue, conditionValue) >= 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java new file mode 100644 index 000000000..7d403f693 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionGTMatch.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * SemanticVersionGTMatch performs a "greater than" comparison via {@link SemanticVersion#compare(Object, Object)}. + */ +class SemanticVersionGTMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. + return SemanticVersion.compare(attributeValue, conditionValue) > 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java new file mode 100644 index 000000000..b3aed672e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLEMatch.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * SemanticVersionLEMatch performs a "less than or equal to" comparison + * via {@link SemanticVersion#compare(Object, Object)}. + */ +class SemanticVersionLEMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. + return SemanticVersion.compare(attributeValue, conditionValue) <= 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java new file mode 100644 index 000000000..d65251f54 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SemanticVersionLTMatch.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * SemanticVersionLTMatch performs a "less than" comparison via {@link SemanticVersion#compare(Object, Object)}. + */ +class SemanticVersionLTMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (attributeValue == null) return null; // stay silent (no WARNING) when attribute value is missing or empty. + return SemanticVersion.compare(attributeValue, conditionValue) < 0; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java new file mode 100644 index 000000000..5a573e495 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/SubstringMatch.java @@ -0,0 +1,43 @@ +/** + * + * Copyright 2018-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import javax.annotation.Nullable; + +/** + * SubstringMatch checks if the attribute value contains the condition value. + * This assumes both the condition and attribute values are provided as Strings. + */ +class SubstringMatch implements Match { + @Nullable + public Boolean eval(Object conditionValue, Object attributeValue) throws UnexpectedValueTypeException { + if (!(conditionValue instanceof String)) { + throw new UnexpectedValueTypeException(); + } + + if (!(attributeValue instanceof String)) { + return null; + } + + try { + return attributeValue.toString().contains(conditionValue.toString()); + } catch (Exception e) { + return null; + } + } +} + diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java new file mode 100644 index 000000000..39cde7a21 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnexpectedValueTypeException.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2019-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.config.audience.match; + +/** + * UnexpectedValueTypeException is thrown when the condition value found in the datafile is + * not one of an expected type for this version of the SDK. + */ +public class UnexpectedValueTypeException extends Exception { + private static String message = "has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."; + + public UnexpectedValueTypeException() { + super(message); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java new file mode 100644 index 000000000..1f371586b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownMatchTypeException.java @@ -0,0 +1,29 @@ +/** + * + * Copyright 2019-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.config.audience.match; + +/** + * UnknownMatchTypeException is thrown when the specified match type cannot be mapped via the MatchRegistry. + */ +public class UnknownMatchTypeException extends Exception { + private static String message = "uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK."; + + public UnknownMatchTypeException() { + super(message); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownValueTypeException.java b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownValueTypeException.java new file mode 100644 index 000000000..6df4ef1e1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/audience/match/UnknownValueTypeException.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.config.audience.match; + +/** + * UnknownValueTypeException is thrown when the passed in value for a user attribute does + * not map to a known allowable type. + */ +public class UnknownValueTypeException extends Exception { + private static String message = "has an unsupported attribute value."; + + public UnknownValueTypeException() { + super(message); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java index 8158aeb4b..7108a7052 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,25 +24,20 @@ import com.google.gson.JsonParser; import com.google.gson.JsonParseException; -import com.google.gson.internal.LinkedTreeMap; - -import com.optimizely.ab.config.audience.AndCondition; import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.NotCondition; -import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; import java.lang.reflect.Type; -import java.util.ArrayList; import java.util.List; public class AudienceGsonDeserializer implements JsonDeserializer<Audience> { @Override public Audience deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { + throws JsonParseException { Gson gson = new Gson(); JsonParser parser = new JsonParser(); JsonObject jsonObject = json.getAsJsonObject(); @@ -50,38 +45,20 @@ public Audience deserialize(JsonElement json, Type typeOfT, JsonDeserializationC String id = jsonObject.get("id").getAsString(); String name = jsonObject.get("name").getAsString(); - JsonElement conditionsElement = parser.parse(jsonObject.get("conditions").getAsString()); - List<Object> rawObjectList = gson.fromJson(conditionsElement, List.class); - Condition conditions = parseConditions(rawObjectList); - - return new Audience(id, name, conditions); - } - - private Condition parseConditions(List<Object> rawObjectList) { - List<Condition> conditions = new ArrayList<Condition>(); - String operand = (String)rawObjectList.get(0); - - for (int i = 1; i < rawObjectList.size(); i++) { - Object obj = rawObjectList.get(i); - if (obj instanceof List) { - List<Object> objectList = (List<Object>)rawObjectList.get(i); - conditions.add(parseConditions(objectList)); - } else { - LinkedTreeMap<String, String> conditionMap = (LinkedTreeMap<String, String>)rawObjectList.get(i); - conditions.add(new UserAttribute(conditionMap.get("name"), conditionMap.get("type"), - conditionMap.get("value"))); - } + JsonElement conditionsElement = jsonObject.get("conditions"); + if (!typeOfT.toString().contains("TypedAudience")) { + conditionsElement = parser.parse(jsonObject.get("conditions").getAsString()); } - - Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); + Condition conditions = null; + if (conditionsElement.isJsonArray()) { + List<Object> rawObjectList = gson.fromJson(conditionsElement, List.class); + conditions = ConditionUtils.parseConditions(UserAttribute.class, rawObjectList); + } else if (conditionsElement.isJsonObject()) { + Object object = gson.fromJson(conditionsElement, Object.class); + conditions = ConditionUtils.parseConditions(UserAttribute.class, object); } - return condition; + return new Audience(id, name, conditions); } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java index 1d0efc3e7..4dbe95bbb 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/AudienceJacksonDeserializer.java @@ -17,64 +17,40 @@ package com.optimizely.ab.config.parser; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; - -import com.optimizely.ab.config.audience.Audience; -import com.optimizely.ab.config.audience.AndCondition; -import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.UserAttribute; -import com.optimizely.ab.config.audience.NotCondition; -import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.config.audience.*; import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; public class AudienceJacksonDeserializer extends JsonDeserializer<Audience> { + private ObjectMapper objectMapper; + + public AudienceJacksonDeserializer() { + this(new ObjectMapper()); + } + + AudienceJacksonDeserializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } @Override public Audience deserialize(JsonParser parser, DeserializationContext context) throws IOException { - ObjectMapper mapper = new ObjectMapper(); - JsonNode node = parser.getCodec().readTree(parser); + ObjectCodec codec = parser.getCodec(); + JsonNode node = codec.readTree(parser); String id = node.get("id").textValue(); String name = node.get("name").textValue(); - List<Object> rawObjectList = (List<Object>)mapper.readValue(node.get("conditions").textValue(), List.class); - Condition conditions = parseConditions(rawObjectList); - - return new Audience(id, name, conditions); - } - - private Condition parseConditions(List<Object> rawObjectList) { - List<Condition> conditions = new ArrayList<Condition>(); - String operand = (String)rawObjectList.get(0); - for (int i = 1; i < rawObjectList.size(); i++) { - Object obj = rawObjectList.get(i); - if (obj instanceof List) { - List<Object> objectList = (List<Object>)rawObjectList.get(i); - conditions.add(parseConditions(objectList)); - } else { - HashMap<String, String> conditionMap = (HashMap<String, String>)rawObjectList.get(i); - conditions.add(new UserAttribute(conditionMap.get("name"), conditionMap.get("type"), - conditionMap.get("value"))); - } - } + JsonNode conditionsJson = node.get("conditions"); + conditionsJson = objectMapper.readTree(conditionsJson.textValue()); - Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); - } + Condition conditions = ConditionJacksonDeserializer.<UserAttribute>parseCondition(UserAttribute.class, objectMapper, conditionsJson); - return condition; + return new Audience(id, name, conditions); } -} +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java new file mode 100644 index 000000000..f443d9d07 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ConditionJacksonDeserializer.java @@ -0,0 +1,135 @@ +/** + * + * Copyright 2018-2019, 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.NotCondition; +import com.optimizely.ab.config.audience.EmptyCondition; +import com.optimizely.ab.config.audience.NullCondition; +import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; +import com.optimizely.ab.internal.InvalidAudienceCondition; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + + +public class ConditionJacksonDeserializer extends JsonDeserializer<Condition> { + private ObjectMapper objectMapper; + + public ConditionJacksonDeserializer() { + this(new ObjectMapper()); + } + + ConditionJacksonDeserializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Condition deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode node = parser.getCodec().readTree(parser); + Condition conditions = ConditionJacksonDeserializer.<AudienceIdCondition>parseCondition(AudienceIdCondition.class, objectMapper, node); + + return conditions; + } + + private static String operand(JsonNode opNode) { + if (opNode != null && opNode.isTextual()) { + String op = opNode.asText(); + switch (op) { + case "and": + case "or": + case "not": + return op; + default: + break; + } + } + return null; + } + + protected static <T> Condition parseCondition(Class<T> clazz, ObjectMapper objectMapper, JsonNode conditionNode) + throws JsonProcessingException, InvalidAudienceCondition { + + if (conditionNode.isArray()) { + return ConditionJacksonDeserializer.<T>parseConditions(clazz, objectMapper, conditionNode); + } else if (conditionNode.isTextual()) { + if (clazz != AudienceIdCondition.class) { + throw new InvalidAudienceCondition(String.format("Expected AudienceIdCondition got %s", clazz.getCanonicalName())); + + } + return objectMapper.treeToValue(conditionNode, AudienceIdCondition.class); + } else if (conditionNode.isObject()) { + if (clazz != UserAttribute.class) { + throw new InvalidAudienceCondition(String.format("Expected UserAttributes got %s", clazz.getCanonicalName())); + + } + return objectMapper.treeToValue(conditionNode, UserAttribute.class); + } + + return null; + } + + protected static <T> Condition parseConditions(Class<T> clazz, ObjectMapper objectMapper, JsonNode conditionNode) + throws JsonProcessingException, InvalidAudienceCondition { + + if (conditionNode.isArray() && conditionNode.size() == 0) { + return new EmptyCondition(); + } + + List<Condition> conditions = new ArrayList<>(); + int startingParsingIndex = 0; + JsonNode opNode = conditionNode.get(0); + String operand = operand(opNode); + if (operand == null) { + operand = "or"; + } else { // the operand is valid so move to the next node. + startingParsingIndex = 1; + } + + for (int i = startingParsingIndex; i < conditionNode.size(); i++) { + JsonNode subNode = conditionNode.get(i); + conditions.add(ConditionJacksonDeserializer.<T>parseCondition(clazz, objectMapper, subNode)); + } + + Condition condition; + switch (operand) { + case "and": + condition = new AndCondition(conditions); + break; + case "not": + condition = new NotCondition(conditions.isEmpty() ? new NullCondition() : conditions.get(0)); + break; + default: + condition = new OrCondition(conditions); + break; + } + + return condition; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java index eb24b68f3..966478cff 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,9 +33,19 @@ public interface ConfigParser { /** - * @param json the json to parse - * @return generates a {@code ProjectConfig} configuration from the provided json + * @param json The json to parse + * @return The {@code ProjectConfig} configuration from the provided json * @throws ConfigParseException when there's an issue parsing the provided project config */ ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException; + + /** + * OptimizelyJSON parsing + * + * @param src The OptimizelyJSON + * @return The serialized String + * @throws JsonParseException when parsing JSON fails + */ + String toJson(Object src) throws JsonParseException; + <T> T fromJson(String json, Class<T> clazz) throws JsonParseException; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java similarity index 51% rename from core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java rename to core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java index c556b9063..f349805fa 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,23 +22,18 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; -import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Rollout; +import com.optimizely.ab.config.*; import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.TypedAudience; import java.lang.reflect.Type; +import java.util.Collections; import java.util.List; /** - * GSON {@link ProjectConfig} deserializer to allow the constructor to be used. + * GSON {@link DatafileProjectConfig} deserializer to allow the constructor to be used. */ -public class ProjectConfigGsonDeserializer implements JsonDeserializer<ProjectConfig> { +public class DatafileGsonDeserializer implements JsonDeserializer<ProjectConfig> { @Override public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) @@ -52,11 +47,18 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa int datafileVersion = Integer.parseInt(version); // generic list type tokens - Type groupsType = new TypeToken<List<Group>>() {}.getType(); - Type experimentsType = new TypeToken<List<Experiment>>() {}.getType(); - Type attributesType = new TypeToken<List<Attribute>>() {}.getType(); - Type eventsType = new TypeToken<List<EventType>>() {}.getType(); - Type audienceType = new TypeToken<List<Audience>>() {}.getType(); + Type groupsType = new TypeToken<List<Group>>() { + }.getType(); + Type experimentsType = new TypeToken<List<Experiment>>() { + }.getType(); + Type attributesType = new TypeToken<List<Attribute>>() { + }.getType(); + Type eventsType = new TypeToken<List<EventType>>() { + }.getType(); + Type audienceType = new TypeToken<List<Audience>>() { + }.getType(); + Type typedAudienceType = new TypeToken<List<TypedAudience>>() { + }.getType(); List<Group> groups = context.deserialize(jsonObject.get("groups").getAsJsonArray(), groupsType); List<Experiment> experiments = @@ -67,46 +69,68 @@ public ProjectConfig deserialize(JsonElement json, Type typeOfT, JsonDeserializa List<EventType> events = context.deserialize(jsonObject.get("events").getAsJsonArray(), eventsType); - List<Audience> audiences = - context.deserialize(jsonObject.get("audiences").getAsJsonArray(), audienceType); + List<Audience> audiences = Collections.emptyList(); + if (jsonObject.has("audiences")) { + audiences = context.deserialize(jsonObject.get("audiences").getAsJsonArray(), audienceType); + } + List<Audience> typedAudiences = null; + if (jsonObject.has("typedAudiences")) { + typedAudiences = context.deserialize(jsonObject.get("typedAudiences").getAsJsonArray(), typedAudienceType); + } boolean anonymizeIP = false; - // live variables should be null if using V2 - List<LiveVariable> liveVariables = null; - if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { - Type liveVariablesType = new TypeToken<List<LiveVariable>>() {}.getType(); - liveVariables = context.deserialize(jsonObject.getAsJsonArray("variables"), liveVariablesType); - + if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V3.toString())) { anonymizeIP = jsonObject.get("anonymizeIP").getAsBoolean(); } + List<FeatureFlag> featureFlags = null; List<Rollout> rollouts = null; + List<Integration> integrations = null; Boolean botFiltering = null; - if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { - Type featureFlagsType = new TypeToken<List<FeatureFlag>>() {}.getType(); + String sdkKey = null; + String environmentKey = null; + boolean sendFlagDecisions = false; + if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { + Type featureFlagsType = new TypeToken<List<FeatureFlag>>() { + }.getType(); featureFlags = context.deserialize(jsonObject.getAsJsonArray("featureFlags"), featureFlagsType); - Type rolloutsType = new TypeToken<List<Rollout>>() {}.getType(); + Type rolloutsType = new TypeToken<List<Rollout>>() { + }.getType(); rollouts = context.deserialize(jsonObject.get("rollouts").getAsJsonArray(), rolloutsType); - if(jsonObject.has("botFiltering")) + if (jsonObject.has("integrations")) { + Type integrationsType = new TypeToken<List<Integration>>() {}.getType(); + integrations = context.deserialize(jsonObject.get("integrations").getAsJsonArray(), integrationsType); + } + if (jsonObject.has("sdkKey")) + sdkKey = jsonObject.get("sdkKey").getAsString(); + if (jsonObject.has("environmentKey")) + environmentKey = jsonObject.get("environmentKey").getAsString(); + if (jsonObject.has("botFiltering")) botFiltering = jsonObject.get("botFiltering").getAsBoolean(); + if (jsonObject.has("sendFlagDecisions")) + sendFlagDecisions = jsonObject.get("sendFlagDecisions").getAsBoolean(); } - return new ProjectConfig( - accountId, - anonymizeIP, - botFiltering, - projectId, - revision, - version, - attributes, - audiences, - events, - experiments, - featureFlags, - groups, - liveVariables, - rollouts + return new DatafileProjectConfig( + accountId, + anonymizeIP, + sendFlagDecisions, + botFiltering, + projectId, + revision, + sdkKey, + environmentKey, + version, + attributes, + audiences, + typedAudiences, + events, + experiments, + featureFlags, + groups, + rollouts, + integrations ); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java new file mode 100644 index 000000000..4ef104428 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DatafileJacksonDeserializer.java @@ -0,0 +1,113 @@ +/** + * + * Copyright 2016-2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.TypedAudience; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +class DatafileJacksonDeserializer extends JsonDeserializer<DatafileProjectConfig> { + @Override + public DatafileProjectConfig deserialize(JsonParser parser, DeserializationContext context) throws IOException { + ObjectCodec codec = parser.getCodec(); + JsonNode node = codec.readTree(parser); + + String accountId = node.get("accountId").textValue(); + String projectId = node.get("projectId").textValue(); + String revision = node.get("revision").textValue(); + String version = node.get("version").textValue(); + int datafileVersion = Integer.parseInt(version); + + List<Group> groups = JacksonHelpers.arrayNodeToList(node.get("groups"), Group.class, codec); + List<Experiment> experiments = JacksonHelpers.arrayNodeToList(node.get("experiments"), Experiment.class, codec); + List<Attribute> attributes = JacksonHelpers.arrayNodeToList(node.get("attributes"), Attribute.class, codec); + List<EventType> events = JacksonHelpers.arrayNodeToList(node.get("events"), EventType.class, codec); + + List<Audience> audiences = Collections.emptyList(); + if (node.has("audiences")) { + audiences = JacksonHelpers.arrayNodeToList(node.get("audiences"), Audience.class, codec); + } + + List<TypedAudience> typedAudiences = null; + if (node.has("typedAudiences")) { + typedAudiences = JacksonHelpers.arrayNodeToList(node.get("typedAudiences"), TypedAudience.class, codec); + } + + boolean anonymizeIP = false; + if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V3.toString())) { + anonymizeIP = node.get("anonymizeIP").asBoolean(); + } + + List<FeatureFlag> featureFlags = null; + List<Rollout> rollouts = null; + List<Integration> integrations = null; + String sdkKey = null; + String environmentKey = null; + Boolean botFiltering = null; + boolean sendFlagDecisions = false; + if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { + featureFlags = JacksonHelpers.arrayNodeToList(node.get("featureFlags"), FeatureFlag.class, codec); + rollouts = JacksonHelpers.arrayNodeToList(node.get("rollouts"), Rollout.class, codec); + if (node.hasNonNull("integrations")) { + integrations = JacksonHelpers.arrayNodeToList(node.get("integrations"), Integration.class, codec); + } + if (node.hasNonNull("sdkKey")) { + sdkKey = node.get("sdkKey").textValue(); + } + if (node.hasNonNull("environmentKey")) { + environmentKey = node.get("environmentKey").textValue(); + } + if (node.hasNonNull("botFiltering")) { + botFiltering = node.get("botFiltering").asBoolean(); + } + if (node.hasNonNull("sendFlagDecisions")) { + sendFlagDecisions = node.get("sendFlagDecisions").asBoolean(); + } + } + + return new DatafileProjectConfig( + accountId, + anonymizeIP, + sendFlagDecisions, + botFiltering, + projectId, + revision, + sdkKey, + environmentKey, + version, + attributes, + audiences, + (List<Audience>) (List<? extends Audience>) typedAudiences, + events, + experiments, + featureFlags, + groups, + rollouts, + integrations + ); + } + +} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/DefaultConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/DefaultConfigParser.java index 2d19dfb28..ff1748cb0 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/DefaultConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/DefaultConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,12 @@ */ package com.optimizely.ab.config.parser; +import com.optimizely.ab.internal.PropertyUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; +import java.util.function.Supplier; /** * Factory for generating {@link ConfigParser} instances, based on the json parser available on the classpath. @@ -28,49 +30,96 @@ public final class DefaultConfigParser { private static final Logger logger = LoggerFactory.getLogger(DefaultConfigParser.class); - private DefaultConfigParser() { } + private DefaultConfigParser() { + } public static ConfigParser getInstance() { return LazyHolder.INSTANCE; } //======== Helper methods ========// + @FunctionalInterface + public interface ParserSupplier { + + /** + * Gets a result. + * + * @return a result + */ + ConfigParser get(); + } + + public enum ConfigParserSupplier { + // WARNING THESE MUST REMAIN LAMBDAS!!! + // SWITCHING TO METHOD REFERENCES REQUIRES REQUIRES + // ALL PARSERS IN THE CLASSPATH. + GSON_CONFIG_PARSER("com.google.gson.Gson", () -> { return new GsonConfigParser(); }), + JACKSON_CONFIG_PARSER("com.fasterxml.jackson.databind.ObjectMapper", () -> { return new JacksonConfigParser(); }), + JSON_CONFIG_PARSER("org.json.JSONObject", () -> { return new JsonConfigParser(); }), + JSON_SIMPLE_CONFIG_PARSER("org.json.simple.JSONObject", () -> { return new JsonSimpleConfigParser(); }); + + private final String className; + private final ParserSupplier supplier; + + ConfigParserSupplier(String className, ParserSupplier supplier) { + this.className = className; + this.supplier = supplier; + } + + ConfigParser get() { + return supplier.get(); + } + private boolean isPresent() { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + } /** * Creates and returns a {@link ConfigParser} using a json parser available on the classpath. + * * @return the created config parser * @throws MissingJsonParserException if there are no supported json parsers available on the classpath */ - private static @Nonnull ConfigParser create() { - ConfigParser configParser; - - if (isPresent("com.fasterxml.jackson.databind.ObjectMapper")) { - configParser = new JacksonConfigParser(); - } else if (isPresent("com.google.gson.Gson")) { - configParser = new GsonConfigParser(); - } else if (isPresent("org.json.simple.JSONObject")) { - configParser = new JsonSimpleConfigParser(); - } else if (isPresent("org.json.JSONObject")) { - configParser = new JsonConfigParser(); - } else { - throw new MissingJsonParserException("unable to locate a JSON parser. " - + "Please see <link> for more information"); + private static @Nonnull + ConfigParser create() { + + String configParserName = PropertyUtils.get("default_parser"); + + if (configParserName != null) { + try { + ConfigParserSupplier supplier = ConfigParserSupplier.valueOf(configParserName); + if (supplier.isPresent()) { + ConfigParser configParser = supplier.get(); + logger.debug("using json parser: {}, based on override config", configParser.getClass().getSimpleName()); + return configParser; + } + + logger.warn("configured parser {} is not available in the classpath", configParserName); + } catch (IllegalArgumentException e) { + logger.warn("configured parser {} is not a valid value", configParserName); + } } - logger.info("using json parser: {}", configParser.getClass().getSimpleName()); - return configParser; - } + for (ConfigParserSupplier supplier: ConfigParserSupplier.values()) { + if (!supplier.isPresent()) { + continue; + } - private static boolean isPresent(@Nonnull String className) { - try { - Class.forName(className); - return true; - } catch (ClassNotFoundException e) { - return false; + ConfigParser configParser = supplier.get(); + logger.info("using json parser: {}", configParser.getClass().getSimpleName()); + return configParser; } + + throw new MissingJsonParserException("unable to locate a JSON parser. " + + "Please see <link> for more information"); } - //======== Lazy-init Holder ========// + //======== Lazy-init Holder ========// private static class LazyHolder { private static final ConfigParser INSTANCE = create(); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ExperimentGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ExperimentGsonDeserializer.java index 6eea6be21..2268bec3b 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ExperimentGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/ExperimentGsonDeserializer.java @@ -33,6 +33,7 @@ public Experiment deserialize(JsonElement json, Type typeOfT, JsonDeserializatio throws JsonParseException { JsonObject jsonObject = json.getAsJsonObject(); + return GsonHelpers.parseExperiment(jsonObject, context); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java index e26623a8b..c76c93ab9 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/FeatureFlagGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ public class FeatureFlagGsonDeserializer implements JsonDeserializer<FeatureFlag> { @Override public FeatureFlag deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { + throws JsonParseException { JsonObject jsonObject = json.getAsJsonObject(); return GsonHelpers.parseFeatureFlag(jsonObject, context); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GroupGsonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GroupGsonDeserializer.java index 05959a464..6136dbf9d 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GroupGsonDeserializer.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GroupGsonDeserializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,12 +45,12 @@ public Group deserialize(JsonElement json, Type typeOfT, JsonDeserializationCont List<Experiment> experiments = new ArrayList<Experiment>(); JsonArray experimentsJson = jsonObject.getAsJsonArray("experiments"); for (Object obj : experimentsJson) { - JsonObject experimentObj = (JsonObject)obj; + JsonObject experimentObj = (JsonObject) obj; experiments.add(GsonHelpers.parseExperiment(experimentObj, id, context)); } List<TrafficAllocation> trafficAllocations = - GsonHelpers.parseTrafficAllocation(jsonObject.getAsJsonArray("trafficAllocation")); + GsonHelpers.parseTrafficAllocation(jsonObject.getAsJsonArray("trafficAllocation")); return new Group(id, policy, experiments, trafficAllocations); } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GroupJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GroupJacksonDeserializer.java deleted file mode 100644 index 714326fcc..000000000 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GroupJacksonDeserializer.java +++ /dev/null @@ -1,80 +0,0 @@ -/** - * - * Copyright 2016-2017, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.config.parser; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -public class GroupJacksonDeserializer extends JsonDeserializer<Group> { - - @Override - public Group deserialize(JsonParser parser, DeserializationContext context) throws IOException { - ObjectMapper mapper = new ObjectMapper(); - JsonNode node = parser.getCodec().readTree(parser); - - String id = node.get("id").textValue(); - String policy = node.get("policy").textValue(); - List<TrafficAllocation> trafficAllocations = mapper.readValue(node.get("trafficAllocation").toString(), - new TypeReference<List<TrafficAllocation>>(){}); - - JsonNode groupExperimentsJson = node.get("experiments"); - List<Experiment> groupExperiments = new ArrayList<Experiment>(); - if (groupExperimentsJson.isArray()) { - for (JsonNode groupExperimentJson : groupExperimentsJson) { - groupExperiments.add(parseExperiment(groupExperimentJson, id)); - } - } - - return new Group(id, policy, groupExperiments, trafficAllocations); - } - - private Experiment parseExperiment(JsonNode experimentJson, String groupId) throws IOException { - ObjectMapper mapper = new ObjectMapper(); - - String id = experimentJson.get("id").textValue(); - String key = experimentJson.get("key").textValue(); - String status = experimentJson.get("status").textValue(); - JsonNode layerIdJson = experimentJson.get("layerId"); - String layerId = layerIdJson == null ? null : layerIdJson.textValue(); - List<String> audienceIds = mapper.readValue(experimentJson.get("audienceIds").toString(), - new TypeReference<List<String>>(){}); - List<Variation> variations = mapper.readValue(experimentJson.get("variations").toString(), - new TypeReference<List<Variation>>(){}); - List<TrafficAllocation> trafficAllocations = mapper.readValue(experimentJson.get("trafficAllocation").toString(), - new TypeReference<List<TrafficAllocation>>(){}); - Map<String, String> userIdToVariationKeyMap = mapper.readValue( - experimentJson.get("forcedVariations").toString(), new TypeReference<Map<String, String>>(){}); - - return new Experiment(id, key, status, layerId, audienceIds, variations, userIdToVariationKeyMap, - trafficAllocations, groupId); - } - -} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java index e20146520..972d76431 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,18 +18,32 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.*; import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.TypedAudience; import javax.annotation.Nonnull; /** * {@link Gson}-based config parser implementation. */ -final class GsonConfigParser implements ConfigParser { +final public class GsonConfigParser implements ConfigParser { + private Gson gson; + + public GsonConfigParser() { + this(new GsonBuilder() + .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) + .registerTypeAdapter(TypedAudience.class, new AudienceGsonDeserializer()) + .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) + .registerTypeAdapter(FeatureFlag.class, new FeatureFlagGsonDeserializer()) + .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) + .registerTypeAdapter(DatafileProjectConfig.class, new DatafileGsonDeserializer()) + .create()); + } + + GsonConfigParser(Gson gson) { + this.gson = gson; + } @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { @@ -39,18 +53,24 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse if (json.length() == 0) { throw new ConfigParseException("Unable to parse empty json."); } - Gson gson = new GsonBuilder() - .registerTypeAdapter(Audience.class, new AudienceGsonDeserializer()) - .registerTypeAdapter(Experiment.class, new ExperimentGsonDeserializer()) - .registerTypeAdapter(FeatureFlag.class, new FeatureFlagGsonDeserializer()) - .registerTypeAdapter(Group.class, new GroupGsonDeserializer()) - .registerTypeAdapter(ProjectConfig.class, new ProjectConfigGsonDeserializer()) - .create(); try { - return gson.fromJson(json, ProjectConfig.class); + return gson.fromJson(json, DatafileProjectConfig.class); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } } + + public String toJson(Object src) { + return gson.toJson(src); + } + + public <T> T fromJson(String json, Class<T> clazz) throws JsonParseException { + try { + return gson.fromJson(json, clazz); + } catch (Exception e) { + throw new JsonParseException("Unable to parse JSON string: " + e.toString()); + } + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java index 6e18bcd29..1399497b2 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ */ package com.optimizely.ab.config.parser; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonElement; @@ -26,10 +27,13 @@ import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Experiment.ExperimentStatus; import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariableUsageInstance; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.FeatureVariableUsageInstance; import com.optimizely.ab.config.TrafficAllocation; import com.optimizely.ab.config.Variation; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.internal.ConditionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,21 +51,23 @@ final class GsonHelpers { private static List<Variation> parseVariations(JsonArray variationJson, JsonDeserializationContext context) { List<Variation> variations = new ArrayList<Variation>(variationJson.size()); for (Object obj : variationJson) { - JsonObject variationObject = (JsonObject)obj; + JsonObject variationObject = (JsonObject) obj; String id = variationObject.get("id").getAsString(); String key = variationObject.get("key").getAsString(); Boolean featureEnabled = false; - if (variationObject.has("featureEnabled")) + if (variationObject.has("featureEnabled") && !variationObject.get("featureEnabled").isJsonNull()) { featureEnabled = variationObject.get("featureEnabled").getAsBoolean(); + } - List<LiveVariableUsageInstance> variableUsageInstances = null; + List<FeatureVariableUsageInstance> variableUsageInstances = null; // this is an existence check rather than a version check since it's difficult to pass data // across deserializers. if (variationObject.has("variables")) { - Type liveVariableUsageInstancesType = new TypeToken<List<LiveVariableUsageInstance>>() {}.getType(); + Type featureVariableUsageInstancesType = new TypeToken<List<FeatureVariableUsageInstance>>() { + }.getType(); variableUsageInstances = - context.deserialize(variationObject.getAsJsonArray("variables"), - liveVariableUsageInstancesType); + context.deserialize(variationObject.getAsJsonArray("variables"), + featureVariableUsageInstancesType); } variations.add(new Variation(id, key, featureEnabled, variableUsageInstances)); @@ -84,7 +90,7 @@ static List<TrafficAllocation> parseTrafficAllocation(JsonArray trafficAllocatio List<TrafficAllocation> trafficAllocation = new ArrayList<TrafficAllocation>(trafficAllocationJson.size()); for (Object obj : trafficAllocationJson) { - JsonObject allocationObject = (JsonObject)obj; + JsonObject allocationObject = (JsonObject) obj; String entityId = allocationObject.get("entityId").getAsString(); int endOfRange = allocationObject.get("endOfRange").getAsInt(); @@ -94,31 +100,51 @@ static List<TrafficAllocation> parseTrafficAllocation(JsonArray trafficAllocatio return trafficAllocation; } + static Condition parseAudienceConditions(JsonObject experimentJson) { + + if (!experimentJson.has("audienceConditions")) return null; + + Gson gson = new Gson(); + + JsonElement conditionsElement = experimentJson.get("audienceConditions"); + + if (conditionsElement.isJsonArray()) { + List<Object> rawObjectList = gson.fromJson(conditionsElement, List.class); + return ConditionUtils.<AudienceIdCondition>parseConditions(AudienceIdCondition.class, rawObjectList); + } else { + Object jsonObject = gson.fromJson(conditionsElement, Object.class); + return ConditionUtils.<AudienceIdCondition>parseConditions(AudienceIdCondition.class, jsonObject); + } + + } + static Experiment parseExperiment(JsonObject experimentJson, String groupId, JsonDeserializationContext context) { String id = experimentJson.get("id").getAsString(); String key = experimentJson.get("key").getAsString(); JsonElement experimentStatusJson = experimentJson.get("status"); String status = experimentStatusJson.isJsonNull() ? - ExperimentStatus.NOT_STARTED.toString() : experimentStatusJson.getAsString(); + ExperimentStatus.NOT_STARTED.toString() : experimentStatusJson.getAsString(); JsonElement layerIdJson = experimentJson.get("layerId"); String layerId = layerIdJson == null ? null : layerIdJson.getAsString(); JsonArray audienceIdsJson = experimentJson.getAsJsonArray("audienceIds"); - List<String> audienceIds = new ArrayList<String>(audienceIdsJson.size()); + List<String> audienceIds = new ArrayList<>(audienceIdsJson.size()); for (JsonElement audienceIdObj : audienceIdsJson) { audienceIds.add(audienceIdObj.getAsString()); } + Condition conditions = parseAudienceConditions(experimentJson); + // parse the child objects List<Variation> variations = parseVariations(experimentJson.getAsJsonArray("variations"), context); Map<String, String> userIdToVariationKeyMap = - parseForcedVariations(experimentJson.getAsJsonObject("forcedVariations")); + parseForcedVariations(experimentJson.getAsJsonObject("forcedVariations")); List<TrafficAllocation> trafficAllocations = - parseTrafficAllocation(experimentJson.getAsJsonArray("trafficAllocation")); + parseTrafficAllocation(experimentJson.getAsJsonArray("trafficAllocation")); - return new Experiment(id, key, status, layerId, audienceIds, variations, userIdToVariationKeyMap, - trafficAllocations, groupId); + return new Experiment(id, key, status, layerId, audienceIds, conditions, variations, userIdToVariationKeyMap, + trafficAllocations, groupId); } static Experiment parseExperiment(JsonObject experimentJson, JsonDeserializationContext context) { @@ -136,23 +162,23 @@ static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializat experimentIds.add(experimentIdObj.getAsString()); } - List<LiveVariable> liveVariables = new ArrayList<LiveVariable>(); + List<FeatureVariable> FeatureVariables = new ArrayList<>(); try { - Type liveVariableType = new TypeToken<List<LiveVariable>>() {}.getType(); - liveVariables = context.deserialize(featureFlagJson.getAsJsonArray("variables"), - liveVariableType); - } - catch (JsonParseException exception) { + Type FeatureVariableType = new TypeToken<List<FeatureVariable>>() { + }.getType(); + FeatureVariables = context.deserialize(featureFlagJson.getAsJsonArray("variables"), + FeatureVariableType); + } catch (JsonParseException exception) { logger.warn("Unable to parse variables for feature \"" + key - + "\". JsonParseException: " + exception); + + "\". JsonParseException: " + exception); } return new FeatureFlag( - id, - key, - layerId, - experimentIds, - liveVariables + id, + key, + layerId, + experimentIds, + FeatureVariables ); } } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java index 67ab86771..a9b012807 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2018, 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,29 +16,70 @@ */ package com.optimizely.ab.config.parser; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; - +import com.optimizely.ab.config.DatafileProjectConfig; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; import javax.annotation.Nonnull; +import java.io.IOException; /** * {@code Jackson}-based config parser implementation. */ -final class JacksonConfigParser implements ConfigParser { +final public class JacksonConfigParser implements ConfigParser { + private ObjectMapper objectMapper; + + public JacksonConfigParser() { + this(new ObjectMapper()); + } + + JacksonConfigParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.objectMapper.registerModule(new ProjectConfigModule()); + } @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { - ObjectMapper mapper = new ObjectMapper(); - SimpleModule module = new SimpleModule(); - module.addDeserializer(ProjectConfig.class, new ProjectConfigJacksonDeserializer()); - mapper.registerModule(module); - try { - return mapper.readValue(json, ProjectConfig.class); + return objectMapper.readValue(json, DatafileProjectConfig.class); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } } + + class ProjectConfigModule extends SimpleModule { + private final static String NAME = "ProjectConfigModule"; + + public ProjectConfigModule() { + super(NAME); + addDeserializer(DatafileProjectConfig.class, new DatafileJacksonDeserializer()); + addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + addDeserializer(TypedAudience.class, new TypedAudienceJacksonDeserializer(objectMapper)); + addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + } + } + + @Override + public String toJson(Object src) throws JsonParseException { + try { + return objectMapper.writeValueAsString(src); + } catch (JsonProcessingException e) { + throw new JsonParseException("Serialization failed: " + e.toString()); + } + } + + @Override + public <T> T fromJson(String json, Class<T> clazz) throws JsonParseException { + try { + return objectMapper.readValue(json, clazz); + } catch (IOException e) { + throw new JsonParseException("Unable to parse JSON string: " + e.toString()); + } + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonHelpers.java new file mode 100644 index 000000000..5b1bd84b4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JacksonHelpers.java @@ -0,0 +1,47 @@ +/** + * + * Copyright 2018, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +final class JacksonHelpers { + private JacksonHelpers() { + } + + static <T> List<T> arrayNodeToList(JsonNode arrayNode, Class<T> itemClass, ObjectCodec codec) throws IOException { + if (arrayNode == null || arrayNode.isNull() || !arrayNode.isArray()) { + return null; + } + + List<T> items = new ArrayList<>(arrayNode.size()); + + for (int i = 0; i < arrayNode.size(); i++) { + JsonNode itemNode = arrayNode.get(i); + if (itemNode.isNull()) { + continue; + } + items.add(codec.treeToValue(itemNode, itemClass)); + } + + return items; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 593fa56ae..ea5101054 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,40 +16,24 @@ */ package com.optimizely.ab.config.parser; -import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.*; import com.optimizely.ab.config.Experiment.ExperimentStatus; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariable.VariableStatus; -import com.optimizely.ab.config.LiveVariable.VariableType; -import com.optimizely.ab.config.LiveVariableUsageInstance; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.config.audience.AndCondition; import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.NotCondition; -import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; import org.json.JSONArray; import org.json.JSONObject; +import org.json.JSONTokener; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * {@code org.json}-based config parser implementation. */ -final class JsonConfigParser implements ConfigParser { +final public class JsonConfigParser implements ConfigParser { @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { @@ -68,43 +52,69 @@ public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParse attributes = parseAttributes(rootObject.getJSONArray("attributes")); List<EventType> events = parseEvents(rootObject.getJSONArray("events")); - List<Audience> audiences = parseAudiences(rootObject.getJSONArray("audiences")); + List<Audience> audiences = Collections.emptyList(); + + if (rootObject.has("audiences")) { + audiences = parseAudiences(rootObject.getJSONArray("audiences")); + } + + List<Audience> typedAudiences = null; + if (rootObject.has("typedAudiences")) { + typedAudiences = parseTypedAudiences(rootObject.getJSONArray("typedAudiences")); + } + List<Group> groups = parseGroups(rootObject.getJSONArray("groups")); boolean anonymizeIP = false; - List<LiveVariable> liveVariables = null; if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { - liveVariables = parseLiveVariables(rootObject.getJSONArray("variables")); - anonymizeIP = rootObject.getBoolean("anonymizeIP"); } List<FeatureFlag> featureFlags = null; List<Rollout> rollouts = null; + List<Integration> integrations = null; + String sdkKey = null; + String environmentKey = null; Boolean botFiltering = null; + boolean sendFlagDecisions = false; if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags(rootObject.getJSONArray("featureFlags")); rollouts = parseRollouts(rootObject.getJSONArray("rollouts")); - if(rootObject.has("botFiltering")) + if (rootObject.has("integrations")) { + integrations = parseIntegrations(rootObject.getJSONArray("integrations")); + } + if (rootObject.has("sdkKey")) + sdkKey = rootObject.getString("sdkKey"); + if (rootObject.has("environmentKey")) + environmentKey = rootObject.getString("environmentKey"); + if (rootObject.has("botFiltering")) botFiltering = rootObject.getBoolean("botFiltering"); + if (rootObject.has("sendFlagDecisions")) + sendFlagDecisions = rootObject.getBoolean("sendFlagDecisions"); } - return new ProjectConfig( - accountId, - anonymizeIP, - botFiltering, - projectId, - revision, - version, - attributes, - audiences, - events, - experiments, - featureFlags, - groups, - liveVariables, - rollouts + return new DatafileProjectConfig( + accountId, + anonymizeIP, + sendFlagDecisions, + botFiltering, + projectId, + revision, + sdkKey, + environmentKey, + version, + attributes, + audiences, + typedAudiences, + events, + experiments, + featureFlags, + groups, + rollouts, + integrations ); + } catch (RuntimeException e) { + throw new ConfigParseException("Unable to parse datafile: " + json, e); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); } @@ -119,19 +129,27 @@ private List<Experiment> parseExperiments(JSONArray experimentJson) { private List<Experiment> parseExperiments(JSONArray experimentJson, String groupId) { List<Experiment> experiments = new ArrayList<Experiment>(experimentJson.length()); - for (Object obj : experimentJson) { - JSONObject experimentObject = (JSONObject)obj; + for (int i = 0; i < experimentJson.length(); i++) { + Object obj = experimentJson.get(i); + JSONObject experimentObject = (JSONObject) obj; String id = experimentObject.getString("id"); String key = experimentObject.getString("key"); String status = experimentObject.isNull("status") ? - ExperimentStatus.NOT_STARTED.toString() : experimentObject.getString("status"); + ExperimentStatus.NOT_STARTED.toString() : experimentObject.getString("status"); String layerId = experimentObject.has("layerId") ? experimentObject.getString("layerId") : null; JSONArray audienceIdsJson = experimentObject.getJSONArray("audienceIds"); List<String> audienceIds = new ArrayList<String>(audienceIdsJson.length()); - for (Object audienceIdObj : audienceIdsJson) { - audienceIds.add((String)audienceIdObj); + for (int j = 0; j < audienceIdsJson.length(); j++) { + Object audienceIdObj = audienceIdsJson.get(j); + audienceIds.add((String) audienceIdObj); + } + + Condition conditions = null; + if (experimentObject.has("audienceConditions")) { + Object jsonCondition = experimentObject.get("audienceConditions"); + conditions = ConditionUtils.<AudienceIdCondition>parseConditions(AudienceIdCondition.class, jsonCondition); } // parse the child objects @@ -141,8 +159,8 @@ private List<Experiment> parseExperiments(JSONArray experimentJson, String group List<TrafficAllocation> trafficAllocations = parseTrafficAllocation(experimentObject.getJSONArray("trafficAllocation")); - experiments.add(new Experiment(id, key, status, layerId, audienceIds, variations, userIdToVariationKeyMap, - trafficAllocations, groupId)); + experiments.add(new Experiment(id, key, status, layerId, audienceIds, conditions, variations, userIdToVariationKeyMap, + trafficAllocations, groupId)); } return experiments; @@ -151,7 +169,8 @@ private List<Experiment> parseExperiments(JSONArray experimentJson, String group private List<String> parseExperimentIds(JSONArray experimentIdsJson) { ArrayList<String> experimentIds = new ArrayList<String>(experimentIdsJson.length()); - for (Object experimentIdObj : experimentIdsJson) { + for (int i = 0; i < experimentIdsJson.length(); i++) { + Object experimentIdObj = experimentIdsJson.get(i); experimentIds.add((String) experimentIdObj); } @@ -161,7 +180,8 @@ private List<String> parseExperimentIds(JSONArray experimentIdsJson) { private List<FeatureFlag> parseFeatureFlags(JSONArray featureFlagJson) { List<FeatureFlag> featureFlags = new ArrayList<FeatureFlag>(featureFlagJson.length()); - for (Object obj : featureFlagJson) { + for (int i = 0; i < featureFlagJson.length();i++) { + Object obj = featureFlagJson.get(i); JSONObject featureFlagObject = (JSONObject) obj; String id = featureFlagObject.getString("id"); String key = featureFlagObject.getString("key"); @@ -169,14 +189,14 @@ private List<FeatureFlag> parseFeatureFlags(JSONArray featureFlagJson) { List<String> experimentIds = parseExperimentIds(featureFlagObject.getJSONArray("experimentIds")); - List<LiveVariable> variables = parseLiveVariables(featureFlagObject.getJSONArray("variables")); + List<FeatureVariable> variables = parseFeatureVariables(featureFlagObject.getJSONArray("variables")); featureFlags.add(new FeatureFlag( - id, - key, - layerId, - experimentIds, - variables + id, + key, + layerId, + experimentIds, + variables )); } @@ -186,22 +206,24 @@ private List<FeatureFlag> parseFeatureFlags(JSONArray featureFlagJson) { private List<Variation> parseVariations(JSONArray variationJson) { List<Variation> variations = new ArrayList<Variation>(variationJson.length()); - for (Object obj : variationJson) { - JSONObject variationObject = (JSONObject)obj; + for (int i = 0; i < variationJson.length(); i++) { + Object obj = variationJson.get(i); + JSONObject variationObject = (JSONObject) obj; String id = variationObject.getString("id"); String key = variationObject.getString("key"); Boolean featureEnabled = false; - if(variationObject.has("featureEnabled")) + if (variationObject.has("featureEnabled") && !variationObject.isNull("featureEnabled")) { featureEnabled = variationObject.getBoolean("featureEnabled"); + } - List<LiveVariableUsageInstance> liveVariableUsageInstances = null; + List<FeatureVariableUsageInstance> featureVariableUsageInstances = null; if (variationObject.has("variables")) { - liveVariableUsageInstances = - parseLiveVariableInstances(variationObject.getJSONArray("variables")); + featureVariableUsageInstances = + parseFeatureVariableInstances(variationObject.getJSONArray("variables")); } - variations.add(new Variation(id, key, featureEnabled, liveVariableUsageInstances)); + variations.add(new Variation(id, key, featureEnabled, featureVariableUsageInstances)); } return variations; @@ -221,8 +243,9 @@ private Map<String, String> parseForcedVariations(JSONObject forcedVariationJson private List<TrafficAllocation> parseTrafficAllocation(JSONArray trafficAllocationJson) { List<TrafficAllocation> trafficAllocation = new ArrayList<TrafficAllocation>(trafficAllocationJson.length()); - for (Object obj : trafficAllocationJson) { - JSONObject allocationObject = (JSONObject)obj; + for (int i = 0; i < trafficAllocationJson.length();i++) { + Object obj = trafficAllocationJson.get(i); + JSONObject allocationObject = (JSONObject) obj; String entityId = allocationObject.getString("entityId"); int endOfRange = allocationObject.getInt("endOfRange"); @@ -235,8 +258,9 @@ private List<TrafficAllocation> parseTrafficAllocation(JSONArray trafficAllocati private List<Attribute> parseAttributes(JSONArray attributeJson) { List<Attribute> attributes = new ArrayList<Attribute>(attributeJson.length()); - for (Object obj : attributeJson) { - JSONObject attributeObject = (JSONObject)obj; + for (int i = 0; i < attributeJson.length();i++) { + Object obj = attributeJson.get(i); + JSONObject attributeObject = (JSONObject) obj; String id = attributeObject.getString("id"); String key = attributeObject.getString("key"); @@ -249,8 +273,9 @@ private List<Attribute> parseAttributes(JSONArray attributeJson) { private List<EventType> parseEvents(JSONArray eventJson) { List<EventType> events = new ArrayList<EventType>(eventJson.length()); - for (Object obj : eventJson) { - JSONObject eventObject = (JSONObject)obj; + for (int i = 0; i < eventJson.length(); i++) { + Object obj = eventJson.get(i); + JSONObject eventObject = (JSONObject) obj; List<String> experimentIds = parseExperimentIds(eventObject.getJSONArray("experimentIds")); String id = eventObject.getString("id"); @@ -265,59 +290,53 @@ private List<EventType> parseEvents(JSONArray eventJson) { private List<Audience> parseAudiences(JSONArray audienceJson) { List<Audience> audiences = new ArrayList<Audience>(audienceJson.length()); - for (Object obj : audienceJson) { - JSONObject audienceObject = (JSONObject)obj; + for (int i = 0; i < audienceJson.length(); i++) { + Object obj = audienceJson.get(i); + JSONObject audienceObject = (JSONObject) obj; String id = audienceObject.getString("id"); String key = audienceObject.getString("name"); - String conditionString = audienceObject.getString("conditions"); + Object conditionsObject = audienceObject.get("conditions"); + if (conditionsObject instanceof String) { // should always be true + JSONTokener tokener = new JSONTokener((String) conditionsObject); + char token = tokener.nextClean(); + if (token == '[') { + // must be an array + conditionsObject = new JSONArray((String) conditionsObject); + } else if (token == '{') { + conditionsObject = new JSONObject((String) conditionsObject); + } + } - JSONArray conditionJson = new JSONArray(conditionString); - Condition conditions = parseConditions(conditionJson); + Condition conditions = ConditionUtils.<UserAttribute>parseConditions(UserAttribute.class, conditionsObject); audiences.add(new Audience(id, key, conditions)); } return audiences; } - private Condition parseConditions(JSONArray conditionJson) { - List<Condition> conditions = new ArrayList<Condition>(); - String operand = (String)conditionJson.get(0); - - for (int i = 1; i < conditionJson.length(); i++) { - Object obj = conditionJson.get(i); - if (obj instanceof JSONArray) { - conditions.add(parseConditions(conditionJson.getJSONArray(i))); - } else { - JSONObject conditionMap = (JSONObject)obj; - String value = null; - if (conditionMap.has("value")) { - value = conditionMap.getString("value"); - } - conditions.add(new UserAttribute( - (String)conditionMap.get("name"), - (String)conditionMap.get("type"), - value - )); - } - } + private List<Audience> parseTypedAudiences(JSONArray audienceJson) { + List<Audience> audiences = new ArrayList<Audience>(audienceJson.length()); - Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); + for (int i = 0; i < audienceJson.length(); i++) { + Object obj = audienceJson.get(i); + JSONObject audienceObject = (JSONObject) obj; + String id = audienceObject.getString("id"); + String key = audienceObject.getString("name"); + Object conditionsObject = audienceObject.get("conditions"); + + Condition conditions = ConditionUtils.<UserAttribute>parseConditions(UserAttribute.class, conditionsObject); + audiences.add(new Audience(id, key, conditions)); } - return condition; + return audiences; } private List<Group> parseGroups(JSONArray groupJson) { List<Group> groups = new ArrayList<Group>(groupJson.length()); - for (Object obj : groupJson) { - JSONObject groupObject = (JSONObject)obj; + for (int i = 0; i < groupJson.length(); i++) { + Object obj = groupJson.get(i); + JSONObject groupObject = (JSONObject) obj; String id = groupObject.getString("id"); String policy = groupObject.getString("policy"); List<Experiment> experiments = parseExperiments(groupObject.getJSONArray("experiments"), id); @@ -330,44 +349,51 @@ private List<Group> parseGroups(JSONArray groupJson) { return groups; } - private List<LiveVariable> parseLiveVariables(JSONArray liveVariablesJson) { - List<LiveVariable> liveVariables = new ArrayList<LiveVariable>(liveVariablesJson.length()); - - for (Object obj : liveVariablesJson) { - JSONObject liveVariableObject = (JSONObject)obj; - String id = liveVariableObject.getString("id"); - String key = liveVariableObject.getString("key"); - String defaultValue = liveVariableObject.getString("defaultValue"); - VariableType type = VariableType.fromString(liveVariableObject.getString("type")); - VariableStatus status = null; - if (liveVariableObject.has("status")) { - status = VariableStatus.fromString(liveVariableObject.getString("status")); + private List<FeatureVariable> parseFeatureVariables(JSONArray featureVariablesJson) { + List<FeatureVariable> featureVariables = new ArrayList<FeatureVariable>(featureVariablesJson.length()); + + for (int i = 0; i < featureVariablesJson.length();i++) { + Object obj = featureVariablesJson.get(i); + JSONObject FeatureVariableObject = (JSONObject) obj; + String id = FeatureVariableObject.getString("id"); + String key = FeatureVariableObject.getString("key"); + String defaultValue = FeatureVariableObject.getString("defaultValue"); + String type = FeatureVariableObject.getString("type"); + String subType = null; + if (FeatureVariableObject.has("subType")) { + subType = FeatureVariableObject.getString("subType"); + } + FeatureVariable.VariableStatus status = null; + if (FeatureVariableObject.has("status")) { + status = FeatureVariable.VariableStatus.fromString(FeatureVariableObject.getString("status")); } - liveVariables.add(new LiveVariable(id, key, defaultValue, status, type)); + featureVariables.add(new FeatureVariable(id, key, defaultValue, status, type, subType)); } - return liveVariables; + return featureVariables; } - private List<LiveVariableUsageInstance> parseLiveVariableInstances(JSONArray liveVariableInstancesJson) { - List<LiveVariableUsageInstance> liveVariableUsageInstances = new ArrayList<LiveVariableUsageInstance>(liveVariableInstancesJson.length()); + private List<FeatureVariableUsageInstance> parseFeatureVariableInstances(JSONArray featureVariableInstancesJson) { + List<FeatureVariableUsageInstance> featureVariableUsageInstances = new ArrayList<FeatureVariableUsageInstance>(featureVariableInstancesJson.length()); - for (Object obj : liveVariableInstancesJson) { - JSONObject liveVariableInstanceObject = (JSONObject)obj; - String id = liveVariableInstanceObject.getString("id"); - String value = liveVariableInstanceObject.getString("value"); + for (int i = 0; i < featureVariableInstancesJson.length(); i++) { + Object obj = featureVariableInstancesJson.get(i); + JSONObject featureVariableInstanceObject = (JSONObject) obj; + String id = featureVariableInstanceObject.getString("id"); + String value = featureVariableInstanceObject.getString("value"); - liveVariableUsageInstances.add(new LiveVariableUsageInstance(id, value)); + featureVariableUsageInstances.add(new FeatureVariableUsageInstance(id, value)); } - return liveVariableUsageInstances; + return featureVariableUsageInstances; } private List<Rollout> parseRollouts(JSONArray rolloutsJson) { List<Rollout> rollouts = new ArrayList<Rollout>(rolloutsJson.length()); - for (Object obj : rolloutsJson) { + for (int i = 0; i < rolloutsJson.length(); i++) { + Object obj = rolloutsJson.get(i); JSONObject rolloutObject = (JSONObject) obj; String id = rolloutObject.getString("id"); List<Experiment> experiments = parseExperiments(rolloutObject.getJSONArray("experiments")); @@ -377,4 +403,37 @@ private List<Rollout> parseRollouts(JSONArray rolloutsJson) { return rollouts; } + + private List<Integration> parseIntegrations(JSONArray integrationsJson) { + List<Integration> integrations = new ArrayList<Integration>(integrationsJson.length()); + + for (int i = 0; i < integrationsJson.length(); i++) { + Object obj = integrationsJson.get(i); + JSONObject integrationObject = (JSONObject) obj; + String key = integrationObject.getString("key"); + String host = integrationObject.has("host") ? integrationObject.getString("host") : null; + String publicKey = integrationObject.has("publicKey") ? integrationObject.getString("publicKey") : null; + integrations.add(new Integration(key, host, publicKey)); + } + + return integrations; + } + + @Override + public String toJson(Object src) { + JSONObject json = (JSONObject)JsonHelpers.convertToJsonObject(src); + return json.toString(); + } + + @Override + public <T> T fromJson(String json, Class<T> clazz) throws JsonParseException { + if (Map.class.isAssignableFrom(clazz)) { + JSONObject obj = new JSONObject(json); + return (T)JsonHelpers.jsonObjectToMap(obj); + } + + // org.json parser does not support parsing to user objects + throw new JsonParseException("Parsing fails with a unsupported type"); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java new file mode 100644 index 000000000..405c863c5 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonHelpers.java @@ -0,0 +1,81 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.*; + +final class JsonHelpers { + + static Object convertToJsonObject(Object obj) { + if (obj instanceof Map) { + Map<Object, Object> map = (Map)obj; + JSONObject jObj = new JSONObject(); + for (Map.Entry entry : map.entrySet()) { + jObj.put(entry.getKey().toString(), convertToJsonObject(entry.getValue())); + } + return jObj; + } else if (obj instanceof List) { + List list = (List)obj; + JSONArray jArray = new JSONArray(); + for (Object value : list) { + jArray.put(convertToJsonObject(value)); + } + return jArray; + } else { + return obj; + } + } + + static Map<String, Object> jsonObjectToMap(JSONObject jObj) { + Map<String, Object> map = new HashMap<>(); + + Iterator<String> keys = jObj.keys(); + while(keys.hasNext()) { + String key = keys.next(); + Object value = jObj.get(key); + + if (value instanceof JSONArray) { + value = jsonArrayToList((JSONArray)value); + } else if (value instanceof JSONObject) { + value = jsonObjectToMap((JSONObject)value); + } + + map.put(key, value); + } + + return map; + } + + static List<Object> jsonArrayToList(JSONArray array) { + List<Object> list = new ArrayList<>(); + for(Object value : array) { + if (value instanceof JSONArray) { + value = jsonArrayToList((JSONArray)value); + } else if (value instanceof JSONObject) { + value = jsonObjectToMap((JSONObject)value); + } + + list.add(value); + } + + return list; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java new file mode 100644 index 000000000..0e77b7571 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonParseException.java @@ -0,0 +1,27 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +public final class JsonParseException extends Exception { + public JsonParseException(String message) { + super(message); + } + + public JsonParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index c4784b5c4..c65eb6213 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,98 +16,107 @@ */ package com.optimizely.ab.config.parser; -import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.*; import com.optimizely.ab.config.Experiment.ExperimentStatus; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariable.VariableStatus; -import com.optimizely.ab.config.LiveVariable.VariableType; -import com.optimizely.ab.config.LiveVariableUsageInstance; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.FeatureVariable.VariableStatus; import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.NotCondition; -import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; import org.json.simple.JSONArray; import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.json.simple.parser.ContainerFactory; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; /** * {@code json-simple}-based config parser implementation. */ -final class JsonSimpleConfigParser implements ConfigParser { +final public class JsonSimpleConfigParser implements ConfigParser { @Override public ProjectConfig parseProjectConfig(@Nonnull String json) throws ConfigParseException { try { JSONParser parser = new JSONParser(); - JSONObject rootObject = (JSONObject)parser.parse(json); - - String accountId = (String)rootObject.get("accountId"); - String projectId = (String)rootObject.get("projectId"); - String revision = (String)rootObject.get("revision"); - String version = (String)rootObject.get("version"); + JSONObject rootObject = (JSONObject) parser.parse(json); + + String accountId = (String) rootObject.get("accountId"); + String projectId = (String) rootObject.get("projectId"); + String revision = (String) rootObject.get("revision"); + String sdkKey = (String) rootObject.get("sdkKey"); + String environmentKey = (String) rootObject.get("environmentKey"); + String version = (String) rootObject.get("version"); int datafileVersion = Integer.parseInt(version); - List<Experiment> experiments = parseExperiments((JSONArray)rootObject.get("experiments")); + List<Experiment> experiments = parseExperiments((JSONArray) rootObject.get("experiments")); List<Attribute> attributes; - attributes = parseAttributes((JSONArray)rootObject.get("attributes")); + attributes = parseAttributes((JSONArray) rootObject.get("attributes")); - List<EventType> events = parseEvents((JSONArray)rootObject.get("events")); - List<Audience> audiences = parseAudiences((JSONArray)parser.parse(rootObject.get("audiences").toString())); - List<Group> groups = parseGroups((JSONArray)rootObject.get("groups")); + List<EventType> events = parseEvents((JSONArray) rootObject.get("events")); + List<Audience> audiences = Collections.emptyList(); - boolean anonymizeIP = false; - List<LiveVariable> liveVariables = null; - if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { - liveVariables = parseLiveVariables((JSONArray)rootObject.get("variables")); + if (rootObject.containsKey("audiences")) { + audiences = parseAudiences((JSONArray) parser.parse(rootObject.get("audiences").toString())); + } + + List<Audience> typedAudiences = null; + if (rootObject.containsKey("typedAudiences")) { + typedAudiences = parseTypedAudiences((JSONArray) parser.parse(rootObject.get("typedAudiences").toString())); + } + + List<Group> groups = parseGroups((JSONArray) rootObject.get("groups")); - anonymizeIP = (Boolean)rootObject.get("anonymizeIP"); + boolean anonymizeIP = false; + if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V3.toString())) { + anonymizeIP = (Boolean) rootObject.get("anonymizeIP"); } List<FeatureFlag> featureFlags = null; List<Rollout> rollouts = null; + List<Integration> integrations = null; Boolean botFiltering = null; - if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + boolean sendFlagDecisions = false; + if (datafileVersion >= Integer.parseInt(DatafileProjectConfig.Version.V4.toString())) { featureFlags = parseFeatureFlags((JSONArray) rootObject.get("featureFlags")); rollouts = parseRollouts((JSONArray) rootObject.get("rollouts")); - if(rootObject.containsKey("botFiltering")) + if (rootObject.containsKey("integrations")) { + integrations = parseIntegrations((JSONArray) rootObject.get("integrations")); + } + if (rootObject.containsKey("botFiltering")) botFiltering = (Boolean) rootObject.get("botFiltering"); + if (rootObject.containsKey("sendFlagDecisions")) + sendFlagDecisions = (Boolean) rootObject.get("sendFlagDecisions"); } - return new ProjectConfig( - accountId, - anonymizeIP, - botFiltering, - projectId, - revision, - version, - attributes, - audiences, - events, - experiments, - featureFlags, - groups, - liveVariables, - rollouts + return new DatafileProjectConfig( + accountId, + anonymizeIP, + sendFlagDecisions, + botFiltering, + projectId, + revision, + sdkKey, + environmentKey, + version, + attributes, + audiences, + typedAudiences, + events, + experiments, + featureFlags, + groups, + rollouts, + integrations ); - } catch (RuntimeException ex){ + } catch (RuntimeException ex) { throw new ConfigParseException("Unable to parse datafile: " + json, ex); } catch (Exception e) { throw new ConfigParseException("Unable to parse datafile: " + json, e); @@ -124,31 +133,41 @@ private List<Experiment> parseExperiments(JSONArray experimentJson, String group List<Experiment> experiments = new ArrayList<Experiment>(experimentJson.size()); for (Object obj : experimentJson) { - JSONObject experimentObject = (JSONObject)obj; - String id = (String)experimentObject.get("id"); - String key = (String)experimentObject.get("key"); + JSONObject experimentObject = (JSONObject) obj; + String id = (String) experimentObject.get("id"); + String key = (String) experimentObject.get("key"); Object statusJson = experimentObject.get("status"); String status = statusJson == null ? ExperimentStatus.NOT_STARTED.toString() : - (String)experimentObject.get("status"); + (String) experimentObject.get("status"); Object layerIdObject = experimentObject.get("layerId"); - String layerId = layerIdObject == null ? null : (String)layerIdObject; + String layerId = layerIdObject == null ? null : (String) layerIdObject; - JSONArray audienceIdsJson = (JSONArray)experimentObject.get("audienceIds"); + JSONArray audienceIdsJson = (JSONArray) experimentObject.get("audienceIds"); List<String> audienceIds = new ArrayList<String>(audienceIdsJson.size()); for (Object audienceIdObj : audienceIdsJson) { - audienceIds.add((String)audienceIdObj); + audienceIds.add((String) audienceIdObj); } + Condition conditions = null; + if (experimentObject.containsKey("audienceConditions")) { + Object jsonCondition = experimentObject.get("audienceConditions"); + try { + conditions = ConditionUtils.<AudienceIdCondition>parseConditions(AudienceIdCondition.class, jsonCondition); + } catch (Exception e) { + // unable to parse conditions. + Logger.getAnonymousLogger().log(Level.ALL, "problem parsing audience conditions", e); + } + } // parse the child objects - List<Variation> variations = parseVariations((JSONArray)experimentObject.get("variations")); + List<Variation> variations = parseVariations((JSONArray) experimentObject.get("variations")); Map<String, String> userIdToVariationKeyMap = - parseForcedVariations((JSONObject)experimentObject.get("forcedVariations")); + parseForcedVariations((JSONObject) experimentObject.get("forcedVariations")); List<TrafficAllocation> trafficAllocations = - parseTrafficAllocation((JSONArray)experimentObject.get("trafficAllocation")); + parseTrafficAllocation((JSONArray) experimentObject.get("trafficAllocation")); - experiments.add(new Experiment(id, key, status, layerId, audienceIds, variations, userIdToVariationKeyMap, - trafficAllocations, groupId)); + experiments.add(new Experiment(id, key, status, layerId, audienceIds, conditions, variations, userIdToVariationKeyMap, + trafficAllocations, groupId)); } return experiments; @@ -158,7 +177,7 @@ private List<String> parseExperimentIds(JSONArray experimentIdsJsonArray) { List<String> experimentIds = new ArrayList<String>(experimentIdsJsonArray.size()); for (Object experimentIdObj : experimentIdsJsonArray) { - experimentIds.add((String)experimentIdObj); + experimentIds.add((String) experimentIdObj); } return experimentIds; @@ -168,22 +187,22 @@ private List<FeatureFlag> parseFeatureFlags(JSONArray featureFlagJson) { List<FeatureFlag> featureFlags = new ArrayList<FeatureFlag>(featureFlagJson.size()); for (Object obj : featureFlagJson) { - JSONObject featureFlagObject = (JSONObject)obj; - String id = (String)featureFlagObject.get("id"); - String key = (String)featureFlagObject.get("key"); - String layerId = (String)featureFlagObject.get("rolloutId"); + JSONObject featureFlagObject = (JSONObject) obj; + String id = (String) featureFlagObject.get("id"); + String key = (String) featureFlagObject.get("key"); + String layerId = (String) featureFlagObject.get("rolloutId"); - JSONArray experimentIdsJsonArray = (JSONArray)featureFlagObject.get("experimentIds"); + JSONArray experimentIdsJsonArray = (JSONArray) featureFlagObject.get("experimentIds"); List<String> experimentIds = parseExperimentIds(experimentIdsJsonArray); - List<LiveVariable> liveVariables = parseLiveVariables((JSONArray) featureFlagObject.get("variables")); + List<FeatureVariable> featureVariable = parseFeatureVariables((JSONArray) featureFlagObject.get("variables")); featureFlags.add(new FeatureFlag( - id, - key, - layerId, - experimentIds, - liveVariables + id, + key, + layerId, + experimentIds, + featureVariable )); } @@ -194,20 +213,20 @@ private List<Variation> parseVariations(JSONArray variationJson) { List<Variation> variations = new ArrayList<Variation>(variationJson.size()); for (Object obj : variationJson) { - JSONObject variationObject = (JSONObject)obj; - String id = (String)variationObject.get("id"); - String key = (String)variationObject.get("key"); + JSONObject variationObject = (JSONObject) obj; + String id = (String) variationObject.get("id"); + String key = (String) variationObject.get("key"); Boolean featureEnabled = false; - if(variationObject.containsKey("featureEnabled")) - featureEnabled = (Boolean)variationObject.get("featureEnabled"); + if (variationObject.containsKey("featureEnabled")) + featureEnabled = (Boolean) variationObject.get("featureEnabled"); - List<LiveVariableUsageInstance> liveVariableUsageInstances = null; + List<FeatureVariableUsageInstance> featureVariableUsageInstances = null; if (variationObject.containsKey("variables")) { - liveVariableUsageInstances = parseLiveVariableInstances((JSONArray)variationObject.get("variables")); + featureVariableUsageInstances = parseFeatureVariableInstances((JSONArray) variationObject.get("variables")); } - variations.add(new Variation(id, key, featureEnabled, liveVariableUsageInstances)); + variations.add(new Variation(id, key, featureEnabled, featureVariableUsageInstances)); } return variations; @@ -216,7 +235,7 @@ private List<Variation> parseVariations(JSONArray variationJson) { private Map<String, String> parseForcedVariations(JSONObject forcedVariationJson) { Map<String, String> userIdToVariationKeyMap = new HashMap<String, String>(); for (Object obj : forcedVariationJson.entrySet()) { - Map.Entry<String, String> entry = (Map.Entry<String, String>)obj; + Map.Entry<String, String> entry = (Map.Entry<String, String>) obj; userIdToVariationKeyMap.put(entry.getKey(), entry.getValue()); } @@ -227,11 +246,11 @@ private List<TrafficAllocation> parseTrafficAllocation(JSONArray trafficAllocati List<TrafficAllocation> trafficAllocation = new ArrayList<TrafficAllocation>(trafficAllocationJson.size()); for (Object obj : trafficAllocationJson) { - JSONObject allocationObject = (JSONObject)obj; - String entityId = (String)allocationObject.get("entityId"); - long endOfRange = (Long)allocationObject.get("endOfRange"); + JSONObject allocationObject = (JSONObject) obj; + String entityId = (String) allocationObject.get("entityId"); + long endOfRange = (Long) allocationObject.get("endOfRange"); - trafficAllocation.add(new TrafficAllocation(entityId, (int)endOfRange)); + trafficAllocation.add(new TrafficAllocation(entityId, (int) endOfRange)); } return trafficAllocation; @@ -241,10 +260,10 @@ private List<Attribute> parseAttributes(JSONArray attributeJson) { List<Attribute> attributes = new ArrayList<Attribute>(attributeJson.size()); for (Object obj : attributeJson) { - JSONObject attributeObject = (JSONObject)obj; - String id = (String)attributeObject.get("id"); - String key = (String)attributeObject.get("key"); - String segmentId = (String)attributeObject.get("segmentId"); + JSONObject attributeObject = (JSONObject) obj; + String id = (String) attributeObject.get("id"); + String key = (String) attributeObject.get("key"); + String segmentId = (String) attributeObject.get("segmentId"); attributes.add(new Attribute(id, key, segmentId)); } @@ -256,12 +275,12 @@ private List<EventType> parseEvents(JSONArray eventJson) { List<EventType> events = new ArrayList<EventType>(eventJson.size()); for (Object obj : eventJson) { - JSONObject eventObject = (JSONObject)obj; - JSONArray experimentIdsJson = (JSONArray)eventObject.get("experimentIds"); + JSONObject eventObject = (JSONObject) obj; + JSONArray experimentIdsJson = (JSONArray) eventObject.get("experimentIds"); List<String> experimentIds = parseExperimentIds(experimentIdsJson); - String id = (String)eventObject.get("id"); - String key = (String)eventObject.get("key"); + String id = (String) eventObject.get("id"); + String key = (String) eventObject.get("key"); events.add(new EventType(id, key, experimentIds)); } @@ -274,56 +293,43 @@ private List<Audience> parseAudiences(JSONArray audienceJson) throws ParseExcept List<Audience> audiences = new ArrayList<Audience>(audienceJson.size()); for (Object obj : audienceJson) { - JSONObject audienceObject = (JSONObject)obj; - String id = (String)audienceObject.get("id"); - String key = (String)audienceObject.get("name"); - String conditionString = (String)audienceObject.get("conditions"); - - JSONArray conditionJson = (JSONArray)parser.parse(conditionString); - Condition conditions = parseConditions(conditionJson); + JSONObject audienceObject = (JSONObject) obj; + String id = (String) audienceObject.get("id"); + String key = (String) audienceObject.get("name"); + Object conditionObject = audienceObject.get("conditions"); + Object conditionJson = parser.parse((String) conditionObject); + Condition conditions = ConditionUtils.<UserAttribute>parseConditions(UserAttribute.class, conditionJson); audiences.add(new Audience(id, key, conditions)); } return audiences; } - private Condition parseConditions(JSONArray conditionJson) { - List<Condition> conditions = new ArrayList<Condition>(); - String operand = (String)conditionJson.get(0); - - for (int i = 1; i < conditionJson.size(); i++) { - Object obj = conditionJson.get(i); - if (obj instanceof JSONArray) { - conditions.add(parseConditions((JSONArray)conditionJson.get(i))); - } else { - JSONObject conditionMap = (JSONObject)obj; - conditions.add(new UserAttribute((String)conditionMap.get("name"), (String)conditionMap.get("type"), - (String)conditionMap.get("value"))); - } - } + private List<Audience> parseTypedAudiences(JSONArray audienceJson) throws ParseException { + List<Audience> audiences = new ArrayList<Audience>(audienceJson.size()); - Condition condition; - if (operand.equals("and")) { - condition = new AndCondition(conditions); - } else if (operand.equals("or")) { - condition = new OrCondition(conditions); - } else { - condition = new NotCondition(conditions.get(0)); + for (Object obj : audienceJson) { + JSONObject audienceObject = (JSONObject) obj; + String id = (String) audienceObject.get("id"); + String key = (String) audienceObject.get("name"); + Object conditionObject = audienceObject.get("conditions"); + Condition conditions = ConditionUtils.<UserAttribute>parseConditions(UserAttribute.class, conditionObject); + audiences.add(new Audience(id, key, conditions)); } - return condition; + return audiences; } private List<Group> parseGroups(JSONArray groupJson) { List<Group> groups = new ArrayList<Group>(groupJson.size()); for (Object obj : groupJson) { - JSONObject groupObject = (JSONObject)obj; - String id = (String)groupObject.get("id"); - String policy = (String)groupObject.get("policy"); - List<Experiment> experiments = parseExperiments((JSONArray)groupObject.get("experiments"), id); + JSONObject groupObject = (JSONObject) obj; + String id = (String) groupObject.get("id"); + String policy = (String) groupObject.get("policy"); + List<Experiment> experiments = parseExperiments((JSONArray) groupObject.get("experiments"), id); List<TrafficAllocation> trafficAllocations = - parseTrafficAllocation((JSONArray)groupObject.get("trafficAllocation")); + parseTrafficAllocation((JSONArray) groupObject.get("trafficAllocation")); groups.add(new Group(id, policy, experiments, trafficAllocations)); } @@ -331,36 +337,37 @@ private List<Group> parseGroups(JSONArray groupJson) { return groups; } - private List<LiveVariable> parseLiveVariables(JSONArray liveVariablesJson) { - List<LiveVariable> liveVariables = new ArrayList<LiveVariable>(liveVariablesJson.size()); + private List<FeatureVariable> parseFeatureVariables(JSONArray featureVariablesJson) { + List<FeatureVariable> featureVariables = new ArrayList<FeatureVariable>(featureVariablesJson.size()); - for (Object obj : liveVariablesJson) { - JSONObject liveVariableObject = (JSONObject)obj; - String id = (String)liveVariableObject.get("id"); - String key = (String)liveVariableObject.get("key"); - String defaultValue = (String)liveVariableObject.get("defaultValue"); - VariableType type = VariableType.fromString((String)liveVariableObject.get("type")); - VariableStatus status = VariableStatus.fromString((String)liveVariableObject.get("status")); + for (Object obj : featureVariablesJson) { + JSONObject featureVariableObject = (JSONObject) obj; + String id = (String) featureVariableObject.get("id"); + String key = (String) featureVariableObject.get("key"); + String defaultValue = (String) featureVariableObject.get("defaultValue"); + String type = (String) featureVariableObject.get("type"); + String subType = (String) featureVariableObject.get("subType"); + VariableStatus status = VariableStatus.fromString((String) featureVariableObject.get("status")); - liveVariables.add(new LiveVariable(id, key, defaultValue, status, type)); + featureVariables.add(new FeatureVariable(id, key, defaultValue, status, type, subType)); } - return liveVariables; + return featureVariables; } - private List<LiveVariableUsageInstance> parseLiveVariableInstances(JSONArray liveVariableInstancesJson) { - List<LiveVariableUsageInstance> liveVariableUsageInstances = - new ArrayList<LiveVariableUsageInstance>(liveVariableInstancesJson.size()); + private List<FeatureVariableUsageInstance> parseFeatureVariableInstances(JSONArray featureVariableInstancesJson) { + List<FeatureVariableUsageInstance> featureVariableUsageInstances = + new ArrayList<FeatureVariableUsageInstance>(featureVariableInstancesJson.size()); - for (Object obj : liveVariableInstancesJson) { - JSONObject liveVariableInstanceObject = (JSONObject)obj; - String id = (String)liveVariableInstanceObject.get("id"); - String value = (String)liveVariableInstanceObject.get("value"); + for (Object obj : featureVariableInstancesJson) { + JSONObject featureVariableInstanceObject = (JSONObject) obj; + String id = (String) featureVariableInstanceObject.get("id"); + String value = (String) featureVariableInstanceObject.get("value"); - liveVariableUsageInstances.add(new LiveVariableUsageInstance(id, value)); + featureVariableUsageInstances.add(new FeatureVariableUsageInstance(id, value)); } - return liveVariableUsageInstances; + return featureVariableUsageInstances; } private List<Rollout> parseRollouts(JSONArray rolloutsJson) { @@ -376,5 +383,49 @@ private List<Rollout> parseRollouts(JSONArray rolloutsJson) { return rollouts; } + + private List<Integration> parseIntegrations(JSONArray integrationsJson) { + List<Integration> integrations = new ArrayList<>(integrationsJson.size()); + + for (Object obj : integrationsJson) { + JSONObject integrationObject = (JSONObject) obj; + String key = (String) integrationObject.get("key"); + String host = (String) integrationObject.get("host"); + String publicKey = (String) integrationObject.get("publicKey"); + integrations.add(new Integration(key, host, publicKey)); + } + + return integrations; + } + + @Override + public String toJson(Object src) { + return JSONValue.toJSONString(src); + } + + @Override + public <T> T fromJson(String json, Class<T> clazz) throws JsonParseException { + if (Map.class.isAssignableFrom(clazz)) { + try { + return (T)new JSONParser().parse(json, new ContainerFactory() { + @Override + public Map createObjectContainer() { + return new HashMap(); + } + + @Override + public List creatArrayContainer() { + return new ArrayList(); + } + }); + } catch (ParseException e) { + throw new JsonParseException("Unable to parse JSON string: " + e.toString()); + } + } + + // org.json.simple does not support parsing to user objects + throw new JsonParseException("Parsing fails with a unsupported type"); + } + } diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java deleted file mode 100644 index 76cac7412..000000000 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/ProjectConfigJacksonDeserializer.java +++ /dev/null @@ -1,106 +0,0 @@ -/** - * - * Copyright 2016-2018, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.config.parser; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.Group; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.audience.Audience; - -import java.io.IOException; -import java.util.List; - -class ProjectConfigJacksonDeserializer extends JsonDeserializer<ProjectConfig> { - - @Override - public ProjectConfig deserialize(JsonParser parser, DeserializationContext context) throws IOException { - ObjectMapper mapper = new ObjectMapper(); - SimpleModule module = new SimpleModule(); - module.addDeserializer(Audience.class, new AudienceJacksonDeserializer()); - module.addDeserializer(Group.class, new GroupJacksonDeserializer()); - mapper.registerModule(module); - - JsonNode node = parser.getCodec().readTree(parser); - - String accountId = node.get("accountId").textValue(); - String projectId = node.get("projectId").textValue(); - String revision = node.get("revision").textValue(); - String version = node.get("version").textValue(); - int datafileVersion = Integer.parseInt(version); - - List<Group> groups = mapper.readValue(node.get("groups").toString(), new TypeReference<List<Group>>() {}); - List<Experiment> experiments = mapper.readValue(node.get("experiments").toString(), - new TypeReference<List<Experiment>>() {}); - - List<Attribute> attributes; - attributes = mapper.readValue(node.get("attributes").toString(), new TypeReference<List<Attribute>>() {}); - - List<EventType> events = mapper.readValue(node.get("events").toString(), - new TypeReference<List<EventType>>() {}); - List<Audience> audiences = mapper.readValue(node.get("audiences").toString(), - new TypeReference<List<Audience>>() {}); - - boolean anonymizeIP = false; - List<LiveVariable> liveVariables = null; - if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V3.toString())) { - liveVariables = mapper.readValue(node.get("variables").toString(), - new TypeReference<List<LiveVariable>>() {}); - anonymizeIP = node.get("anonymizeIP").asBoolean(); - } - - List<FeatureFlag> featureFlags = null; - List<Rollout> rollouts = null; - Boolean botFiltering = null; - if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { - featureFlags = mapper.readValue(node.get("featureFlags").toString(), - new TypeReference<List<FeatureFlag>>() {}); - rollouts = mapper.readValue(node.get("rollouts").toString(), - new TypeReference<List<Rollout>>(){}); - if (node.hasNonNull("botFiltering")) - botFiltering = node.get("botFiltering").asBoolean(); - } - - return new ProjectConfig( - accountId, - anonymizeIP, - botFiltering, - projectId, - revision, - version, - attributes, - audiences, - events, - experiments, - featureFlags, - groups, - liveVariables, - rollouts - ); - } -} \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/TypedAudienceJacksonDeserializer.java b/core-api/src/main/java/com/optimizely/ab/config/parser/TypedAudienceJacksonDeserializer.java new file mode 100644 index 000000000..d6be5bfa4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/TypedAudienceJacksonDeserializer.java @@ -0,0 +1,57 @@ +/** + * Copyright 2019, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.config.audience.UserAttribute; + +import java.io.IOException; + +public class TypedAudienceJacksonDeserializer extends JsonDeserializer<TypedAudience> { + private ObjectMapper objectMapper; + + public TypedAudienceJacksonDeserializer() { + this(new ObjectMapper()); + } + + TypedAudienceJacksonDeserializer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public TypedAudience deserialize(JsonParser parser, DeserializationContext context) throws IOException { + ObjectCodec codec = parser.getCodec(); + JsonNode node = codec.readTree(parser); + + String id = node.get("id").textValue(); + String name = node.get("name").textValue(); + + JsonNode conditionsJson = node.get("conditions"); + + Condition conditions = ConditionJacksonDeserializer.<UserAttribute>parseCondition(UserAttribute.class, objectMapper, conditionsJson); + + return new TypedAudience(id, name, conditions); + } + +} + diff --git a/core-api/src/main/java/com/optimizely/ab/error/NoOpErrorHandler.java b/core-api/src/main/java/com/optimizely/ab/error/NoOpErrorHandler.java index ef763a21f..2799f4fec 100644 --- a/core-api/src/main/java/com/optimizely/ab/error/NoOpErrorHandler.java +++ b/core-api/src/main/java/com/optimizely/ab/error/NoOpErrorHandler.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,5 +24,6 @@ public class NoOpErrorHandler implements ErrorHandler { @Override - public <T extends OptimizelyRuntimeException> void handleError(T exception) {} + public <T extends OptimizelyRuntimeException> void handleError(T exception) { + } } diff --git a/core-api/src/main/java/com/optimizely/ab/error/RaiseExceptionErrorHandler.java b/core-api/src/main/java/com/optimizely/ab/error/RaiseExceptionErrorHandler.java index 9aa6f17c1..bc1ba8194 100644 --- a/core-api/src/main/java/com/optimizely/ab/error/RaiseExceptionErrorHandler.java +++ b/core-api/src/main/java/com/optimizely/ab/error/RaiseExceptionErrorHandler.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java new file mode 100644 index 000000000..4f31b37e8 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java @@ -0,0 +1,377 @@ +/** + * + * Copyright 2019,2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.event.internal.EventFactory; +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.notification.NotificationCenter; +import java.util.concurrent.locks.ReentrantLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedList; +import java.util.concurrent.*; + +import static com.optimizely.ab.internal.SafetyUtils.tryClose; + +/** + * BatchEventProcessor is a batched implementation of the {@link EventProcessor} + * + * Events passed to the BatchEventProcessor are immediately added to a BlockingQueue. + * + * The BatchEventProcessor maintains a single consumer thread that pulls events off of + * the BlockingQueue and buffers them for either a configured batch size or for a + * maximum duration before the resulting LogEvent is sent to the EventHandler + * and NotificationCenter. + */ +public class BatchEventProcessor implements EventProcessor, AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(BatchEventProcessor.class); + + public static final String CONFIG_BATCH_SIZE = "event.processor.batch.size"; + public static final String CONFIG_BATCH_INTERVAL = "event.processor.batch.interval"; + public static final String CONFIG_CLOSE_TIMEOUT = "event.processor.close.timeout"; + + public static final int DEFAULT_QUEUE_CAPACITY = 1000; + public static final int DEFAULT_EMPTY_COUNT = 2; + public static final int DEFAULT_BATCH_SIZE = 10; + public static final long DEFAULT_BATCH_INTERVAL = TimeUnit.SECONDS.toMillis(30); + public static final long DEFAULT_TIMEOUT_INTERVAL = TimeUnit.SECONDS.toMillis(5); + + private static final Object SHUTDOWN_SIGNAL = new Object(); + private static final Object FLUSH_SIGNAL = new Object(); + + private final BlockingQueue<Object> eventQueue; + @VisibleForTesting + public final EventHandler eventHandler; + + final int batchSize; + final long flushInterval; + final long timeoutMillis; + private final ExecutorService executor; + private final NotificationCenter notificationCenter; + + private Future<?> future; + private boolean isStarted = false; + private final ReentrantLock lock = new ReentrantLock(); + + private BatchEventProcessor(BlockingQueue<Object> eventQueue, EventHandler eventHandler, Integer batchSize, Long flushInterval, Long timeoutMillis, ExecutorService executor, NotificationCenter notificationCenter) { + this.eventHandler = eventHandler; + this.eventQueue = eventQueue; + this.batchSize = batchSize; + this.flushInterval = flushInterval; + this.timeoutMillis = timeoutMillis; + this.notificationCenter = notificationCenter; + this.executor = executor; + } + + public void start() { + lock.lock(); + try { + if (isStarted) { + logger.info("Executor already started."); + return; + } + + isStarted = true; + EventConsumer runnable = new EventConsumer(); + future = executor.submit(runnable); + } finally { + lock.unlock(); + } + } + + @Override + public void close() throws Exception { + logger.info("Start close"); + eventQueue.put(SHUTDOWN_SIGNAL); + try { + future.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + logger.warn("Interrupted while awaiting termination."); + Thread.currentThread().interrupt(); + } catch (TimeoutException e) { + logger.error("Timeout exceeded attempting to close for {} ms", timeoutMillis); + } finally { + isStarted = false; + tryClose(eventHandler); + } + } + + public void process(UserEvent userEvent) { + logger.debug("Received userEvent: {}", userEvent); + + if (executor.isShutdown()) { + logger.warn("Executor shutdown, not accepting tasks."); + return; + } + + if (!eventQueue.offer(userEvent)) { + logger.warn("Payload not accepted by the queue. Current size: {}", eventQueue.size()); + } + } + + public void flush() throws InterruptedException { + eventQueue.put(FLUSH_SIGNAL); + } + + public class EventConsumer implements Runnable { + private LinkedList<UserEvent> currentBatch = new LinkedList<>(); + private long deadline = System.currentTimeMillis() + flushInterval; + + @Override + public void run() { + try { + int emptyCount = 0; + + while (true) { + if (System.currentTimeMillis() >= deadline) { + logger.debug("Deadline exceeded flushing current batch."); + flush(); + deadline = System.currentTimeMillis() + flushInterval; + } + + long timeout = deadline - System.currentTimeMillis(); + Object item = emptyCount > DEFAULT_EMPTY_COUNT ? eventQueue.take() : eventQueue.poll(timeout, TimeUnit.MILLISECONDS); + + if (item == null) { + logger.debug("Empty item after waiting flush interval."); + emptyCount++; + continue; + } + + emptyCount = 0; + + if (item == SHUTDOWN_SIGNAL) { + logger.info("Received shutdown signal."); + break; + } + + if (item == FLUSH_SIGNAL) { + logger.debug("Received flush signal."); + flush(); + continue; + } + + addToBatch((UserEvent) item); + } + } catch (InterruptedException e) { + logger.info("Interrupted while processing buffer."); + } catch (Exception e) { + logger.error("Uncaught exception processing buffer.", e); + } finally { + logger.info("Exiting processing loop. Attempting to flush pending events."); + flush(); + } + } + + private void addToBatch(UserEvent userEvent) { + if (shouldSplit(userEvent)) { + flush(); + currentBatch = new LinkedList<>(); + } + + // Reset the deadline if starting a new batch. + if (currentBatch.isEmpty()) { + deadline = System.currentTimeMillis() + flushInterval; + } + + currentBatch.add(userEvent); + if (currentBatch.size() >= batchSize) { + flush(); + } + } + + private boolean shouldSplit(UserEvent userEvent) { + if (currentBatch.isEmpty()) { + return false; + } + + ProjectConfig currentConfig = currentBatch.peekLast().getUserContext().getProjectConfig(); + ProjectConfig newConfig = userEvent.getUserContext().getProjectConfig(); + + // Projects should match + if (!currentConfig.getProjectId().equals(newConfig.getProjectId())) { + return true; + } + + // Revisions should match + if (!currentConfig.getRevision().equals(newConfig.getRevision())) { + return true; + } + + return false; + } + + private void flush() { + if (currentBatch.isEmpty()) { + return; + } + + LogEvent logEvent = EventFactory.createLogEvent(currentBatch); + + if (notificationCenter != null) { + notificationCenter.send(logEvent); + } + + try { + eventHandler.dispatchEvent(logEvent); + } catch (Exception e) { + logger.error("Error dispatching event: {}", logEvent, e); + } + currentBatch = new LinkedList<>(); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private BlockingQueue<Object> eventQueue = new ArrayBlockingQueue<>(DEFAULT_QUEUE_CAPACITY); + private EventHandler eventHandler = null; + private Integer batchSize = PropertyUtils.getInteger(CONFIG_BATCH_SIZE, DEFAULT_BATCH_SIZE); + private Long flushInterval = PropertyUtils.getLong(CONFIG_BATCH_INTERVAL, DEFAULT_BATCH_INTERVAL); + private Long timeoutMillis = PropertyUtils.getLong(CONFIG_CLOSE_TIMEOUT, DEFAULT_TIMEOUT_INTERVAL); + private ExecutorService executor = null; + private NotificationCenter notificationCenter = null; + + /** + * {@link EventHandler} implementation used to dispatch events to Optimizely. + * + * @param eventHandler The event handler + * @return The BatchEventProcessor builder + */ + public Builder withEventHandler(EventHandler eventHandler) { + this.eventHandler = eventHandler; + return this; + } + + /** + * EventQueue is the underlying BlockingQueue used to buffer events before being added to the batch payload. + * + * @param eventQueue The event queue + * @return The BatchEventProcessor builder + */ + public Builder withEventQueue(BlockingQueue<Object> eventQueue) { + this.eventQueue = eventQueue; + return this; + } + + /** + * BatchSize is the maximum number of events contained within a single event batch. + * + * @param batchSize The batch size + * @return The BatchEventProcessor builder + */ + public Builder withBatchSize(Integer batchSize) { + this.batchSize = batchSize; + return this; + } + + /** + * FlushInterval is the maximum duration, in milliseconds, that an event will remain in flight before + * being flushed to the event dispatcher. + * + * @param flushInterval The flush interval + * @return The BatchEventProcessor builder + */ + public Builder withFlushInterval(Long flushInterval) { + this.flushInterval = flushInterval; + return this; + } + + /** + * ExecutorService used to execute the {@link EventConsumer} thread. + * + * @param executor The ExecutorService + * @return The BatchEventProcessor builder + */ + public Builder withExecutor(ExecutorService executor) { + this.executor = executor; + return this; + } + + /** + * Timeout is the maximum time to wait for the EventProcessor to close. + * + * @param duration The max time to wait for the EventProcessor to close + * @param timeUnit The time unit + * @return The BatchEventProcessor builder + */ + public Builder withTimeout(long duration, TimeUnit timeUnit) { + this.timeoutMillis = timeUnit.toMillis(duration); + return this; + } + + /** + * NotificationCenter used to notify when event batches are flushed. + * + * @param notificationCenter The NotificationCenter + * @return The BatchEventProcessor builder + */ + public Builder withNotificationCenter(NotificationCenter notificationCenter) { + this.notificationCenter = notificationCenter; + return this; + } + + public BatchEventProcessor build() { + return build(true); + } + + public BatchEventProcessor build(boolean shouldStart) { + if (batchSize < 0) { + logger.warn("Invalid batchSize of {}, Defaulting to {}", batchSize, DEFAULT_BATCH_SIZE); + batchSize = DEFAULT_BATCH_SIZE; + } + + if (flushInterval < 0) { + logger.warn("Invalid flushInterval of {}, Defaulting to {}", flushInterval, DEFAULT_BATCH_INTERVAL); + flushInterval = DEFAULT_BATCH_INTERVAL; + } + + if (timeoutMillis < 0) { + logger.warn("Invalid timeoutMillis of {}, Defaulting to {}", timeoutMillis, DEFAULT_TIMEOUT_INTERVAL); + timeoutMillis = DEFAULT_TIMEOUT_INTERVAL; + } + + if (eventHandler == null) { + throw new IllegalArgumentException("EventHandler was not configured"); + } + + if (executor == null) { + final ThreadFactory threadFactory = Executors.defaultThreadFactory(); + executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = threadFactory.newThread(runnable); + thread.setDaemon(true); + return thread; + }); + } + + BatchEventProcessor batchEventProcessor = new BatchEventProcessor(eventQueue, eventHandler, batchSize, flushInterval, timeoutMillis, executor, notificationCenter); + + if (shouldStart) { + batchEventProcessor.start(); + } + + return batchEventProcessor; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/EventHandler.java b/core-api/src/main/java/com/optimizely/ab/event/EventHandler.java index 7df38fabb..e76cf65c8 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/EventHandler.java +++ b/core-api/src/main/java/com/optimizely/ab/event/EventHandler.java @@ -20,6 +20,5 @@ * Implementations are responsible for dispatching event's to the Optimizely event end-point. */ public interface EventHandler { - void dispatchEvent(LogEvent logEvent) throws Exception; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/EventProcessor.java b/core-api/src/main/java/com/optimizely/ab/event/EventProcessor.java new file mode 100644 index 000000000..d03ea85c0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/EventProcessor.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.notification.NotificationHandler; + +/** + * EventProcessor interface is used to provide an intermediary processing stage within + * event production. It's assumed that the EventProcessor dispatches events via a provided + * {@link EventHandler}. + */ +public interface EventProcessor { + void process(UserEvent userEvent); +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/ForwardingEventProcessor.java b/core-api/src/main/java/com/optimizely/ab/event/ForwardingEventProcessor.java new file mode 100644 index 000000000..a9575b147 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/ForwardingEventProcessor.java @@ -0,0 +1,55 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.optimizely.ab.event.internal.EventFactory; +import com.optimizely.ab.event.internal.UserEvent; +import com.optimizely.ab.notification.NotificationCenter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ForwardingEventProcessor is a basic transformation stage for converting + * the event batch into a LogEvent to be dispatched. + */ +public class ForwardingEventProcessor implements EventProcessor { + + private static final Logger logger = LoggerFactory.getLogger(ForwardingEventProcessor.class); + + private final EventHandler eventHandler; + private final NotificationCenter notificationCenter; + + public ForwardingEventProcessor(EventHandler eventHandler, NotificationCenter notificationCenter) { + this.eventHandler = eventHandler; + this.notificationCenter = notificationCenter; + } + + @Override + public void process(UserEvent userEvent) { + LogEvent logEvent = EventFactory.createLogEvent(userEvent); + + if (notificationCenter != null) { + notificationCenter.send(logEvent); + } + + try { + eventHandler.dispatchEvent(logEvent); + } catch(Exception e) { + logger.error("Error dispatching event: {}", logEvent, e); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/LogEvent.java b/core-api/src/main/java/com/optimizely/ab/event/LogEvent.java index d985c3f4f..b73ad431e 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/LogEvent.java +++ b/core-api/src/main/java/com/optimizely/ab/event/LogEvent.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import com.optimizely.ab.event.internal.serializer.Serializer; import java.util.Map; +import java.util.Objects; import javax.annotation.Nonnull; import javax.annotation.concurrent.Immutable; @@ -69,16 +70,36 @@ public String getBody() { return serializer.serialize(eventBatch); } + public EventBatch getEventBatch() { + return eventBatch; + } + //======== Overriding method ========// @Override public String toString() { return "LogEvent{" + - "requestMethod=" + requestMethod + - ", endpointUrl='" + endpointUrl + '\'' + - ", requestParams=" + requestParams + - ", body='" + getBody() + '\'' + - '}'; + "requestMethod=" + requestMethod + + ", endpointUrl='" + endpointUrl + '\'' + + ", requestParams=" + requestParams + + ", body='" + getBody() + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LogEvent logEvent = (LogEvent) o; + return requestMethod == logEvent.requestMethod && + Objects.equals(endpointUrl, logEvent.endpointUrl) && + Objects.equals(requestParams, logEvent.requestParams) && + Objects.equals(eventBatch, logEvent.eventBatch); + } + + @Override + public int hashCode() { + return Objects.hash(requestMethod, endpointUrl, requestParams, eventBatch); } //======== Helper classes ========// diff --git a/core-api/src/main/java/com/optimizely/ab/event/NoopEventHandler.java b/core-api/src/main/java/com/optimizely/ab/event/NoopEventHandler.java index e84ff4c58..7d3e6a522 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/NoopEventHandler.java +++ b/core-api/src/main/java/com/optimizely/ab/event/NoopEventHandler.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,6 @@ public class NoopEventHandler implements EventHandler { @Override public void dispatchEvent(LogEvent logEvent) { logger.debug("Called dispatchEvent with URL: {} and params: {}", logEvent.getEndpointUrl(), - logEvent.getRequestParams()); + logEvent.getRequestParams()); } } \ No newline at end of file diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/BaseEvent.java b/core-api/src/main/java/com/optimizely/ab/event/internal/BaseEvent.java new file mode 100644 index 000000000..c62a29917 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/BaseEvent.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import java.util.UUID; + +/** + * BaseEvent provides a GUID implementation along with a system timestamp. + */ +public class BaseEvent { + + private final String uuid = UUID.randomUUID().toString(); + private final long timestamp = System.currentTimeMillis(); + + public final String getUUID() { + return uuid; + } + + public final long getTimestamp() { + return timestamp; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java index b600dd763..f69be7cb5 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/BuildVersionInfo.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,30 +30,52 @@ /** * Helper class to retrieve the SDK version information. */ -@Immutable public final class BuildVersionInfo { private static final Logger logger = LoggerFactory.getLogger(BuildVersionInfo.class); + @Deprecated public final static String VERSION = readVersionNumber(); + + public final static String DEFAULT_VERSION = readVersionNumber(); + // can be overridden by other wrapper client (android-sdk, etc) + private static String clientVersion = DEFAULT_VERSION; + + public static void setClientVersion(String version) { + if (version == null || version.isEmpty()) { + logger.warn("ClientVersion cannot be empty, defaulting to the core java-sdk version."); + return; + } + clientVersion = version; + } + + public static String getClientVersion() { + return clientVersion; + } + private static String readVersionNumber() { - BufferedReader bufferedReader = - new BufferedReader( - new InputStreamReader(BuildVersionInfo.class.getResourceAsStream("/optimizely-build-version"), - Charset.forName("UTF-8"))); + BufferedReader bufferedReader = null; try { + bufferedReader = + new BufferedReader( + new InputStreamReader(BuildVersionInfo.class.getResourceAsStream("/optimizely-build-version"), + Charset.forName("UTF-8"))); + return bufferedReader.readLine(); } catch (Exception e) { logger.error("unable to read version number"); return "unknown"; } finally { try { - bufferedReader.close(); + if (bufferedReader != null) { + bufferedReader.close(); + } } catch (Exception e) { logger.error("unable to close reader cleanly"); } } } - private BuildVersionInfo() { } + private BuildVersionInfo() { + } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java b/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java new file mode 100644 index 000000000..85573d7fc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/ClientEngineInfo.java @@ -0,0 +1,82 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.notification.DecisionNotification; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * ClientEngineInfo is a utility to globally get and set the ClientEngine used in + * event tracking. The ClientEngine defaults to JAVA_SDK but can be overridden at + * runtime. + */ +public class ClientEngineInfo { + private static final Logger logger = LoggerFactory.getLogger(ClientEngineInfo.class); + + public static final String DEFAULT_NAME = "java-sdk"; + private static String clientEngineName = DEFAULT_NAME; + + public static void setClientEngineName(@Nullable String name) { + if (name == null || name.isEmpty()) { + logger.warn("ClientEngineName cannot be empty, defaulting to {}", ClientEngineInfo.clientEngineName); + return; + } + ClientEngineInfo.clientEngineName = name; + } + + @Nonnull + public static String getClientEngineName() { + return clientEngineName; + } + + private ClientEngineInfo() { + } + + @Deprecated + public static final EventBatch.ClientEngine DEFAULT = EventBatch.ClientEngine.JAVA_SDK; + @Deprecated + private static EventBatch.ClientEngine clientEngine = DEFAULT; + + /** + * @deprecated in favor of {@link #setClientEngineName(String)} which can set with arbitrary client names. + */ + @Deprecated + public static void setClientEngine(EventBatch.ClientEngine clientEngine) { + if (clientEngine == null) { + logger.warn("ClientEngine cannot be null, defaulting to {}", ClientEngineInfo.clientEngine.getClientEngineValue()); + return; + } + + logger.info("Setting Optimizely client engine to {}", clientEngine.getClientEngineValue()); + ClientEngineInfo.clientEngine = clientEngine; + ClientEngineInfo.clientEngineName = clientEngine.getClientEngineValue(); + } + + /** + * @deprecated in favor of {@link #getClientEngineName()}. + */ + @Deprecated + public static EventBatch.ClientEngine getClientEngine() { + return clientEngine; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/ConversionEvent.java b/core-api/src/main/java/com/optimizely/ab/event/internal/ConversionEvent.java new file mode 100644 index 000000000..ce013ac9a --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/ConversionEvent.java @@ -0,0 +1,124 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import java.util.Map; +import java.util.StringJoiner; + +/** + * ConversionEvent encapsulates information specific to conversion events. + */ +public class ConversionEvent extends BaseEvent implements UserEvent { + + private final UserContext userContext; + private final String eventId; + private final String eventKey; + private final Number revenue; + private final Number value; + private final Map<String, ?> tags; + + + private ConversionEvent(UserContext userContext, String eventId, String eventKey, Number revenue, Number value, Map<String, ?> tags) { + this.userContext = userContext; + this.eventId = eventId; + this.eventKey = eventKey; + this.revenue = revenue; + this.value = value; + this.tags = tags; + } + + @Override + public UserContext getUserContext() { + return userContext; + } + + public String getEventId() { + return eventId; + } + + public String getEventKey() { + return eventKey; + } + + public Number getRevenue() { + return revenue; + } + + public Number getValue() { + return value; + } + + public Map<String, ?> getTags() { + return tags; + } + + public static class Builder { + + private UserContext userContext; + private String eventId; + private String eventKey; + private Number revenue; + private Number value; + private Map<String, ?> tags; + + public Builder withUserContext(UserContext userContext) { + this.userContext = userContext; + return this; + } + + public Builder withEventId(String eventId) { + this.eventId = eventId; + return this; + } + + public Builder withEventKey(String eventKey) { + this.eventKey = eventKey; + return this; + } + + public Builder withRevenue(Number revenue) { + this.revenue = revenue; + return this; + } + + public Builder withValue(Number value) { + this.value = value; + return this; + } + + public Builder withTags(Map<String, ?> tags) { + this.tags = tags; + return this; + } + + public ConversionEvent build() { + return new ConversionEvent(userContext, eventId, eventKey, revenue, value, tags); + } + } + + @Override + public String toString() { + return new StringJoiner(", ", ConversionEvent.class.getSimpleName() + "[", "]") + .add("userContext=" + userContext) + .add("eventId='" + eventId + "'") + .add("eventKey='" + eventKey + "'") + .add("revenue=" + revenue) + .add("value=" + value) + .add("tags=" + tags) + .toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java index a7aedda50..47839810d 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/EventFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,11 +16,7 @@ */ package com.optimizely.ab.event.internal; -import com.optimizely.ab.annotations.VisibleForTesting; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Variation; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.payload.Attribute; import com.optimizely.ab.event.internal.payload.Decision; @@ -28,169 +24,181 @@ import com.optimizely.ab.event.internal.payload.Event; import com.optimizely.ab.event.internal.payload.Snapshot; import com.optimizely.ab.event.internal.payload.Visitor; -import com.optimizely.ab.internal.EventTagUtils; import com.optimizely.ab.internal.ControlAttribute; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.annotation.Nonnull; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.UUID; +import static com.optimizely.ab.internal.AttributesUtil.isValidNumber; + +/** + * EventFactory builds {@link LogEvent} objects from a given {@link UserEvent} + * + * This class serves to separate concerns between events in the SDK and the API used + * to record the events via the <a href="https://developers.optimizely.com/x/events/api/index.html">Optimizely Events API</a>. + */ public class EventFactory { private static final Logger logger = LoggerFactory.getLogger(EventFactory.class); - static final String EVENT_ENDPOINT = "https://logx.optimizely.com/v1/events"; // Should be part of the datafile - static final String ACTIVATE_EVENT_KEY = "campaign_activated"; + public static final String EVENT_ENDPOINT = "https://logx.optimizely.com/v1/events"; // Should be part of the datafile + private static final String ACTIVATE_EVENT_KEY = "campaign_activated"; - @VisibleForTesting - public final String clientVersion; - @VisibleForTesting - public final EventBatch.ClientEngine clientEngine; - - public EventFactory() { - this(EventBatch.ClientEngine.JAVA_SDK, BuildVersionInfo.VERSION); + public static LogEvent createLogEvent(UserEvent userEvent) { + return createLogEvent(Collections.singletonList(userEvent)); } - public EventFactory(EventBatch.ClientEngine clientEngine, String clientVersion) { - this.clientEngine = clientEngine; - this.clientVersion = clientVersion; - } + public static LogEvent createLogEvent(List<UserEvent> userEvents) { + EventBatch.Builder builder = new EventBatch.Builder(); + List<Visitor> visitors = new ArrayList<>(userEvents.size()); - public LogEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment activatedExperiment, - @Nonnull Variation variation, - @Nonnull String userId, - @Nonnull Map<String, String> attributes) { + for (UserEvent userEvent: userEvents) { - Decision decision = new Decision.Builder() - .setCampaignId(activatedExperiment.getLayerId()) - .setExperimentId(activatedExperiment.getId()) - .setVariationId(variation.getId()) - .setIsCampaignHoldback(false) - .build(); + if (userEvent == null) { + continue; + } - Event impressionEvent = new Event.Builder() - .setTimestamp(System.currentTimeMillis()) - .setUuid(UUID.randomUUID().toString()) - .setEntityId(activatedExperiment.getLayerId()) - .setKey(ACTIVATE_EVENT_KEY) - .setType(ACTIVATE_EVENT_KEY) - .build(); + if (userEvent instanceof ImpressionEvent) { + visitors.add(createVisitor((ImpressionEvent) userEvent)); + } - Snapshot snapshot = new Snapshot.Builder() - .setDecisions(Collections.singletonList(decision)) - .setEvents(Collections.singletonList(impressionEvent)) - .build(); + if (userEvent instanceof ConversionEvent) { + visitors.add(createVisitor((ConversionEvent) userEvent)); + } - Visitor visitor = new Visitor.Builder() - .setVisitorId(userId) - .setAttributes(buildAttributeList(projectConfig, attributes)) - .setSnapshots(Collections.singletonList((snapshot))) - .build(); + // This needs an interface. + UserContext userContext = userEvent.getUserContext(); + ProjectConfig projectConfig = userContext.getProjectConfig(); - EventBatch eventBatch = new EventBatch.Builder() - .setClientName(clientEngine.getClientEngineValue()) - .setClientVersion(clientVersion) + builder + .setClientName(ClientEngineInfo.getClientEngineName()) + .setClientVersion(BuildVersionInfo.getClientVersion()) .setAccountId(projectConfig.getAccountId()) - .setVisitors(Collections.singletonList(visitor)) .setAnonymizeIp(projectConfig.getAnonymizeIP()) .setProjectId(projectConfig.getProjectId()) - .setRevision(projectConfig.getRevision()) - .build(); - - return new LogEvent(LogEvent.RequestMethod.POST, EVENT_ENDPOINT, Collections.<String, String>emptyMap(), eventBatch); - } - - public LogEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, - @Nonnull Map<Experiment, Variation> experimentVariationMap, - @Nonnull String userId, - @Nonnull String eventId, // Why is this not used? - @Nonnull String eventName, - @Nonnull Map<String, String> attributes, - @Nonnull Map<String, ?> eventTags) { + .setRevision(projectConfig.getRevision()); + } - if (experimentVariationMap.isEmpty()) { + if (visitors.isEmpty()) { return null; } - ArrayList<Decision> decisions = new ArrayList<Decision>(experimentVariationMap.size()); - for (Map.Entry<Experiment, Variation> entry : experimentVariationMap.entrySet()) { - Decision decision = new Decision.Builder() - .setCampaignId(entry.getKey().getLayerId()) - .setExperimentId(entry.getKey().getId()) - .setVariationId(entry.getValue().getId()) - .setIsCampaignHoldback(false) - .build(); + builder.setVisitors(visitors); + return new LogEvent(LogEvent.RequestMethod.POST, EVENT_ENDPOINT, Collections.emptyMap(), builder.build()); + } - decisions.add(decision); + private static Visitor createVisitor(ImpressionEvent impressionEvent) { + if (impressionEvent == null) { + return null; } - EventType eventType = projectConfig.getEventNameMapping().get(eventName); - - Event conversionEvent = new Event.Builder() - .setTimestamp(System.currentTimeMillis()) - .setUuid(UUID.randomUUID().toString()) - .setEntityId(eventType.getId()) - .setKey(eventType.getKey()) - .setRevenue(EventTagUtils.getRevenueValue(eventTags)) - .setTags(eventTags) - .setType(eventType.getKey()) - .setValue(EventTagUtils.getNumericValue(eventTags)) - .build(); + UserContext userContext = impressionEvent.getUserContext(); - Snapshot snapshot = new Snapshot.Builder() - .setDecisions(decisions) - .setEvents(Collections.singletonList((conversionEvent))) - .build(); + Decision decision = new Decision.Builder() + .setCampaignId(impressionEvent.getLayerId()) + .setExperimentId(impressionEvent.getExperimentId()) + .setVariationId(impressionEvent.getVariationId()) + .setMetadata(impressionEvent.getMetadata()) + .setIsCampaignHoldback(false) + .build(); + + Event event = new Event.Builder() + .setTimestamp(impressionEvent.getTimestamp()) + .setUuid(impressionEvent.getUUID()) + .setEntityId(impressionEvent.getLayerId()) + .setKey(ACTIVATE_EVENT_KEY) + .setType(ACTIVATE_EVENT_KEY) + .build(); - Visitor visitor = new Visitor.Builder() - .setVisitorId(userId) - .setAttributes(buildAttributeList(projectConfig, attributes)) - .setSnapshots(Collections.singletonList(snapshot)) - .build(); + Snapshot snapshot = new Snapshot.Builder() + .setDecisions(Collections.singletonList(decision)) + .setEvents(Collections.singletonList(event)) + .build(); + + return new Visitor.Builder() + .setVisitorId(userContext.getUserId()) + .setAttributes(buildAttributeList(userContext.getProjectConfig(), userContext.getAttributes())) + .setSnapshots(Collections.singletonList((snapshot))) + .build(); + } - EventBatch eventBatch = new EventBatch.Builder() - .setClientName(clientEngine.getClientEngineValue()) - .setClientVersion(clientVersion) - .setAccountId(projectConfig.getAccountId()) - .setVisitors(Collections.singletonList(visitor)) - .setAnonymizeIp(projectConfig.getAnonymizeIP()) - .setProjectId(projectConfig.getProjectId()) - .setRevision(projectConfig.getRevision()) - .build(); + private static Visitor createVisitor(ConversionEvent conversionEvent) { + if (conversionEvent == null) { + return null; + } - return new LogEvent(LogEvent.RequestMethod.POST, EVENT_ENDPOINT, Collections.<String, String>emptyMap(), eventBatch); - } + UserContext userContext = conversionEvent.getUserContext(); - private List<Attribute> buildAttributeList(ProjectConfig projectConfig, Map<String, String> attributes) { - List<Attribute> attributesList = new ArrayList<Attribute>(); + Event event = new Event.Builder() + .setTimestamp(conversionEvent.getTimestamp()) + .setUuid(conversionEvent.getUUID()) + .setEntityId(conversionEvent.getEventId()) + .setKey(conversionEvent.getEventKey()) + .setRevenue(conversionEvent.getRevenue()) + .setTags(conversionEvent.getTags()) + .setType(conversionEvent.getEventKey()) + .setValue(conversionEvent.getValue()) + .build(); - for (Map.Entry<String, String> entry : attributes.entrySet()) { - String attributeId = projectConfig.getAttributeId(projectConfig, entry.getKey()); - if(attributeId == null) { - continue; - } + Snapshot snapshot = new Snapshot.Builder() + .setEvents(Collections.singletonList(event)) + .build(); + + return new Visitor.Builder() + .setVisitorId(userContext.getUserId()) + .setAttributes(buildAttributeList(userContext.getProjectConfig(), userContext.getAttributes())) + .setSnapshots(Collections.singletonList(snapshot)) + .build(); + } - Attribute attribute = new Attribute.Builder() + private static List<Attribute> buildAttributeList(ProjectConfig projectConfig, Map<String, ?> attributes) { + List<Attribute> attributesList = new ArrayList<>(); + + if (attributes != null) { + for (Map.Entry<String, ?> entry : attributes.entrySet()) { + + // Ignore attributes with empty key + if (entry.getKey().isEmpty()) { + continue; + } + + // Filter down to the types of values we're allowed to track. + // Don't allow Longs, BigIntegers, or BigDecimals - they /can/ theoretically be serialized as JSON numbers + // but may take on values that can't be faithfully parsed by the backend. + // https://developers.optimizely.com/x/events/api/#Attribute + if (entry.getValue() == null || + !((entry.getValue() instanceof String) || + (entry.getValue() instanceof Boolean) || + (isValidNumber(entry.getValue())))) { + continue; + } + + String attributeId = projectConfig.getAttributeId(projectConfig, entry.getKey()); + if (attributeId == null) { + continue; + } + + Attribute attribute = new Attribute.Builder() .setEntityId(attributeId) .setKey(entry.getKey()) .setType(Attribute.CUSTOM_ATTRIBUTE_TYPE) .setValue(entry.getValue()) .build(); - attributesList.add(attribute); + attributesList.add(attribute); + + } } //checks if botFiltering value is not set in the project config file. - if(projectConfig.getBotFiltering() != null) { + if (projectConfig.getBotFiltering() != null) { Attribute attribute = new Attribute.Builder() - .setEntityId(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) - .setKey(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) - .setType(Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue(projectConfig.getBotFiltering()) - .build(); + .setEntityId(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) + .setKey(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) + .setType(Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue(projectConfig.getBotFiltering()) + .build(); attributesList.add(attribute); } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java b/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java new file mode 100644 index 000000000..38f9dc905 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/ImpressionEvent.java @@ -0,0 +1,139 @@ +/** + * + * Copyright 2019-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import com.optimizely.ab.event.internal.payload.DecisionMetadata; + +import java.util.StringJoiner; + +/** + * ImpressionEvent encapsulates information specific to conversion events. + */ +public class ImpressionEvent extends BaseEvent implements UserEvent { + private final UserContext userContext; + private final String layerId; + private final String experimentId; + private final String experimentKey; + private final String variationKey; + private final String variationId; + private final DecisionMetadata metadata; + + private ImpressionEvent(UserContext userContext, + String layerId, + String experimentId, + String experimentKey, + String variationKey, + String variationId, + DecisionMetadata metadata) { + this.userContext = userContext; + this.layerId = layerId; + this.experimentId = experimentId; + this.experimentKey = experimentKey; + this.variationKey = variationKey; + this.variationId = variationId; + this.metadata = metadata; + } + + @Override + public UserContext getUserContext() { + return userContext; + } + + public String getLayerId() { + return layerId; + } + + public String getExperimentId() { + return experimentId; + } + + public String getExperimentKey() { + return experimentKey; + } + + public String getVariationKey() { + return variationKey; + } + + public String getVariationId() { + return variationId; + } + + public DecisionMetadata getMetadata() { return metadata; } + + public static class Builder { + + private UserContext userContext; + private String layerId; + private String experimentId; + private String experimentKey; + private String variationKey; + private String variationId; + private DecisionMetadata metadata; + + public Builder withUserContext(UserContext userContext) { + this.userContext = userContext; + return this; + } + + public Builder withLayerId(String layerId) { + this.layerId = layerId; + return this; + } + + public Builder withExperimentId(String experimentId) { + this.experimentId = experimentId; + return this; + } + + public Builder withExperimentKey(String experimentKey) { + this.experimentKey = experimentKey; + return this; + } + + public Builder withVariationKey(String variationKey) { + this.variationKey = variationKey; + return this; + } + + public Builder withVariationId(String variationId) { + this.variationId = variationId; + return this; + } + + public Builder withMetadata(DecisionMetadata metadata) { + this.metadata = metadata; + return this; + } + + public ImpressionEvent build() { + return new ImpressionEvent(userContext, layerId, experimentId, experimentKey, variationKey, variationId, metadata); + } + } + + @Override + public String toString() { + return new StringJoiner(", ", ImpressionEvent.class.getSimpleName() + "[", "]") + .add("userContext=" + userContext) + .add("layerId='" + layerId + "'") + .add("experimentId='" + experimentId + "'") + .add("experimentKey='" + experimentKey + "'") + .add("variationKey='" + variationKey + "'") + .add("variationId='" + variationId + "'") + .toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserContext.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserContext.java new file mode 100644 index 000000000..e5d359652 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserContext.java @@ -0,0 +1,84 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import com.optimizely.ab.config.ProjectConfig; + +import java.util.Map; +import java.util.StringJoiner; + +/** + * UserContext stores the user id, attributes and a reference to the current {@link ProjectConfig}. + */ +public class UserContext { + private final ProjectConfig projectConfig; + private final String userId; + private final Map<String, ?> attributes; + + private UserContext(ProjectConfig projectConfig, String userId, Map<String, ?> attributes) { + this.projectConfig = projectConfig; + this.userId = userId; + this.attributes = attributes; + } + + public ProjectConfig getProjectConfig() { + return projectConfig; + } + + public String getUserId() { + return userId; + } + + public Map<String, ?> getAttributes() { + return attributes; + } + + public static class Builder { + + private ProjectConfig projectConfig; + private String userId; + private Map<String, ?> attributes; + + public Builder withProjectConfig(ProjectConfig projectConfig) { + this.projectConfig = projectConfig; + return this; + } + + public Builder withUserId(String userId) { + this.userId = userId; + return this; + } + + public Builder withAttributes(Map<String, ?> attributes) { + this.attributes = attributes; + return this; + } + + public UserContext build() { + return new UserContext(projectConfig, userId, attributes); + } + } + + @Override + public String toString() { + return new StringJoiner(", ", UserContext.class.getSimpleName() + "[", "]") + .add("projectConfig=" + projectConfig.getRevision()) + .add("userId='" + userId + "'") + .add("attributes=" + attributes) + .toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEvent.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEvent.java new file mode 100644 index 000000000..345e6e2de --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEvent.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +/** + * UserEvent interface is used to identify events containing a {@link UserContext} + * Examples include: + * <ul> + * <li>{@link ConversionEvent}</li> + * <li>{@link ImpressionEvent}</li> + * </ul> + */ +public interface UserEvent { + UserContext getUserContext(); + String getUUID(); + long getTimestamp(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java new file mode 100644 index 000000000..9c44f455b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/UserEventFactory.java @@ -0,0 +1,110 @@ +/** + * + * Copyright 2016-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.internal.EventTagUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; + +public class UserEventFactory { + private static final Logger logger = LoggerFactory.getLogger(UserEventFactory.class); + + public static ImpressionEvent createImpressionEvent(@Nonnull ProjectConfig projectConfig, + @Nullable Experiment activatedExperiment, + @Nullable Variation variation, + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nonnull String flagKey, + @Nonnull String ruleType, + @Nonnull boolean enabled) { + + if ((FeatureDecision.DecisionSource.ROLLOUT.toString().equals(ruleType) || variation == null) && !projectConfig.getSendFlagDecisions()) + { + return null; + } + + String variationKey = ""; + String variationID = ""; + String layerID = null; + String experimentId = null; + String experimentKey = ""; + + if (variation != null) { + variationKey = variation.getKey(); + variationID = variation.getId(); + layerID = activatedExperiment != null ? activatedExperiment.getLayerId() : ""; + experimentId = activatedExperiment != null ? activatedExperiment.getId() : ""; + experimentKey = activatedExperiment != null ? activatedExperiment.getKey() : ""; + } + + UserContext userContext = new UserContext.Builder() + .withUserId(userId) + .withAttributes(attributes) + .withProjectConfig(projectConfig) + .build(); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(ruleType) + .setVariationKey(variationKey) + .setEnabled(enabled) + .build(); + + return new ImpressionEvent.Builder() + .withUserContext(userContext) + .withLayerId(layerID) + .withExperimentId(experimentId) + .withExperimentKey(experimentKey) + .withVariationId(variationID) + .withVariationKey(variationKey) + .withMetadata(metadata) + .build(); + } + + public static ConversionEvent createConversionEvent(@Nonnull ProjectConfig projectConfig, + @Nonnull String userId, + @Nonnull String eventId, // Why is this not used? + @Nonnull String eventName, + @Nonnull Map<String, ?> attributes, + @Nonnull Map<String, ?> eventTags) { + + + UserContext userContext = new UserContext.Builder() + .withUserId(userId) + .withAttributes(attributes) + .withProjectConfig(projectConfig) + .build(); + + return new ConversionEvent.Builder() + .withUserContext(userContext) + .withEventId(eventId) + .withEventKey(eventName) + .withRevenue(EventTagUtils.getRevenueValue(eventTags)) + .withValue(EventTagUtils.getNumericValue(eventTags)) + .withTags(eventTags) + .build(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Attribute.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Attribute.java index fdbb276a9..bbafb236c 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Attribute.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Attribute.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018, Optimizely and contributors + * Copyright 2018-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,8 @@ public class Attribute { Object value; @VisibleForTesting - public Attribute() { } + public Attribute() { + } private Attribute(String entityId, String key, String type, Object value) { this.entityId = entityId; diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java index a12c9e36c..e472d236e 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Decision.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018, Optimizely and contributors + * Copyright 2018-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,27 +16,34 @@ */ package com.optimizely.ab.event.internal.payload; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.optimizely.ab.annotations.VisibleForTesting; public class Decision { + @JsonInclude(JsonInclude.Include.ALWAYS) @JsonProperty("campaign_id") String campaignId; + @JsonInclude(JsonInclude.Include.ALWAYS) @JsonProperty("experiment_id") String experimentId; @JsonProperty("variation_id") String variationId; @JsonProperty("is_campaign_holdback") boolean isCampaignHoldback; + @JsonProperty("metadata") + DecisionMetadata metadata; @VisibleForTesting - public Decision() { } + public Decision() { + } - public Decision(String campaignId, String experimentId, String variationId, boolean isCampaignHoldback) { + public Decision(String campaignId, String experimentId, String variationId, boolean isCampaignHoldback, DecisionMetadata metadata) { this.campaignId = campaignId; this.experimentId = experimentId; this.variationId = variationId; this.isCampaignHoldback = isCampaignHoldback; + this.metadata = metadata; } public String getCampaignId() { @@ -55,6 +62,8 @@ public boolean getIsCampaignHoldback() { return isCampaignHoldback; } + public DecisionMetadata getMetadata() { return metadata; } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -73,6 +82,7 @@ public int hashCode() { int result = campaignId.hashCode(); result = 31 * result + experimentId.hashCode(); result = 31 * result + variationId.hashCode(); + result = 31 * result + metadata.hashCode(); result = 31 * result + (isCampaignHoldback ? 1 : 0); return result; } @@ -83,6 +93,7 @@ public static class Builder { private String experimentId; private String variationId; private boolean isCampaignHoldback; + private DecisionMetadata metadata; public Builder setCampaignId(String campaignId) { this.campaignId = campaignId; @@ -94,6 +105,11 @@ public Builder setExperimentId(String experimentId) { return this; } + public Builder setMetadata(DecisionMetadata metadata) { + this.metadata = metadata; + return this; + } + public Builder setVariationId(String variationId) { this.variationId = variationId; return this; @@ -105,7 +121,7 @@ public Builder setIsCampaignHoldback(boolean isCampaignHoldback) { } public Decision build() { - return new Decision(campaignId, experimentId, variationId, isCampaignHoldback); + return new Decision(campaignId, experimentId, variationId, isCampaignHoldback, metadata); } } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java new file mode 100644 index 000000000..aec6cdce2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/DecisionMetadata.java @@ -0,0 +1,141 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal.payload; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.optimizely.ab.annotations.VisibleForTesting; + +import java.util.StringJoiner; + +public class DecisionMetadata { + + @JsonProperty("flag_key") + String flagKey; + @JsonProperty("rule_key") + String ruleKey; + @JsonProperty("rule_type") + String ruleType; + @JsonProperty("variation_key") + String variationKey; + @JsonProperty("enabled") + boolean enabled; + + @VisibleForTesting + public DecisionMetadata() { + } + + public DecisionMetadata(String flagKey, String ruleKey, String ruleType, String variationKey, boolean enabled) { + this.flagKey = flagKey; + this.ruleKey = ruleKey; + this.ruleType = ruleType; + this.variationKey = variationKey; + this.enabled = enabled; + } + + public String getRuleType() { + return ruleType; + } + + public String getRuleKey() { + return ruleKey; + } + + public boolean getEnabled() { + return enabled; + } + + public String getFlagKey() { + return flagKey; + } + + public String getVariationKey() { + return variationKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DecisionMetadata that = (DecisionMetadata) o; + + if (!ruleType.equals(that.ruleType)) return false; + if (!ruleKey.equals(that.ruleKey)) return false; + if (!flagKey.equals(that.flagKey)) return false; + if (enabled != that.enabled) return false; + return variationKey.equals(that.variationKey); + } + + @Override + public int hashCode() { + int result = ruleType.hashCode(); + result = 31 * result + flagKey.hashCode(); + result = 31 * result + ruleKey.hashCode(); + result = 31 * result + variationKey.hashCode(); + return result; + } + + @Override + public String toString() { + return new StringJoiner(", ", DecisionMetadata.class.getSimpleName() + "[", "]") + .add("flagKey='" + flagKey + "'") + .add("ruleKey='" + ruleKey + "'") + .add("ruleType='" + ruleType + "'") + .add("variationKey='" + variationKey + "'") + .add("enabled=" + enabled) + .toString(); + } + + + public static class Builder { + + private String ruleType; + private String ruleKey; + private String flagKey; + private String variationKey; + private boolean enabled; + + public Builder setEnabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder setRuleType(String ruleType) { + this.ruleType = ruleType; + return this; + } + + public Builder setRuleKey(String ruleKey) { + this.ruleKey = ruleKey; + return this; + } + + public Builder setFlagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public Builder setVariationKey(String variationKey) { + this.variationKey = variationKey; + return this; + } + + public DecisionMetadata build() { + return new DecisionMetadata(flagKey, ruleKey, ruleType, variationKey, enabled); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Event.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Event.java index 4d38f8f93..e48c1403e 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Event.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Event.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018, Optimizely and contributors + * Copyright 2018-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,7 +34,8 @@ public class Event { Number value; @VisibleForTesting - public Event() { } + public Event() { + } public Event(long timestamp, String uuid, String entityId, String key, Number quantity, Number revenue, Map<String, ?> tags, String type, Number value) { diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java index f3850e6ef..43965dafa 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/EventBatch.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018, Optimizely and contributors + * Copyright 2018-2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,10 +24,12 @@ import java.util.List; public class EventBatch { + + @Deprecated public enum ClientEngine { - JAVA_SDK ("java-sdk"), - ANDROID_SDK ("android-sdk"), - ANDROID_TV_SDK ("android-tv-sdk"); + JAVA_SDK("java-sdk"), + ANDROID_SDK("android-sdk"), + ANDROID_TV_SDK("android-tv-sdk"); private final String clientEngineValue; @@ -44,6 +46,8 @@ public String getClientEngineValue() { @JsonProperty("account_id") String accountId; List<Visitor> visitors; + @JsonProperty("enrich_decisions") + Boolean enrichDecisions; @JsonProperty("anonymize_ip") Boolean anonymizeIp; @JsonProperty("client_name") @@ -55,11 +59,13 @@ public String getClientEngineValue() { String revision; @VisibleForTesting - public EventBatch() { } + public EventBatch() { + } private EventBatch(String clientName, String clientVersion, String accountId, List<Visitor> visitors, Boolean anonymizeIp, String projectId, String revision) { this.accountId = accountId; this.visitors = visitors; + this.enrichDecisions = true; this.anonymizeIp = anonymizeIp; this.clientName = clientName; this.clientVersion = clientVersion; @@ -83,6 +89,12 @@ public void setVisitors(List<Visitor> visitors) { this.visitors = visitors; } + public Boolean getEnrichDecisions() { return enrichDecisions; } + + public void setEnrichDecisions(Boolean enrichDecisions) { + this.enrichDecisions = enrichDecisions; + } + public Boolean getAnonymizeIp() { return anonymizeIp; } @@ -155,7 +167,7 @@ public int hashCode() { public static class Builder { private String clientName = ClientEngine.JAVA_SDK.getClientEngineValue(); - private String clientVersion = BuildVersionInfo.VERSION; + private String clientVersion = BuildVersionInfo.getClientVersion(); private String accountId; private List<Visitor> visitors; private Boolean anonymizeIp; diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Snapshot.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Snapshot.java index a1b2ad759..efd701305 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Snapshot.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Snapshot.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018, Optimizely and contributors + * Copyright 2018-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,8 @@ public class Snapshot { Long activationTimestamp; @VisibleForTesting - public Snapshot() { } + public Snapshot() { + } public Snapshot(List<Decision> decisions, List<Event> events) { this.decisions = decisions; @@ -70,7 +71,9 @@ public boolean equals(Object o) { if (activationTimestamp != null ? !activationTimestamp.equals(snapshot.activationTimestamp) : snapshot.activationTimestamp != null) return false; - if (!decisions.equals(snapshot.decisions)) return false; + if (decisions != null ? + !decisions.equals(snapshot.decisions) : + snapshot.decisions != null) return false; return events.equals(snapshot.events); } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Visitor.java b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Visitor.java index b9e4326e1..b640e412f 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Visitor.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/payload/Visitor.java @@ -1,6 +1,6 @@ /** * - * Copyright 2018, Optimizely and contributors + * Copyright 2018-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,8 @@ public class Visitor { List<Snapshot> snapshots; @VisibleForTesting - public Visitor() { } + public Visitor() { + } private Visitor(String visitorId, String sessionId, List<Attribute> attributes, List<Snapshot> snapshots) { this.visitorId = visitorId; diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/DefaultJsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/DefaultJsonSerializer.java index 717ac9e0b..a914f3e90 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/DefaultJsonSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/DefaultJsonSerializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,8 @@ public final class DefaultJsonSerializer { private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSerializer.class); - private DefaultJsonSerializer() { } + private DefaultJsonSerializer() { + } public static Serializer getInstance() { return LazyHolder.INSTANCE; @@ -40,6 +41,7 @@ public static Serializer getInstance() { /** * Creates and returns a {@link Serializer} using a json library available on the classpath. + * * @return the created serializer * @throws MissingJsonParserException if there are no supported json libraries available on the classpath */ @@ -57,10 +59,10 @@ Serializer create() { serializer = new JsonSerializer(); } else { throw new MissingJsonParserException("unable to locate a JSON parser. " - + "Please see <link> for more information"); + + "Please see <link> for more information"); } - logger.info("using json serializer: {}", serializer.getClass().getSimpleName()); + logger.debug("using json serializer: {}", serializer.getClass().getSimpleName()); return serializer; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/GsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/GsonSerializer.java index 802e02515..c78674afd 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/GsonSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/GsonSerializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,8 +23,8 @@ class GsonSerializer implements Serializer { private Gson gson = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create(); + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); public <T> String serialize(T payload) { return gson.toJson(payload); diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java index 8a42908ce..6087b4cce 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JacksonSerializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,8 +23,8 @@ class JacksonSerializer implements Serializer { - private final ObjectMapper mapper = - new ObjectMapper().setPropertyNamingStrategy( + private ObjectMapper mapper = + new ObjectMapper().setPropertyNamingStrategy( PropertyNamingStrategy.SNAKE_CASE); public <T> String serialize(T payload) { diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSerializer.java index 53c8d478a..2cc9be04d 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSerializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,18 +25,17 @@ public <T> String serialize(T payload) { String jsonResponse = payloadJsonObject.toString(); StringBuilder stringBuilder = new StringBuilder(); - for (int i = 0; i < jsonResponse.length(); i ++) { + for (int i = 0; i < jsonResponse.length(); i++) { Character ch = jsonResponse.charAt(i); Character nextChar = null; - if (i +1 < jsonResponse.length()) { - nextChar = jsonResponse.charAt(i+1); + if (i + 1 < jsonResponse.length()) { + nextChar = jsonResponse.charAt(i + 1); } if ((Character.isLetter(ch) || Character.isDigit(ch)) && Character.isUpperCase(ch) && - ((Character.isLetter(nextChar) || Character.isDigit(nextChar)))) { + ((Character.isLetter(nextChar) || Character.isDigit(nextChar)))) { stringBuilder.append('_'); stringBuilder.append(Character.toLowerCase(ch)); - } - else { + } else { stringBuilder.append(ch); } } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java index ea47970f3..b35c74ba6 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializer.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,7 +33,7 @@ class JsonSimpleSerializer implements Serializer { public <T> String serialize(T payload) { - JSONObject payloadJsonObj = serializeEventBatch((EventBatch)payload); + JSONObject payloadJsonObj = serializeEventBatch((EventBatch) payload); return payloadJsonObj.toJSONString(); } @@ -42,6 +42,7 @@ private JSONObject serializeEventBatch(EventBatch eventBatch) { JSONObject jsonObject = new JSONObject(); jsonObject.put("account_id", eventBatch.getAccountId()); + jsonObject.put("enrich_decisions", eventBatch.getEnrichDecisions()); jsonObject.put("visitors", serializeVisitors(eventBatch.getVisitors())); if (eventBatch.getAnonymizeIp() != null) jsonObject.put("anonymize_ip", eventBatch.getAnonymizeIp()); if (eventBatch.getClientName() != null) jsonObject.put("client_name", eventBatch.getClientName()); @@ -50,13 +51,12 @@ private JSONObject serializeEventBatch(EventBatch eventBatch) { if (eventBatch.getRevision() != null) jsonObject.put("revision", eventBatch.getRevision()); return jsonObject; - } private JSONArray serializeVisitors(List<Visitor> visitors) { JSONArray jsonArray = new JSONArray(); - for (Visitor v: visitors) { + for (Visitor v : visitors) { jsonArray.add(serializeVisitor(v)); } @@ -90,7 +90,7 @@ private JSONArray serializeSnapshots(List<Snapshot> snapshots) { private JSONObject serializeSnapshot(Snapshot snapshot) { JSONObject jsonObject = new JSONObject(); - jsonObject.put("decisions", serializeDecisions(snapshot.getDecisions())); + if (snapshot.getDecisions() != null) jsonObject.put("decisions", serializeDecisions(snapshot.getDecisions())); jsonObject.put("events", serializeEvents(snapshot.getEvents())); return jsonObject; @@ -109,16 +109,16 @@ private JSONArray serializeEvents(List<Event> events) { private JSONObject serializeEvent(Event eventV3) { JSONObject jsonObject = new JSONObject(); - jsonObject.put("timestamp",eventV3.getTimestamp()); - jsonObject.put("uuid",eventV3.getUuid()); + jsonObject.put("timestamp", eventV3.getTimestamp()); + jsonObject.put("uuid", eventV3.getUuid()); jsonObject.put("key", eventV3.getKey()); - if (eventV3.getEntityId() != null) jsonObject.put("entity_id",eventV3.getEntityId()); - if (eventV3.getQuantity() != null) jsonObject.put("quantity",eventV3.getQuantity()); - if (eventV3.getRevenue() != null) jsonObject.put("revenue",eventV3.getRevenue()); - if (eventV3.getTags() != null) jsonObject.put("tags",serializeTags(eventV3.getTags())); - if (eventV3.getType() != null) jsonObject.put("type",eventV3.getType()); - if (eventV3.getValue() != null) jsonObject.put("value",eventV3.getValue()); + if (eventV3.getEntityId() != null) jsonObject.put("entity_id", eventV3.getEntityId()); + if (eventV3.getQuantity() != null) jsonObject.put("quantity", eventV3.getQuantity()); + if (eventV3.getRevenue() != null) jsonObject.put("revenue", eventV3.getRevenue()); + if (eventV3.getTags() != null) jsonObject.put("tags", serializeTags(eventV3.getTags())); + if (eventV3.getType() != null) jsonObject.put("type", eventV3.getType()); + if (eventV3.getValue() != null) jsonObject.put("value", eventV3.getValue()); return jsonObject; } diff --git a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/SerializationException.java b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/SerializationException.java index 798d1a552..47bbf31d5 100644 --- a/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/SerializationException.java +++ b/core-api/src/main/java/com/optimizely/ab/event/internal/serializer/SerializationException.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ /** * Wrapper around all types of JSON serialization exceptions. */ -public class SerializationException extends OptimizelyRuntimeException{ +public class SerializationException extends OptimizelyRuntimeException { public SerializationException(String message, Throwable cause) { super(message, cause); diff --git a/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java b/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java new file mode 100644 index 000000000..378e4acb0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/AttributesUtil.java @@ -0,0 +1,63 @@ +/** + * + * Copyright 2019-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.internal; + +public class AttributesUtil { + + /** + * Validate that value is not infinite, NAN or greater than Math.pow(2, 53). + * + * @param value attribute value or condition value. + * @return boolean value of is valid or not. + */ + public static boolean isValidNumber(Object value) { + if (value instanceof Integer) { + return Math.abs((Integer) value) <= Math.pow(2, 53); + } else if (value instanceof Double || value instanceof Float) { + Double doubleValue = ((Number) value).doubleValue(); + return !(doubleValue.isNaN() || doubleValue.isInfinite() || Math.abs(doubleValue) > Math.pow(2, 53)); + } else if (value instanceof Long) { + return Math.abs((Long) value) <= Math.pow(2, 53); + } + return false; + } + + /** + * Parse and validate that String is parse able to integer. + * + * @param str String value of integer. + * @return Integer value if is valid and null if not. + */ + public static Integer parseNumeric(String str) { + try { + return Integer.parseInt(str, 10); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Checks if string is null or empty. + * + * @param str String value. + * @return true if is null or empty else false. + */ + public static boolean stringIsNullOrEmpty(String str) { + return str == null || str.isEmpty(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/Cache.java b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java new file mode 100644 index 000000000..ba667ebd2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/Cache.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +public interface Cache<T> { + int DEFAULT_MAX_SIZE = 10000; + int DEFAULT_TIMEOUT_SECONDS = 600; + void save(String key, T value); + T lookup(String key); + void reset(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java new file mode 100644 index 000000000..32ab45cc4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/ConditionUtils.java @@ -0,0 +1,216 @@ +/** + * + * Copyright 2018-2019, 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.google.gson.internal.LinkedTreeMap; +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.NotCondition; +import com.optimizely.ab.config.audience.EmptyCondition; +import com.optimizely.ab.config.audience.NullCondition; +import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.config.audience.UserAttribute; +import org.json.simple.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ConditionUtils { + + static Logger logger = LoggerFactory.getLogger(ConditionUtils.class); + + static public <T> Condition parseConditions(Class<T> clazz, Object object) throws InvalidAudienceCondition { + + if (object instanceof List) { + List<Object> objectList = (List<Object>) object; + return ConditionUtils.<T>parseConditions(clazz, objectList); + } else if (object instanceof String) { // looking for audience conditions in experiment + AudienceIdCondition audienceIdCondition = new AudienceIdCondition<T>((String) object); + if (clazz.isInstance(audienceIdCondition)) { + return audienceIdCondition; + } else { + throw new InvalidAudienceCondition(String.format("Expected AudienceIdCondition got %s", clazz.getCanonicalName())); + } + } else { + try { + if (object instanceof LinkedTreeMap) { // gson + if (clazz != UserAttribute.class) { + throw new InvalidAudienceCondition(String.format("Expected UserAttributes got %s", clazz.getCanonicalName())); + + } + + LinkedTreeMap<String, ?> conditionMap = (LinkedTreeMap<String, ?>) object; + return new UserAttribute((String) conditionMap.get("name"), (String) conditionMap.get("type"), + (String) conditionMap.get("match"), conditionMap.get("value")); + } + } + catch (NoClassDefFoundError ex) { + // no gson loaded... not sure we need to log this if they don't use gson. + logger.debug("parser: gson library not loaded"); + } + + try { + if (object instanceof JSONObject) { // simple json + if (clazz != UserAttribute.class) { + throw new InvalidAudienceCondition(String.format("Expected UserAttributes got %s", clazz.getCanonicalName())); + + } + + JSONObject conditionMap = (JSONObject) object; + return new UserAttribute((String) conditionMap.get("name"), (String) conditionMap.get("type"), + (String) conditionMap.get("match"), conditionMap.get("value")); + } + } + catch (NoClassDefFoundError ex) { + logger.debug("parser: simple json not found"); + } + + try { + if (object instanceof org.json.JSONArray) { // json + return ConditionUtils.<T>parseConditions(clazz, (org.json.JSONArray) object); + } else if (object instanceof org.json.JSONObject){ //json + if (clazz != UserAttribute.class) { + throw new InvalidAudienceCondition(String.format("Expected UserAttributes got %s", clazz.getCanonicalName())); + + } + org.json.JSONObject conditionMap = (org.json.JSONObject) object; + String match = null; + Object value = null; + if (conditionMap.has("match")) { + match = (String) conditionMap.get("match"); + } + if (conditionMap.has("value")) { + value = conditionMap.get("value"); + } + return new UserAttribute((String) conditionMap.get("name"), (String) conditionMap.get("type"), + match, value); + } + } + catch (NoClassDefFoundError ex) { + logger.debug("parser: json package not found."); + } + if (clazz != UserAttribute.class) { + throw new InvalidAudienceCondition(String.format("Expected UserAttributes got %s", clazz.getCanonicalName())); + + } + + Map<String, ?> conditionMap = (Map<String, ?>) object; + return new UserAttribute((String) conditionMap.get("name"), (String) conditionMap.get("type"), + (String) conditionMap.get("match"), conditionMap.get("value")); + } + + } + + /** + * parse conditions using List and Map + * + * @param clazz the class of parsed condition + * @param rawObjectList list of conditions + * @param <T> This is the type parameter + * @return audienceCondition + */ + static public <T> Condition parseConditions(Class<T> clazz, List<Object> rawObjectList) throws InvalidAudienceCondition { + + if (rawObjectList.size() == 0) { + return new EmptyCondition(); + } + + List<Condition> conditions = new ArrayList<Condition>(); + int startingParseIndex = 0; + String operand = operand(rawObjectList.get(startingParseIndex)); + if (operand != null) { + startingParseIndex = 1; + } else { + operand = "or"; + } + + for (int i = startingParseIndex; i < rawObjectList.size(); i++) { + Object obj = rawObjectList.get(i); + conditions.add(parseConditions(clazz, obj)); + } + + return buildCondition(operand, conditions); + } + + static public String operand(Object object) { + if (object != null && object instanceof String) { + String operand = (String) object; + switch (operand) { + case "or": + case "and": + case "not": + return operand; + default: + break; + } + } + + return null; + } + + /** + * Parse conditions from org.json.JsonArray + * + * @param clazz the class of parsed condition + * @param conditionJson jsonArray to parse + * @param <T> This is the type parameter + * @return condition parsed from conditionJson. + */ + static public <T> Condition parseConditions(Class<T> clazz, org.json.JSONArray conditionJson) throws InvalidAudienceCondition { + + if (conditionJson.length() == 0) { + return new EmptyCondition(); + } + + List<Condition> conditions = new ArrayList<Condition>(); + int startingParseIndex = 0; + String operand = operand(conditionJson.get(startingParseIndex)); + if (operand != null) { + startingParseIndex = 1; + } else { + operand = "or"; + } + + for (int i = startingParseIndex; i < conditionJson.length(); i++) { + Object obj = conditionJson.get(i); + conditions.add(parseConditions(clazz, obj)); + } + + return buildCondition(operand, conditions); + } + + private static Condition buildCondition(String operand, List<Condition> conditions) { + Condition condition; + switch (operand) { + case "and": + condition = new AndCondition(conditions); + break; + case "not": + condition = new NotCondition(conditions.isEmpty() ? new NullCondition() : conditions.get(0)); + break; + default: + condition = new OrCondition(conditions); + break; + } + + return condition; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java new file mode 100644 index 000000000..b946a65ea --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/DefaultLRUCache.java @@ -0,0 +1,106 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.annotations.VisibleForTesting; + +import java.util.*; +import java.util.concurrent.locks.ReentrantLock; + +public class DefaultLRUCache<T> implements Cache<T> { + + private final ReentrantLock lock = new ReentrantLock(); + + private final Integer maxSize; + + private final Long timeoutMillis; + + @VisibleForTesting + final LinkedHashMap<String, CacheEntity> linkedHashMap = new LinkedHashMap<String, CacheEntity>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry<String, CacheEntity> eldest) { + return this.size() > maxSize; + } + }; + + public DefaultLRUCache() { + this(DEFAULT_MAX_SIZE, DEFAULT_TIMEOUT_SECONDS); + } + + public DefaultLRUCache(Integer maxSize, Integer timeoutSeconds) { + this.maxSize = maxSize < 0 ? Integer.valueOf(0) : maxSize; + this.timeoutMillis = (timeoutSeconds < 0) ? 0 : (timeoutSeconds * 1000L); + } + + public void save(String key, T value) { + if (maxSize == 0) { + // Cache is disabled when maxSize = 0 + return; + } + + lock.lock(); + try { + linkedHashMap.put(key, new CacheEntity(value)); + } finally { + lock.unlock(); + } + } + + public T lookup(String key) { + if (maxSize == 0) { + // Cache is disabled when maxSize = 0 + return null; + } + + lock.lock(); + try { + if (linkedHashMap.containsKey(key)) { + CacheEntity entity = linkedHashMap.get(key); + Long nowMs = new Date().getTime(); + + // ttl = 0 means entities never expire. + if (timeoutMillis == 0 || (nowMs - entity.timestamp < timeoutMillis)) { + return entity.value; + } + + linkedHashMap.remove(key); + } + return null; + } finally { + lock.unlock(); + } + } + + public void reset() { + lock.lock(); + try { + linkedHashMap.clear(); + } finally { + lock.unlock(); + } + } + + private class CacheEntity { + public T value; + public Long timestamp; + + public CacheEntity(T value) { + this.value = value; + this.timestamp = new Date().getTime(); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java index e7c0359c5..76b6b9ae3 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/EventTagUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,18 +28,19 @@ public final class EventTagUtils { /** * Grab the revenue value from the event tags. "revenue" is a reserved keyword. - * @param eventTags - * @return Long + * + * @param eventTags The event tags + * @return Long The revenue value */ public static Long getRevenueValue(@Nonnull Map<String, ?> eventTags) { Long eventValue = null; - if (eventTags.containsKey(ReservedEventKey.REVENUE.toString())) { + if (eventTags != null && eventTags.containsKey(ReservedEventKey.REVENUE.toString())) { Object rawValue = eventTags.get(ReservedEventKey.REVENUE.toString()); if (Long.class.isInstance(rawValue)) { - eventValue = (Long)rawValue; + eventValue = (Long) rawValue; logger.info("Parsed revenue value \"{}\" from event tags.", eventValue); } else if (Integer.class.isInstance(rawValue)) { - eventValue = ((Integer)rawValue).longValue(); + eventValue = ((Integer) rawValue).longValue(); logger.info("Parsed revenue value \"{}\" from event tags.", eventValue); } else { logger.warn("Failed to parse revenue value \"{}\" from event tags.", rawValue); @@ -50,13 +51,20 @@ public static Long getRevenueValue(@Nonnull Map<String, ?> eventTags) { /** * Fetch the numeric metric value from event tags. "value" is a reserved keyword. + * + * @param eventTags The event tags + * @return The numeric metric value */ public static Double getNumericValue(@Nonnull Map<String, ?> eventTags) { Double eventValue = null; - if (eventTags.containsKey(ReservedEventKey.VALUE.toString())) { + if (eventTags != null && eventTags.containsKey(ReservedEventKey.VALUE.toString())) { Object rawValue = eventTags.get(ReservedEventKey.VALUE.toString()); if (rawValue instanceof Number) { eventValue = ((Number) rawValue).doubleValue(); + if (eventValue.isInfinite() || eventValue.isNaN()) { + eventValue = null; + logger.warn("Failed to parse numeric metric value \"{}\" from event tags.", rawValue); + } logger.info("Parsed numeric metric value \"{}\" from event tags.", eventValue); } else { logger.warn("Failed to parse numeric metric value \"{}\" from event tags.", rawValue); diff --git a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java index a0ba4b99b..8da421885 100644 --- a/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/ExperimentUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017-2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,13 +16,20 @@ */ package com.optimizely.ab.internal; +import com.optimizely.ab.OptimizelyUserContext; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelydecision.DefaultDecisionReasons; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -30,7 +37,8 @@ public final class ExperimentUtils { private static final Logger logger = LoggerFactory.getLogger(ExperimentUtils.class); - private ExperimentUtils() {} + private ExperimentUtils() { + } /** * Helper method to validate all pre-conditions before bucketing a user. @@ -39,45 +47,97 @@ private ExperimentUtils() {} * @return whether the pre-conditions are satisfied */ public static boolean isExperimentActive(@Nonnull Experiment experiment) { - - if (!experiment.isActive()) { - logger.info("Experiment \"{}\" is not running.", experiment.getKey()); - return false; - } - - return true; + return experiment.isActive(); } /** * Determines whether a user satisfies audience conditions for the experiment. * - * @param projectConfig the current projectConfig - * @param experiment the experiment we are evaluating audiences for - * @param attributes the attributes of the user + * @param projectConfig the current projectConfig + * @param experiment the experiment we are evaluating audiences for + * @param user the current OptimizelyUserContext + * @param loggingEntityType It can be either experiment or rule. + * @param loggingKey In case of loggingEntityType is experiment it will be experiment key or else it will be rule number. * @return whether the user meets the criteria for the experiment */ - public static boolean isUserInExperiment(@Nonnull ProjectConfig projectConfig, - @Nonnull Experiment experiment, - @Nonnull Map<String, String> attributes) { + @Nonnull + public static DecisionResponse<Boolean> doesUserMeetAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + DecisionResponse<Boolean> decisionResponse; + if (experiment.getAudienceConditions() != null) { + logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, experiment.getAudienceConditions()); + decisionResponse = evaluateAudienceConditions(projectConfig, experiment, user, loggingEntityType, loggingKey); + } else { + decisionResponse = evaluateAudience(projectConfig, experiment, user, loggingEntityType, loggingKey); + } + + Boolean resolveReturn = decisionResponse.getResult(); + reasons.merge(decisionResponse.getReasons()); + + return new DecisionResponse( + resolveReturn != null && resolveReturn, // make it Nonnull for if-evaluation + reasons); + } + + @Nonnull + public static DecisionResponse<Boolean> evaluateAudience(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + List<String> experimentAudienceIds = experiment.getAudienceIds(); // if there are no audiences, ALL users should be part of the experiment if (experimentAudienceIds.isEmpty()) { - return true; + return new DecisionResponse(true, reasons); } - // if there are audiences, but no user attributes, the user is not in the experiment. - if (attributes.isEmpty()) { - return false; + List<Condition> conditions = new ArrayList<>(); + for (String audienceId : experimentAudienceIds) { + AudienceIdCondition condition = new AudienceIdCondition(audienceId); + conditions.add(condition); } - for (String audienceId : experimentAudienceIds) { - Condition conditions = projectConfig.getAudienceConditionsFromId(audienceId); - if (conditions.evaluate(attributes)) { - return true; - } + OrCondition implicitOr = new OrCondition(conditions); + + logger.debug("Evaluating audiences for {} \"{}\": {}.", loggingEntityType, loggingKey, conditions); + + Boolean result = implicitOr.evaluate(projectConfig, user); + String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); + + return new DecisionResponse(result, reasons); + } + + @Nonnull + public static DecisionResponse<Boolean> evaluateAudienceConditions(@Nonnull ProjectConfig projectConfig, + @Nonnull Experiment experiment, + @Nonnull OptimizelyUserContext user, + @Nonnull String loggingEntityType, + @Nonnull String loggingKey) { + DecisionReasons reasons = DefaultDecisionReasons.newInstance(); + + Condition conditions = experiment.getAudienceConditions(); + if (conditions == null) return new DecisionResponse(null, reasons); + + Boolean result = null; + try { + result = conditions.evaluate(projectConfig, user); + String message = reasons.addInfo("Audiences for %s \"%s\" collectively evaluated to %s.", loggingEntityType, loggingKey, result); + logger.info(message); + } catch (Exception e) { + String message = reasons.addInfo("Condition invalid: %s", e.getMessage()); + logger.error(message); } - return false; + return new DecisionResponse(result, reasons); } + } diff --git a/core-api/src/main/java/com/optimizely/ab/UnknownLiveVariableException.java b/core-api/src/main/java/com/optimizely/ab/internal/InvalidAudienceCondition.java similarity index 55% rename from core-api/src/main/java/com/optimizely/ab/UnknownLiveVariableException.java rename to core-api/src/main/java/com/optimizely/ab/internal/InvalidAudienceCondition.java index bd8e0989b..f4c7e20c3 100644 --- a/core-api/src/main/java/com/optimizely/ab/UnknownLiveVariableException.java +++ b/core-api/src/main/java/com/optimizely/ab/internal/InvalidAudienceCondition.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2018-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,22 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.optimizely.ab; +package com.optimizely.ab.internal; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.OptimizelyRuntimeException; -/** - * Exception thrown when attempting to use/refer to a {@link LiveVariable} that isn't present in the current - * {@link ProjectConfig}. - */ -public class UnknownLiveVariableException extends OptimizelyRuntimeException { +public class InvalidAudienceCondition extends OptimizelyRuntimeException { - public UnknownLiveVariableException(String message) { + public InvalidAudienceCondition(String message) { super(message); } - public UnknownLiveVariableException(String message, Throwable cause) { + public InvalidAudienceCondition(String message, Throwable cause) { super(message, cause); } } diff --git a/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java new file mode 100644 index 000000000..8bde2ac66 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java @@ -0,0 +1,74 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.config.parser.MissingJsonParserException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public enum JsonParserProvider { + GSON_CONFIG_PARSER("com.google.gson.Gson"), + JACKSON_CONFIG_PARSER("com.fasterxml.jackson.databind.ObjectMapper" ), + JSON_CONFIG_PARSER("org.json.JSONObject"), + JSON_SIMPLE_CONFIG_PARSER("org.json.simple.JSONObject"); + + private static final Logger logger = LoggerFactory.getLogger(JsonParserProvider.class); + + private final String className; + + JsonParserProvider(String className) { + this.className = className; + } + + private boolean isAvailable() { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + public static JsonParserProvider getDefaultParser() { + String defaultParserName = PropertyUtils.get("default_parser"); + + if (defaultParserName != null) { + try { + JsonParserProvider parser = JsonParserProvider.valueOf(defaultParserName); + if (parser.isAvailable()) { + logger.debug("using json parser: {}, based on override config", parser.className); + return parser; + } + + logger.warn("configured parser {} is not available in the classpath", defaultParserName); + } catch (IllegalArgumentException e) { + logger.warn("configured parser {} is not a valid value", defaultParserName); + } + } + + for (JsonParserProvider parser: JsonParserProvider.values()) { + if (!parser.isAvailable()) { + continue; + } + + logger.debug("using json parser: {}", parser.className); + return parser; + } + + throw new MissingJsonParserException("unable to locate a JSON parser. " + + "Please see <link> for more information"); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java b/core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java new file mode 100644 index 000000000..66387f2e7 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/LoggingConstants.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +public class LoggingConstants { + public static class LoggingEntityType { + public static final String EXPERIMENT = "experiment"; + public static final String RULE = "rule"; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java b/core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java new file mode 100644 index 000000000..92d0c6d38 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/NotificationRegistry.java @@ -0,0 +1,52 @@ +/** + * + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.notification.NotificationCenter; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class NotificationRegistry { + private final static Map<String, NotificationCenter> _notificationCenters = new ConcurrentHashMap<>(); + + private NotificationRegistry() + { + } + + public static NotificationCenter getInternalNotificationCenter(@Nonnull String sdkKey) + { + NotificationCenter notificationCenter = null; + if (sdkKey != null) { + if (_notificationCenters.containsKey(sdkKey)) { + notificationCenter = _notificationCenters.get(sdkKey); + } else { + notificationCenter = new NotificationCenter(); + _notificationCenters.put(sdkKey, notificationCenter); + } + } + return notificationCenter; + } + + public static void clearNotificationCenterRegistry(@Nonnull String sdkKey) { + if (sdkKey != null) { + _notificationCenters.remove(sdkKey); + } + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java new file mode 100644 index 000000000..4ef03b2cc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/PropertyUtils.java @@ -0,0 +1,284 @@ +/** + * + * Copyright 2019,2021, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.util.Properties; + +/** + * PropertyUtils is a utility for pulling parameters from system properties, environment variable + * or a Optimizely properties file respectively. + */ +public final class PropertyUtils { + private static final Logger logger = LoggerFactory.getLogger(PropertyUtils.class); + /** + * The filename of the Optimizely configuration file. + */ + private static final String OPTIMIZELY_PROP_FILE = "optimizely.properties"; + /** + * Properties loaded from the Optimizely configuration file, or null if no file was + * found or it failed to parse. + */ + private static Properties properties; + + // Attempt to load an Optimizely configuration class for override parameters. + static { + InputStream input = null; + try { + input = getInputStream(OPTIMIZELY_PROP_FILE); + + if (input != null) { + properties = new Properties(); + properties.load(input); + } else { + logger.debug("Optimizely properties file not found in filesystem or classpath: '{}'.", OPTIMIZELY_PROP_FILE); + } + } catch (Exception e) { + logger.error("Error loading Optimizely properties file '{}': ", OPTIMIZELY_PROP_FILE, e); + } finally { + if (input != null) { + try { + input.close(); + } catch (IOException e) { + logger.warn("Error closing properties file.", e); + } + } + } + } + + /** + * Clears a System property prepended with "optimizely.". + * @param key The configuration key + */ + public static void clear(String key) { + System.clearProperty("optimizely." + key); + } + + /** + * Sets a System property prepended with "optimizely.". + * + * @param key The configuration key + * @param value The String value + */ + public static void set(String key, String value) { + System.setProperty("optimizely." + key, value); + } + + /** + * Get a configuration value from one of the supported locations. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", uppercased and "." are replaced with "_".</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @return The String value + */ + public static String get(String key) { + return get(key, null); + } + + /** + * Get a configuration value from one of the supported locations. If a value cannot be found, then the default + * is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @param dafault The default value + * @return The String value + */ + public static String get(String key, String dafault) { + // Try to obtain from a Java System Property + String value = System.getProperty("optimizely." + key.toLowerCase()); + if (value != null) { + logger.debug("Found {}={} in Java System Properties.", key, value); + return value.trim(); + } + + // Try to obtain from a System Environment Variable + value = System.getenv("OPTIMIZELY_" + key.replace(".", "_").toUpperCase()); + if (value != null) { + logger.debug("Found {}={} in System Environment Variables.", key, value); + return value.trim(); + } + + // Try to obtain from config file + value = properties == null ? null : properties.getProperty(key); + if (value != null) { + logger.debug("Found {}={} in {}.", key, value, OPTIMIZELY_PROP_FILE); + return value.trim(); + } + + return dafault; + } + + /** + * Get a configuration value as Long from one of the supported locations. If a value cannot be found, or if + * the value is not a valid number, then null is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @return The integer value + */ + public static Long getLong(String key) { + return getLong(key, null); + } + + /** + * Get a configuration value as Long from one of the supported locations. If a value cannot be found, then the default + * is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @param dafault The default value + * @return The long value + */ + public static Long getLong(String key, Long dafault) { + String value = get(key); + if (value == null) { + return dafault; + } + + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + logger.warn("Cannot convert {} to an long.", value, e); + } + + return dafault; + } + + /** + * Get a configuration value as Integer from one of the supported locations. If a value cannot be found, or if + * * the value is not a valid number, then null is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @return The integer value + */ + + public static Integer getInteger(String key) { + return getInteger(key, null); + } + + /** + * Get a configuration value as Integer from one of the supported locations. If a value cannot be found, or if + * the value is not a valid number, then the default is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @param dafault The default value + * @return The integer value + */ + + public static Integer getInteger(String key, Integer dafault) { + String value = get(key); + if (value == null) { + return dafault; + } + + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + logger.warn("Cannot convert {} to an integer.", value, e); + } + + return dafault; + } + + /** + * Get a configuration value as Enum from one of the supported locations. Of not a valid enum value, then null is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @param clazz The value class + * @param <T> This is the type parameter + * @return The value + */ + public static <T> T getEnum(String key, Class<T> clazz) { + return getEnum(key, clazz, null); + } + + /** + * Get a configuration value as Enum from one of the supported locations. If a value cannot be found, or if it + * is not a valid enum value, then the default is returned. + * <ul> + * <li>System Properties - Key is prepended with "optimizely."</li> + * <li>Environment variables - Key is prepended with "optimizely.", upper cased and "."s are replaced with "_"s.</li> + * <li>Optimizely Properties - Key is sourced as-is.</li> + * </ul> + * + * @param key The configuration key + * @param clazz The value class + * @param dafault The default value + * @param <T> This is the type parameter + * @return The value + */ + @SuppressWarnings("unchecked") + public static <T> T getEnum(String key, Class<T> clazz, T dafault) { + String value = get(key); + if (value == null) { + return dafault; + } + + try { + return (T)Enum.valueOf((Class<Enum>)clazz, value); + } catch (Exception e) { + logger.warn("Cannot convert {} to an integer.", value, e); + } + + return dafault; + } + + private static InputStream getInputStream(String filePath) throws FileNotFoundException { + File file = new File(filePath); + if (file.isFile() && file.canRead()) { + return new FileInputStream(file); + } + + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + return classLoader.getResourceAsStream(filePath); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java b/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java new file mode 100644 index 000000000..eaed2e9d5 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/SafetyUtils.java @@ -0,0 +1,45 @@ +/** + * + * Copyright 2019,2021, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Collection of utils used to prevent the Optimizely SDK from throwing or crashing the hosting application. + */ +public class SafetyUtils { + + private static final Logger logger = LoggerFactory.getLogger(SafetyUtils.class); + + /** + * Helper method which checks if Object is an instance of AutoCloseable and calls close() on it. + * + * @param obj The object + */ + public static void tryClose(Object obj) { + if (!(obj instanceof AutoCloseable)) { + return; + } + + try { + ((AutoCloseable) obj).close(); + } catch (Exception e) { + logger.warn("Unexpected exception on trying to close {}.", obj); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java new file mode 100644 index 000000000..dc70079de --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java @@ -0,0 +1,90 @@ +/** + * + * Copyright 2019,2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.LogEvent; + +import java.util.Map; + +/** + * ActivateNotification supplies notification for AB activatation. + * + * @deprecated in favor of {@link DecisionNotification} which provides notifications for Experiment, Feature + * and Rollout decisions. + */ +@Deprecated +public final class ActivateNotification { + + private final Experiment experiment; + private final String userId; + private final Map<String, ?> attributes; + private final Variation variation; + private final LogEvent event; + + @VisibleForTesting + ActivateNotification() { + this(null, null, null, null, null); + } + + /** + * @param experiment - The experiment object being activated. + * @param userId - The userId passed into activate. + * @param attributes - The filtered attribute list passed into activate + * @param variation - The variation that was returned from activate. + * @param event - The impression event that was triggered. + */ + public ActivateNotification(Experiment experiment, String userId, Map<String, ?> attributes, Variation variation, LogEvent event) { + this.experiment = experiment; + this.userId = userId; + this.attributes = attributes; + this.variation = variation; + this.event = event; + } + + public Experiment getExperiment() { + return experiment; + } + + public String getUserId() { + return userId; + } + + public Map<String, ?> getAttributes() { + return attributes; + } + + public Variation getVariation() { + return variation; + } + + /** + * This interface is deprecated since this is no longer a one-to-one mapping. + * Please use a {@link NotificationHandler} explicitly for LogEvent messages. + * {@link com.optimizely.ab.Optimizely#addLogEventNotificationHandler(NotificationHandler)} + * + * @return The event + */ + @Deprecated + public LogEvent getEvent() { + return event; + } + + +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListener.java b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListener.java index 883bb6963..4ca602c77 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListener.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListener.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,42 +24,67 @@ import javax.annotation.Nonnull; import java.util.Map; - -public abstract class ActivateNotificationListener implements NotificationListener, ActivateNotificationListenerInterface { +/** + * ActivateNotificationListener handles the activate event notification. + * + * @deprecated along with {@link ActivateNotification} and users should implement + * NotificationHandler<DecisionNotification> directly. + */ +@Deprecated +public abstract class ActivateNotificationListener implements NotificationHandler<ActivateNotification>, NotificationListener, ActivateNotificationListenerInterface { /** * Base notify called with var args. This method parses the parameters and calls the abstract method. + * * @param args - variable argument list based on the type of notification. + * + * @deprecated by {@link ActivateNotificationListener#handle(ActivateNotification)} */ @Override + @Deprecated public final void notify(Object... args) { - assert(args[0] instanceof Experiment); + assert (args[0] instanceof Experiment); Experiment experiment = (Experiment) args[0]; - assert(args[1] instanceof String); + assert (args[1] instanceof String); String userId = (String) args[1]; - assert(args[2] instanceof java.util.Map); - Map<String, String> attributes = (Map<String, String>) args[2]; - assert(args[3] instanceof Variation); + Map<String, ?> attributes = null; + if (args[2] != null) { + assert (args[2] instanceof java.util.Map); + attributes = (Map<String, ?>) args[2]; + } + assert (args[3] instanceof Variation); Variation variation = (Variation) args[3]; - assert(args[4] instanceof LogEvent); + assert (args[4] instanceof LogEvent); LogEvent logEvent = (LogEvent) args[4]; onActivate(experiment, userId, attributes, variation, logEvent); } + @Override + public final void handle(ActivateNotification message) { + onActivate( + message.getExperiment(), + message.getUserId(), + message.getAttributes(), + message.getVariation(), + message.getEvent() + ); + } + /** * onActivate called when an activate was triggered + * * @param experiment - The experiment object being activated. - * @param userId - The userId passed into activate. + * @param userId - The userId passed into activate. * @param attributes - The filtered attribute list passed into activate - * @param variation - The variation that was returned from activate. - * @param event - The impression event that was triggered. + * @param variation - The variation that was returned from activate. + * @param event - The impression event that was triggered. */ public abstract void onActivate(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map<String, String> attributes, - @Nonnull Variation variation, - @Nonnull LogEvent event) ; + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nonnull Variation variation, + @Nonnull LogEvent event); } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListenerInterface.java b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListenerInterface.java index fdae1fc5d..c0a1e3a73 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListenerInterface.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotificationListenerInterface.java @@ -1,3 +1,19 @@ +/** + * + * Copyright 2018-2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.optimizely.ab.notification; import com.optimizely.ab.config.Experiment; @@ -7,19 +23,27 @@ import javax.annotation.Nonnull; import java.util.Map; +/** + * ActivateNotificationListenerInterface provides and interface for activate event notification. + * + * @deprecated along with {@link ActivateNotification} and {@link ActivateNotificationListener} + * and users should implement NotificationHandler<DecisionNotification> directly. + */ +@Deprecated public interface ActivateNotificationListenerInterface { /** * onActivate called when an activate was triggered + * * @param experiment - The experiment object being activated. - * @param userId - The userId passed into activate. + * @param userId - The userId passed into activate. * @param attributes - The filtered attribute list passed into activate - * @param variation - The variation that was returned from activate. - * @param event - The impression event that was triggered. + * @param variation - The variation that was returned from activate. + * @param event - The impression event that was triggered. */ public void onActivate(@Nonnull Experiment experiment, - @Nonnull String userId, - @Nonnull Map<String, String> attributes, - @Nonnull Variation variation, - @Nonnull LogEvent event) ; + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nonnull Variation variation, + @Nonnull LogEvent event); } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java new file mode 100644 index 000000000..ab3fdc03d --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/DecisionNotification.java @@ -0,0 +1,468 @@ +/**************************************************************************** + * Copyright 2019-2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +package com.optimizely.ab.notification; + + +import com.optimizely.ab.OptimizelyRuntimeException; +import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.config.Variation; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * DecisionNotification encapsulates the arguments and responses when using the following methods: + * + * activate {@link com.optimizely.ab.Optimizely#activate} + * getEnabledFeatures {@link com.optimizely.ab.Optimizely#getEnabledFeatures} + * getFeatureVariableBoolean {@link com.optimizely.ab.Optimizely#getFeatureVariableBoolean} + * getFeatureVariableDouble {@link com.optimizely.ab.Optimizely#getFeatureVariableDouble} + * getFeatureVariableInteger {@link com.optimizely.ab.Optimizely#getFeatureVariableInteger} + * getFeatureVariableString {@link com.optimizely.ab.Optimizely#getFeatureVariableString} + * getVariation {@link com.optimizely.ab.Optimizely#getVariation} + * isFeatureEnabled {@link com.optimizely.ab.Optimizely#isFeatureEnabled} + * + * @see <a href="https://docs.developers.optimizely.com/full-stack/docs/register-notification-listeners">Notification Listeners</a> + */ +public final class DecisionNotification { + protected String type; + protected String userId; + protected Map<String, ?> attributes; + protected Map<String, ?> decisionInfo; + + protected DecisionNotification() { + } + + protected DecisionNotification(@Nonnull String type, + @Nonnull String userId, + @Nullable Map<String, ?> attributes, + @Nonnull Map<String, ?> decisionInfo) { + this.type = type; + this.userId = userId; + if (attributes == null) { + attributes = new HashMap<>(); + } + this.attributes = attributes; + this.decisionInfo = decisionInfo; + } + + public String getType() { + return type; + } + + public String getUserId() { + return userId; + } + + public Map<String, ?> getAttributes() { + return attributes; + } + + public Map<String, ?> getDecisionInfo() { + return decisionInfo; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("DecisionNotification{"); + sb.append("type='").append(type).append('\''); + sb.append(", userId='").append(userId).append('\''); + sb.append(", attributes=").append(attributes); + sb.append(", decisionInfo=").append(decisionInfo); + sb.append('}'); + return sb.toString(); + } + + public static ExperimentDecisionNotificationBuilder newExperimentDecisionNotificationBuilder() { + return new ExperimentDecisionNotificationBuilder(); + } + + public static class ExperimentDecisionNotificationBuilder { + public final static String EXPERIMENT_KEY = "experimentKey"; + public final static String VARIATION_KEY = "variationKey"; + + private String type; + private String experimentKey; + private Variation variation; + private String userId; + private Map<String, ?> attributes; + private Map<String, Object> decisionInfo; + + public ExperimentDecisionNotificationBuilder withUserId(String userId) { + this.userId = userId; + return this; + } + + public ExperimentDecisionNotificationBuilder withAttributes(Map<String, ?> attributes) { + this.attributes = attributes; + return this; + } + + public ExperimentDecisionNotificationBuilder withExperimentKey(String experimentKey) { + this.experimentKey = experimentKey; + return this; + } + + public ExperimentDecisionNotificationBuilder withType(String type) { + this.type = type; + return this; + } + + public ExperimentDecisionNotificationBuilder withVariation(Variation variation) { + this.variation = variation; + return this; + } + + public DecisionNotification build() { + if (type == null) { + throw new OptimizelyRuntimeException("type not set"); + } + + if (experimentKey == null) { + throw new OptimizelyRuntimeException("experimentKey not set"); + } + + decisionInfo = new HashMap<>(); + decisionInfo.put(EXPERIMENT_KEY, experimentKey); + decisionInfo.put(VARIATION_KEY, variation != null ? variation.getKey() : null); + + return new DecisionNotification( + type, + userId, + attributes, + decisionInfo); + } + } + + public static FeatureDecisionNotificationBuilder newFeatureDecisionNotificationBuilder() { + return new FeatureDecisionNotificationBuilder(); + } + + public static class FeatureDecisionNotificationBuilder { + public final static String FEATURE_KEY = "featureKey"; + public final static String FEATURE_ENABLED = "featureEnabled"; + public final static String SOURCE = "source"; + public final static String SOURCE_INFO = "sourceInfo"; + + private String featureKey; + private Boolean featureEnabled; + private SourceInfo sourceInfo; + private FeatureDecision.DecisionSource source; + private String userId; + private Map<String, ?> attributes; + private Map<String, Object> decisionInfo; + + public FeatureDecisionNotificationBuilder withUserId(String userId) { + this.userId = userId; + return this; + } + + public FeatureDecisionNotificationBuilder withAttributes(Map<String, ?> attributes) { + this.attributes = attributes; + return this; + } + + public FeatureDecisionNotificationBuilder withSourceInfo(SourceInfo sourceInfo) { + this.sourceInfo = sourceInfo; + return this; + } + + public FeatureDecisionNotificationBuilder withSource(FeatureDecision.DecisionSource source) { + this.source = source; + return this; + } + + public FeatureDecisionNotificationBuilder withFeatureKey(String featureKey) { + this.featureKey = featureKey; + return this; + } + + public FeatureDecisionNotificationBuilder withFeatureEnabled(Boolean featureEnabled) { + this.featureEnabled = featureEnabled; + return this; + } + + public DecisionNotification build() { + if (source == null) { + throw new OptimizelyRuntimeException("source not set"); + } + + if (featureKey == null) { + throw new OptimizelyRuntimeException("featureKey not set"); + } + + if (featureEnabled == null) { + throw new OptimizelyRuntimeException("featureEnabled not set"); + } + + decisionInfo = new HashMap<>(); + decisionInfo.put(FEATURE_KEY, featureKey); + decisionInfo.put(FEATURE_ENABLED, featureEnabled); + decisionInfo.put(SOURCE, source.toString()); + decisionInfo.put(SOURCE_INFO, sourceInfo.get()); + + return new DecisionNotification( + NotificationCenter.DecisionNotificationType.FEATURE.toString(), + userId, + attributes, + decisionInfo); + } + } + + public static FeatureVariableDecisionNotificationBuilder newFeatureVariableDecisionNotificationBuilder() { + return new FeatureVariableDecisionNotificationBuilder(); + } + + public static class FeatureVariableDecisionNotificationBuilder { + + public static final String FEATURE_KEY = "featureKey"; + public static final String FEATURE_ENABLED = "featureEnabled"; + public static final String SOURCE = "source"; + public static final String SOURCE_INFO = "sourceInfo"; + public static final String VARIABLE_KEY = "variableKey"; + public static final String VARIABLE_TYPE = "variableType"; + public static final String VARIABLE_VALUE = "variableValue"; + public static final String VARIABLE_VALUES = "variableValues"; + + private NotificationCenter.DecisionNotificationType notificationType; + private String featureKey; + private Boolean featureEnabled; + private FeatureDecision featureDecision; + private String variableKey; + private String variableType; + private Object variableValue; + private Object variableValues; + private String userId; + private Map<String, ?> attributes; + private Map<String, Object> decisionInfo; + + protected FeatureVariableDecisionNotificationBuilder() { + } + + public FeatureVariableDecisionNotificationBuilder withUserId(String userId) { + this.userId = userId; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withAttributes(Map<String, ?> attributes) { + this.attributes = attributes; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withFeatureKey(String featureKey) { + this.featureKey = featureKey; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withFeatureEnabled(boolean featureEnabled) { + this.featureEnabled = featureEnabled; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withFeatureDecision(FeatureDecision featureDecision) { + this.featureDecision = featureDecision; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withVariableKey(String variableKey) { + this.variableKey = variableKey; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withVariableType(String variableType) { + this.variableType = variableType; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withVariableValue(Object variableValue) { + this.variableValue = variableValue; + return this; + } + + public FeatureVariableDecisionNotificationBuilder withVariableValues(Object variableValues) { + this.variableValues = variableValues; + return this; + } + + public DecisionNotification build() { + if (featureKey == null) { + throw new OptimizelyRuntimeException("featureKey not set"); + } + + if (featureEnabled == null) { + throw new OptimizelyRuntimeException("featureEnabled not set"); + } + + + decisionInfo = new HashMap<>(); + decisionInfo.put(FEATURE_KEY, featureKey); + decisionInfo.put(FEATURE_ENABLED, featureEnabled); + + if (variableValues != null) { + notificationType = NotificationCenter.DecisionNotificationType.ALL_FEATURE_VARIABLES; + decisionInfo.put(VARIABLE_VALUES, variableValues); + } else { + notificationType = NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE; + + if (variableKey == null) { + throw new OptimizelyRuntimeException("variableKey not set"); + } + + if (variableType == null) { + throw new OptimizelyRuntimeException("variableType not set"); + } + + decisionInfo.put(VARIABLE_KEY, variableKey); + decisionInfo.put(VARIABLE_TYPE, variableType.toString()); + decisionInfo.put(VARIABLE_VALUE, variableValue); + } + + SourceInfo sourceInfo = new RolloutSourceInfo(); + + if (featureDecision != null && FeatureDecision.DecisionSource.FEATURE_TEST.equals(featureDecision.decisionSource)) { + sourceInfo = new FeatureTestSourceInfo(featureDecision.experiment.getKey(), featureDecision.variation.getKey()); + decisionInfo.put(SOURCE, featureDecision.decisionSource.toString()); + } else { + decisionInfo.put(SOURCE, FeatureDecision.DecisionSource.ROLLOUT.toString()); + } + decisionInfo.put(SOURCE_INFO, sourceInfo.get()); + + return new DecisionNotification( + notificationType.toString(), + userId, + attributes, + decisionInfo); + } + } + + public static FlagDecisionNotificationBuilder newFlagDecisionNotificationBuilder() { + return new FlagDecisionNotificationBuilder(); + } + + public static class FlagDecisionNotificationBuilder { + public final static String FLAG_KEY = "flagKey"; + public final static String ENABLED = "enabled"; + public final static String VARIABLES = "variables"; + public final static String VARIATION_KEY = "variationKey"; + public final static String RULE_KEY = "ruleKey"; + public final static String REASONS = "reasons"; + public final static String DECISION_EVENT_DISPATCHED = "decisionEventDispatched"; + public final static String EXPERIMENT_ID = "experimentId"; + public final static String VARIATION_ID = "variationId"; + + private String flagKey; + private Boolean enabled; + private Object variables; + private String userId; + private Map<String, ?> attributes; + private String variationKey; + private String ruleKey; + private List<String> reasons; + private Boolean decisionEventDispatched; + private String experimentId; + private String variationId; + + private Map<String, Object> decisionInfo; + + public FlagDecisionNotificationBuilder withUserId(String userId) { + this.userId = userId; + return this; + } + + public FlagDecisionNotificationBuilder withAttributes(Map<String, ?> attributes) { + this.attributes = attributes; + return this; + } + + public FlagDecisionNotificationBuilder withFlagKey(String flagKey) { + this.flagKey = flagKey; + return this; + } + + public FlagDecisionNotificationBuilder withEnabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + public FlagDecisionNotificationBuilder withVariables(Object variables) { + this.variables = variables; + return this; + } + + public FlagDecisionNotificationBuilder withVariationKey(String key) { + this.variationKey = key; + return this; + } + + public FlagDecisionNotificationBuilder withRuleKey(String key) { + this.ruleKey = key; + return this; + } + + public FlagDecisionNotificationBuilder withReasons(List<String> reasons) { + this.reasons = reasons; + return this; + } + + public FlagDecisionNotificationBuilder withDecisionEventDispatched(Boolean dispatched) { + this.decisionEventDispatched = dispatched; + return this; + } + + public FlagDecisionNotificationBuilder withExperimentId(String experimentId) { + this.experimentId = experimentId; + return this; + } + + public FlagDecisionNotificationBuilder withVariationId(String variationId) { + this.variationId = variationId; + return this; + } + + public DecisionNotification build() { + if (flagKey == null) { + throw new OptimizelyRuntimeException("flagKey not set"); + } + + if (enabled == null) { + throw new OptimizelyRuntimeException("enabled not set"); + } + + decisionInfo = new HashMap<String, Object>() {{ + put(FLAG_KEY, flagKey); + put(ENABLED, enabled); + put(VARIABLES, variables); + put(VARIATION_KEY, variationKey); + put(RULE_KEY, ruleKey); + put(REASONS, reasons); + put(DECISION_EVENT_DISPATCHED, decisionEventDispatched); + put(EXPERIMENT_ID, experimentId); + put(VARIATION_ID, variationId); + }}; + + return new DecisionNotification( + NotificationCenter.DecisionNotificationType.FLAG.toString(), + userId, + attributes, + decisionInfo); + } + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/FeatureTestSourceInfo.java b/core-api/src/main/java/com/optimizely/ab/notification/FeatureTestSourceInfo.java new file mode 100644 index 000000000..55f89ec08 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/FeatureTestSourceInfo.java @@ -0,0 +1,42 @@ +/**************************************************************************** + * Copyright 2019, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +package com.optimizely.ab.notification; + +import java.util.HashMap; +import java.util.Map; + +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.EXPERIMENT_KEY; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; + +public class FeatureTestSourceInfo implements SourceInfo { + private String experimentKey; + private String variationKey; + + public FeatureTestSourceInfo(String experimentKey, String variationKey) { + this.experimentKey = experimentKey; + this.variationKey = variationKey; + } + + @Override + public Map<String, String> get() { + Map<String, String> sourceInfo = new HashMap<>(); + sourceInfo.put(EXPERIMENT_KEY, experimentKey); + sourceInfo.put(VARIATION_KEY, variationKey); + + return sourceInfo; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java index 9b68c75ba..df8a7afbb 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2018, Optimizely and contributors + * Copyright 2017-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,26 +16,64 @@ */ package com.optimizely.ab.notification; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.Variation; +import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.event.LogEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.annotation.Nonnull; -import java.util.ArrayList; +import javax.annotation.Nullable; +import java.util.Collections; import java.util.HashMap; import java.util.Map; - +import java.util.concurrent.atomic.AtomicInteger; /** - * This class handles impression and conversion notificationsListeners. It replaces NotificationBroadcaster and is intended to be - * more flexible. + * NotificationCenter handles all notification listeners. + * It replaces NotificationBroadcaster and is intended to be more flexible. + * + * NotificationCenter is a holder for a set of supported {@link NotificationManager} instances. + * If a notification object is sent via {@link NotificationCenter#send(Object)} that is not supported + * an {@link OptimizelyRuntimeException} will be thrown. This is an internal interface so + * usage should be restricted to the SDK. + * + * Supported notification classes are setup within {@link NotificationCenter#NotificationCenter()} + * as an unmodifiable map so additional notifications must be added there. + * + * Currently supported notification classes are: + * * {@link ActivateNotification} + * * {@link TrackNotification} + * * {@link DecisionNotification} with this class replacing {@link ActivateNotification} */ public class NotificationCenter { + + private static final Logger logger = LoggerFactory.getLogger(NotificationCenter.class); + private final Map<Class, NotificationManager> notifierMap; + + // TODO move to DecisionNotification. + public enum DecisionNotificationType { + AB_TEST("ab-test"), + FEATURE("feature"), + FEATURE_TEST("feature-test"), + FEATURE_VARIABLE("feature-variable"), + ALL_FEATURE_VARIABLES("all-feature-variables"), + FLAG("flag"); + + private final String key; + + DecisionNotificationType(String key) { + this.key = key; + } + + @Override + public String toString() { + return key; + } + } + /** * NotificationType is used for the notification types supported. */ + @Deprecated public enum NotificationType { Activate(ActivateNotificationListener.class), // Activate was called. Track an impression event @@ -50,121 +88,138 @@ public enum NotificationType { public Class getNotificationTypeClass() { return notificationTypeClass; } - }; + } + public NotificationCenter() { + AtomicInteger counter = new AtomicInteger(); + Map<Class, NotificationManager> validManagers = new HashMap<>(); + validManagers.put(ActivateNotification.class, new NotificationManager<ActivateNotification>(counter)); + validManagers.put(TrackNotification.class, new NotificationManager<TrackNotification>(counter)); + validManagers.put(DecisionNotification.class, new NotificationManager<DecisionNotification>(counter)); + validManagers.put(UpdateConfigNotification.class, new NotificationManager<UpdateConfigNotification>(counter)); + validManagers.put(LogEvent.class, new NotificationManager<LogEvent>(counter)); - // the notification id is incremented and is assigned as the callback id, it can then be used to remove the notification. - private int notificationListenerID = 1; + notifierMap = Collections.unmodifiableMap(validManagers); + } - final private static Logger logger = LoggerFactory.getLogger(NotificationCenter.class); + @Nullable + @SuppressWarnings("unchecked") + public <T> NotificationManager<T> getNotificationManager(Class clazz) { + return notifierMap.get(clazz); + } - // notification holder holds the id as well as the notification. - private static class NotificationHolder - { - int notificationId; - NotificationListener notificationListener; + public <T> int addNotificationHandler(Class<T> clazz, NotificationHandler<T> handler) { + NotificationManager<T> notificationManager = getNotificationManager(clazz); - NotificationHolder(int id, NotificationListener notificationListener) { - notificationId = id; - this.notificationListener = notificationListener; + if (notificationManager == null) { + logger.warn("{} not supported by the NotificationCenter.", clazz); + return -1; } - } - /** - * Instantiate a new NotificationCenter - */ - public NotificationCenter() { - notificationsListeners.put(NotificationType.Activate, new ArrayList<NotificationHolder>()); - notificationsListeners.put(NotificationType.Track, new ArrayList<NotificationHolder>()); + return notificationManager.addHandler(handler); } - // private list of notification by notification type. - // we used a list so that notification order can mean something. - private Map<NotificationType, ArrayList<NotificationHolder>> notificationsListeners =new HashMap<NotificationType, ArrayList<NotificationHolder>>(); - /** * Convenience method to support lambdas as callbacks in later version of Java (8+). - * @param activateNotificationListenerInterface + * + * @param activateNotificationListener The ActivateNotificationListener * @return greater than zero if added. + * + * @deprecated by {@link NotificationManager#addHandler(NotificationHandler)} */ - public int addActivateNotificationListener(final ActivateNotificationListenerInterface activateNotificationListenerInterface) { - if (activateNotificationListenerInterface instanceof ActivateNotificationListener) { - return addNotificationListener(NotificationType.Activate, (NotificationListener)activateNotificationListenerInterface); - } - else { - return addNotificationListener(NotificationType.Activate, new ActivateNotificationListener() { - @Override - public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { - activateNotificationListenerInterface.onActivate(experiment, userId, attributes, variation, event); - } - }); + @Deprecated + public int addActivateNotificationListener(final ActivateNotificationListenerInterface activateNotificationListener) { + NotificationManager<ActivateNotification> notificationManager = getNotificationManager(ActivateNotification.class); + if (notificationManager == null) { + logger.warn("Notification listener was the wrong type. It was not added to the notification center."); + return -1; + } + + if (activateNotificationListener instanceof ActivateNotificationListener) { + return notificationManager.addHandler((ActivateNotificationListener) activateNotificationListener); + } else { + return notificationManager.addHandler(message -> activateNotificationListener.onActivate( + message.getExperiment(), + message.getUserId(), + message.getAttributes(), + message.getVariation(), + message.getEvent() + )); } } /** * Convenience method to support lambdas as callbacks in later versions of Java (8+) - * @param trackNotificationListenerInterface + * + * @param trackNotificationListener The TrackNotificationListener * @return greater than zero if added. + * + * @deprecated by {@link NotificationManager#addHandler(NotificationHandler)} */ - public int addTrackNotificationListener(final TrackNotificationListenerInterface trackNotificationListenerInterface) { - if (trackNotificationListenerInterface instanceof TrackNotificationListener) { - return addNotificationListener(NotificationType.Activate, (NotificationListener)trackNotificationListenerInterface); - } - else { - return addNotificationListener(NotificationType.Track, new TrackNotificationListener() { - @Override - public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { - trackNotificationListenerInterface.onTrack(eventKey, userId, attributes, eventTags, event); - } - }); + @Deprecated + public int addTrackNotificationListener(final TrackNotificationListenerInterface trackNotificationListener) { + NotificationManager<TrackNotification> notificationManager = getNotificationManager(TrackNotification.class); + if (notificationManager == null) { + logger.warn("Notification listener was the wrong type. It was not added to the notification center."); + return -1; + } + + if (trackNotificationListener instanceof TrackNotificationListener) { + return notificationManager.addHandler((TrackNotificationListener) trackNotificationListener); + } else { + return notificationManager.addHandler(message -> trackNotificationListener.onTrack( + message.getEventKey(), + message.getUserId(), + message.getAttributes(), + message.getEventTags(), + message.getEvent() + )); } } /** * Add a notification listener to the notification center. * - * @param notificationType - enum NotificationType to add. + * @param notificationType - enum NotificationType to add. * @param notificationListener - Notification to add. * @return the notification id used to remove the notification. It is greater than 0 on success. + * + * @deprecated by {@link NotificationManager#addHandler(NotificationHandler)} */ + @Deprecated public int addNotificationListener(NotificationType notificationType, NotificationListener notificationListener) { - Class clazz = notificationType.notificationTypeClass; + Class clazz = notificationType.getNotificationTypeClass(); if (clazz == null || !clazz.isInstance(notificationListener)) { logger.warn("Notification listener was the wrong type. It was not added to the notification center."); return -1; } - for (NotificationHolder holder : notificationsListeners.get(notificationType)) { - if (holder.notificationListener == notificationListener) { - logger.warn("Notificication listener was already added"); - return -1; - } + switch (notificationType) { + case Track: + return addTrackNotificationListener((TrackNotificationListener) notificationListener); + case Activate: + return addActivateNotificationListener((ActivateNotificationListener) notificationListener); + default: + throw new OptimizelyRuntimeException("Unsupported notificationType"); } - int id = notificationListenerID++; - notificationsListeners.get(notificationType).add(new NotificationHolder(id, notificationListener )); - logger.info("Notification listener {} was added with id {}", notificationListener.toString(), id); - return id; } /** - * Remove the notification listener based on the notificationId passed back from addNotificationListener. + * Remove the notification listener based on the notificationId passed back from addDecisionNotificationHandler. + * * @param notificationID the id passed back from add notification. * @return true if removed otherwise false (if the notification is already registered, it returns false). */ - public boolean removeNotificationListener(int notificationID) { - for (NotificationType type : NotificationType.values()) { - for (NotificationHolder holder : notificationsListeners.get(type)) { - if (holder.notificationId == notificationID) { - notificationsListeners.get(type).remove(holder); - logger.info("Notification listener removed {}", notificationID); - return true; - } + public boolean removeNotificationListener(int notificationID) { + for (NotificationManager<?> manager : notifierMap.values()) { + if (manager.remove(notificationID)) { + logger.info("Notification listener removed {}", notificationID); + return true; } } logger.warn("Notification listener with id {} not found", notificationID); - return false; } @@ -172,30 +227,53 @@ public boolean removeNotificationListener(int notificationID) { * Clear out all the notification listeners. */ public void clearAllNotificationListeners() { - for (NotificationType type : NotificationType.values()) { - clearNotificationListeners(type); + for (NotificationManager<?> manager : notifierMap.values()) { + manager.clear(); } } /** * Clear notification listeners by notification type. + * * @param notificationType type of notificationsListeners to remove. + * + * @deprecated by {@link NotificationCenter#clearNotificationListeners(Class)} */ + @Deprecated public void clearNotificationListeners(NotificationType notificationType) { - notificationsListeners.get(notificationType).clear(); + switch (notificationType) { + case Track: + clearNotificationListeners(TrackNotification.class); + break; + case Activate: + clearNotificationListeners(ActivateNotification.class); + break; + default: + throw new OptimizelyRuntimeException("Unsupported notificationType"); + } } - // fire a notificaiton of a certain type. The arg list changes depending on the type of notification sent. - public void sendNotifications(NotificationType notificationType, Object ...args) { - ArrayList<NotificationHolder> holders = notificationsListeners.get(notificationType); - for (NotificationHolder holder : holders) { - try { - holder.notificationListener.notify(args); - } - catch (Exception e) { - logger.error("Unexpected exception calling notification listener {}", holder.notificationId, e); - } + /** + * Clear notification listeners by notification class. + * + * @param clazz The NotificationLister class + */ + public void clearNotificationListeners(Class clazz) { + NotificationManager notificationManager = getNotificationManager(clazz); + if (notificationManager == null) { + throw new OptimizelyRuntimeException("Unsupported notification type."); } + + notificationManager.clear(); } + @SuppressWarnings("unchecked") + public void send(Object notification) { + NotificationManager handler = getNotificationManager(notification.getClass()); + if (handler == null) { + throw new OptimizelyRuntimeException("Unsupported notificationType"); + } + + handler.send(notification); + } } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationHandler.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationHandler.java new file mode 100644 index 000000000..454b25bb6 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationHandler.java @@ -0,0 +1,28 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +/** + * NotificationHandler is a generic interface Optimizely notification listeners. + * This interface replaces {@link NotificationListener} which didn't provide adequate type safety. + * + * While this class adds generic handler implementations to be created, the domain of supported + * implementations is maintained by the {@link NotificationCenter} + */ +public interface NotificationHandler<T> { + void handle(T message) throws Exception; +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java index 687870979..1caea5ca4 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,26 +16,21 @@ */ package com.optimizely.ab.notification; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.event.LogEvent; - -import java.util.Map; - -import javax.annotation.CheckForNull; -import javax.annotation.Nonnull; - /** * An interface class for Optimizely notification listeners. * <p> * We changed this from a abstract class to a interface to support lambdas moving forward in Java 8 and beyond. + * + * @deprecated in favor of the {@link NotificationHandler} interface. */ +@Deprecated public interface NotificationListener { /** * This is the base method of notification. Implementation classes such as {@link ActivateNotificationListener} * will implement this call and provide another method with the correct parameters * Notify called when a notification is triggered via the {@link com.optimizely.ab.notification.NotificationCenter} + * * @param args - variable argument list based on the type of notification. */ public void notify(Object... args); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java new file mode 100644 index 000000000..986a142a8 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationManager.java @@ -0,0 +1,98 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import java.util.concurrent.locks.ReentrantLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * NotificationManger is a generic class for managing notifications for a given class. + * + * The NotificationManager is responsible for storing a collection of NotificationHandlers and mapping + * them to a globally unique integer so that they can be removed on demand. + */ +public class NotificationManager<T> { + + private static final Logger logger = LoggerFactory.getLogger(NotificationManager.class); + + private final Map<Integer, NotificationHandler<T>> handlers = Collections.synchronizedMap(new LinkedHashMap<>()); + private final AtomicInteger counter; + private final ReentrantLock lock = new ReentrantLock(); + + public NotificationManager() { + this(new AtomicInteger()); + } + + public NotificationManager(AtomicInteger counter) { + this.counter = counter; + } + + public int addHandler(NotificationHandler<T> newHandler) { + + // Prevent registering a duplicate listener. + lock.lock(); + try { + for (NotificationHandler<T> handler : handlers.values()) { + if (handler.equals(newHandler)) { + logger.warn("Notification listener was already added"); + return -1; + } + } + } finally { + lock.unlock(); + } + + int notificationId = counter.incrementAndGet(); + handlers.put(notificationId, newHandler); + + return notificationId; + } + + public void send(final T message) { + lock.lock(); + try { + for (Map.Entry<Integer, NotificationHandler<T>> handler: handlers.entrySet()) { + try { + handler.getValue().handle(message); + } catch (Exception e) { + logger.warn("Catching exception sending notification for class: {}, handler: {}", message.getClass(), handler.getKey()); + } + } + } finally { + lock.unlock(); + } + } + + public void clear() { + handlers.clear(); + } + + public boolean remove(int notificationID) { + NotificationHandler<T> handler = handlers.remove(notificationID); + return handler != null; + } + + public int size() { + return handlers.size(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/RolloutSourceInfo.java b/core-api/src/main/java/com/optimizely/ab/notification/RolloutSourceInfo.java new file mode 100644 index 000000000..3c73c8dea --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/RolloutSourceInfo.java @@ -0,0 +1,27 @@ +/**************************************************************************** + * Copyright 2019, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +package com.optimizely.ab.notification; + +import java.util.Collections; +import java.util.Map; + +public class RolloutSourceInfo implements SourceInfo { + @Override + public Map<String, String> get() { + return Collections.EMPTY_MAP; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/SourceInfo.java b/core-api/src/main/java/com/optimizely/ab/notification/SourceInfo.java new file mode 100644 index 000000000..e17a4e566 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/SourceInfo.java @@ -0,0 +1,23 @@ +/**************************************************************************** + * Copyright 2019, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ + +package com.optimizely.ab.notification; + +import java.util.Map; + +public interface SourceInfo { + Map<String, String> get(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java new file mode 100644 index 000000000..4651d5bbb --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java @@ -0,0 +1,94 @@ +/** + * + * Copyright 2019,2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.event.LogEvent; + +import java.util.Map; + +/** + * TrackNotification encapsulates the arguments used to submit tracking calls. + */ +public final class TrackNotification { + + private final String eventKey; + private final String userId; + private final Map<String, ?> attributes; + private final Map<String, ?> eventTags; + private final LogEvent event; + + @VisibleForTesting + TrackNotification() { + this(null, null, null, null, null); + } + + /** + * @param eventKey - The event key that was triggered. + * @param userId - user id passed into track. + * @param attributes - filtered attributes list after passed into track + * @param eventTags - event tags if any were passed in. + * @param event - The event being recorded. + */ + public TrackNotification(String eventKey, String userId, Map<String, ?> attributes, Map<String, ?> eventTags, LogEvent event) { + this.eventKey = eventKey; + this.userId = userId; + this.attributes = attributes; + this.eventTags = eventTags; + this.event = event; + } + + public String getEventKey() { + return eventKey; + } + + public String getUserId() { + return userId; + } + + public Map<String, ?> getAttributes() { + return attributes; + } + + public Map<String, ?> getEventTags() { + return eventTags; + } + + /** + * This interface is deprecated since this is no longer a one-to-one mapping. + * Please use a {@link NotificationHandler} explicitly for LogEvent messages. + * {@link com.optimizely.ab.Optimizely#addLogEventNotificationHandler(NotificationHandler)} + * + * @return The event + */ + @Deprecated + public LogEvent getEvent() { + return event; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("TrackNotification{"); + sb.append("eventKey='").append(eventKey).append('\''); + sb.append(", userId='").append(userId).append('\''); + sb.append(", attributes=").append(attributes); + sb.append(", eventTags=").append(eventTags); + sb.append(", event=").append(event); + sb.append('}'); + return sb.toString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListener.java b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListener.java index 16809f716..d6eae25e2 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListener.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListener.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017-2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,40 +22,66 @@ import com.optimizely.ab.event.LogEvent; /** - * This class handles the track event notification. + * TrackNotificationListener handles the track event notification. + * + * @deprecated and users should implement NotificationHandler<TrackNotification> directly. */ -public abstract class TrackNotificationListener implements NotificationListener, TrackNotificationListenerInterface { +@Deprecated +public abstract class TrackNotificationListener implements NotificationHandler<TrackNotification>, NotificationListener, TrackNotificationListenerInterface { + /** * Base notify called with var args. This method parses the parameters and calls the abstract method. + * * @param args - variable argument list based on the type of notification. + * + * @deprecated by {@link TrackNotificationListener#handle(TrackNotification)} */ @Override + @Deprecated public final void notify(Object... args) { - assert(args[0] instanceof String); + assert (args[0] instanceof String); String eventKey = (String) args[0]; - assert(args[1] instanceof String); + assert (args[1] instanceof String); String userId = (String) args[1]; - assert(args[2] instanceof java.util.Map); - Map<String, String> attributes = (Map<String, String>) args[2]; - assert(args[3] instanceof java.util.Map); - Map<String, ?> eventTags = (Map<String, ?>) args[3]; - assert(args[4] instanceof LogEvent); + Map<String, ?> attributes = null; + if (args[2] != null) { + assert (args[2] instanceof java.util.Map); + attributes = (Map<String, ?>) args[2]; + } + Map<String, ?> eventTags = null; + if (args[3] != null) { + assert (args[3] instanceof java.util.Map); + eventTags = (Map<String, ?>) args[3]; + } + assert (args[4] instanceof LogEvent); LogEvent logEvent = (LogEvent) args[4]; - onTrack(eventKey, userId,attributes,eventTags, logEvent); + onTrack(eventKey, userId, attributes, eventTags, logEvent); + } + + @Override + public final void handle(TrackNotification message) { + onTrack( + message.getEventKey(), + message.getUserId(), + message.getAttributes(), + message.getEventTags(), + message.getEvent() + ); } /** * onTrack is called when a track event is triggered - * @param eventKey - The event key that was triggered. - * @param userId - user id passed into track. + * + * @param eventKey - The event key that was triggered. + * @param userId - user id passed into track. * @param attributes - filtered attributes list after passed into track - * @param eventTags - event tags if any were passed in. - * @param event - The event being recorded. + * @param eventTags - event tags if any were passed in. + * @param event - The event being recorded. */ public abstract void onTrack(@Nonnull String eventKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes, - @Nonnull Map<String, ?> eventTags, - @Nonnull LogEvent event) ; + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nonnull Map<String, ?> eventTags, + @Nonnull LogEvent event); } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListenerInterface.java b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListenerInterface.java index 74df611dd..746de567f 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListenerInterface.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotificationListenerInterface.java @@ -1,3 +1,19 @@ +/** + * + * Copyright 2018-2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.optimizely.ab.notification; import com.optimizely.ab.event.LogEvent; @@ -5,19 +21,26 @@ import javax.annotation.Nonnull; import java.util.Map; +/** + * TrackNotificationListenerInterface provides an interface for track event notification. + * + * @deprecated and users should implement NotificationHandler<TrackNotification> directly. + */ +@Deprecated public interface TrackNotificationListenerInterface { /** * onTrack is called when a track event is triggered - * @param eventKey - The event key that was triggered. - * @param userId - user id passed into track. + * + * @param eventKey - The event key that was triggered. + * @param userId - user id passed into track. * @param attributes - filtered attributes list after passed into track - * @param eventTags - event tags if any were passed in. - * @param event - The event being recorded. + * @param eventTags - event tags if any were passed in. + * @param event - The event being recorded. */ public void onTrack(@Nonnull String eventKey, - @Nonnull String userId, - @Nonnull Map<String, String> attributes, - @Nonnull Map<String, ?> eventTags, - @Nonnull LogEvent event) ; + @Nonnull String userId, + @Nonnull Map<String, ?> attributes, + @Nonnull Map<String, ?> eventTags, + @Nonnull LogEvent event); } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/UpdateConfigNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/UpdateConfigNotification.java new file mode 100644 index 000000000..63ede01a0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/UpdateConfigNotification.java @@ -0,0 +1,23 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +/** + * UpdateConfigNotification signals a change in the current configuration. + */ +public class UpdateConfigNotification { +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java new file mode 100644 index 000000000..b45bd937f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java @@ -0,0 +1,25 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import java.util.List; +import java.util.Set; + +public interface ODPApiManager { + List<String> fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set<String> segmentsToCheck); + + Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java new file mode 100644 index 000000000..8ffaaeada --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java @@ -0,0 +1,130 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; + +public class ODPConfig { + + private String apiKey; + + private String apiHost; + + private Set<String> allSegments; + + private final ReentrantLock lock = new ReentrantLock(); + + public ODPConfig(String apiKey, String apiHost, Set<String> allSegments) { + this.apiKey = apiKey; + this.apiHost = apiHost; + this.allSegments = allSegments; + } + + public ODPConfig(String apiKey, String apiHost) { + this(apiKey, apiHost, Collections.emptySet()); + } + + public Boolean isReady() { + lock.lock(); + try { + return !( + this.apiKey == null || this.apiKey.isEmpty() + || this.apiHost == null || this.apiHost.isEmpty() + ); + } finally { + lock.unlock(); + } + } + + public Boolean hasSegments() { + lock.lock(); + try { + return allSegments != null && !allSegments.isEmpty(); + } finally { + lock.unlock(); + } + } + + public void setApiKey(String apiKey) { + lock.lock(); + try { + this.apiKey = apiKey; + } finally { + lock.unlock(); + } + } + + public void setApiHost(String apiHost) { + lock.lock(); + try { + this.apiHost = apiHost; + } finally { + lock.unlock(); + } + } + + public String getApiKey() { + lock.lock(); + try { + return apiKey; + } finally { + lock.unlock(); + } + } + + public String getApiHost() { + lock.lock(); + try { + return apiHost; + } finally { + lock.unlock(); + } + } + + public Set<String> getAllSegments() { + lock.lock(); + try { + return allSegments; + } finally { + lock.unlock(); + } + } + + public void setAllSegments(Set<String> allSegments) { + lock.lock(); + try { + this.allSegments = allSegments; + } finally { + lock.unlock(); + } + } + + public Boolean equals(ODPConfig toCompare) { + return getApiHost().equals(toCompare.getApiHost()) && getApiKey().equals(toCompare.getApiKey()) && getAllSegments().equals(toCompare.allSegments); + } + + public ODPConfig getClone() { + lock.lock(); + try { + return new ODPConfig(apiKey, apiHost, allSegments); + } finally { + lock.unlock(); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java new file mode 100644 index 000000000..a505bf6d1 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEvent.java @@ -0,0 +1,93 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.beans.Transient; +import java.util.Collections; +import java.util.Map; + +public class ODPEvent { + public static final String EVENT_TYPE_FULLSTACK = "fullstack"; + + @Nonnull private String type; + @Nonnull private String action; + @Nonnull private Map<String, String> identifiers; + @Nonnull private Map<String, Object> data; + + public ODPEvent(@Nullable String type, @Nonnull String action, @Nullable Map<String, String> identifiers, @Nullable Map<String, Object> data) { + this.type = type == null || type.trim().isEmpty() ? EVENT_TYPE_FULLSTACK : type; + this.action = action; + this.identifiers = identifiers != null ? identifiers : Collections.emptyMap(); + this.data = data != null ? data : Collections.emptyMap(); + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public Map<String, String> getIdentifiers() { + return identifiers; + } + + public void setIdentifiers(Map<String, String> identifiers) { + this.identifiers = identifiers; + } + + public Map<String, Object> getData() { + return data; + } + + public void setData(Map<String, Object> data) { + this.data = data; + } + + @Transient + public Boolean isDataValid() { + for (Object entry: this.data.values()) { + if ( + !( entry instanceof String + || entry instanceof Integer + || entry instanceof Long + || entry instanceof Boolean + || entry instanceof Float + || entry instanceof Double + || entry == null)) { + return false; + } + } + return true; + } + + @Transient + public Boolean isIdentifiersValid() { + return !identifiers.isEmpty(); + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java new file mode 100644 index 000000000..43727b501 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPEventManager.java @@ -0,0 +1,321 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.odp.serializer.ODPJsonSerializerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.*; +import java.util.concurrent.*; + +public class ODPEventManager { + private static final Logger logger = LoggerFactory.getLogger(ODPEventManager.class); + private static final int DEFAULT_BATCH_SIZE = 10; + private static final int DEFAULT_QUEUE_SIZE = 10000; + private static final int DEFAULT_FLUSH_INTERVAL = 1000; + private static final int MAX_RETRIES = 3; + private static final String EVENT_URL_PATH = "/v3/events"; + private static final List<String> FS_USER_ID_MATCHES = new ArrayList<>(Arrays.asList( + ODPUserKey.FS_USER_ID.getKeyString(), + ODPUserKey.FS_USER_ID_ALIAS.getKeyString() + )); + private static final Object SHUTDOWN_SIGNAL = new Object(); + + private final int queueSize; + private final int batchSize; + private final int flushInterval; + @Nonnull private Map<String, Object> userCommonData = Collections.emptyMap(); + @Nonnull private Map<String, String> userCommonIdentifiers = Collections.emptyMap(); + + private Boolean isRunning = false; + + // This needs to be volatile because it will be updated in the main thread and the event dispatcher thread + // needs to see the change immediately. + private volatile ODPConfig odpConfig; + private EventDispatcherThread eventDispatcherThread; + @VisibleForTesting + public final ODPApiManager apiManager; + + // The eventQueue needs to be thread safe. We are not doing anything extra for thread safety here + // because `LinkedBlockingQueue` itself is thread safe. + private final BlockingQueue<Object> eventQueue = new LinkedBlockingQueue<>(); + private ThreadFactory threadFactory; + + public ODPEventManager(@Nonnull ODPApiManager apiManager) { + this(apiManager, null, null); + } + + public ODPEventManager(@Nonnull ODPApiManager apiManager, @Nullable Integer queueSize, @Nullable Integer flushInterval) { + this(apiManager, queueSize, flushInterval, null); + } + + public ODPEventManager(@Nonnull ODPApiManager apiManager, + @Nullable Integer queueSize, + @Nullable Integer flushInterval, + @Nullable ThreadFactory threadFactory) { + this.apiManager = apiManager; + this.queueSize = queueSize != null ? queueSize : DEFAULT_QUEUE_SIZE; + this.flushInterval = (flushInterval != null && flushInterval > 0) ? flushInterval : DEFAULT_FLUSH_INTERVAL; + this.batchSize = (flushInterval != null && flushInterval == 0) ? 1 : DEFAULT_BATCH_SIZE; + this.threadFactory = threadFactory != null ? threadFactory : Executors.defaultThreadFactory(); + } + + // these user-provided common data are included in all ODP events in addition to the SDK-generated common data. + public void setUserCommonData(@Nullable Map<String, Object> commonData) { + if (commonData != null) this.userCommonData = commonData; + } + + // these user-provided common identifiers are included in all ODP events in addition to the SDK-generated identifiers. + public void setUserCommonIdentifiers(@Nullable Map<String, String> commonIdentifiers) { + if (commonIdentifiers != null) this.userCommonIdentifiers = commonIdentifiers; + } + + public void start() { + if (eventDispatcherThread == null) { + eventDispatcherThread = new EventDispatcherThread(); + } + if (!isRunning) { + ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = threadFactory.newThread(runnable); + thread.setDaemon(true); + return thread; + }); + executor.submit(eventDispatcherThread); + } + isRunning = true; + } + + public void updateSettings(ODPConfig newConfig) { + if (odpConfig == null || (!odpConfig.equals(newConfig) && eventQueue.offer(new FlushEvent(odpConfig)))) { + odpConfig = newConfig; + } + } + + public void identifyUser(String userId) { + identifyUser(null, userId); + } + + public void identifyUser(@Nullable String vuid, @Nullable String userId) { + Map<String, String> identifiers = new HashMap<>(); + if (vuid != null) { + identifiers.put(ODPUserKey.VUID.getKeyString(), vuid); + } + if (userId != null) { + if (ODPManager.isVuid(userId)) { + identifiers.put(ODPUserKey.VUID.getKeyString(), userId); + } else { + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), userId); + } + } + ODPEvent event = new ODPEvent("fullstack", "identified", identifiers, null); + sendEvent(event); + } + + public void sendEvent(ODPEvent event) { + event.setData(augmentCommonData(event.getData())); + event.setIdentifiers(convertCriticalIdentifiers(augmentCommonIdentifiers(event.getIdentifiers()))); + + if (!event.isIdentifiersValid()) { + logger.error("ODP event send failed (event identifiers must have at least one key-value pair)"); + return; + } + + if (!event.isDataValid()) { + logger.error("ODP event send failed (event data is not valid)"); + return; + } + + + processEvent(event); + } + + @VisibleForTesting + protected Map<String, Object> augmentCommonData(Map<String, Object> sourceData) { + // priority: sourceData > userCommonData > sdkCommonData + + Map<String, Object> data = new HashMap<>(); + data.put("idempotence_id", UUID.randomUUID().toString()); + data.put("data_source_type", "sdk"); + data.put("data_source", ClientEngineInfo.getClientEngineName()); + data.put("data_source_version", BuildVersionInfo.getClientVersion()); + + data.putAll(userCommonData); + data.putAll(sourceData); + return data; + } + + @VisibleForTesting + protected Map<String, String> augmentCommonIdentifiers(Map<String, String> sourceIdentifiers) { + // priority: sourceIdentifiers > userCommonIdentifiers + + Map<String, String> identifiers = new HashMap<>(); + identifiers.putAll(userCommonIdentifiers); + identifiers.putAll(sourceIdentifiers); + + return identifiers; + } + + private static Map<String, String> convertCriticalIdentifiers(Map<String, String> identifiers) { + + if (identifiers.containsKey(ODPUserKey.FS_USER_ID.getKeyString())) { + return identifiers; + } + + List<Map.Entry<String, String>> identifiersList = new ArrayList<>(identifiers.entrySet()); + + for (Map.Entry<String, String> kvp : identifiersList) { + + if (FS_USER_ID_MATCHES.contains(kvp.getKey().toLowerCase())) { + identifiers.remove(kvp.getKey()); + identifiers.put(ODPUserKey.FS_USER_ID.getKeyString(), kvp.getValue()); + break; + } + } + + return identifiers; + } + + private void processEvent(ODPEvent event) { + if (!isRunning) { + logger.warn("Failed to Process ODP Event. ODPEventManager is not running"); + return; + } + + if (odpConfig == null || !odpConfig.isReady()) { + logger.debug("Unable to Process ODP Event. ODPConfig is not ready."); + return; + } + + if (eventQueue.size() >= queueSize) { + logger.warn("Failed to Process ODP Event. Event Queue full. queueSize = " + queueSize); + return; + } + + if (!eventQueue.offer(event)) { + logger.error("Failed to Process ODP Event. Event Queue is not accepting any more events"); + } + } + + public void stop() { + logger.debug("Sending stop signal to ODP Event Dispatcher Thread"); + eventDispatcherThread.signalStop(); + } + + private class EventDispatcherThread extends Thread { + + private final List<ODPEvent> currentBatch = new ArrayList<>(); + + private long nextFlushTime = new Date().getTime(); + + @Override + public void run() { + while (true) { + try { + Object nextEvent = null; + + // If batch has events, set the timeout to remaining time for flush interval, + // otherwise wait for the new event indefinitely + if (currentBatch.size() > 0) { + nextEvent = eventQueue.poll(nextFlushTime - new Date().getTime(), TimeUnit.MILLISECONDS); + } else { + nextEvent = eventQueue.take(); + } + + if (nextEvent == null) { + // null means no new events received and flush interval is over, dispatch whatever is in the batch. + if (!currentBatch.isEmpty()) { + flush(); + } + continue; + } + + if (nextEvent instanceof FlushEvent) { + flush(((FlushEvent) nextEvent).getOdpConfig()); + continue; + } + + if (currentBatch.size() == 0) { + // Batch starting, create a new flush time + nextFlushTime = new Date().getTime() + flushInterval; + } + if (nextEvent == SHUTDOWN_SIGNAL) { + flush(); + logger.info("Received shutdown signal."); + break; + } + currentBatch.add((ODPEvent) nextEvent); + + if (currentBatch.size() >= batchSize) { + flush(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + isRunning = false; + logger.debug("Exiting ODP Event Dispatcher Thread."); + } + + private void flush(ODPConfig odpConfig) { + if (currentBatch.size() == 0) { + return; + } + + if (odpConfig.isReady()) { + String payload = ODPJsonSerializerFactory.getSerializer().serializeEvents(currentBatch); + String endpoint = odpConfig.getApiHost() + EVENT_URL_PATH; + Integer statusCode; + int numAttempts = 0; + do { + statusCode = apiManager.sendEvents(odpConfig.getApiKey(), endpoint, payload); + numAttempts ++; + } while (numAttempts < MAX_RETRIES && statusCode != null && (statusCode == 0 || statusCode >= 500)); + } else { + logger.debug("ODPConfig not ready, discarding event batch"); + } + currentBatch.clear(); + } + + private void flush() { + flush(odpConfig); + } + + public void signalStop() { + if (!eventQueue.offer(SHUTDOWN_SIGNAL)) { + logger.error("Failed to Process Shutdown odp Event. Event Queue is not accepting any more events"); + } + } + } + + private static class FlushEvent { + private final ODPConfig odpConfig; + public FlushEvent(ODPConfig odpConfig) { + this.odpConfig = odpConfig.getClone(); + } + + public ODPConfig getOdpConfig() { + return odpConfig; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java new file mode 100644 index 000000000..3a47e3f04 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPManager.java @@ -0,0 +1,224 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.internal.Cache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class ODPManager implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(ODPManager.class); + + private volatile ODPConfig odpConfig; + private final ODPSegmentManager segmentManager; + private final ODPEventManager eventManager; + + private ODPManager(@Nonnull ODPApiManager apiManager) { + this(new ODPSegmentManager(apiManager), new ODPEventManager(apiManager)); + } + + private ODPManager(@Nonnull ODPSegmentManager segmentManager, @Nonnull ODPEventManager eventManager) { + this.segmentManager = segmentManager; + this.eventManager = eventManager; + this.eventManager.start(); + } + + public ODPSegmentManager getSegmentManager() { + return segmentManager; + } + + public ODPEventManager getEventManager() { + return eventManager; + } + + public Boolean updateSettings(String apiHost, String apiKey, Set<String> allSegments) { + ODPConfig newConfig = new ODPConfig(apiKey, apiHost, allSegments); + if (odpConfig == null || !odpConfig.equals(newConfig)) { + logger.debug("Updating ODP Config"); + odpConfig = newConfig; + eventManager.updateSettings(odpConfig); + segmentManager.resetCache(); + segmentManager.updateSettings(odpConfig); + return true; + } + return false; + } + + public void close() { + eventManager.stop(); + } + + public static boolean isVuid(String userId) { + return userId.startsWith("vuid_"); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private ODPSegmentManager segmentManager; + private ODPEventManager eventManager; + private ODPApiManager apiManager; + private Integer cacheSize; + private Integer cacheTimeoutSeconds; + private Cache<List<String>> cacheImpl; + private Map<String, Object> userCommonData; + private Map<String, String> userCommonIdentifiers; + + /** + * Provide a custom {@link ODPManager} instance which makes http calls to fetch segments and send events. + * + * A Default ODPApiManager is available in `core-httpclient-impl` package. + * + * @param apiManager The implementation of {@link ODPManager} + * @return ODPManager builder + */ + public Builder withApiManager(ODPApiManager apiManager) { + this.apiManager = apiManager; + return this; + } + + /** + * Provide an optional custom {@link ODPSegmentManager} instance. + * + * A Default {@link ODPSegmentManager} implementation is automatically used if none provided. + * + * @param segmentManager The implementation of {@link ODPSegmentManager} + * @return ODPManager builder + */ + public Builder withSegmentManager(ODPSegmentManager segmentManager) { + this.segmentManager = segmentManager; + return this; + } + + /** + * Provide an optional custom {@link ODPEventManager} instance. + * + * A Default {@link ODPEventManager} implementation is automatically used if none provided. + * + * @param eventManager The implementation of {@link ODPEventManager} + * @return ODPManager builder + */ + public Builder withEventManager(ODPEventManager eventManager) { + this.eventManager = eventManager; + return this; + } + + /** + * Provide an optional custom cache size + * + * A Default cache size is automatically used if none provided. + * + * @param cacheSize Custom cache size to be used. + * @return ODPManager builder + */ + public Builder withSegmentCacheSize(Integer cacheSize) { + this.cacheSize = cacheSize; + return this; + } + + /** + * Provide an optional custom cache timeout. + * + * A Default cache timeout is automatically used if none provided. + * + * @param cacheTimeoutSeconds Custom cache timeout in seconds. + * @return ODPManager builder + */ + public Builder withSegmentCacheTimeout(Integer cacheTimeoutSeconds) { + this.cacheTimeoutSeconds = cacheTimeoutSeconds; + return this; + } + + /** + * Provide an optional custom Segment Cache implementation. + * + * A Default LRU Cache implementation is automatically used if none provided. + * + * @param cacheImpl Customer Cache Implementation. + * @return ODPManager builder + */ + public Builder withSegmentCache(Cache<List<String>> cacheImpl) { + this.cacheImpl = cacheImpl; + return this; + } + + /** + * Provide an optional group of user data that should be included in all ODP events. + * + * Note that this is in addition to the default data that is automatically included in all ODP events by this SDK (sdk-name, sdk-version, etc). + * + * @param commonData A key-value map of common user data. + * @return ODPManager builder + */ + public Builder withUserCommonData(@Nonnull Map<String, Object> commonData) { + this.userCommonData = commonData; + return this; + } + + /** + * Provide an optional group of identifiers that should be included in all ODP events. + * + * Note that this is in addition to the identifiers that is automatically included in all ODP events by this SDK. + * + * @param commonIdentifiers A key-value map of common identifiers. + * @return ODPManager builder + */ + public Builder withUserCommonIdentifiers(@Nonnull Map<String, String> commonIdentifiers) { + this.userCommonIdentifiers = commonIdentifiers; + return this; + } + + public ODPManager build() { + if ((segmentManager == null || eventManager == null) && apiManager == null) { + logger.warn("ApiManager instance is needed when using default EventManager or SegmentManager"); + return null; + } + + if (segmentManager == null) { + if (cacheImpl != null) { + segmentManager = new ODPSegmentManager(apiManager, cacheImpl); + } else if (cacheSize != null || cacheTimeoutSeconds != null) { + // Converting null to -1 so that DefaultCache uses the default values; + if (cacheSize == null) { + cacheSize = -1; + } + if (cacheTimeoutSeconds == null) { + cacheTimeoutSeconds = -1; + } + segmentManager = new ODPSegmentManager(apiManager, cacheSize, cacheTimeoutSeconds); + } else { + segmentManager = new ODPSegmentManager(apiManager); + } + } + + if (eventManager == null) { + eventManager = new ODPEventManager(apiManager); + } + eventManager.setUserCommonData(userCommonData); + eventManager.setUserCommonIdentifiers(userCommonIdentifiers); + + return new ODPManager(segmentManager, eventManager); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java new file mode 100644 index 000000000..57bc5097a --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentCallback.java @@ -0,0 +1,22 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +@FunctionalInterface +public interface ODPSegmentCallback { + void onCompleted(Boolean isFetchSuccessful); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java new file mode 100644 index 000000000..6caae29ca --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -0,0 +1,163 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.internal.Cache; +import com.optimizely.ab.internal.DefaultLRUCache; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParserFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; + +public class ODPSegmentManager { + + private static final Logger logger = LoggerFactory.getLogger(ODPSegmentManager.class); + + private static final String SEGMENT_URL_PATH = "/v3/graphql"; + @VisibleForTesting + public final ODPApiManager apiManager; + + private volatile ODPConfig odpConfig; + + private final Cache<List<String>> segmentsCache; + + public ODPSegmentManager(ODPApiManager apiManager) { + this(apiManager, Cache.DEFAULT_MAX_SIZE, Cache.DEFAULT_TIMEOUT_SECONDS); + } + + public ODPSegmentManager(ODPApiManager apiManager, Cache<List<String>> cache) { + this.apiManager = apiManager; + this.segmentsCache = cache; + } + + public ODPSegmentManager(ODPApiManager apiManager, Integer cacheSize, Integer cacheTimeoutSeconds) { + this.apiManager = apiManager; + this.segmentsCache = new DefaultLRUCache<>(cacheSize, cacheTimeoutSeconds); + } + + public List<String> getQualifiedSegments(String userId) { + return getQualifiedSegments(userId, Collections.emptyList()); + } + public List<String> getQualifiedSegments(String userId, List<ODPSegmentOption> options) { + if (ODPManager.isVuid(userId)) { + return getQualifiedSegments(ODPUserKey.VUID, userId, options); + } else { + return getQualifiedSegments(ODPUserKey.FS_USER_ID, userId, options); + } + } + + public List<String> getQualifiedSegments(ODPUserKey userKey, String userValue) { + return getQualifiedSegments(userKey, userValue, Collections.emptyList()); + } + + public List<String> getQualifiedSegments(ODPUserKey userKey, String userValue, List<ODPSegmentOption> options) { + if (odpConfig == null || !odpConfig.isReady()) { + logger.error("Audience segments fetch failed (ODP is not enabled)"); + return null; + } + + if (!odpConfig.hasSegments()) { + logger.debug("No Segments are used in the project, Not Fetching segments. Returning empty list"); + return Collections.emptyList(); + } + + List<String> qualifiedSegments; + String cacheKey = getCacheKey(userKey.getKeyString(), userValue); + + if (options.contains(ODPSegmentOption.RESET_CACHE)) { + segmentsCache.reset(); + } else if (!options.contains(ODPSegmentOption.IGNORE_CACHE)) { + qualifiedSegments = segmentsCache.lookup(cacheKey); + if (qualifiedSegments != null) { + logger.debug("ODP Cache Hit. Returning segments from Cache."); + return qualifiedSegments; + } + } + + logger.debug("ODP Cache Miss. Making a call to ODP Server."); + + qualifiedSegments = apiManager.fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + SEGMENT_URL_PATH, userKey.getKeyString(), userValue, odpConfig.getAllSegments()); + if (qualifiedSegments != null && !options.contains(ODPSegmentOption.IGNORE_CACHE)) { + segmentsCache.save(cacheKey, qualifiedSegments); + } + + return qualifiedSegments; + } + + public void getQualifiedSegments(ODPUserKey userKey, String userValue, ODPSegmentFetchCallback callback, List<ODPSegmentOption> options) { + AsyncSegmentFetcher segmentFetcher = new AsyncSegmentFetcher(userKey, userValue, options, callback); + segmentFetcher.start(); + } + + public void getQualifiedSegments(ODPUserKey userKey, String userValue, ODPSegmentFetchCallback callback) { + getQualifiedSegments(userKey, userValue, callback, Collections.emptyList()); + } + + public void getQualifiedSegments(String userId, ODPSegmentFetchCallback callback, List<ODPSegmentOption> options) { + if (ODPManager.isVuid(userId)) { + getQualifiedSegments(ODPUserKey.VUID, userId, callback, options); + } else { + getQualifiedSegments(ODPUserKey.FS_USER_ID, userId, callback, options); + } + } + + public void getQualifiedSegments(String userId, ODPSegmentFetchCallback callback) { + getQualifiedSegments(userId, callback, Collections.emptyList()); + } + + private String getCacheKey(String userKey, String userValue) { + return userKey + "-$-" + userValue; + } + + public void updateSettings(ODPConfig odpConfig) { + this.odpConfig = odpConfig; + } + + public void resetCache() { + segmentsCache.reset(); + } + + @FunctionalInterface + public interface ODPSegmentFetchCallback { + void onCompleted(List<String> segments); + } + + private class AsyncSegmentFetcher extends Thread { + + private final ODPUserKey userKey; + private final String userValue; + private final List<ODPSegmentOption> segmentOptions; + private final ODPSegmentFetchCallback callback; + + public AsyncSegmentFetcher(ODPUserKey userKey, String userValue, List<ODPSegmentOption> segmentOptions, ODPSegmentFetchCallback callback) { + this.userKey = userKey; + this.userValue = userValue; + this.segmentOptions = segmentOptions; + this.callback = callback; + } + + @Override + public void run() { + List<String> segments = getQualifiedSegments(userKey, userValue, segmentOptions); + callback.onCompleted(segments); + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java new file mode 100644 index 000000000..8e2eb901b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +public enum ODPSegmentOption { + + IGNORE_CACHE, + + RESET_CACHE; + +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java new file mode 100644 index 000000000..ef0bce3ff --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +public enum ODPUserKey { + + VUID("vuid"), + + FS_USER_ID("fs_user_id"), + + FS_USER_ID_ALIAS("fs-user-id"); + + private final String keyString; + + ODPUserKey(String keyString) { + this.keyString = keyString; + } + + public String getKeyString() { + return keyString; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java new file mode 100644 index 000000000..d494a78d0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java @@ -0,0 +1,22 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import java.util.List; + +public interface ResponseJsonParser { + public List<String> parseQualifiedSegments(String responseJson); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java new file mode 100644 index 000000000..111c7ae85 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java @@ -0,0 +1,49 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.odp.parser.impl.GsonParser; +import com.optimizely.ab.odp.parser.impl.JacksonParser; +import com.optimizely.ab.odp.parser.impl.JsonParser; +import com.optimizely.ab.odp.parser.impl.JsonSimpleParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ResponseJsonParserFactory { + private static final Logger logger = LoggerFactory.getLogger(ResponseJsonParserFactory.class); + + public static ResponseJsonParser getParser() { + JsonParserProvider parserProvider = JsonParserProvider.getDefaultParser(); + ResponseJsonParser jsonParser = null; + switch (parserProvider) { + case GSON_CONFIG_PARSER: + jsonParser = new GsonParser(); + break; + case JACKSON_CONFIG_PARSER: + jsonParser = new JacksonParser(); + break; + case JSON_CONFIG_PARSER: + jsonParser = new JsonParser(); + break; + case JSON_SIMPLE_CONFIG_PARSER: + jsonParser = new JsonSimpleParser(); + break; + } + logger.debug("Using " + parserProvider.toString() + " parser"); + return jsonParser; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java new file mode 100644 index 000000000..70136536f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java @@ -0,0 +1,63 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.google.gson.*; +import com.google.gson.JsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class GsonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(GsonParser.class); + + @Override + public List<String> parseQualifiedSegments(String responseJson) { + List<String> parsedSegments = new ArrayList<>(); + try { + JsonObject root = JsonParser.parseString(responseJson).getAsJsonObject(); + + if (root.has("errors")) { + JsonArray errors = root.getAsJsonArray("errors"); + JsonObject extensions = errors.get(0).getAsJsonObject().get("extensions").getAsJsonObject(); + if (extensions != null) { + if (extensions.has("code") && extensions.get("code").getAsString().equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.get("classification") == null ? "decode error" : extensions.get("classification").getAsString(); + logger.error("Audience segments fetch failed (" + errorMessage + ")"); + } + } + return null; + } + + JsonArray edges = root.getAsJsonObject("data").getAsJsonObject("customer").getAsJsonObject("audiences").getAsJsonArray("edges"); + for (int i = 0; i < edges.size(); i++) { + JsonObject node = edges.get(i).getAsJsonObject().getAsJsonObject("node"); + if (node.has("state") && node.get("state").getAsString().equals("qualified")) { + parsedSegments.add(node.get("name").getAsString()); + } + } + return parsedSegments; + } catch (JsonSyntaxException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java new file mode 100644 index 000000000..b9a2b668f --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java @@ -0,0 +1,67 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; + +import java.util.List; + +public class JacksonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JacksonParser.class); + + @Override + public List<String> parseQualifiedSegments(String responseJson) { + ObjectMapper objectMapper = new ObjectMapper(); + List<String> parsedSegments = new ArrayList<>(); + JsonNode root; + try { + root = objectMapper.readTree(responseJson); + + if (root.has("errors")) { + JsonNode errors = root.path("errors"); + JsonNode extensions = errors.get(0).path("extensions"); + if (extensions != null) { + if (extensions.has("code") && extensions.path("code").asText().equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.has("classification") ? extensions.path("classification").asText() : "decode error"; + logger.error("Audience segments fetch failed (" + errorMessage + ")"); + } + } + return null; + } + + JsonNode edges = root.path("data").path("customer").path("audiences").path("edges"); + for (JsonNode edgeNode : edges) { + String state = edgeNode.path("node").path("state").asText(); + if (state.equals("qualified")) { + parsedSegments.add(edgeNode.path("node").path("name").asText()); + } + } + return parsedSegments; + } catch (JsonProcessingException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java new file mode 100644 index 000000000..e0e23c366 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java @@ -0,0 +1,65 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class JsonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JsonParser.class); + + @Override + public List<String> parseQualifiedSegments(String responseJson) { + List<String> parsedSegments = new ArrayList<>(); + try { + JSONObject root = new JSONObject(responseJson); + + if (root.has("errors")) { + JSONArray errors = root.getJSONArray("errors"); + JSONObject extensions = errors.getJSONObject(0).getJSONObject("extensions"); + if (extensions != null) { + if (extensions.has("code") && extensions.getString("code").equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.has("classification") ? + extensions.getString("classification") : "decode error"; + logger.error("Audience segments fetch failed (" + errorMessage + ")"); + } + } + return null; + } + + JSONArray edges = root.getJSONObject("data").getJSONObject("customer").getJSONObject("audiences").getJSONArray("edges"); + for (int i = 0; i < edges.length(); i++) { + JSONObject node = edges.getJSONObject(i).getJSONObject("node"); + if (node.has("state") && node.getString("state").equals("qualified")) { + parsedSegments.add(node.getString("name")); + } + } + return parsedSegments; + } catch (JSONException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java new file mode 100644 index 000000000..de444e3c2 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java @@ -0,0 +1,66 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class JsonSimpleParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JsonSimpleParser.class); + + @Override + public List<String> parseQualifiedSegments(String responseJson) { + List<String> parsedSegments = new ArrayList<>(); + JSONParser parser = new JSONParser(); + JSONObject root = null; + try { + root = (JSONObject) parser.parse(responseJson); + if (root.containsKey("errors")) { + JSONArray errors = (JSONArray) root.get("errors"); + JSONObject extensions = (JSONObject) ((JSONObject) errors.get(0)).get("extensions"); + if (extensions != null) { + if (extensions.containsKey("code") && extensions.get("code").equals("INVALID_IDENTIFIER_EXCEPTION")) { + logger.warn("Audience segments fetch failed (invalid identifier)"); + } else { + String errorMessage = extensions.get("classification") == null ? "decode error" : (String) extensions.get("classification"); + logger.error("Audience segments fetch failed (" + errorMessage + ")"); + } + } + return null; + } + + JSONArray edges = (JSONArray)((JSONObject)((JSONObject)(((JSONObject) root.get("data"))).get("customer")).get("audiences")).get("edges"); + for (int i = 0; i < edges.size(); i++) { + JSONObject node = (JSONObject) ((JSONObject) edges.get(i)).get("node"); + if (node.containsKey("state") && (node.get("state")).equals("qualified")) { + parsedSegments.add((String) node.get("name")); + } + } + return parsedSegments; + } catch (ParseException | NullPointerException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java new file mode 100644 index 000000000..4f3922340 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializer.java @@ -0,0 +1,24 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.optimizely.ab.odp.ODPEvent; + +import java.util.List; + +public interface ODPJsonSerializer { + public String serializeEvents(List<ODPEvent> events); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java new file mode 100644 index 000000000..ca47e3bf4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactory.java @@ -0,0 +1,49 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.odp.serializer.impl.GsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JacksonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSimpleSerializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ODPJsonSerializerFactory { + private static final Logger logger = LoggerFactory.getLogger(ODPJsonSerializerFactory.class); + + public static ODPJsonSerializer getSerializer() { + JsonParserProvider parserProvider = JsonParserProvider.getDefaultParser(); + ODPJsonSerializer jsonSerializer = null; + switch (parserProvider) { + case GSON_CONFIG_PARSER: + jsonSerializer = new GsonSerializer(); + break; + case JACKSON_CONFIG_PARSER: + jsonSerializer = new JacksonSerializer(); + break; + case JSON_CONFIG_PARSER: + jsonSerializer = new JsonSerializer(); + break; + case JSON_SIMPLE_CONFIG_PARSER: + jsonSerializer = new JsonSimpleSerializer(); + break; + } + logger.info("Using " + parserProvider.toString() + " serializer"); + return jsonSerializer; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java new file mode 100644 index 000000000..d72963260 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/GsonSerializer.java @@ -0,0 +1,31 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; + +import java.util.List; + +public class GsonSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List<ODPEvent> events) { + Gson gson = new GsonBuilder().serializeNulls().create(); + return gson.toJson(events); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java new file mode 100644 index 000000000..80cffa7d0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JacksonSerializer.java @@ -0,0 +1,36 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; + +import java.util.List; + +public class JacksonSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List<ODPEvent> events) { + ObjectMapper objectMapper = new ObjectMapper(); + try { + return objectMapper.writeValueAsString(events); + } catch (JsonProcessingException e) { + // log error here + } + return null; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java new file mode 100644 index 000000000..c65c1fda3 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSerializer.java @@ -0,0 +1,59 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.List; +import java.util.Map; + +public class JsonSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List<ODPEvent> events) { + JSONArray jsonArray = new JSONArray(); + for (ODPEvent event: events) { + JSONObject eventObject = new JSONObject(); + eventObject.put("type", event.getType()); + eventObject.put("action", event.getAction()); + + if (event.getIdentifiers() != null) { + JSONObject identifiers = new JSONObject(); + for (Map.Entry<String, String> identifier : event.getIdentifiers().entrySet()) { + identifiers.put(identifier.getKey(), identifier.getValue()); + } + eventObject.put("identifiers", identifiers); + } + + if (event.getData() != null) { + JSONObject data = new JSONObject(); + for (Map.Entry<String, Object> dataEntry : event.getData().entrySet()) { + data.put(dataEntry.getKey(), getJSONObjectValue(dataEntry.getValue())); + } + eventObject.put("data", data); + } + + jsonArray.put(eventObject); + } + return jsonArray.toString(); + } + + private static Object getJSONObjectValue(Object value) { + return value == null ? JSONObject.NULL : value; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java new file mode 100644 index 000000000..96e5a7357 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/serializer/impl/JsonSimpleSerializer.java @@ -0,0 +1,55 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer.impl; + +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.ODPJsonSerializer; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + +import java.util.List; +import java.util.Map; + +public class JsonSimpleSerializer implements ODPJsonSerializer { + @Override + public String serializeEvents(List<ODPEvent> events) { + JSONArray jsonArray = new JSONArray(); + for (ODPEvent event: events) { + JSONObject eventObject = new JSONObject(); + eventObject.put("type", event.getType()); + eventObject.put("action", event.getAction()); + + if (event.getIdentifiers() != null) { + JSONObject identifiers = new JSONObject(); + for (Map.Entry<String, String> identifier : event.getIdentifiers().entrySet()) { + identifiers.put(identifier.getKey(), identifier.getValue()); + } + eventObject.put("identifiers", identifiers); + } + + if (event.getData() != null) { + JSONObject data = new JSONObject(); + for (Map.Entry<String, Object> dataEntry : event.getData().entrySet()) { + data.put(dataEntry.getKey(), dataEntry.getValue()); + } + eventObject.put("data", data); + } + + jsonArray.add(eventObject); + } + return jsonArray.toJSONString(); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java new file mode 100644 index 000000000..2c142bc86 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttribute.java @@ -0,0 +1,53 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +/** + * Represents the Attribute's map {@link OptimizelyConfig} + */ +public class OptimizelyAttribute implements IdKeyMapped { + + private String id; + private String key; + + public OptimizelyAttribute(String id, + String key) { + this.id = id; + this.key = key; + } + + public String getId() { return id; } + + public String getKey() { return key; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyAttribute optimizelyAttribute = (OptimizelyAttribute) obj; + return id.equals(optimizelyAttribute.getId()) && + key.equals(optimizelyAttribute.getKey()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + key.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java new file mode 100644 index 000000000..d874b900e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyAudience.java @@ -0,0 +1,63 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; +import com.optimizely.ab.config.audience.Condition; + +import java.util.List; + +/** + * Represents the Audiences list {@link OptimizelyConfig} + */ +public class OptimizelyAudience{ + + private String id; + private String name; + private String conditions; + + public OptimizelyAudience(String id, + String name, + String conditions) { + this.id = id; + this.name = name; + this.conditions = conditions; + } + + public String getId() { return id; } + + public String getName() { return name; } + + public String getConditions() { return conditions; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyAudience optimizelyAudience = (OptimizelyAudience) obj; + return id.equals(optimizelyAudience.getId()) && + name.equals(optimizelyAudience.getName()) && + conditions.equals(optimizelyAudience.getConditions()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + conditions.hashCode(); + return hash; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java new file mode 100644 index 000000000..7fa890b66 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfig.java @@ -0,0 +1,114 @@ +/**************************************************************************** + * Copyright 2020-2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.optimizely.ab.config.Attribute; +import com.optimizely.ab.config.EventType; + +import java.util.*; + +/** + * Interface for OptimizleyConfig + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OptimizelyConfig { + + private Map<String, OptimizelyExperiment> experimentsMap; + private Map<String, OptimizelyFeature> featuresMap; + private List<OptimizelyAttribute> attributes; + private List<OptimizelyEvent> events; + private List<OptimizelyAudience> audiences; + private String revision; + private String sdkKey; + private String environmentKey; + private String datafile; + + public OptimizelyConfig(Map<String, OptimizelyExperiment> experimentsMap, + Map<String, OptimizelyFeature> featuresMap, + String revision, + String sdkKey, + String environmentKey, + List<OptimizelyAttribute> attributes, + List<OptimizelyEvent> events, + List<OptimizelyAudience> audiences, + String datafile) { + + // This experimentsMap is for experiments of legacy projects only. + // For flag projects, experiment keys are not guaranteed to be unique + // across multiple flags, so this map may not include all experiments + // when keys conflict. + this.experimentsMap = experimentsMap; + + this.featuresMap = featuresMap; + this.revision = revision; + this.sdkKey = sdkKey == null ? "" : sdkKey; + this.environmentKey = environmentKey == null ? "" : environmentKey; + this.attributes = attributes; + this.events = events; + this.audiences = audiences; + this.datafile = datafile; + } + + public Map<String, OptimizelyExperiment> getExperimentsMap() { + return experimentsMap; + } + + public Map<String, OptimizelyFeature> getFeaturesMap() { + return featuresMap; + } + + public List<OptimizelyAttribute> getAttributes() { return attributes; } + + public List<OptimizelyEvent> getEvents() { return events; } + + public List<OptimizelyAudience> getAudiences() { return audiences; } + + public String getRevision() { + return revision; + } + + public String getSdkKey() { return sdkKey; } + + public String getEnvironmentKey() { + return environmentKey; + } + + public String getDatafile() { + return datafile; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyConfig optimizelyConfig = (OptimizelyConfig) obj; + return revision.equals(optimizelyConfig.getRevision()) && + experimentsMap.equals(optimizelyConfig.getExperimentsMap()) && + featuresMap.equals(optimizelyConfig.getFeaturesMap()) && + attributes.equals(optimizelyConfig.getAttributes()) && + events.equals(optimizelyConfig.getEvents()) && + audiences.equals(optimizelyConfig.getAudiences()); + } + + @Override + public int hashCode() { + int hash = revision.hashCode(); + hash = 31 * hash + experimentsMap.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigManager.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigManager.java new file mode 100644 index 000000000..254a1e5c0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigManager.java @@ -0,0 +1,26 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyconfig; + +public interface OptimizelyConfigManager { + /** + * Implementations of this method should return {@link OptimizelyConfig} + * + * @return {@link OptimizelyConfig} + */ + OptimizelyConfig getOptimizelyConfig(); +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java new file mode 100644 index 000000000..c1ec93c01 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigService.java @@ -0,0 +1,371 @@ +/**************************************************************************** + * Copyright 2020-2021, 2023, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.audience.Audience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class OptimizelyConfigService { + + private ProjectConfig projectConfig; + private OptimizelyConfig optimizelyConfig; + private List<OptimizelyAudience> audiences; + private List<OptimizelyExperiment> experimentRules; + private Map<String, String> audiencesMap; + private Map<String, List<FeatureVariable>> featureIdToVariablesMap = new HashMap<>(); + private Map<String, OptimizelyExperiment> experimentMapByExperimentId = new HashMap<>(); + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyConfigService.class); + + public OptimizelyConfigService(ProjectConfig projectConfig) { + this.projectConfig = projectConfig; + this.audiences = getAudiencesList(projectConfig.getTypedAudiences(), projectConfig.getAudiences()); + this.audiencesMap = getAudiencesMap(this.audiences); + + List<OptimizelyAttribute> optimizelyAttributes = new ArrayList<>(); + List<OptimizelyEvent> optimizelyEvents = new ArrayList<>(); + + Map<String, OptimizelyExperiment> experimentsMap = getExperimentsMap(); + + if (projectConfig.getAttributes() != null) { + for (Attribute attribute : projectConfig.getAttributes()) { + OptimizelyAttribute copyAttribute = new OptimizelyAttribute( + attribute.getId(), + attribute.getKey() + ); + optimizelyAttributes.add(copyAttribute); + } + } + + if (projectConfig.getEventTypes() != null) { + for (EventType event : projectConfig.getEventTypes()) { + OptimizelyEvent copyEvent = new OptimizelyEvent( + event.getId(), + event.getKey(), + event.getExperimentIds() + ); + optimizelyEvents.add(copyEvent); + } + } + + optimizelyConfig = new OptimizelyConfig( + experimentsMap, + getFeaturesMap(experimentsMap), + projectConfig.getRevision(), + projectConfig.getSdkKey(), + projectConfig.getEnvironmentKey(), + optimizelyAttributes, + optimizelyEvents, + this.audiences, + projectConfig.toDatafile() + ); + } + + /** + * returns maps for experiment and features to be returned as one object + * + * @return {@link OptimizelyConfig} containing experiments and features + */ + public OptimizelyConfig getConfig() { + return optimizelyConfig; + } + + /** + * Generates a Map which contains list of variables for each feature key. + * This map is used for merging variation and feature variables. + */ + @VisibleForTesting + Map<String, List<FeatureVariable>> generateFeatureKeyToVariablesMap() { + List<FeatureFlag> featureFlags = projectConfig.getFeatureFlags(); + if (featureFlags == null) { + return Collections.emptyMap(); + } + Map<String, List<FeatureVariable>> featureVariableIdMap = new HashMap<>(); + for (FeatureFlag featureFlag : featureFlags) { + featureVariableIdMap.put(featureFlag.getKey(), featureFlag.getVariables()); + featureIdToVariablesMap.put(featureFlag.getId(), featureFlag.getVariables()); + } + return featureVariableIdMap; + } + + @VisibleForTesting + String getExperimentFeatureKey(String experimentId) { + List<String> featureKeys = projectConfig.getExperimentFeatureKeyMapping().get(experimentId); + return featureKeys != null ? featureKeys.get(0) : null; + } + + @VisibleForTesting + Map<String, OptimizelyExperiment> getExperimentsMap() { + List<Experiment> experiments = projectConfig.getExperiments(); + + if (experiments == null) { + return Collections.emptyMap(); + } + Map<String, OptimizelyExperiment> featureExperimentMap = new HashMap<>(); + + for (Experiment experiment : experiments) { + OptimizelyExperiment optimizelyExperiment = new OptimizelyExperiment( + experiment.getId(), + experiment.getKey(), + getVariationsMap(experiment.getVariations(), experiment.getId(), null), + experiment.serializeConditions(this.audiencesMap) + ); + + if (featureExperimentMap.containsKey(experiment.getKey())) { + // continue with this warning, so the later experiment will be used. + logger.warn("Duplicate experiment keys found in datafile: {}", experiment.getKey()); + } + + featureExperimentMap.put(experiment.getKey(), optimizelyExperiment); + experimentMapByExperimentId.put(experiment.getId(), optimizelyExperiment); + } + return featureExperimentMap; + } + + @VisibleForTesting + Map<String, OptimizelyVariation> getVariationsMap(List<Variation> variations, String experimentId, String featureId) { + if (variations == null) { + return Collections.emptyMap(); + } + + Map<String, OptimizelyVariation> variationKeyMap = new HashMap<>(); + for (Variation variation : variations) { + variationKeyMap.put(variation.getKey(), new OptimizelyVariation( + variation.getId(), + variation.getKey(), + variation.getFeatureEnabled(), + getMergedVariablesMap(variation, experimentId, featureId) + )); + } + return variationKeyMap; + } + + /** + * Merges Additional information from variables in feature flag with variation variables as per the following logic. + * 1. If Variation has variables and feature is enabled, then only `type` and `key` are merged from feature variable. + * 2. If Variation has variables and feature is disabled, then `type` and `key` are merged and `defaultValue` of feature variable is merged as `value` of variation variable. + * 3. If Variation does not contain a variable, then all `id`, `key`, `type` and defaultValue as `value` is used from feature varaible and added to variation. + */ + @VisibleForTesting + Map<String, OptimizelyVariable> getMergedVariablesMap(Variation variation, String experimentId, String featureId) { + String featureKey = this.getExperimentFeatureKey(experimentId); + Map<String, List<FeatureVariable>> featureKeyToVariablesMap = generateFeatureKeyToVariablesMap(); + if (featureKey == null && featureId == null) { + return Collections.emptyMap(); + } + + // Generate temp map of all the available variable values from variation. + Map<String, OptimizelyVariable> tempVariableIdMap = getFeatureVariableUsageInstanceMap(variation.getFeatureVariableUsageInstances()); + + // Iterate over all the variables available in associated feature. + // Use value from variation variable if variable is available in variation and feature is enabled, otherwise use defaultValue from feature variable. + List<FeatureVariable> featureVariables; + + if (featureId != null) { + featureVariables = featureIdToVariablesMap.get(featureId); + } else { + featureVariables = featureKeyToVariablesMap.get(featureKey); + } + if (featureVariables == null) { + return Collections.emptyMap(); + } + + Map<String, OptimizelyVariable> featureVariableKeyMap = new HashMap<>(); + for (FeatureVariable featureVariable : featureVariables) { + featureVariableKeyMap.put(featureVariable.getKey(), new OptimizelyVariable( + featureVariable.getId(), + featureVariable.getKey(), + featureVariable.getType(), + variation.getFeatureEnabled() && tempVariableIdMap.get(featureVariable.getId()) != null + ? tempVariableIdMap.get(featureVariable.getId()).getValue() + : featureVariable.getDefaultValue() + )); + } + return featureVariableKeyMap; + } + + @VisibleForTesting + Map<String, OptimizelyVariable> getFeatureVariableUsageInstanceMap(List<FeatureVariableUsageInstance> featureVariableUsageInstances) { + if (featureVariableUsageInstances == null) { + return Collections.emptyMap(); + } + + Map<String, OptimizelyVariable> featureVariableIdMap = new HashMap<>(); + for (FeatureVariableUsageInstance featureVariableUsageInstance : featureVariableUsageInstances) { + featureVariableIdMap.put(featureVariableUsageInstance.getId(), new OptimizelyVariable( + featureVariableUsageInstance.getId(), + null, + null, + featureVariableUsageInstance.getValue() + )); + } + + return featureVariableIdMap; + } + + @VisibleForTesting + Map<String, OptimizelyFeature> getFeaturesMap(Map<String, OptimizelyExperiment> allExperimentsMap) { + List<FeatureFlag> featureFlags = projectConfig.getFeatureFlags(); + if (featureFlags == null) { + return Collections.emptyMap(); + } + + Map<String, OptimizelyFeature> optimizelyFeatureKeyMap = new HashMap<>(); + for (FeatureFlag featureFlag : featureFlags) { + Map<String, OptimizelyExperiment> experimentsMapForFeature = + getExperimentsMapForFeature(featureFlag.getExperimentIds()); + + List<OptimizelyExperiment> deliveryRules = + this.getDeliveryRules(featureFlag.getRolloutId(), featureFlag.getId()); + + OptimizelyFeature optimizelyFeature = new OptimizelyFeature( + featureFlag.getId(), + featureFlag.getKey(), + experimentsMapForFeature, + getFeatureVariablesMap(featureFlag.getVariables()), + experimentRules, + deliveryRules + ); + + optimizelyFeatureKeyMap.put(featureFlag.getKey(), optimizelyFeature); + } + return optimizelyFeatureKeyMap; + } + + List<OptimizelyExperiment> getDeliveryRules(String rolloutId, String featureId) { + + List<OptimizelyExperiment> deliveryRules = new ArrayList<OptimizelyExperiment>(); + + Rollout rollout = projectConfig.getRolloutIdMapping().get(rolloutId); + + if (rollout != null) { + List<Experiment> rolloutExperiments = rollout.getExperiments(); + for (Experiment experiment: rolloutExperiments) { + OptimizelyExperiment optimizelyExperiment = new OptimizelyExperiment( + experiment.getId(), + experiment.getKey(), + this.getVariationsMap(experiment.getVariations(), experiment.getId(), featureId), + experiment.serializeConditions(this.audiencesMap) + ); + + deliveryRules.add(optimizelyExperiment); + } + return deliveryRules; + } + + return Collections.emptyList(); + } + + @VisibleForTesting + Map<String, OptimizelyExperiment> getExperimentsMapForFeature(List<String> experimentIds) { + if (experimentIds == null) { + return Collections.emptyMap(); + } + + List<OptimizelyExperiment> experimentRulesList = new ArrayList<>(); + + Map<String, OptimizelyExperiment> optimizelyExperimentKeyMap = new HashMap<>(); + for (String experimentId : experimentIds) { + OptimizelyExperiment optimizelyExperiment = experimentMapByExperimentId.get(experimentId); + optimizelyExperimentKeyMap.put(optimizelyExperiment.getKey(), optimizelyExperiment); + experimentRulesList.add(optimizelyExperiment); + } + + this.experimentRules = experimentRulesList; + + return optimizelyExperimentKeyMap; + } + + @VisibleForTesting + Map<String, OptimizelyVariable> getFeatureVariablesMap(List<FeatureVariable> featureVariables) { + if (featureVariables == null) { + return Collections.emptyMap(); + } + + Map<String, OptimizelyVariable> featureVariableKeyMap = new HashMap<>(); + for (FeatureVariable featureVariable : featureVariables) { + featureVariableKeyMap.put(featureVariable.getKey(), new OptimizelyVariable( + featureVariable.getId(), + featureVariable.getKey(), + featureVariable.getType(), + featureVariable.getDefaultValue() + )); + } + + return featureVariableKeyMap; + } + + @VisibleForTesting + List<OptimizelyAudience> getAudiencesList(List<Audience> typedAudiences, List<Audience> audiences) { + /* + * This method merges typedAudiences with audiences from the Project + * config. Precedence is given to typedAudiences over audiences. + * + * Returns: + * A new list with the merged audiences as OptimizelyAudience objects. + * */ + List<OptimizelyAudience> audiencesList = new ArrayList<>(); + Map<String, String> idLookupMap = new HashMap<>(); + if (typedAudiences != null) { + for (Audience audience : typedAudiences) { + OptimizelyAudience optimizelyAudience = new OptimizelyAudience( + audience.getId(), + audience.getName(), + audience.getConditions().toJson() + ); + audiencesList.add(optimizelyAudience); + idLookupMap.put(audience.getId(), audience.getId()); + } + } + + if (audiences != null) { + for (Audience audience : audiences) { + if (!idLookupMap.containsKey(audience.getId()) && !audience.getId().equals("$opt_dummy_audience")) { + OptimizelyAudience optimizelyAudience = new OptimizelyAudience( + audience.getId(), + audience.getName(), + audience.getConditions().toJson() + ); + audiencesList.add(optimizelyAudience); + } + } + } + + return audiencesList; + } + + @VisibleForTesting + Map<String, String> getAudiencesMap(List<OptimizelyAudience> optimizelyAudiences) { + Map<String, String> audiencesMap = new HashMap<>(); + + // Build audienceMap as [id:name] + if (optimizelyAudiences != null) { + for (OptimizelyAudience audience : optimizelyAudiences) { + audiencesMap.put( + audience.getId(), + audience.getName() + ); + } + } + + return audiencesMap; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java new file mode 100644 index 000000000..9edda8700 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyEvent.java @@ -0,0 +1,61 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +import java.util.List; + +/** + * Represents the Events's map {@link OptimizelyConfig} + */ +public class OptimizelyEvent implements IdKeyMapped { + + private String id; + private String key; + private List<String> experimentIds; + + public OptimizelyEvent(String id, + String key, + List<String> experimentIds) { + this.id = id; + this.key = key; + this.experimentIds = experimentIds; + } + + public String getId() { return id; } + + public String getKey() { return key; } + + public List<String> getExperimentIds() { return experimentIds; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyEvent optimizelyEvent = (OptimizelyEvent) obj; + return id.equals(optimizelyEvent.getId()) && + key.equals(optimizelyEvent.getKey()) && + experimentIds.equals(optimizelyEvent.getExperimentIds()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + experimentIds.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java new file mode 100644 index 000000000..0f5b9e193 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperiment.java @@ -0,0 +1,70 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +import java.util.Map; + +/** + * Represents the experiment's map in {@link OptimizelyConfig} + */ +public class OptimizelyExperiment implements IdKeyMapped { + + private String id; + private String key; + private String audiences = ""; + private Map<String, OptimizelyVariation> variationsMap; + + public OptimizelyExperiment(String id, String key, Map<String, OptimizelyVariation> variationsMap, String audiences) { + this.id = id; + this.key = key; + this.variationsMap = variationsMap; + this.audiences = audiences; + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + public String getAudiences() { return audiences; } + + public Map<String, OptimizelyVariation> getVariationsMap() { + return variationsMap; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyExperiment optimizelyExperiment = (OptimizelyExperiment) obj; + return id.equals(optimizelyExperiment.getId()) && + key.equals(optimizelyExperiment.getKey()) && + variationsMap.equals(optimizelyExperiment.getVariationsMap()) && + audiences.equals(optimizelyExperiment.getAudiences()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + variationsMap.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java new file mode 100644 index 000000000..7dec828a6 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeature.java @@ -0,0 +1,105 @@ +/**************************************************************************** + * Copyright 2020-2021, 2023, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Represents the feature's map in {@link OptimizelyConfig} + */ +public class OptimizelyFeature implements IdKeyMapped { + + private String id; + private String key; + + private List<OptimizelyExperiment> deliveryRules; + private List<OptimizelyExperiment> experimentRules; + + /** + * @deprecated use {@link #experimentRules} and {@link #deliveryRules} instead + */ + @Deprecated + private Map<String, OptimizelyExperiment> experimentsMap; + private Map<String, OptimizelyVariable> variablesMap; + + public OptimizelyFeature(String id, + String key, + Map<String, OptimizelyExperiment> experimentsMap, + Map<String, OptimizelyVariable> variablesMap, + List<OptimizelyExperiment> experimentRules, + List<OptimizelyExperiment> deliveryRules) { + this.id = id; + this.key = key; + this.experimentsMap = experimentsMap; + this.variablesMap = variablesMap; + this.experimentRules = experimentRules; + this.deliveryRules = deliveryRules; + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + /** + * @deprecated use {@link #getExperimentRules()} and {@link #getDeliveryRules()} instead + * + * @return a map of ExperimentKey to OptimizelyExperiment + */ + @Deprecated + public Map<String, OptimizelyExperiment> getExperimentsMap() { + return experimentsMap; + } + + public Map<String, OptimizelyVariable> getVariablesMap() { + return variablesMap; + } + + public List<OptimizelyExperiment> getExperimentRules() { return experimentRules; } + + public List<OptimizelyExperiment> getDeliveryRules() { return deliveryRules; } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyFeature optimizelyFeature = (OptimizelyFeature) obj; + return id.equals(optimizelyFeature.getId()) && + key.equals(optimizelyFeature.getKey()) && + experimentsMap.equals(optimizelyFeature.getExperimentsMap()) && + variablesMap.equals(optimizelyFeature.getVariablesMap()) && + experimentRules.equals(optimizelyFeature.getExperimentRules()) && + deliveryRules.equals(optimizelyFeature.getDeliveryRules()); + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + + experimentsMap.hashCode() + + variablesMap.hashCode() + + experimentRules.hashCode() + + deliveryRules.hashCode(); + return result; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariable.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariable.java new file mode 100644 index 000000000..a175e5bdc --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariable.java @@ -0,0 +1,71 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; + +/** + * Details of feature variable in {@link OptimizelyConfig} + */ +public class OptimizelyVariable implements IdKeyMapped { + + private String id; + private String key; + private String type; + private String value; + + public OptimizelyVariable(String id, + String key, + String type, + String value) { + this.id = id; + this.key = key; + this.type = type; + this.value = value; + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + public String getType() { + return type; + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyVariable optimizelyVariable = (OptimizelyVariable) obj; + return id.equals(optimizelyVariable.getId()) && + value.equals(optimizelyVariable.getValue()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + value.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariation.java b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariation.java new file mode 100644 index 000000000..863e21a91 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariation.java @@ -0,0 +1,76 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import com.optimizely.ab.config.IdKeyMapped; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Map; +/** + * Details of variation in {@link OptimizelyExperiment} + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OptimizelyVariation implements IdKeyMapped { + + private String id; + private String key; + private Boolean featureEnabled; + + private Map<String, OptimizelyVariable> variablesMap; + + public OptimizelyVariation(String id, + String key, + Boolean featureEnabled, + Map<String, OptimizelyVariable> variablesMap) { + this.id = id; + this.key = key; + this.featureEnabled = featureEnabled; + this.variablesMap = variablesMap; + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } + + public Boolean getFeatureEnabled() { + return featureEnabled; + } + + public Map<String, OptimizelyVariable> getVariablesMap() { + return variablesMap; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyVariation optimizelyVariation = (OptimizelyVariation) obj; + return id.equals(optimizelyVariation.getId()) && + key.equals(optimizelyVariation.getKey()) && + variablesMap.equals(optimizelyVariation.getVariablesMap()); + } + + @Override + public int hashCode() { + int hash = id.hashCode(); + hash = 31 * hash + variablesMap.hashCode(); + return hash; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java new file mode 100644 index 000000000..c66be6bee --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionMessage.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.optimizelydecision; + +public enum DecisionMessage { + SDK_NOT_READY("Optimizely SDK not configured properly yet."), + FLAG_KEY_INVALID("No flag was found for key \"%s\"."), + VARIABLE_VALUE_INVALID("Variable value for key \"%s\" is invalid or wrong type."); + + private String format; + + DecisionMessage(String format) { + this.format = format; + } + + public String reason(Object... args){ + return String.format(format, args); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java new file mode 100644 index 000000000..82400a17b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionReasons.java @@ -0,0 +1,49 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import java.util.ArrayList; +import java.util.List; + +public class DecisionReasons { + + protected final List<String> errors = new ArrayList<>(); + protected final List<String> infos = new ArrayList<>(); + + public void addError(String format, Object... args) { + String message = String.format(format, args); + errors.add(message); + } + + public String addInfo(String format, Object... args) { + String message = String.format(format, args); + infos.add(message); + return message; + } + + public void merge(DecisionReasons target) { + errors.addAll(target.errors); + infos.addAll(target.infos); + } + + public List<String> toReport() { + List<String> reasons = new ArrayList<>(errors); + reasons.addAll(infos); + return reasons; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java new file mode 100644 index 000000000..fee8aa32b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java @@ -0,0 +1,48 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class DecisionResponse<T> { + private T result; + private DecisionReasons reasons; + + public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) { + this.result = result; + this.reasons = reasons; + } + + public static <E> DecisionResponse responseNoReasons(@Nullable E result) { + return new DecisionResponse(result, DefaultDecisionReasons.newInstance()); + } + + public static DecisionResponse nullNoReasons() { + return new DecisionResponse(null, DefaultDecisionReasons.newInstance()); + } + + @Nullable + public T getResult() { + return result; + } + + @Nonnull + public DecisionReasons getReasons() { + return reasons; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java new file mode 100644 index 000000000..6f0f609f0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/DefaultDecisionReasons.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import javax.annotation.Nullable; +import java.util.List; + +public class DefaultDecisionReasons extends DecisionReasons { + + public static DecisionReasons newInstance(@Nullable List<OptimizelyDecideOption> options) { + if (options == null || options.contains(OptimizelyDecideOption.INCLUDE_REASONS)) return new DecisionReasons(); + else return new DefaultDecisionReasons(); + } + + public static DecisionReasons newInstance() { + return newInstance(null); + } + + @Override + public String addInfo(String format, Object... args) { + // skip tracking and pass-through reasons other than critical errors. + return String.format(format, args); + } + + @Override + public void merge(DecisionReasons target) { + // ignore infos + errors.addAll(target.errors); + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java new file mode 100644 index 000000000..ccd08bb63 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecideOption.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +public enum OptimizelyDecideOption { + DISABLE_DECISION_EVENT, + ENABLED_FLAGS_ONLY, + IGNORE_USER_PROFILE_SERVICE, + INCLUDE_REASONS, + EXCLUDE_VARIABLES +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java new file mode 100644 index 000000000..1741afbcd --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelydecision/OptimizelyDecision.java @@ -0,0 +1,178 @@ +/** + * + * Copyright 2020-2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class OptimizelyDecision { + /** + * The variation key of the decision. This value will be null when decision making fails. + */ + @Nullable + private final String variationKey; + + /** + * The boolean value indicating if the flag is enabled or not. + */ + private final boolean enabled; + + /** + * The collection of variables associated with the decision. + */ + @Nonnull + private final OptimizelyJSON variables; + + /** + * The rule key of the decision. + */ + @Nullable + private final String ruleKey; + + /** + * The flag key for which the decision has been made for. + */ + @Nonnull + private final String flagKey; + + /** + * A copy of the user context for which the decision has been made for. + */ + @Nonnull + private final OptimizelyUserContext userContext; + + /** + * An array of error/info messages describing why the decision has been made. + */ + @Nonnull + private List<String> reasons; + + + public OptimizelyDecision(@Nullable String variationKey, + boolean enabled, + @Nonnull OptimizelyJSON variables, + @Nullable String ruleKey, + @Nonnull String flagKey, + @Nonnull OptimizelyUserContext userContext, + @Nonnull List<String> reasons) { + this.variationKey = variationKey; + this.enabled = enabled; + this.variables = variables; + this.ruleKey = ruleKey; + this.flagKey = flagKey; + this.userContext = userContext; + this.reasons = reasons; + } + + @Nullable + public String getVariationKey() { + return variationKey; + } + + public boolean getEnabled() { + return enabled; + } + + @Nonnull + public OptimizelyJSON getVariables() { + return variables; + } + + @Nullable + public String getRuleKey() { + return ruleKey; + } + + @Nonnull + public String getFlagKey() { + return flagKey; + } + + @Nullable + public OptimizelyUserContext getUserContext() { + return userContext; + } + + @Nonnull + public List<String> getReasons() { + return reasons; + } + + public static OptimizelyDecision newErrorDecision(@Nonnull String key, + @Nonnull OptimizelyUserContext user, + @Nonnull String error) { + return new OptimizelyDecision( + null, + false, + new OptimizelyJSON(Collections.emptyMap()), + null, + key, + user, + Arrays.asList(error)); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + OptimizelyDecision d = (OptimizelyDecision) obj; + return equals(variationKey, d.getVariationKey()) && + equals(enabled, d.getEnabled()) && + equals(variables, d.getVariables()) && + equals(ruleKey, d.getRuleKey()) && + equals(flagKey, d.getFlagKey()) && + equals(userContext, d.getUserContext()) && + equals(reasons, d.getReasons()); + } + + private static boolean equals(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + @Override + public int hashCode() { + int hash = variationKey != null ? variationKey.hashCode() : 0; + hash = 31 * hash + (enabled ? 1 : 0); + hash = 31 * hash + variables.hashCode(); + hash = 31 * hash + (ruleKey != null ? ruleKey.hashCode() : 0); + hash = 31 * hash + flagKey.hashCode(); + hash = 31 * hash + userContext.hashCode(); + hash = 31 * hash + reasons.hashCode(); + return hash; + } + + @Override + public String toString() { + return "OptimizelyDecision {" + + "variationKey='" + variationKey + '\'' + + ", enabled='" + enabled + '\'' + + ", variables='" + variables + '\'' + + ", ruleKey='" + ruleKey + '\'' + + ", flagKey='" + flagKey + '\'' + + ", userContext='" + userContext + '\'' + + ", enabled='" + enabled + '\'' + + ", reasons='" + reasons + '\'' + + '}'; + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java new file mode 100644 index 000000000..4cb835958 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/optimizelyjson/OptimizelyJSON.java @@ -0,0 +1,184 @@ +/** + * + * Copyright 2020-2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; + +/** + * OptimizelyJSON is an object for accessing values of JSON-type feature variables + */ +public class OptimizelyJSON { + @Nullable + private String payload; + @Nullable + private Map<String,Object> map; + + private ConfigParser parser; + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyJSON.class); + + public OptimizelyJSON(@Nonnull String payload) { + this(payload, DefaultConfigParser.getInstance()); + } + + public OptimizelyJSON(@Nonnull String payload, ConfigParser parser) { + this.payload = payload; + this.parser = parser; + } + + public OptimizelyJSON(@Nonnull Map<String,Object> map) { + this(map, DefaultConfigParser.getInstance()); + } + + public OptimizelyJSON(@Nonnull Map<String,Object> map, ConfigParser parser) { + this.map = map; + this.parser = parser; + } + + /** + * Returns the string representation of json data + */ + @Nonnull + public String toString() { + if (payload == null && map != null) { + try { + payload = parser.toJson(map); + } catch (JsonParseException e) { + logger.error("Provided map could not be converted to a string ({})", e.toString()); + } + } + + return payload != null ? payload : ""; + } + + /** + * Returns the {@code Map<String,Object>} representation of json data + * + * @return The {@code Map<String,Object>} representation of json data + */ + @Nullable + public Map<String,Object> toMap() { + if (map == null && payload != null) { + try { + map = parser.fromJson(payload, Map.class); + } catch (Exception e) { + logger.error("Provided string could not be converted to a dictionary ({})", e.toString()); + } + } + + return map; + } + + /** + * Populates the schema passed by the user - it takes primitive types and complex struct type + * <p> + * Example: + * <pre> + * JSON data is {"k1":true, "k2":{"k22":"v22"}} + * + * Set jsonKey to "k2" to access {"k22":"v22"} or set it to to "k2.k22" to access "v22". + * Set it to null to access the entire JSON data. + * </pre> + * + * @param jsonKey The JSON key paths for the data to access + * @param clazz The user-defined class that the json data will be parsed to + * @param <T> This is the type parameter + * @return an instance of clazz type with the parsed data filled in (or null if parse fails) + * @throws JsonParseException when a JSON parser is not available. + */ + @Nullable + public <T> T getValue(@Nullable String jsonKey, Class<T> clazz) throws JsonParseException { + if (!(parser instanceof GsonConfigParser || parser instanceof JacksonConfigParser)) { + throw new JsonParseException("A proper JSON parser is not available. Use Gson or Jackson parser for this operation."); + } + + Map<String,Object> subMap = toMap(); + T result = null; + + if (jsonKey == null || jsonKey.isEmpty()) { + return getValueInternal(subMap, clazz); + } + + String[] keys = jsonKey.split("\\.", -1); // -1 to keep trailing empty fields + + for(int i=0; i<keys.length; i++) { + if (subMap == null) break; + + String key = keys[i]; + if (key.isEmpty()) break; + + if (i == keys.length - 1) { + result = getValueInternal(subMap.get(key), clazz); + break; + } + + if (subMap.get(key) instanceof Map) { + subMap = (Map<String, Object>) subMap.get(key); + } else { + logger.error("Value for JSON key ({}) not found.", jsonKey); + break; + } + } + + if (result == null) { + logger.error("Value for path could not be assigned to provided schema."); + } + return result; + } + + private <T> T getValueInternal(@Nullable Object object, Class<T> clazz) { + if (object == null) return null; + + if (clazz.isInstance(object)) return (T)object; // primitive (String, Boolean, Integer, Double) + + try { + String payload = parser.toJson(object); + return parser.fromJson(payload, clazz); + } catch (Exception e) { + logger.error("Map to Java Object failed ({})", e.toString()); + } + + return null; + } + + public boolean isEmpty() { + return map == null || map.isEmpty(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) return false; + if (obj == this) return true; + if (toMap() == null) return false; + + return toMap().equals(((OptimizelyJSON) obj).toMap()); + } + + @Override + public int hashCode() { + int hash = toMap() != null ? toMap().hashCode() : 0; + return hash; + } + +} + diff --git a/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java b/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java new file mode 100644 index 000000000..577a1891d --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/EventHandlerRule.java @@ -0,0 +1,246 @@ +/** + * Copyright 2019, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.LogEvent; +import com.optimizely.ab.event.internal.payload.*; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +import static com.optimizely.ab.config.ProjectConfig.RESERVED_ATTRIBUTE_PREFIX; +import static org.junit.Assert.assertEquals; + +/** + * EventHandlerRule is a JUnit rule that implements an Optimizely {@link EventHandler}. + * + * This implementation captures events being dispatched in a List. + * + * The List of "actual" events are compared, in order, against a list of "expected" events. + * + * Expected events are validated at the end of the test to allow asynchronous event dispatching. + * + * A failure is raised if at the end of the test there remain non-validated actual events. This is by design + * to ensure that all outbound traffic is known and validated. + */ +public class EventHandlerRule implements EventHandler, TestRule { + + private static final Logger logger = LoggerFactory.getLogger(EventHandlerRule.class); + private static final String IMPRESSION_EVENT_NAME = "campaign_activated"; + + private List<CanonicalEvent> expectedEvents; + private LinkedList<CanonicalEvent> actualEvents; + private int actualCalls; + private Integer expectedCalls; + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + before(); + try { + base.evaluate(); + verify(); + } finally { + after(); + } + } + }; + } + + private void before() { + expectedEvents = new LinkedList<>(); + actualEvents = new LinkedList<>(); + + expectedCalls = null; + actualCalls = 0; + } + + private void after() { + } + + private void verify() { + if (expectedCalls != null) { + assertEquals(expectedCalls.intValue(), actualCalls); + } + + assertEquals(expectedEvents.size(), actualEvents.size()); + + ListIterator<CanonicalEvent> expectedIterator = expectedEvents.listIterator(); + ListIterator<CanonicalEvent> actualIterator = actualEvents.listIterator(); + + while (expectedIterator.hasNext()) { + CanonicalEvent expected = expectedIterator.next(); + CanonicalEvent actual = actualIterator.next(); + + assertEquals(expected, actual); + } + } + + public void expectCalls(int expected) { + expectedCalls = expected; + } + + public void expectImpression(String experientId, String variationId, String userId) { + expectImpression(experientId, variationId, userId, Collections.emptyMap()); + } + + public void expectImpression(String experientId, String variationId, String userId, Map<String, ?> attributes) { + expectImpression(experientId, variationId, userId, attributes, null); + } + + public void expectImpression(String experientId, String variationId, String userId, Map<String, ?> attributes, DecisionMetadata metadata) { + expect(experientId, variationId, IMPRESSION_EVENT_NAME, userId, attributes, null, metadata); + } + + + public void expectConversion(String eventName, String userId) { + expectConversion(eventName, userId, Collections.emptyMap()); + } + + public void expectConversion(String eventName, String userId, Map<String, ?> attributes) { + expectConversion(eventName, userId, attributes, Collections.emptyMap()); + } + + public void expectConversion(String eventName, String userId, Map<String, ?> attributes, Map<String, ?> tags) { + expect(null, null, eventName, userId, attributes, tags); + } + + public void expect(String experientId, String variationId, String eventName, String userId, + Map<String, ?> attributes, Map<String, ?> tags, DecisionMetadata metadata) { + CanonicalEvent expectedEvent = new CanonicalEvent(experientId, variationId, eventName, userId, attributes, tags, metadata); + expectedEvents.add(expectedEvent); + } + + public void expect(String experientId, String variationId, String eventName, String userId, + Map<String, ?> attributes, Map<String, ?> tags) { + expect(experientId, variationId, eventName, userId, attributes, tags, null); + } + + + @Override + public void dispatchEvent(LogEvent logEvent) { + logger.info("Receiving event: {}", logEvent); + actualCalls++; + + List<Visitor> visitors = logEvent.getEventBatch().getVisitors(); + + if (visitors == null) { + return; + } + + for (Visitor visitor: visitors) { + for (Snapshot snapshot: visitor.getSnapshots()) { + List<Decision> decisions = snapshot.getDecisions(); + if (decisions == null) { + decisions = new ArrayList<>(); + } + + if (decisions.isEmpty()) { + decisions.add(new Decision()); + } + + for (Decision decision: decisions) { + for (Event event: snapshot.getEvents()) { + CanonicalEvent actual = new CanonicalEvent( + decision.getExperimentId(), + decision.getVariationId(), + event.getKey(), + visitor.getVisitorId(), + visitor.getAttributes().stream() + .filter(attribute -> !attribute.getKey().startsWith(RESERVED_ATTRIBUTE_PREFIX)) + .collect(Collectors.toMap(Attribute::getKey, Attribute::getValue)), + event.getTags(), + decision.getMetadata() + ); + + logger.info("Adding dispatched, event: {}", actual); + actualEvents.add(actual); + } + } + } + } + } + + private static class CanonicalEvent { + private String experimentId; + private String variationId; + private String eventName; + private String visitorId; + private Map<String, ?> attributes; + private Map<String, ?> tags; + private DecisionMetadata metadata; + + public CanonicalEvent(String experimentId, String variationId, String eventName, + String visitorId, Map<String, ?> attributes, Map<String, ?> tags, + DecisionMetadata metadata) { + this.experimentId = experimentId; + this.variationId = variationId; + this.eventName = eventName; + this.visitorId = visitorId; + this.attributes = attributes; + this.tags = tags; + this.metadata = metadata; + } + + public CanonicalEvent(String experimentId, String variationId, String eventName, + String visitorId, Map<String, ?> attributes, Map<String, ?> tags) { + this(experimentId, variationId, eventName, visitorId, attributes, tags, null); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CanonicalEvent that = (CanonicalEvent) o; + + boolean isMetaDataEqual = (metadata == null) || Objects.equals(metadata, that.metadata); + return Objects.equals(experimentId, that.experimentId) && + Objects.equals(variationId, that.variationId) && + Objects.equals(eventName, that.eventName) && + Objects.equals(visitorId, that.visitorId) && + Objects.equals(attributes, that.attributes) && + Objects.equals(tags, that.tags) && + isMetaDataEqual; + } + + @Override + public int hashCode() { + return Objects.hash(experimentId, variationId, eventName, visitorId, attributes, tags, metadata); + } + + @Override + public String toString() { + return new StringJoiner(", ", CanonicalEvent.class.getSimpleName() + "[", "]") + .add("experimentId='" + experimentId + "'") + .add("variationId='" + variationId + "'") + .add("eventName='" + eventName + "'") + .add("visitorId='" + visitorId + "'") + .add("attributes=" + attributes) + .add("tags=" + tags) + .add("metadata=" + metadata) + .toString(); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java index e142091ec..6f091fdf8 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyBuilderTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,30 +17,37 @@ package com.optimizely.ab; import com.optimizely.ab.bucketing.UserProfileService; -import com.optimizely.ab.config.ProjectConfigTestUtils; -import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.BuildVersionInfo; -import com.optimizely.ab.event.internal.EventFactory; -import com.optimizely.ab.event.internal.payload.EventBatch.ClientEngine; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.payload.Event; +import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.odp.ODPEventManager; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; +import java.util.Arrays; +import java.util.List; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; +import static junit.framework.Assert.assertEquals; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.assertThat; -import static org.mockito.Mockito.mock; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; /** * Tests for {@link Optimizely#builder(String, EventHandler)}. @@ -54,9 +61,14 @@ public class OptimizelyBuilderTest { @Rule public MockitoRule rule = MockitoJUnit.rule(); - @Mock private EventHandler mockEventHandler; + @Mock + private EventHandler mockEventHandler; + + @Mock + private ErrorHandler mockErrorHandler; - @Mock private ErrorHandler mockErrorHandler; + @Mock + ProjectConfigManager mockProjectConfigManager; @Test public void withEventHandler() throws Exception { @@ -71,7 +83,7 @@ public void projectConfigV2() throws Exception { Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV2(), mockEventHandler) .build(); - ProjectConfigTestUtils.verifyProjectConfig(optimizelyClient.getProjectConfig(), validProjectConfigV2()); + DatafileProjectConfigTestUtils.verifyProjectConfig(optimizelyClient.getProjectConfig(), validProjectConfigV2()); } @Test @@ -79,7 +91,7 @@ public void projectConfigV3() throws Exception { Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV3(), mockEventHandler) .build(); - ProjectConfigTestUtils.verifyProjectConfig(optimizelyClient.getProjectConfig(), validProjectConfigV3()); + DatafileProjectConfigTestUtils.verifyProjectConfig(optimizelyClient.getProjectConfig(), validProjectConfigV3()); } @Test @@ -109,65 +121,142 @@ public void withUserProfileService() throws Exception { assertThat(optimizelyClient.getUserProfileService(), is(userProfileService)); } + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") @Test - public void withDefaultClientEngine() throws Exception { - Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV2(), mockEventHandler) - .build(); + public void nullDatafileResultsInInvalidOptimizelyInstance() throws Exception { + Optimizely optimizelyClient = Optimizely.builder((String) null, mockEventHandler).build(); - assertThat(((EventFactory)optimizelyClient.eventFactory).clientEngine, is(ClientEngine.JAVA_SDK)); + assertFalse(optimizelyClient.isValid()); } @Test - public void withAndroidSDKClientEngine() throws Exception { - Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV2(), mockEventHandler) - .withClientEngine(ClientEngine.ANDROID_SDK) - .build(); + public void emptyDatafileResultsInInvalidOptimizelyInstance() throws Exception { + Optimizely optimizelyClient = Optimizely.builder("", mockEventHandler).build(); - assertThat(((EventFactory)optimizelyClient.eventFactory).clientEngine, is(ClientEngine.ANDROID_SDK)); + assertFalse(optimizelyClient.isValid()); } @Test - public void withAndroidTVSDKClientEngine() throws Exception { - Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV2(), mockEventHandler) - .withClientEngine(ClientEngine.ANDROID_TV_SDK) + public void invalidDatafileResultsInInvalidOptimizelyInstance() throws Exception { + Optimizely optimizelyClient = Optimizely.builder("{invalidDatafile}", mockEventHandler).build(); + + assertFalse(optimizelyClient.isValid()); + } + + @Test + public void unsupportedDatafileResultsInInvalidOptimizelyInstance() throws Exception { + Optimizely optimizelyClient = Optimizely.builder(invalidProjectConfigV5(), mockEventHandler) .build(); - assertThat(((EventFactory)optimizelyClient.eventFactory).clientEngine, is(ClientEngine.ANDROID_TV_SDK)); + assertFalse(optimizelyClient.isValid()); } @Test - public void withDefaultClientVersion() throws Exception { - Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV2(), mockEventHandler) + public void withValidProjectConfigManagerOnly() throws Exception { + ProjectConfig projectConfig = new DatafileProjectConfig.Builder().withDatafile(validConfigJsonV4()).build(); + when(mockProjectConfigManager.getConfig()).thenReturn(projectConfig); + + Optimizely optimizelyClient = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withEventHandler(mockEventHandler) .build(); - assertThat(((EventFactory)optimizelyClient.eventFactory).clientVersion, is(BuildVersionInfo.VERSION)); + assertTrue(optimizelyClient.isValid()); + verifyProjectConfig(optimizelyClient.getProjectConfig(), projectConfig); } @Test - public void withCustomClientVersion() throws Exception { - Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV2(), mockEventHandler) - .withClientVersion("0.0.0") + public void withInvalidProjectConfigManagerOnly() throws Exception { + Optimizely optimizelyClient = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withEventHandler(mockEventHandler) + .build(); + assertFalse(optimizelyClient.isValid()); + } + + @Test + public void withProjectConfigManagerAndFallbackDatafile() throws Exception { + Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .withConfigManager(new AtomicProjectConfigManager()) .build(); - assertThat(((EventFactory)optimizelyClient.eventFactory).clientVersion, is("0.0.0")); + // Project Config manager takes precedence. + assertFalse(optimizelyClient.isValid()); } - @SuppressFBWarnings(value="NP_NONNULL_PARAM_VIOLATION", justification="Testing nullness contract violation") @Test - public void builderThrowsConfigParseExceptionForNullDatafile() throws Exception { - thrown.expect(ConfigParseException.class); - Optimizely.builder(null, mockEventHandler).build(); + public void withDefaultDecideOptions() throws Exception { + List<OptimizelyDecideOption> options = Arrays.asList( + OptimizelyDecideOption.DISABLE_DECISION_EVENT, + OptimizelyDecideOption.ENABLED_FLAGS_ONLY, + OptimizelyDecideOption.EXCLUDE_VARIABLES + ); + + Optimizely optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .build(); + assertEquals(optimizelyClient.defaultDecideOptions.size(), 0); + + optimizelyClient = Optimizely.builder(validConfigJsonV4(), mockEventHandler) + .withDefaultDecideOptions(options) + .build(); + assertEquals(optimizelyClient.defaultDecideOptions.get(0), OptimizelyDecideOption.DISABLE_DECISION_EVENT); + assertEquals(optimizelyClient.defaultDecideOptions.get(1), OptimizelyDecideOption.ENABLED_FLAGS_ONLY); + assertEquals(optimizelyClient.defaultDecideOptions.get(2), OptimizelyDecideOption.EXCLUDE_VARIABLES); } @Test - public void builderThrowsConfigParseExceptionForEmptyDatafile() throws Exception { - thrown.expect(ConfigParseException.class); - Optimizely.builder("", mockEventHandler).build(); + public void withClientInfo() throws Exception { + Optimizely optimizely; + EventHandler eventHandler; + ArgumentCaptor<LogEvent> argument = ArgumentCaptor.forClass(LogEvent.class); + + // default client-engine info (java-sdk) + + eventHandler = mock(EventHandler.class); + optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler).build(); + optimizely.track("basic_event", "tester"); + + verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); + assertEquals(argument.getValue().getEventBatch().getClientName(), "java-sdk"); + assertEquals(argument.getValue().getEventBatch().getClientVersion(), BuildVersionInfo.getClientVersion()); + + // invalid override with null inputs + + reset(eventHandler); + optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler) + .build(); + optimizely.track("basic_event", "tester"); + + verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); + assertEquals(argument.getValue().getEventBatch().getClientName(), "java-sdk"); + assertEquals(argument.getValue().getEventBatch().getClientVersion(), BuildVersionInfo.getClientVersion()); + + // override client-engine info + + reset(eventHandler); + optimizely = Optimizely.builder(validConfigJsonV4(), eventHandler) + .withClientInfo(EventBatch.ClientEngine.ANDROID_SDK, "1.2.3") + .build(); + optimizely.track("basic_event", "tester"); + + verify(eventHandler, timeout(5000)).dispatchEvent(argument.capture()); + assertEquals(argument.getValue().getEventBatch().getClientName(), "android-sdk"); + assertEquals(argument.getValue().getEventBatch().getClientVersion(), "1.2.3"); + + // restore the default values for other tests + ClientEngineInfo.setClientEngineName(ClientEngineInfo.DEFAULT_NAME); + BuildVersionInfo.setClientVersion(BuildVersionInfo.DEFAULT_VERSION); } @Test - public void builderThrowsConfigParseExceptionForInvalidDatafile() throws Exception { - thrown.expect(ConfigParseException.class); - Optimizely.builder("{invalidDatafile}", mockEventHandler).build(); + public void withODPManager() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withODPManager(mockODPManager) + .build(); + assertEquals(mockODPManager, optimizely.getODPManager()); } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java new file mode 100644 index 000000000..daaf59d61 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyDecisionContextTest.java @@ -0,0 +1,44 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.junit.Test; +import static junit.framework.TestCase.assertEquals; + +public class OptimizelyDecisionContextTest { + + @Test + public void initializeOptimizelyDecisionContextWithFlagKeyAndRuleKey() { + String flagKey = "test-flag-key"; + String ruleKey = "1029384756"; + String expectedKey = flagKey + OptimizelyDecisionContext.OPTI_KEY_DIVIDER + ruleKey; + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + assertEquals(flagKey, optimizelyDecisionContext.getFlagKey()); + assertEquals(ruleKey, optimizelyDecisionContext.getRuleKey()); + assertEquals(expectedKey, optimizelyDecisionContext.getKey()); + } + + @Test + public void initializeOptimizelyDecisionContextWithFlagKey() { + String flagKey = "test-flag-key"; + String expectedKey = flagKey + OptimizelyDecisionContext.OPTI_KEY_DIVIDER + OptimizelyDecisionContext.OPTI_NULL_RULE_KEY; + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + assertEquals(flagKey, optimizelyDecisionContext.getFlagKey()); + assertEquals(OptimizelyDecisionContext.OPTI_NULL_RULE_KEY, optimizelyDecisionContext.getRuleKey()); + assertEquals(expectedKey, optimizelyDecisionContext.getKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java new file mode 100644 index 000000000..90c0f9e50 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyForcedDecisionTest.java @@ -0,0 +1,30 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.junit.Test; +import static junit.framework.TestCase.assertEquals; + +public class OptimizelyForcedDecisionTest { + + @Test + public void initializeOptimizelyForcedDecision() { + String variationKey = "test-variation-key"; + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + assertEquals(variationKey, optimizelyForcedDecision.getVariationKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyRule.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyRule.java new file mode 100644 index 000000000..87e7f185e --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyRule.java @@ -0,0 +1,99 @@ +/** + * Copyright 2019, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.bucketing.Bucketer; +import com.optimizely.ab.bucketing.DecisionService; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; +import org.junit.rules.ExternalResource; + +/** + * Factory class for building and maintaining an Optimizely instance. The methods mirror the + * {@link Optimizely.Builder} methods so test can can use either class interchangeably. + * + * The main motivation of this class is to ensure that the built Optimizely resource get's + * explicitly closed at the end of each test run. + */ +public class OptimizelyRule extends ExternalResource { + + private Optimizely.Builder builder; + private Optimizely optimizely; + + public OptimizelyRule withEventProcessor(EventProcessor eventProcessor) { + builder.withEventProcessor(eventProcessor); + return this; + } + + public OptimizelyRule withDecisionService(DecisionService decisionService) { + builder.withDecisionService(decisionService); + return this; + } + + public OptimizelyRule withDatafile(String datafile) { + builder.withDatafile(datafile); + return this; + } + + public OptimizelyRule withErrorHandler(ErrorHandler errorHandler) { + builder.withErrorHandler(errorHandler); + return this; + } + + public OptimizelyRule withBucketing(Bucketer bucketer) { + builder.withBucketing(bucketer); + return this; + } + + public OptimizelyRule withEventHandler(EventHandler eventHandler) { + builder.withEventHandler(eventHandler); + return this; + } + + public OptimizelyRule withConfig(ProjectConfig projectConfig) { + builder.withConfig(projectConfig); + return this; + } + + public OptimizelyRule withConfigManager(ProjectConfigManager projectConfigManager) { + builder.withConfigManager(projectConfigManager); + return this; + } + + public Optimizely build() { + optimizely = builder.build(); + return optimizely; + } + + public void before() { + builder = Optimizely.builder(); + } + + public void after() { + if (optimizely == null) { + // Build so we can shut everything down. + build(); + } + + // Blocks and waits for graceful shutdown. + optimizely.close(); + optimizely = null; + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index b121624c6..b444dbc26 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2016-2018, Optimizely, Inc. and contributors * + * Copyright 2016-2023, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -17,69 +17,57 @@ import ch.qos.logback.classic.Level; import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; import com.optimizely.ab.bucketing.Bucketer; import com.optimizely.ab.bucketing.DecisionService; import com.optimizely.ab.bucketing.FeatureDecision; -import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.LiveVariable; -import com.optimizely.ab.config.LiveVariableUsageInstance; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.config.parser.ConfigParseException; -import com.optimizely.ab.error.ErrorHandler; +import com.optimizely.ab.config.*; import com.optimizely.ab.error.NoOpErrorHandler; import com.optimizely.ab.error.RaiseExceptionErrorHandler; +import com.optimizely.ab.event.BatchEventProcessor; import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; import com.optimizely.ab.event.LogEvent; -import com.optimizely.ab.event.internal.EventFactory; -import com.optimizely.ab.event.internal.payload.EventBatch; -import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.event.internal.UserEventFactory; import com.optimizely.ab.internal.ControlAttribute; -import com.optimizely.ab.notification.ActivateNotificationListener; -import com.optimizely.ab.notification.NotificationCenter; -import com.optimizely.ab.notification.TrackNotificationListener; +import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.notification.*; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.ODPEventManager; +import com.optimizely.ab.odp.ODPManager; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.junit.rules.RuleChain; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import javax.annotation.Nonnull; import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static com.optimizely.ab.config.ProjectConfigTestUtils.*; +import java.util.*; +import java.util.function.Function; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; import static com.optimizely.ab.config.ValidProjectConfigV4.*; import static com.optimizely.ab.event.LogEvent.RequestMethod; -import static com.optimizely.ab.event.internal.EventFactoryTest.createExperimentVariationMap; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.EXPERIMENT_KEY; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FeatureVariableDecisionNotificationBuilder.*; import static java.util.Arrays.asList; import static junit.framework.TestCase.assertTrue; import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasEntry; -import static org.hamcrest.Matchers.hasKey; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; +import static org.junit.Assert.*; import static org.junit.Assume.assumeTrue; import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; @@ -90,30 +78,35 @@ @RunWith(Parameterized.class) public class OptimizelyTest { - @Parameters + @Parameterized.Parameters(name = "{index}: Version: {2}") public static Collection<Object[]> data() throws IOException { - return Arrays.asList(new Object[][] { - { - 2, - validConfigJsonV2(), - noAudienceProjectConfigJsonV2(), - validProjectConfigV2(), - noAudienceProjectConfigV2() - }, - { - 3, - validConfigJsonV3(), - noAudienceProjectConfigJsonV3(), - validProjectConfigV3(), - noAudienceProjectConfigV3() - }, - { - 4, - validConfigJsonV4(), - validConfigJsonV4(), - validProjectConfigV4(), - validProjectConfigV4() - } + return Arrays.asList(new Object[][]{ + { + validConfigJsonV2(), + noAudienceProjectConfigJsonV2(), + 2, + (Function<EventHandler, EventProcessor>) (eventHandler) -> null + }, + { + validConfigJsonV3(), + noAudienceProjectConfigJsonV3(), // FIX-ME this is not a valid v3 datafile + 3, + (Function<EventHandler, EventProcessor>) (eventHandler) -> null + }, + { + validConfigJsonV4(), + validConfigJsonV4(), + 4, + (Function<EventHandler, EventProcessor>) (eventHandler) -> null + }, + { + validConfigJsonV4(), + validConfigJsonV4(), + 4, + (Function<EventHandler, EventProcessor>) (eventHandler) -> BatchEventProcessor.builder() + .withEventHandler(eventHandler) + .build() + } }); } @@ -121,40 +114,116 @@ public static Collection<Object[]> data() throws IOException { @SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") public MockitoRule rule = MockitoJUnit.rule(); - @Rule public ExpectedException thrown = ExpectedException.none(); - - @Rule public LogbackVerifier logbackVerifier = new LogbackVerifier(); + public OptimizelyRule optimizelyBuilder = new OptimizelyRule(); + public EventHandlerRule eventHandler = new EventHandlerRule(); + + public ProjectConfigManager projectConfigManagerReturningNull = new ProjectConfigManager() { + @Override + public ProjectConfig getConfig() { + return null; + } + + @Override + public ProjectConfig getCachedConfig() { + return null; + } + + @Override + public String getSDKKey() { + return null; + } + }; - @Mock EventHandler mockEventHandler; - @Mock Bucketer mockBucketer; - @Mock DecisionService mockDecisionService; - @Mock ErrorHandler mockErrorHandler; + @Rule + @SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") + public RuleChain ruleChain = RuleChain.outerRule(thrown) + .around(logbackVerifier) + .around(eventHandler) + .around(optimizelyBuilder); + + @Mock + EventHandler mockEventHandler; + @Mock + Bucketer mockBucketer; + @Mock + DecisionService mockDecisionService; private static final String genericUserId = "genericUserId"; private static final String testUserId = "userId"; private static final String testBucketingId = "bucketingId"; private static final String testBucketingIdKey = ControlAttribute.BUCKETING_ATTRIBUTE.toString(); - private static final Map<String, String> testParams = Collections.singletonMap("test", "params"); - private static final LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, null); - private int datafileVersion; - private String validDatafile; - private String noAudienceDatafile; + @Parameterized.Parameter(0) + public String validDatafile; + + @Parameterized.Parameter(1) + public String noAudienceDatafile; + + @Parameterized.Parameter(2) + public int datafileVersion; + + @Parameterized.Parameter(3) + public Function<EventHandler, EventProcessor> eventProcessorSupplier; + private ProjectConfig validProjectConfig; private ProjectConfig noAudienceProjectConfig; - public OptimizelyTest(int datafileVersion, - String validDatafile, - String noAudienceDatafile, - ProjectConfig validProjectConfig, - ProjectConfig noAudienceProjectConfig) { - this.datafileVersion = datafileVersion; - this.validDatafile = validDatafile; - this.noAudienceDatafile = noAudienceDatafile; - this.validProjectConfig = validProjectConfig; - this.noAudienceProjectConfig = noAudienceProjectConfig; + @Before + public void setUp() throws Exception { + validProjectConfig = new DatafileProjectConfig.Builder().withDatafile(validDatafile).build(); + noAudienceProjectConfig = new DatafileProjectConfig.Builder().withDatafile(noAudienceDatafile).build(); + + // FIX-ME + //assertEquals(validProjectConfig.getVersion(), noAudienceProjectConfig.getVersion()); + + optimizelyBuilder + .withEventProcessor(eventProcessorSupplier.apply(eventHandler)) + .withEventHandler(eventHandler) + .withConfig(validProjectConfig); + } + + @Test + public void testClose() throws Exception { + EventHandler mockEventHandler = mock( + EventHandler.class, + withSettings().extraInterfaces(AutoCloseable.class) + ); + + ProjectConfigManager mockProjectConfigManager = mock( + ProjectConfigManager.class, + withSettings().extraInterfaces(AutoCloseable.class) + ); + + EventProcessor mockEventProcessor = mock( + EventProcessor.class, + withSettings().extraInterfaces(AutoCloseable.class) + ); + + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + Mockito.doNothing().when(mockODPEventManager).sendEvent(any()); + + ODPManager mockODPManager = mock( + ODPManager.class, + withSettings().extraInterfaces(AutoCloseable.class) + ); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + + Optimizely optimizely = Optimizely.builder() + .withEventHandler(mockEventHandler) + .withEventProcessor(mockEventProcessor) + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + optimizely.close(); + + verify((AutoCloseable) mockEventHandler).close(); + verify((AutoCloseable) mockProjectConfigManager).close(); + verify((AutoCloseable) mockEventProcessor).close(); + verify((AutoCloseable) mockODPManager).close(); } //======== activate tests ========// @@ -166,51 +235,171 @@ public OptimizelyTest(int datafileVersion, @Test public void activateEndToEnd() throws Exception { Experiment activatedExperiment; - Map<String, String> testUserAttributes = new HashMap<String, String>(); - String bucketingKey = testBucketingIdKey; - String userId = testUserId; - String bucketingId = testBucketingId; - if(datafileVersion >= 4) { + Map<String, Object> testUserAttributes = new HashMap<>(); + + if (datafileVersion >= 4) { activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { + } else { activatedExperiment = validProjectConfig.getExperiments().get(0); testUserAttributes.put("browser_type", "chrome"); } - testUserAttributes.put(bucketingKey, bucketingId); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + logbackVerifier.expectMessage(Level.DEBUG, "This decision will not be saved since the UserProfileService is null."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); + + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceInt() throws Exception { + assumeTrue(datafileVersion >= 4); + + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping() + .get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + Map<String, Object> testUserAttributes = Collections.singletonMap(ATTRIBUTE_INTEGER_KEY, 2); // should be gt 1. + + logbackVerifier.expectMessage(Level.DEBUG, "This decision will not be saved since the UserProfileService is null."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); + + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + } + + /** + * Verify that activating using typed audiences works for numeric match exact using double and integer. + */ + @Test + public void activateEndToEndWithTypedAudienceIntExactDouble() throws Exception { + assumeTrue(datafileVersion >= 4); + + Map<String, Object> testUserAttributes = Collections.singletonMap(ATTRIBUTE_INTEGER_KEY, 1.0); + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); + + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + } + + /** + * Verify that activating using typed audiences works for numeric match exact using double and integer. + */ + @Test + public void activateEndToEndWithTypedAudienceIntExact() throws Exception { + assumeTrue(datafileVersion >= 4); + + Map<String, Object> testUserAttributes = Collections.singletonMap(ATTRIBUTE_INTEGER_KEY, 1); + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); + + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceBool() throws Exception { + assumeTrue(datafileVersion >= 4); + + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping() + .get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + Map<String, Object> testUserAttributes = Collections.singletonMap(ATTRIBUTE_BOOLEAN_KEY, true); // should be eq true. + + logbackVerifier.expectMessage(Level.DEBUG, "This decision will not be saved since the UserProfileService is null."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + } - when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, testUserId, - testUserAttributes)) - .thenReturn(logEventToDispatch); + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceDouble() throws Exception { + assumeTrue(datafileVersion >= 4); - when(mockBucketer.bucket(activatedExperiment, bucketingId)) - .thenReturn(bucketedVariation); + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping() + .get(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY); + Map<String, Object> testUserAttributes = Collections.singletonMap(ATTRIBUTE_DOUBLE_KEY, 99.9); // should be lt 100. + logbackVerifier.expectMessage(Level.DEBUG, "This decision will not be saved since the UserProfileService is null."); logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + - activatedExperiment.getKey() + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + - testParams + " and payload \"\""); + activatedExperiment.getKey() + "\"."); // activate the experiment - Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, testUserAttributes); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, bucketingId); - assertThat(actualVariation, is(bucketedVariation)); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceBoolWithAndAudienceConditions() throws Exception { + assumeTrue(datafileVersion >= 4); + + Map<String, Object> testUserAttributes = Collections.singletonMap(ATTRIBUTE_BOOLEAN_KEY, true); + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_KEY); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNull(actualVariation); + } + + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateEndToEndWithTypedAudienceWithAnd() throws Exception { + assumeTrue(datafileVersion >= 4); + + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping() + .get(EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_KEY); + Map<String, Object> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_DOUBLE_KEY, 99.9); // should be lt 100. + testUserAttributes.put(ATTRIBUTE_BOOLEAN_KEY, true); // should be eq true. + testUserAttributes.put(ATTRIBUTE_INTEGER_KEY, 2); // should be gt 1. + + logbackVerifier.expectMessage(Level.DEBUG, "This decision will not be saved since the UserProfileService is null."); + logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + + activatedExperiment.getKey() + "\"."); + + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertNotNull(actualVariation); - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); } /** @@ -220,39 +409,22 @@ public void activateEndToEnd() throws Exception { @Test public void activateForNullVariation() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Map<String, String> testUserAttributes = Collections.singletonMap("browser_type", "chromey"); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - testUserAttributes.put(testBucketingIdKey, - testBucketingId); - - when(mockBucketer.bucket(activatedExperiment, testBucketingId)) - .thenReturn(null); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.nullNoReasons()); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + - activatedExperiment.getKey() + "\"."); + activatedExperiment.getKey() + "\"."); - // activate the experiment + Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); - - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testBucketingId); assertNull(actualVariation); - - // verify that dispatchEvent was NOT called - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } /** * Verify the case were {@link Optimizely#activate(Experiment, String)} is called with an {@link Experiment} - * that is not present in the current {@link ProjectConfig}. We should NOT throw an error in that case. - * + * that is not present in the current {@link DatafileProjectConfig}. We should NOT throw an error in that case. + * <p> * This may happen if an experiment is retrieved from the project config, the project config is updated and the * referenced experiment removed, then activate is called given the now removed experiment. * Could also happen if an experiment was manually created and passed through. @@ -260,18 +432,15 @@ public void activateForNullVariation() throws Exception { @Test public void activateWhenExperimentIsNotInProject() throws Exception { Experiment unknownExperiment = createUnknownExperiment(); - Variation bucketedVariation = unknownExperiment.getVariations().get(0); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(validProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder + .withErrorHandler(new RaiseExceptionErrorHandler()) + .build(); - when(mockBucketer.bucket(unknownExperiment, testUserId)) - .thenReturn(bucketedVariation); + Variation actualVariation = optimizely.activate(unknownExperiment, testUserId); + assertNotNull(actualVariation); - optimizely.activate(unknownExperiment, testUserId); + eventHandler.expectImpression(unknownExperiment.getId(), actualVariation.getId(), testUserId); } /** @@ -283,47 +452,24 @@ public void activateWhenExperimentIsNotInProject() throws Exception { public void activateWithExperimentKeyForced() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); Variation forcedVariation = activatedExperiment.getVariations().get(1); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey() ); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); + Map<String, String> testUserAttributes = new HashMap<>(); if (datafileVersion >= 4) { testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { + } else { testUserAttributes.put("browser_type", "chrome"); } - testUserAttributes.put(testBucketingIdKey, - testBucketingId); - - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(forcedVariation), - eq(testUserId), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey()); - when(mockBucketer.bucket(activatedExperiment, testBucketingId)) - .thenReturn(bucketedVariation); - - // activate the experiment Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); - assertThat(actualVariation, is(forcedVariation)); - verify(mockEventHandler).dispatchEvent(logEventToDispatch); - - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null ); - - assertEquals(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId), null); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null); + assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId)); } /** @@ -334,48 +480,26 @@ public void activateWithExperimentKeyForced() throws Exception { @Test public void getVariationWithExperimentKeyForced() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation forcedVariation = activatedExperiment.getVariations().get(1); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); + Variation forcedVariation = activatedExperiment.getVariations().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey() ); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); + Map<String, String> testUserAttributes = new HashMap<>(); if (datafileVersion >= 4) { testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { + } else { testUserAttributes.put("browser_type", "chrome"); } - testUserAttributes.put(testBucketingIdKey, - testBucketingId); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey()); - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(forcedVariation), - eq(testUserId), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); - - when(mockBucketer.bucket(activatedExperiment, testBucketingId)) - .thenReturn(bucketedVariation); - - // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId, testUserAttributes); - assertThat(actualVariation, is(forcedVariation)); - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null ); - - assertEquals(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId), null); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null); + assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId)); actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId, testUserAttributes); - assertThat(actualVariation, is(bucketedVariation)); } @@ -389,38 +513,19 @@ public void isFeatureEnabledWithExperimentKeyForced() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - Variation forcedVariation = activatedExperiment.getVariations().get(1); - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey() ); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - if (datafileVersion < 4) { - testUserAttributes.put("browser_type", "chrome"); - } + Variation forcedVariation = activatedExperiment.getVariations().get(0); - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(forcedVariation), - eq(testUserId), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey()); // activate the experiment assertTrue(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId)); + eventHandler.expectImpression(activatedExperiment.getId(), forcedVariation.getId(), testUserId); - verify(mockEventHandler).dispatchEvent(logEventToDispatch); - - assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null )); - + assertTrue(optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, null)); assertNull(optimizely.getForcedVariation(activatedExperiment.getKey(), testUserId)); - assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId)); - + eventHandler.expectImpression(null, "", testUserId); } /** @@ -431,46 +536,20 @@ public void isFeatureEnabledWithExperimentKeyForced() throws Exception { @Test public void activateWithExperimentKey() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - Variation userIdBucketVariation = activatedExperiment.getVariations().get(1); - EventFactory mockEventFactory = mock(EventFactory.class); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); + Map<String, String> testUserAttributes = new HashMap<>(); if (datafileVersion >= 4) { testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { + } else { testUserAttributes.put("browser_type", "chrome"); } - testUserAttributes.put(testBucketingIdKey, testBucketingId); - - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); - - when(mockBucketer.bucket(activatedExperiment, testUserId)) - .thenReturn(userIdBucketVariation); - - when(mockBucketer.bucket(activatedExperiment, testBucketingId)) - .thenReturn(bucketedVariation); - - // activate the experiment + Optimizely optimizely = optimizelyBuilder.build(); Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); - - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testBucketingId); assertThat(actualVariation, is(bucketedVariation)); - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); } /** @@ -480,41 +559,16 @@ public void activateWithExperimentKey() throws Exception { @Test public void activateWithUnknownExperimentKeyAndNoOpErrorHandler() throws Exception { Experiment unknownExperiment = createUnknownExperiment(); + Optimizely optimizely = optimizelyBuilder.withErrorHandler(new NoOpErrorHandler()).build(); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); - - logbackVerifier.expectMessage(Level.ERROR, "Experiment \"unknown_experiment\" is not in the datafile."); + logbackVerifier.expectMessage(Level.WARN, "Experiment \"unknown_experiment\" is not in the datafile."); logbackVerifier.expectMessage(Level.INFO, - "Not activating user \"userId\" for experiment \"unknown_experiment\"."); + "Not activating user \"userId\" for experiment \"unknown_experiment\"."); - // since we use a NoOpErrorHandler, we should fail and return null Variation actualVariation = optimizely.activate(unknownExperiment.getKey(), testUserId); - - // verify that null is returned, as no project config was available assertNull(actualVariation); } - /** - * Verify that {@link Optimizely#activate(String, String)} handles the case where an unknown experiment - * (i.e., not in the config) is passed through and a {@link RaiseExceptionErrorHandler} is provided. - */ - @Test - public void activateWithUnknownExperimentKeyAndRaiseExceptionErrorHandler() throws Exception { - thrown.expect(UnknownExperimentException.class); - - Experiment unknownExperiment = createUnknownExperiment(); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); - - // since we use a RaiseExceptionErrorHandler, we should throw an error - optimizely.activate(unknownExperiment.getKey(), testUserId); - } - /** * Verify that {@link Optimizely#activate(String, String, Map)} passes through attributes. */ @@ -522,213 +576,103 @@ public void activateWithUnknownExperimentKeyAndRaiseExceptionErrorHandler() thro @SuppressWarnings("unchecked") public void activateWithAttributes() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - Variation userIdBucketedVariation = activatedExperiment.getVariations().get(1); - Attribute attribute = validProjectConfig.getAttributes().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); + Attribute attributeString = validProjectConfig.getAttributes().get(0); - // setup a mock event builder to return expected impression params - EventFactory mockEventFactory = mock(EventFactory.class); + Map<String, String> attr = Collections.singletonMap(attributeString.getKey(), "attributeValue"); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, attr); + assertThat(actualVariation, is(bucketedVariation)); - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, attr); + } - when(mockBucketer.bucket(activatedExperiment, testUserId)) - .thenReturn(userIdBucketedVariation); + @Test + public void activateWithTypedAttributes() throws Exception { + assumeTrue(datafileVersion >= 4); - when(mockBucketer.bucket(activatedExperiment, testBucketingId)) - .thenReturn(bucketedVariation); + Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); - Map<String, String> attr = new HashMap<String,String>(); - attr.put(attribute.getKey(), "attributeValue"); - attr.put(testBucketingIdKey, testBucketingId); + Attribute attributeString = validProjectConfig.getAttributes().get(0); + Attribute attributeBoolean = validProjectConfig.getAttributes().get(3); + Attribute attributeInteger = validProjectConfig.getAttributes().get(4); + Attribute attributeDouble = validProjectConfig.getAttributes().get(5); - // activate the experiment - Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, - attr); + Map<String, Object> attr = new HashMap<>(); + attr.put(attributeString.getKey(), "attributeValue"); + attr.put(attributeBoolean.getKey(), true); + attr.put(attributeInteger.getKey(), 3); + attr.put(attributeDouble.getKey(), 3.123); - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testBucketingId); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, attr); assertThat(actualVariation, is(bucketedVariation)); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockEventFactory).createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq(testUserId), attributeCaptor.capture()); - - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); - - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, attr); } /** * Verify that {@link Optimizely#activate(String, String, Map<String, String>)} handles the case * where an unknown attribute (i.e., not in the config) is passed through. - * - * In this case, the activate call should remove the unknown attribute from the given map. + * <p> + * In this case, the eventual payload will NOT include the unknownAttribute */ @Test @SuppressWarnings("unchecked") public void activateWithUnknownAttribute() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - // setup a mock event builder to return mock params and endpoint - EventFactory mockEventFactory = mock(EventFactory.class); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - if (datafileVersion >= 4) { - testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { - testUserAttributes.put("browser_type", "chrome"); - } + Map<String, String> testUserAttributes = new HashMap<>(); testUserAttributes.put("unknownAttribute", "dimValue"); - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); - - when(mockBucketer.bucket(activatedExperiment, testUserId)) - .thenReturn(bucketedVariation); - logbackVerifier.expectMessage(Level.INFO, "Activating user \"userId\" in experiment \"" + - activatedExperiment.getKey() + "\"."); - logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching impression event to URL test_url with params " + - testParams + " and payload \"\""); - - // Use an immutable map to also check that we're not attempting to change the provided attribute map - Variation actualVariation = - optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + activatedExperiment.getKey() + "\"."); + Optimizely optimizely = optimizelyBuilder.withErrorHandler(new RaiseExceptionErrorHandler()).build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); assertThat(actualVariation, is(bucketedVariation)); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq(testUserId), attributeCaptor.capture()); - - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, not(hasKey("unknownAttribute"))); - - // verify that dispatchEvent was called with the correct LogEvent object. - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId); } /** - * Verify that {@link Optimizely#activate(String, String, Map)} ignores null attributes. + * Verify that {@link Optimizely#activate(String, String, Map)} if passed null attributes than it returns null attributes. */ @Test @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") public void activateWithNullAttributes() throws Exception { - Experiment activatedExperiment = noAudienceProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - // setup a mock event builder to return expected impression params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - when(mockEventFactory.createImpressionEvent(eq(noAudienceProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), eq(Collections.<String, String>emptyMap()))) - .thenReturn(logEventToDispatch); - - when(mockBucketer.bucket(activatedExperiment, testUserId)) - .thenReturn(bucketedVariation); - - // activate the experiment - Map<String, String> attributes = null; - Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, attributes); - - logbackVerifier.expectMessage(Level.WARN, "Attributes is null when non-null was expected. Defaulting to an empty attributes map."); + Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, null); assertThat(actualVariation, is(bucketedVariation)); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockEventFactory).createImpressionEvent(eq(noAudienceProjectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq(testUserId), attributeCaptor.capture()); - - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, is(Collections.<String, String>emptyMap())); - - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId); } /** * Verify that {@link Optimizely#activate(String, String, Map)} gracefully handles null attribute values. + * Null values are striped within the EventFactory. Not sure the intent of this test. */ @Test public void activateWithNullAttributeValues() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(1); Attribute attribute = validProjectConfig.getAttributes().get(0); - // setup a mock event builder to return expected impression params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), anyMapOf(String.class, String.class))) - .thenReturn(logEventToDispatch); + Map<String, String> attributes = Collections.singletonMap(attribute.getKey(), null); - when(mockBucketer.bucket(activatedExperiment, testUserId)) - .thenReturn(bucketedVariation); - - // activate the experiment - Map<String, String> attributes = new HashMap<String, String>(); - attributes.put(attribute.getKey(), null); + Optimizely optimizely = optimizelyBuilder.build(); Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, attributes); - - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId); assertThat(actualVariation, is(bucketedVariation)); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockEventFactory).createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq(testUserId), attributeCaptor.capture()); - - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, hasEntry(attribute.getKey(), null)); - - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId); } /** @@ -740,23 +684,18 @@ public void activateDraftExperiment() throws Exception { Experiment inactiveExperiment; if (datafileVersion == 4) { inactiveExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); - } - else { + } else { inactiveExperiment = validProjectConfig.getExperiments().get(1); } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "Experiment \"" + inactiveExperiment.getKey() + - "\" is not running."); + "\" is not running."); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + - inactiveExperiment.getKey() + "\"."); + inactiveExperiment.getKey() + "\"."); Variation variation = optimizely.activate(inactiveExperiment.getKey(), testUserId); - - // verify that null is returned, as the experiment isn't running assertNull(variation); } @@ -767,17 +706,12 @@ public void activateDraftExperiment() throws Exception { public void activateUserInAudience() throws Exception { Experiment experimentToCheck = validProjectConfig.getExperiments().get(0); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(experimentToCheck.getKey(), testUserId); + assertNotNull(actualVariation); - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - - Variation actualVariation = optimizely.activate(experimentToCheck.getKey(), testUserId, testUserAttributes); - assertNotNull(actualVariation); - } + eventHandler.expectImpression(experimentToCheck.getId(), actualVariation.getId(), testUserId); + } /** * Verify that if user ID sent is null will return null variation. @@ -786,48 +720,12 @@ public void activateUserInAudience() throws Exception { @Test public void activateUserIDIsNull() throws Exception { Experiment experimentToCheck = validProjectConfig.getExperiments().get(0); - String nullUserID = null; - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - - Variation nullVariation = optimizely.activate(experimentToCheck.getKey(), nullUserID, testUserAttributes); - assertNull(nullVariation); - - logbackVerifier.expectMessage( - Level.ERROR, - "The user ID parameter must be nonnull." - ); - } - - /** - * Verify that if user ID sent is null will return null variation. - * In activate override function where experiment object is passed - */ - @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") - @Test - public void activateWithExperimentUserIDIsNull() throws Exception { - Experiment experimentToCheck = validProjectConfig.getExperiments().get(0); - String nullUserID = null; - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - Variation nullVariation = optimizely.activate(experimentToCheck, nullUserID, testUserAttributes); + Optimizely optimizely = optimizelyBuilder.build(); + Variation nullVariation = optimizely.activate(experimentToCheck.getKey(), null); assertNull(nullVariation); - logbackVerifier.expectMessage( - Level.ERROR, - "The user ID parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.ERROR, "The user ID parameter must be nonnull."); } /** @@ -836,23 +734,13 @@ public void activateWithExperimentUserIDIsNull() throws Exception { @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test public void activateExperimentKeyIsNull() throws Exception { - String nullExperimentKey = null; - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - - Variation nullVariation = optimizely.activate(nullExperimentKey, testUserId, testUserAttributes); + Optimizely optimizely = optimizelyBuilder.build(); + Variation nullVariation = optimizely.activate((String) null, testUserId); assertNull(nullVariation); - logbackVerifier.expectMessage( - Level.ERROR, - "The experimentKey parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.ERROR, "The experimentKey parameter must be nonnull."); } + /** * Verify that a user not in any of an experiment's audiences isn't assigned to a variation. */ @@ -861,25 +749,19 @@ public void activateUserNotInAudience() throws Exception { Experiment experimentToCheck; if (datafileVersion == 4) { experimentToCheck = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - } - else { + } else { experimentToCheck = validProjectConfig.getExperiments().get(0); } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "firefox"); + Map<String, String> testUserAttributes = Collections.singletonMap("browser_type", "firefox"); logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"" + - experimentToCheck.getKey() + "\"."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + + "User \"userId\" does not meet conditions to be in experiment \"" + experimentToCheck.getKey() + "\"."); + logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + + experimentToCheck.getKey() + "\"."); + Optimizely optimizely = optimizelyBuilder.build(); Variation actualVariation = optimizely.activate(experimentToCheck.getKey(), testUserId, testUserAttributes); assertNull(actualVariation); } @@ -891,12 +773,11 @@ public void activateUserNotInAudience() throws Exception { @Test public void activateUserWithNoAudiences() throws Exception { Experiment experimentToCheck = noAudienceProjectConfig.getExperiments().get(0); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(experimentToCheck.getKey(), testUserId); + assertNotNull(actualVariation); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withErrorHandler(mockErrorHandler) - .build(); - - assertNotNull(optimizely.activate(experimentToCheck.getKey(), testUserId)); + eventHandler.expectImpression(experimentToCheck.getId(), actualVariation.getId(), testUserId); } /** @@ -908,38 +789,40 @@ public void activateUserNoAttributesWithAudiences() throws Exception { Experiment experiment; if (datafileVersion == 4) { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - } - else { + } else { experiment = validProjectConfig.getExperiments().get(0); } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .build(); - - logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"" + experiment.getKey() + "\"."); - logbackVerifier.expectMessage(Level.INFO, "Not activating user \"userId\" for experiment \"" + - experiment.getKey() + "\"."); + Optimizely optimizely = optimizelyBuilder.build(); - assertNull(optimizely.activate(experiment.getKey(), testUserId)); + /** + * TBD: This should be fixed. The v4 datafile does not contain the same condition so + * results are different. We have made a change around 9/7/18 where we evaluate audience + * regardless if you pass in a attribute list or not. In this case there is a not("broswer_type = "firefox") + * This causes the user to be bucketed now because they don't have browser_type set to firefox. + */ + if (datafileVersion == 4) { + assertNull(optimizely.activate(experiment.getKey(), testUserId)); + } else { + Variation actualVariation = optimizely.activate(experiment.getKey(), testUserId); + assertNotNull(actualVariation); + eventHandler.expectImpression(experiment.getId(), actualVariation.getId(), testUserId, Collections.emptyMap()); + } } /** - * Verify that {@link Optimizely#activate(String, String)} doesn't return a variation when provided an empty string. + * Verify that {@link Optimizely#activate(String, String)} return a variation when provided an empty string. */ @Test public void activateWithEmptyUserId() throws Exception { - Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); + Experiment experiment = validProjectConfig.getExperiments().get(0); String experimentKey = experiment.getKey(); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(experimentKey, ""); + assertNotNull(actualVariation); - logbackVerifier.expectMessage(Level.ERROR, "Non-empty user ID required"); - logbackVerifier.expectMessage(Level.INFO, "Not activating user for experiment \"" + experimentKey + "\"."); - assertNull(optimizely.activate(experimentKey, "")); + eventHandler.expectImpression(experiment.getId(), actualVariation.getId(), "", Collections.emptyMap()); } /** @@ -949,28 +832,24 @@ public void activateWithEmptyUserId() throws Exception { @Test public void activateForGroupExperimentWithMatchingAttributes() throws Exception { Experiment experiment = validProjectConfig.getGroups() - .get(0) - .getExperiments() - .get(0); - Variation variation = experiment.getVariations().get(0); + .get(0) + .getExperiments() + .get(0); + String userId = testUserId; - Map<String, String> attributes = new HashMap<String, String>(); + Map<String, String> attributes = new HashMap<>(); if (datafileVersion == 4) { attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { + userId = "user"; // To make sure the user gets allocated. + } else { attributes.put("browser_type", "chrome"); } - when(mockBucketer.bucket(experiment, "user")).thenReturn(variation); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); + Variation actualVariation = optimizely.activate(experiment.getKey(), userId, attributes); + assertNotNull(actualVariation); - assertThat(optimizely.activate(experiment.getKey(), "user", attributes), - is(variation)); + eventHandler.expectImpression(experiment.getId(), actualVariation.getId(), userId, attributes); } /** @@ -980,22 +859,20 @@ public void activateForGroupExperimentWithMatchingAttributes() throws Exception @Test public void activateForGroupExperimentWithNonMatchingAttributes() throws Exception { Experiment experiment = validProjectConfig.getGroups() - .get(0) - .getExperiments() - .get(0); + .get(0) + .getExperiments() + .get(0); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); String experimentKey = experiment.getKey(); logbackVerifier.expectMessage( - Level.INFO, - "User \"user\" does not meet conditions to be in experiment \"" + experimentKey + "\"."); + Level.INFO, + "User \"user\" does not meet conditions to be in experiment \"" + experimentKey + "\"."); logbackVerifier.expectMessage(Level.INFO, - "Not activating user \"user\" for experiment \"" + experimentKey + "\"."); + "Not activating user \"user\" for experiment \"" + experimentKey + "\"."); assertNull(optimizely.activate(experiment.getKey(), "user", - Collections.singletonMap("browser_type", "firefox"))); + Collections.singletonMap("browser_type", "firefox"))); } /** @@ -1011,22 +888,23 @@ public void activateForcedVariationPrecedesAudienceEval() throws Exception { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); whitelistedUserId = MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; expectedVariation = experiment.getVariationKeyToVariationMap().get(VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY); - } - else { + } else { experiment = validProjectConfig.getExperiments().get(0); whitelistedUserId = "testUser1"; expectedVariation = experiment.getVariations().get(0); } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"" + - expectedVariation.getKey() + "\"."); + expectedVariation.getKey() + "\"."); // no attributes provided for a experiment that has an audience assertTrue(experiment.getUserIdToVariationKeyMap().containsKey(whitelistedUserId)); - assertThat(optimizely.activate(experiment.getKey(), whitelistedUserId), is(expectedVariation)); + Variation actualVariation = optimizely.activate(experiment.getKey(), whitelistedUserId); + assertThat(actualVariation, is(expectedVariation)); + + eventHandler.expectImpression(experiment.getId(), actualVariation.getId(), whitelistedUserId); + } /** @@ -1040,75 +918,59 @@ public void activateExperimentStatusPrecedesForcedVariation() throws Exception { if (datafileVersion == 4) { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); whitelistedUserId = PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; - } - else { + } else { experiment = validProjectConfig.getExperiments().get(1); whitelistedUserId = "testUser3"; } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "Experiment \"" + experiment.getKey() + "\" is not running."); logbackVerifier.expectMessage(Level.INFO, "Not activating user \"" + whitelistedUserId + - "\" for experiment \"" + experiment.getKey() + "\"."); + "\" for experiment \"" + experiment.getKey() + "\"."); // testUser3 has a corresponding forced variation, but experiment status should be checked first assertTrue(experiment.getUserIdToVariationKeyMap().containsKey(whitelistedUserId)); assertNull(optimizely.activate(experiment.getKey(), whitelistedUserId)); } /** - * Verify that {@link Optimizely#activate(String, String)} handles exceptions thrown by - * {@link EventHandler#dispatchEvent(LogEvent)} gracefully. + * Verify that {@link Optimizely#activate(String, String)} dispatches an event for an experiment with a + * "Launched" status when SendFlagDecisions is true. */ @Test - public void activateDispatchEventThrowsException() throws Exception { - Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - - doThrow(new Exception("Test Exception")).when(mockEventHandler).dispatchEvent(any(LogEvent.class)); - - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .build(); - - logbackVerifier.expectMessage(Level.ERROR, "Unexpected exception in event dispatcher"); - optimizely.activate(experiment.getKey(), testUserId); - } - - /** - * Verify that {@link Optimizely#activate(String, String)} doesn't dispatch an event for an experiment with a - * "Launched" status. - */ - @Test - public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { - Experiment launchedExperiment; - if (datafileVersion == 4) { - launchedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_LAUNCHED_EXPERIMENT_KEY); - } - else { - launchedExperiment = noAudienceProjectConfig.getExperiments().get(2); - } - - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(noAudienceProjectConfig) - .build(); + public void activateLaunchedExperimentDispatchesEvent() throws Exception { + Experiment launchedExperiment = datafileVersion == 4 ? + noAudienceProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_LAUNCHED_EXPERIMENT_KEY) : + noAudienceProjectConfig.getExperiments().get(2); + Optimizely optimizely = optimizelyBuilder.withConfig(noAudienceProjectConfig).build(); Variation expectedVariation = launchedExperiment.getVariations().get(0); - when(mockBucketer.bucket(launchedExperiment, testUserId)) - .thenReturn(launchedExperiment.getVariations().get(0)); + // Force variation to launched experiment. + optimizely.setForcedVariation(launchedExperiment.getKey(), testUserId, expectedVariation.getKey()); - logbackVerifier.expectMessage(Level.INFO, - "Experiment has \"Launched\" status so not dispatching event during activation."); Variation variation = optimizely.activate(launchedExperiment.getKey(), testUserId); - assertNotNull(variation); assertThat(variation.getKey(), is(expectedVariation.getKey())); + eventHandler.expectImpression(launchedExperiment.getId(), expectedVariation.getId(), testUserId); + } - // verify that we did NOT dispatch an event - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + /** + * Verify that we don't attempt to activate the user when the Optimizely instance is not valid + */ + @Test + public void activateWithInvalidDatafile() throws Exception { + Optimizely optimizely = optimizelyBuilder + .withDatafile(invalidProjectConfigV5()) + .withConfig(null) + .withBucketing(mockBucketer) + .build(); + + Variation expectedVariation = optimizely.activate("etag1", genericUserId); + assertNull(expectedVariation); + + // make sure we didn't even attempt to bucket the user + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } //======== track tests ========// @@ -1119,72 +981,13 @@ public void activateLaunchedExperimentDoesNotDispatchEvent() throws Exception { */ @Test public void trackEventEndToEndForced() throws Exception { - EventType eventType; - String datafile; - ProjectConfig config; - if (datafileVersion >= 4) { - config = spy(validProjectConfig); - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - datafile = validDatafile; - } - else { - config = spy(noAudienceProjectConfig); - eventType = noAudienceProjectConfig.getEventTypes().get(0); - datafile = noAudienceDatafile; - } - List<Experiment> allExperiments = new ArrayList<Experiment>(); - allExperiments.add(config.getExperiments().get(0)); - EventFactory eventFactory = new EventFactory(); - DecisionService spyDecisionService = spy(new DecisionService(mockBucketer, - mockErrorHandler, - config, - null)); - - Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) - .withDecisionService(spyDecisionService) - .withEventBuilder(eventFactory) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - // Bucket to null for all experiments. However, only a subset of the experiments will actually - // call the bucket function. - for (Experiment experiment : allExperiments) { - when(mockBucketer.bucket(experiment, testUserId)) - .thenReturn(null); - } - // Force to the first variation for all experiments. However, only a subset of the experiments will actually - // call get forced. - for (Experiment experiment : allExperiments) { - optimizely.projectConfig.setForcedVariation(experiment.getKey(), - testUserId, experiment.getVariations().get(0).getKey()); - } + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + noAudienceProjectConfig.getEventTypes().get(0); - // call track + Optimizely optimizely = optimizelyBuilder.build(); optimizely.track(eventType.getKey(), testUserId); - - // verify that the bucketing algorithm was called only on experiments corresponding to the specified goal. - List<Experiment> experimentsForEvent = config.getExperimentsForEventKey(eventType.getKey()); - for (Experiment experiment : allExperiments) { - if (experiment.isRunning() && experimentsForEvent.contains(experiment)) { - verify(spyDecisionService).getVariation(experiment, testUserId, - Collections.<String, String>emptyMap()); - verify(config).getForcedVariation(experiment.getKey(), testUserId); - } else { - verify(spyDecisionService, never()).getVariation(experiment, testUserId, - Collections.<String, String>emptyMap()); - } - } - - // verify that dispatchEvent was called - verify(mockEventHandler).dispatchEvent(any(LogEvent.class)); - - for (Experiment experiment : allExperiments) { - assertEquals(optimizely.projectConfig.getForcedVariation(experiment.getKey(), testUserId), experiment.getVariations().get(0)); - optimizely.projectConfig.setForcedVariation(experiment.getKey(), testUserId, null); - assertNull(optimizely.projectConfig.getForcedVariation(experiment.getKey(), testUserId)); - } - + eventHandler.expectConversion(eventType.getKey(), testUserId); } /** @@ -1193,59 +996,13 @@ public void trackEventEndToEndForced() throws Exception { */ @Test public void trackEventEndToEnd() throws Exception { - EventType eventType; - String datafile; - ProjectConfig config; - if (datafileVersion >= 4) { - config = spy(validProjectConfig); - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - datafile = validDatafile; - } - else { - config = spy(noAudienceProjectConfig); - eventType = noAudienceProjectConfig.getEventTypes().get(0); - datafile = noAudienceDatafile; - } - List<Experiment> allExperiments = config.getExperiments(); - - EventFactory eventFactory = new EventFactory(); - DecisionService spyDecisionService = spy(new DecisionService(mockBucketer, - mockErrorHandler, - config, - null)); - - Optimizely optimizely = Optimizely.builder(datafile, mockEventHandler) - .withDecisionService(spyDecisionService) - .withEventBuilder(eventFactory) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - // Bucket to the first variation for all experiments. However, only a subset of the experiments will actually - // call the bucket function. - for (Experiment experiment : allExperiments) { - when(mockBucketer.bucket(experiment, testUserId)) - .thenReturn(experiment.getVariations().get(0)); - } + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + noAudienceProjectConfig.getEventTypes().get(0); - // call track + Optimizely optimizely = optimizelyBuilder.build(); optimizely.track(eventType.getKey(), testUserId); - - // verify that the bucketing algorithm was called only on experiments corresponding to the specified goal. - List<Experiment> experimentsForEvent = config.getExperimentsForEventKey(eventType.getKey()); - for (Experiment experiment : allExperiments) { - if (experiment.isRunning() && experimentsForEvent.contains(experiment)) { - verify(spyDecisionService).getVariation(experiment, testUserId, - Collections.<String, String>emptyMap()); - verify(config).getForcedVariation(experiment.getKey(), testUserId); - } else { - verify(spyDecisionService, never()).getVariation(experiment, testUserId, - Collections.<String, String>emptyMap()); - } - } - - // verify that dispatchEvent was called - verify(mockEventHandler).dispatchEvent(any(LogEvent.class)); + eventHandler.expectConversion(eventType.getKey(), testUserId); } /** @@ -1256,17 +1013,11 @@ public void trackEventEndToEnd() throws Exception { public void trackEventWithUnknownEventKeyAndNoOpErrorHandler() throws Exception { EventType unknownEventType = createUnknownEventType(); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(new NoOpErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder.withErrorHandler(new NoOpErrorHandler()).build(); - logbackVerifier.expectMessage(Level.ERROR, "Event \"unknown_event_type\" is not in the datafile."); + logbackVerifier.expectMessage(Level.WARN, "Event \"unknown_event_type\" is not in the datafile."); logbackVerifier.expectMessage(Level.INFO, "Not tracking event \"unknown_event_type\" for user \"userId\"."); optimizely.track(unknownEventType.getKey(), testUserId); - - // verify that we did NOT dispatch an event - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } /** @@ -1279,10 +1030,7 @@ public void trackEventWithUnknownEventKeyAndRaiseExceptionErrorHandler() throws EventType unknownEventType = createUnknownEventType(); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder.withErrorHandler(new RaiseExceptionErrorHandler()).build(); // since we use a RaiseExceptionErrorHandler, we should throw an error optimizely.track(unknownEventType.getKey(), testUserId); @@ -1295,140 +1043,38 @@ public void trackEventWithUnknownEventKeyAndRaiseExceptionErrorHandler() throws @SuppressWarnings("unchecked") public void trackEventWithAttributes() throws Exception { Attribute attribute = validProjectConfig.getAttributes().get(0); - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); - Map<String, String> attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - attributes); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - anyMapOf(String.class, String.class), - eq(Collections.<String, Object>emptyMap()))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + "\" for user \"" + genericUserId + "\"."); - // call track + Map<String, String> attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); optimizely.track(eventType.getKey(), genericUserId, attributes); - - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - attributeCaptor.capture(), - eq(Collections.<String, Object>emptyMap())); - - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectConversion(eventType.getKey(), genericUserId, attributes); } /** - * Verify that {@link Optimizely#track(String, String)} ignores null attributes. + * Verify that {@link Optimizely#track(String, String)} if passed null attributes than it returns null attributes. */ @Test @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") public void trackEventWithNullAttributes() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, Object>emptyMap()))) - .thenReturn(logEventToDispatch); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); - - // call track - Map<String, String> attributes = null; - optimizely.track(eventType.getKey(), genericUserId, attributes); - - logbackVerifier.expectMessage(Level.WARN, "Attributes is null when non-null was expected. Defaulting to an empty attributes map."); - - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - attributeCaptor.capture(), - eq(Collections.<String, Object>emptyMap())); + "\" for user \"" + genericUserId + "\"."); - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, is(Collections.<String, String>emptyMap())); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.track(eventType.getKey(), genericUserId, null); + eventHandler.expectConversion(eventType.getKey(), genericUserId); } /** @@ -1436,327 +1082,109 @@ public void trackEventWithNullAttributes() throws Exception { */ @Test public void trackEventWithNullAttributeValues() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, Object>emptyMap()))) - .thenReturn(logEventToDispatch); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); - - // call track - Map<String, String> attributes = new HashMap<String, String>(); - attributes.put("test", null); - optimizely.track(eventType.getKey(), genericUserId, attributes); + "\" for user \"" + genericUserId + "\"."); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); + Optimizely optimizely = optimizelyBuilder.build(); - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - attributeCaptor.capture(), - eq(Collections.<String, Object>emptyMap())); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + Map<String, String> attributes = Collections.singletonMap("test", null); + optimizely.track(eventType.getKey(), genericUserId, attributes); + eventHandler.expectConversion(eventType.getKey(), genericUserId); } /** * Verify that {@link Optimizely#track(String, String)} handles the case where an unknown attribute * (i.e., not in the config) is passed through. - * - * In this case, the track event call should remove the unknown attribute from the given map. + * <p> + * In this case, the track event call should not remove the unknown attribute from the given map but should go on and track the event successfully. + * <p> + * TODO: Is this a dupe?? Also not sure the intent of the test since the attributes are stripped by the EventFactory */ @Test @SuppressWarnings("unchecked") public void trackEventWithUnknownAttribute() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - anyMapOf(String.class, String.class), - eq(Collections.<String, Object>emptyMap()))) - .thenReturn(logEventToDispatch); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.WARN, "Attribute(s) [unknownAttribute] not in the datafile."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + "\" for user \"" + genericUserId + "\"."); - // call track + Optimizely optimizely = optimizelyBuilder.build(); optimizely.track(eventType.getKey(), genericUserId, ImmutableMap.of("unknownAttribute", "attributeValue")); - - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - attributeCaptor.capture(), - eq(Collections.<String, Object>emptyMap())); - - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, not(hasKey("unknownAttribute"))); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + eventHandler.expectConversion(eventType.getKey(), genericUserId); } /** * Verify that {@link Optimizely#track(String, String, Map, Map)} passes event features to - * {@link EventFactory#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} + * {@link UserEventFactory#createConversionEvent(ProjectConfig, String, String, String, Map, Map)} */ @Test public void trackEventWithEventTags() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); - Map<String, Object> eventTags = new HashMap<String, Object>(); + Map<String, Object> eventTags = new HashMap<>(); eventTags.put("int_param", 123); eventTags.put("string_param", "123"); eventTags.put("boolean_param", false); eventTags.put("float_param", 12.3f); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - anyMapOf(String.class, String.class), - eq(eventTags))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + "\" for user \"" - + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + + genericUserId + "\"."); // call track - optimizely.track(eventType.getKey(), genericUserId, Collections.<String, String>emptyMap(), eventTags); - - // setup the event map captor (so we can verify its content) - ArgumentCaptor<Map> eventTagCaptor = ArgumentCaptor.forClass(Map.class); - - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eventTagCaptor.capture()); - - Map<String, ?> actualValue = eventTagCaptor.getValue(); - assertThat(actualValue, hasEntry("int_param", eventTags.get("int_param"))); - assertThat(actualValue, hasEntry("string_param", eventTags.get("string_param"))); - assertThat(actualValue, hasEntry("boolean_param", eventTags.get("boolean_param"))); - assertThat(actualValue, hasEntry("float_param", eventTags.get("float_param"))); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + optimizely.track(eventType.getKey(), genericUserId, Collections.emptyMap(), eventTags); + eventHandler.expectConversion(eventType.getKey(), genericUserId, Collections.emptyMap(), eventTags); } /** - * Verify that {@link Optimizely#track(String, String, Map, Map)} called with null event tags will default to - * an empty map when calling {@link EventFactory#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} + * Verify that {@link Optimizely#track(String, String, Map, Map)} called with null event tags will return null eventTag + * when calling {@link UserEventFactory#createConversionEvent(ProjectConfig, String, String, String, Map, Map)} */ @Test @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") public void trackEventWithNullEventTags() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, String>emptyMap()))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); + "\" for user \"" + genericUserId + "\"."); // call track - optimizely.track(eventType.getKey(), genericUserId, Collections.<String, String>emptyMap(), null); - - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, String>emptyMap())); - - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + optimizely.track(eventType.getKey(), genericUserId, Collections.emptyMap(), null); + eventHandler.expectConversion(eventType.getKey(), genericUserId, Collections.emptyMap(), null); } - /** * Verify that {@link Optimizely#track(String, String, Map, Map)} called with null User ID will return and will not track */ @Test @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") public void trackEventWithNullOrEmptyUserID() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } else { - eventType = validProjectConfig.getEventTypes().get(0); - } - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, String>emptyMap()))) - .thenReturn(logEventToDispatch); + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); + + // call track with null userId + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.track(eventType.getKey(), null); - String userID = null; - // call track with null event key - optimizely.track(eventType.getKey(), userID, Collections.<String, String>emptyMap(), Collections.<String, Object>emptyMap()); logbackVerifier.expectMessage(Level.ERROR, "The user ID parameter must be nonnull."); - logbackVerifier.expectMessage(Level.INFO, "Not tracking event \""+eventType.getKey()+"\"."); + logbackVerifier.expectMessage(Level.INFO, "Not tracking event \"" + eventType.getKey() + "\"."); } /** @@ -1764,224 +1192,57 @@ public void trackEventWithNullOrEmptyUserID() throws Exception { */ @Test @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") public void trackEventWithNullOrEmptyEventKey() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } else { - eventType = validProjectConfig.getEventTypes().get(0); - } - String nullEventKey = null; - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, String>emptyMap()))) - .thenReturn(logEventToDispatch); - // call track with null event key - optimizely.track(nullEventKey, genericUserId, Collections.<String, String>emptyMap(), Collections.<String, Object>emptyMap()); - logbackVerifier.expectMessage(Level.ERROR, "Event Key is null or empty when non-null and non-empty String was expected."); - logbackVerifier.expectMessage(Level.INFO, "Not tracking event for user \""+genericUserId+"\"."); - - } - /** - * Verify that {@link Optimizely#track(String, String, Map)} doesn't dispatch an event when no valid experiments - * correspond to an event. - */ - @Test - public void trackEventWithNoValidExperiments() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventNameMapping().get("clicked_purchase"); - } - - when(mockDecisionService.getVariation(any(Experiment.class), any(String.class), anyMapOf(String.class, String.class))) - .thenReturn(null); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withDecisionService(mockDecisionService) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.track(null, genericUserId); - Map<String, String> attributes = new HashMap<String, String>(); - attributes.put("browser_type", "firefox"); - - logbackVerifier.expectMessage(Level.INFO, - "There are no valid experiments for event \"" + eventType.getKey() + "\" to track."); - logbackVerifier.expectMessage(Level.INFO, "Not tracking event \"" + eventType.getKey() + - "\" for user \"userId\"."); - optimizely.track(eventType.getKey(), testUserId, attributes); - - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + logbackVerifier.expectMessage(Level.ERROR, "Event Key is null or empty when non-null and non-empty String was expected."); + logbackVerifier.expectMessage(Level.INFO, "Not tracking event for user \"" + genericUserId + "\"."); } /** - * Verify that {@link Optimizely#track(String, String)} handles exceptions thrown by - * {@link EventHandler#dispatchEvent(LogEvent)} gracefully. + * Verify that {@link Optimizely#track(String, String, Map)} dispatches an event always and logs appropriate message */ @Test - public void trackDispatchEventThrowsException() throws Exception { - EventType eventType = noAudienceProjectConfig.getEventTypes().get(0); + public void trackEventWithNoValidExperiments() throws Exception { + EventType eventType = datafileVersion >= 4 ? + validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY) : + validProjectConfig.getEventTypes().get(0); - doThrow(new Exception("Test Exception")).when(mockEventHandler).dispatchEvent(any(LogEvent.class)); + Attribute attribute = validProjectConfig.getAttributes().get(0); + Map<String, String> attributes = Collections.singletonMap(attribute.getKey(), "value"); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .build(); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.ERROR, "Unexpected exception in event dispatcher"); - optimizely.track(eventType.getKey(), testUserId); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.track(eventType.getKey(), genericUserId, attributes); + eventHandler.expectConversion(eventType.getKey(), genericUserId, attributes); } /** * Verify that {@link Optimizely#track(String, String, Map)} - * doesn't dispatch events when the event links only to launched experiments + * dispatches events even if the event links only to launched experiments */ @Test public void trackDoesNotSendEventWhenExperimentsAreLaunchedOnly() throws Exception { - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY); - } - else { - eventType = noAudienceProjectConfig.getEventNameMapping().get("launched_exp_event"); - } - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - for (Experiment experiment : noAudienceProjectConfig.getExperiments()) { - Variation variation = experiment.getVariations().get(0); - when(mockBucketAlgorithm.bucket( - eq(experiment), - eq(genericUserId))) - .thenReturn(variation); - } + EventType eventType = datafileVersion >= 4 ? + noAudienceProjectConfig.getEventNameMapping().get(EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY) : + noAudienceProjectConfig.getEventNameMapping().get("launched_exp_event"); - Optimizely client = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withBucketing(mockBucketAlgorithm) - .build(); - - List<Experiment> eventExperiments = noAudienceProjectConfig.getExperimentsForEventKey(eventType.getKey()); - for (Experiment experiment : eventExperiments) { - logbackVerifier.expectMessage( - Level.INFO, - "Not tracking event \"" + eventType.getKey() + "\" for experiment \"" + experiment.getKey() + - "\" because experiment has status \"Launched\"." - ); - } - - logbackVerifier.expectMessage( - Level.INFO, - "There are no valid experiments for event \"" + eventType.getKey() + "\" to track." - ); - logbackVerifier.expectMessage( - Level.INFO, - "Not tracking event \"" + eventType.getKey() + "\" for user \"" + genericUserId + "\"." - ); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); - // only 1 experiment uses the event and it has a "Launched" status so experimentsForEvent map is empty - // and the returned event will be null - // this means we will never call the dispatcher - client.track(eventType.getKey(), genericUserId, Collections.<String, String>emptyMap()); - // bucket should never be called since experiments are launched so we never get variation for them - verify(mockBucketAlgorithm, never()).bucket( - any(Experiment.class), - anyString()); - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + Optimizely optimizely = optimizelyBuilder.withConfig(noAudienceProjectConfig).build(); + optimizely.track(eventType.getKey(), genericUserId); + eventHandler.expectConversion(eventType.getKey(), genericUserId); } /** - * Verify that {@link Optimizely#track(String, String, Map)} - * dispatches log events when the tracked event links to both launched and running experiments. - */ - @Test - public void trackDispatchesWhenEventHasLaunchedAndRunningExperiments() throws Exception { - EventFactory mockEventFactory = mock(EventFactory.class); - EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = noAudienceProjectConfig.getEventNameMapping().get("event_with_launched_and_running_experiments"); - } - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - for (Experiment experiment : validProjectConfig.getExperiments()) { - when(mockBucketAlgorithm.bucket(experiment, genericUserId)) - .thenReturn(experiment.getVariations().get(0)); - } - - Optimizely client = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withBucketing(mockBucketAlgorithm) - .withEventBuilder(mockEventFactory) - .build(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - noAudienceProjectConfig, - client.decisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - // Create an Argument Captor to ensure we are creating a correct experiment variation map - ArgumentCaptor<Map> experimentVariationMapCaptor = ArgumentCaptor.forClass(Map.class); - - when(mockEventFactory.createConversionEvent( - eq(noAudienceProjectConfig), - experimentVariationMapCaptor.capture(), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, Object>emptyMap()) - )).thenReturn(logEventToDispatch); - - List<Experiment> eventExperiments = noAudienceProjectConfig.getExperimentsForEventKey(eventType.getKey()); - for (Experiment experiment : eventExperiments) { - if (experiment.isLaunched()) { - logbackVerifier.expectMessage( - Level.INFO, - "Not tracking event \"" + eventType.getKey() + "\" for experiment \"" + experiment.getKey() + - "\" because experiment has status \"Launched\"." - ); - } - } - - // The event has 1 launched experiment and 1 running experiment. - // It should send a track event with the running experiment - client.track(eventType.getKey(), genericUserId, Collections.<String, String>emptyMap()); - verify(client.eventHandler).dispatchEvent(eq(logEventToDispatch)); - - // Check the argument captor got the correct arguments - Map<Experiment, Variation> actualExperimentVariationMap = experimentVariationMapCaptor.getValue(); - assertEquals(experimentVariationMap, actualExperimentVariationMap); - } - - /** - * Verify that an event is not dispatched if a user doesn't satisfy audience conditions for an experiment. + * Verify that an event is dispatched even if a user doesn't satisfy audience conditions for an experiment. */ @Test public void trackDoesNotSendEventWhenUserDoesNotSatisfyAudiences() throws Exception { @@ -1991,14 +1252,26 @@ public void trackDoesNotSendEventWhenUserDoesNotSatisfyAudiences() throws Except // the audience for the experiments is "NOT firefox" so this user shouldn't satisfy audience conditions Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "firefox"); - Optimizely client = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.INFO, "There are no valid experiments for event \"" + eventType.getKey() - + "\" to track."); + Optimizely optimizely = optimizelyBuilder.build(); + optimizely.track(eventType.getKey(), genericUserId, attributeMap); + eventHandler.expectConversion(eventType.getKey(), genericUserId, attributeMap); + } - client.track(eventType.getKey(), genericUserId, attributeMap); + /** + * Verify that we don't attempt to track any events if the Optimizely instance is not valid + */ + @Test + public void trackWithInvalidDatafile() throws Exception { + Optimizely optimizely = Optimizely.builder(invalidProjectConfigV5(), mockEventHandler) + .withBucketing(mockBucketer) + .build(); + optimizely.track("event_with_launched_and_running_experiments", genericUserId); + + // make sure we didn't even attempt to bucket the user or fire any conversion events + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } @@ -2006,30 +1279,26 @@ public void trackDoesNotSendEventWhenUserDoesNotSatisfyAudiences() throws Except /** * Verify that {@link Optimizely#getVariation(Experiment, String)} correctly makes the - * {@link Bucketer#bucket(Experiment, String)} call and does NOT dispatch an event. + * {@link Bucketer#bucket(Experiment, String, ProjectConfig)} call and does NOT dispatch an event. */ @Test public void getVariation() throws Exception { Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); Variation bucketedVariation = activatedExperiment.getVariations().get(0); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); - when(mockBucketer.bucket(activatedExperiment, testUserId)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); - Map<String, String> testUserAttributes = new HashMap<String, String>(); + Map<String, String> testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId, - testUserAttributes); + testUserAttributes); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(validProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -2038,26 +1307,25 @@ public void getVariation() throws Exception { /** * Verify that {@link Optimizely#getVariation(String, String)} correctly makes the - * {@link Bucketer#bucket(Experiment, String)} call and does NOT dispatch an event. + * {@link Bucketer#bucket(Experiment, String, ProjectConfig)} call and does NOT dispatch an event. */ @Test public void getVariationWithExperimentKey() throws Exception { Experiment activatedExperiment = noAudienceProjectConfig.getExperiments().get(0); Variation bucketedVariation = activatedExperiment.getVariations().get(0); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder + .withBucketing(mockBucketer) + .withConfig(noAudienceProjectConfig) + .build(); - when(mockBucketer.bucket(activatedExperiment, testUserId)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); // activate the experiment Variation actualVariation = optimizely.getVariation(activatedExperiment.getKey(), testUserId); // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId); + verify(mockBucketer).bucket(eq(activatedExperiment), eq(testUserId), eq(noAudienceProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); // verify that we didn't attempt to dispatch an event @@ -2071,11 +1339,7 @@ public void getVariationWithExperimentKey() throws Exception { @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test public void getVariationWithNullExperimentKey() throws Exception { - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.withConfig(noAudienceProjectConfig).build(); String nullExperimentKey = null; // activate the experiment @@ -2094,11 +1358,11 @@ public void getVariationWithNullExperimentKey() throws Exception { public void getVariationWithUnknownExperimentKeyAndNoOpErrorHandler() throws Exception { Experiment unknownExperiment = createUnknownExperiment(); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withErrorHandler(new NoOpErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder + .withErrorHandler(new NoOpErrorHandler()) + .build(); - logbackVerifier.expectMessage(Level.ERROR, "Experiment \"unknown_experiment\" is not in the datafile"); + logbackVerifier.expectMessage(Level.WARN, "Experiment \"unknown_experiment\" is not in the datafile."); // since we use a NoOpErrorHandler, we should fail and return null Variation actualVariation = optimizely.getVariation(unknownExperiment.getKey(), testUserId); @@ -2116,20 +1380,16 @@ public void getVariationWithAudiences() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(experiment, testUserId)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); - Map<String, String> testUserAttributes = new HashMap<String, String>(); + Map<String, String> testUserAttributes = new HashMap<>(); testUserAttributes.put("browser_type", "chrome"); Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId, testUserAttributes); - verify(mockBucketer).bucket(experiment, testUserId); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(validProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); } @@ -2142,20 +1402,23 @@ public void getVariationWithAudiencesNoAttributes() throws Exception { Experiment experiment; if (datafileVersion >= 4) { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - } - else { + } else { experiment = validProjectConfig.getExperiments().get(0); } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withErrorHandler(mockErrorHandler) - .build(); - - logbackVerifier.expectMessage(Level.INFO, - "User \"userId\" does not meet conditions to be in experiment \"" + experiment.getKey() + "\"."); + Optimizely optimizely = optimizelyBuilder.build(); Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - assertNull(actualVariation); + /** + * This test now passes because the audience is evaludated even if there is no + * attributes passed in. In version 2,3 of the datafile, the audience is a not condition + * which evaluates to true if it is absent. + */ + if (datafileVersion >= 4) { + assertNull(actualVariation); + } else { + assertNotNull(actualVariation); + } } /** @@ -2167,17 +1430,16 @@ public void getVariationNoAudiences() throws Exception { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); Variation bucketedVariation = experiment.getVariations().get(0); - when(mockBucketer.bucket(experiment, testUserId)).thenReturn(bucketedVariation); + when(mockBucketer.bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(bucketedVariation)); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withBucketing(mockBucketer) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder + .withConfig(noAudienceProjectConfig) + .withBucketing(mockBucketer) + .build(); Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId); - verify(mockBucketer).bucket(experiment, testUserId); + verify(mockBucketer).bucket(eq(experiment), eq(testUserId), eq(noAudienceProjectConfig)); assertThat(actualVariation, is(bucketedVariation)); } @@ -2191,30 +1453,29 @@ public void getVariationWithUnknownExperimentKeyAndRaiseExceptionErrorHandler() Experiment unknownExperiment = createUnknownExperiment(); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder + .withConfig(noAudienceProjectConfig) + .withErrorHandler(new RaiseExceptionErrorHandler()) + .build(); // since we use a RaiseExceptionErrorHandler, we should throw an error optimizely.getVariation(unknownExperiment.getKey(), testUserId); } /** - * Verify that {@link Optimizely#getVariation(String, String)} doesn't return a variation when provided an + * Verify that {@link Optimizely#getVariation(String, String)} return a variation when provided an * empty string. */ @Test public void getVariationWithEmptyUserId() throws Exception { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(new RaiseExceptionErrorHandler()) - .build(); + Optimizely optimizely = optimizelyBuilder + .withConfig(noAudienceProjectConfig) + .withErrorHandler(new RaiseExceptionErrorHandler()) + .build(); - logbackVerifier.expectMessage(Level.ERROR, "Non-empty user ID required"); - assertNull(optimizely.getVariation(experiment.getKey(), "")); + assertNotNull(optimizely.getVariation(experiment.getKey(), "")); } /** @@ -2224,28 +1485,24 @@ public void getVariationWithEmptyUserId() throws Exception { @Test public void getVariationForGroupExperimentWithMatchingAttributes() throws Exception { Experiment experiment = validProjectConfig.getGroups() - .get(0) - .getExperiments() - .get(0); + .get(0) + .getExperiments() + .get(0); Variation variation = experiment.getVariations().get(0); - Map<String, String> attributes = new HashMap<String, String>(); + Map<String, String> attributes = new HashMap<>(); if (datafileVersion >= 4) { attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { + } else { attributes.put("browser_type", "chrome"); } - when(mockBucketer.bucket(experiment, "user")).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq("user"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withBucketing(mockBucketer) - .build(); + Optimizely optimizely = optimizelyBuilder.withBucketing(mockBucketer).build(); assertThat(optimizely.getVariation(experiment.getKey(), "user", attributes), - is(variation)); + is(variation)); } /** @@ -2255,16 +1512,14 @@ public void getVariationForGroupExperimentWithMatchingAttributes() throws Except @Test public void getVariationForGroupExperimentWithNonMatchingAttributes() throws Exception { Experiment experiment = validProjectConfig.getGroups() - .get(0) - .getExperiments() - .get(0); + .get(0) + .getExperiments() + .get(0); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); assertNull(optimizely.getVariation(experiment.getKey(), "user", - Collections.singletonMap("browser_type", "firefox"))); + Collections.singletonMap("browser_type", "firefox"))); } /** @@ -2276,841 +1531,1626 @@ public void getVariationExperimentStatusPrecedesForcedVariation() throws Excepti Experiment experiment; if (datafileVersion >= 4) { experiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_PAUSED_EXPERIMENT_KEY); - } - else { + } else { experiment = validProjectConfig.getExperiments().get(1); } - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); logbackVerifier.expectMessage(Level.INFO, "Experiment \"" + experiment.getKey() + "\" is not running."); // testUser3 has a corresponding forced variation, but experiment status should be checked first assertNull(optimizely.getVariation(experiment.getKey(), "testUser3")); } - //======== Notification listeners ========// - /** - * Verify that the {@link Optimizely#activate(String, String, Map<String, String>)} call - * correctly builds an endpoint url and request params - * and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + * Verify that we don't attempt to track any events if the Optimizely instance is not valid */ @Test - public void activateWithListener() throws Exception { - final Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - final Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); + public void getVariationWithInvalidDatafile() throws Exception { + Optimizely optimizely = Optimizely.builder(invalidProjectConfigV5(), mockEventHandler) + .withBucketing(mockBucketer) + .build(); + Variation variation = optimizely.getVariation("etag1", genericUserId); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + assertNull(variation); - final Map<String, String> testUserAttributes = new HashMap<String, String>(); - if (datafileVersion >= 4) { - testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { - testUserAttributes.put("browser_type", "chrome"); - } + // make sure we didn't even attempt to bucket the user + verify(mockBucketer, never()).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + } - testUserAttributes.put(testBucketingIdKey, testBucketingId); + //======== Notification listeners ========// - ActivateNotificationListener activateNotification = new ActivateNotificationListener() { - @Override - public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { - assertEquals(experiment.getKey(), activatedExperiment.getKey()); - assertEquals(bucketedVariation.getKey(), variation.getKey()); - assertEquals(userId, testUserId); - for (Map.Entry<String, String> entry : attributes.entrySet()) { - assertEquals(testUserAttributes.get(entry.getKey()), entry.getValue()); - } - - assertEquals(event.getRequestMethod(), RequestMethod.GET); - } + boolean isListenerCalled = false; + /** + * Helper method to return decisionListener + **/ + private NotificationHandler<DecisionNotification> getDecisionListener( + final String testType, + final String testUserId, + final Map<String, ?> testUserAttributes, + final Map<String, ?> testDecisionInfo) { + return decisionNotification -> { + assertEquals(decisionNotification.getType(), testType); + assertEquals(decisionNotification.getUserId(), testUserId); + assertEquals(decisionNotification.getAttributes(), testUserAttributes); + for (Map.Entry<String, ?> entry : decisionNotification.getAttributes().entrySet()) { + assertEquals(testUserAttributes.get(entry.getKey()), entry.getValue()); + } + for (Map.Entry<String, ?> entry : decisionNotification.getDecisionInfo().entrySet()) { + assertEquals(testDecisionInfo.get(entry.getKey()), entry.getValue()); + } + isListenerCalled = true; }; + } + + //======Activate Notification TESTS======// - int notificationId = optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, activateNotification); + /** + * Verify that the {@link Optimizely#activate(Experiment, String, Map)} call correctly builds an endpoint url and + * request params and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + @SuppressFBWarnings( + value = "NP_NONNULL_PARAM_VIOLATION") + public void activateEndToEndWithDecisionListener() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + Map<String, String> testUserAttributes = new HashMap<>(); + String userId = "Gred"; - when(mockEventFactory.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), eq(testUserAttributes))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); - when(mockBucketer.bucket(activatedExperiment, testBucketingId)) - .thenReturn(bucketedVariation); + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(EXPERIMENT_KEY, activatedExperiment.getKey()); + testDecisionInfoMap.put(VARIATION_KEY, "Gred"); + int notificationId = optimizely.notificationCenter.<DecisionNotification>getNotificationManager(DecisionNotification.class) + .addHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_TEST.toString(), + userId, + testUserAttributes, + testDecisionInfoMap)); // activate the experiment - Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), userId, null); + assertThat(actualVariation.getKey(), is("Gred")); + + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), userId); + // Verify that listener being called + assertTrue(isListenerCalled); assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testBucketingId); - assertThat(actualVariation, is(bucketedVariation)); - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); } + /** + * Verify that if user is null than listener will not get called. + */ @Test @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") - public void activateWithListenerNullAttributes() throws Exception { - final Experiment activatedExperiment = noAudienceProjectConfig.getExperiments().get(0); - final Variation bucketedVariation = activatedExperiment.getVariations().get(0); - - // setup a mock event builder to return expected impression params - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(noAudienceProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - when(mockEventFactory.createImpressionEvent(eq(noAudienceProjectConfig), eq(activatedExperiment), eq(bucketedVariation), - eq(testUserId), eq(Collections.<String, String>emptyMap()))) - .thenReturn(logEventToDispatch); - - when(mockBucketer.bucket(activatedExperiment, testUserId)) - .thenReturn(bucketedVariation); - - ActivateNotificationListener activateNotification = new ActivateNotificationListener() { - @Override - public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { - assertEquals(experiment.getKey(), activatedExperiment.getKey()); - assertEquals(bucketedVariation.getKey(), variation.getKey()); - assertEquals(userId, testUserId); - assertTrue(attributes.isEmpty()); - - assertEquals(event.getRequestMethod(), RequestMethod.GET); - } - - }; - - int notificationId = optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, activateNotification); + value = "NP_NONNULL_PARAM_VIOLATION") + public void activateUserNullWithListener() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - // activate the experiment - Map<String, String> attributes = null; - Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, attributes); + Optimizely optimizely = optimizelyBuilder.build(); - optimizely.notificationCenter.removeNotificationListener(notificationId); + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(EXPERIMENT_KEY, activatedExperiment.getKey()); + testDecisionInfoMap.put(VARIATION_KEY, null); - logbackVerifier.expectMessage(Level.WARN, "Attributes is null when non-null was expected. Defaulting to an empty attributes map."); + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.AB_TEST.toString(), + null, + Collections.<String, Object>emptyMap(), + testDecisionInfoMap)); - // verify that the bucketing algorithm was called correctly - verify(mockBucketer).bucket(activatedExperiment, testUserId); - assertThat(actualVariation, is(bucketedVariation)); + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), null, Collections.<String, Object>emptyMap()); + assertNull(actualVariation); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockEventFactory).createImpressionEvent(eq(noAudienceProjectConfig), eq(activatedExperiment), - eq(bucketedVariation), eq(testUserId), attributeCaptor.capture()); + // Verify that listener will not get called + assertFalse(isListenerCalled); - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, is(Collections.<String, String>emptyMap())); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); - // verify that dispatchEvent was called with the correct LogEvent object - verify(mockEventHandler).dispatchEvent(logEventToDispatch); } /** - * Verify that {@link com.optimizely.ab.notification.NotificationCenter#addNotificationListener( - * com.optimizely.ab.notification.NotificationCenter.NotificationType, - * com.optimizely.ab.notification.NotificationListener)} properly used - * and the listener is - * added and notified when an experiment is activated. + * Verify that a user not in any of an experiment's audiences isn't assigned to a variation. */ @Test - public void addNotificationListenerFromNotificationCenter() throws Exception { - Experiment activatedExperiment; - EventType eventType; - if (datafileVersion >= 4) { - activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_BASIC_EXPERIMENT_KEY); - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - activatedExperiment = validProjectConfig.getExperiments().get(0); - eventType = validProjectConfig.getEventTypes().get(0); - } - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withDecisionService(mockDecisionService) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - Map<String, String> attributes = Collections.emptyMap(); - - when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, - bucketedVariation, genericUserId, attributes)) - .thenReturn(logEventToDispatch); - - when(mockDecisionService.getVariation( - eq(activatedExperiment), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()))) - .thenReturn(bucketedVariation); + public void activateUserNotInAudienceWithListener() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + Map<String, String> testUserAttributes = new HashMap<>(); - // Add listener - ActivateNotificationListener listener = mock(ActivateNotificationListener.class); - optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, listener); + testUserAttributes.put("invalid", "invalid"); - // Check if listener is notified when experiment is activated - Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); - verify(listener, times(1)) - .onActivate(activatedExperiment, genericUserId, attributes, bucketedVariation, logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); - assertEquals(actualVariation.getKey(), bucketedVariation.getKey()); - // Check if listener is notified after an event is tracked - String eventKey = eventType.getKey(); + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(EXPERIMENT_KEY, activatedExperiment.getKey()); + testDecisionInfoMap.put(VARIATION_KEY, null); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_TEST.toString(), genericUserId, - attributes); - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventKey), - eq(attributes), - anyMapOf(String.class, Object.class))) - .thenReturn(logEventToDispatch); + testUserAttributes, + testDecisionInfoMap)); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), genericUserId, testUserAttributes); + assertNull(actualVariation); - TrackNotificationListener trackNotification = mock(TrackNotificationListener.class); + // Verify that listener being called + assertTrue(isListenerCalled); - optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Track, trackNotification); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); - optimizely.track(eventKey, genericUserId, attributes); - verify(trackNotification, times(1)) - .onTrack(eventKey, genericUserId, attributes, Collections.EMPTY_MAP, logEventToDispatch); } + //======GetEnabledFeatures Notification TESTS======// + /** - * Verify that {@link com.optimizely.ab.notification.NotificationCenter} properly - * calls and the listener is removed and no longer notified when an experiment is activated. + * Verify that the {@link Optimizely#getEnabledFeatures(String, Map)} + * notification listener of getEnabledFeatures is called with multiple FeatureEnabled */ @Test - public void removeNotificationListenerNotificationCenter() throws Exception { - Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + public void getEnabledFeaturesWithListenerMultipleFeatureEnabled() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - Map<String, String> attributes = new HashMap<String, String>(); - attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + isListenerCalled = false; - when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, - bucketedVariation, genericUserId, attributes)) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); - when(mockBucketer.bucket(activatedExperiment, genericUserId)) - .thenReturn(bucketedVariation); + int notificationId = optimizely.addDecisionNotificationHandler( + decisionNotification -> { + isListenerCalled = true; + assertEquals(decisionNotification.getType(), NotificationCenter.DecisionNotificationType.FEATURE.toString()); + }); - when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, genericUserId, - attributes)) - .thenReturn(logEventToDispatch); + List<String> featureFlags = optimizely.getEnabledFeatures(testUserId, Collections.emptyMap()); + assertEquals(2, featureFlags.size()); - // Add and remove listener - ActivateNotificationListener activateNotification = mock(ActivateNotificationListener.class); - int notificationId = optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, activateNotification); - assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression("3794675122", "589640735", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression(null, "", testUserId); + eventHandler.expectImpression("1786133852", "1619235542", testUserId); - TrackNotificationListener trackNotification = mock(TrackNotificationListener.class); - notificationId = optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Track, trackNotification); + // Verify that listener being called + assertTrue(isListenerCalled); assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); - - // Check if listener is notified after an experiment is activated - Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); - verify(activateNotification, never()) - .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); - - // Check if listener is notified after a live variable is accessed - boolean activateExperiment = true; - verify(activateNotification, never()) - .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); - - // Check if listener is notified after an event is tracked - EventType eventType = validProjectConfig.getEventTypes().get(0); - String eventKey = eventType.getKey(); - - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - attributes); - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventKey), - eq(attributes), - anyMapOf(String.class, Object.class))) - .thenReturn(logEventToDispatch); - - optimizely.track(eventKey, genericUserId, attributes); - verify(trackNotification, never()) - .onTrack(eventKey, genericUserId, attributes, Collections.EMPTY_MAP, logEventToDispatch); } /** - * Verify that {@link com.optimizely.ab.notification.NotificationCenter} - * clearAllListerners removes all listeners - * and no longer notified when an experiment is activated. + * Verify {@link Optimizely#getEnabledFeatures(String, Map)} calls into + * {@link DecisionService#getVariationForFeature} for each featureFlag sending + * userId and emptyMap and Mocked {@link Optimizely#isFeatureEnabled(String, String, Map)} + * to return feature disabled so {@link Optimizely#getEnabledFeatures(String, Map)} will + * return empty List of FeatureFlags and no notification listener will get called. */ @Test - public void clearNotificationListenersNotificationCenter() throws Exception { - Experiment activatedExperiment; - Map<String, String> attributes = new HashMap<String, String>(); - if (datafileVersion >= 4) { - activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - } - else { - activatedExperiment = validProjectConfig.getExperiments().get(0); - attributes.put("browser_type", "chrome"); - } - Variation bucketedVariation = activatedExperiment.getVariations().get(0); - EventFactory mockEventFactory = mock(EventFactory.class); - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); - - when(mockEventFactory.createImpressionEvent(validProjectConfig, activatedExperiment, - bucketedVariation, genericUserId, attributes)) - .thenReturn(logEventToDispatch); - - when(mockBucketer.bucket(activatedExperiment, genericUserId)) - .thenReturn(bucketedVariation); - - // set up argument captor for the attributes map to compare map equality - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); - - when(mockEventFactory.createImpressionEvent( - eq(validProjectConfig), - eq(activatedExperiment), - eq(bucketedVariation), - eq(genericUserId), - attributeCaptor.capture() - )).thenReturn(logEventToDispatch); - - ActivateNotificationListener activateNotification = mock(ActivateNotificationListener.class); - TrackNotificationListener trackNotification = mock(TrackNotificationListener.class); - - optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, activateNotification); - optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Track, trackNotification); - - optimizely.notificationCenter.clearAllNotificationListeners(); + public void getEnabledFeaturesWithNoFeatureEnabled() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - // Check if listener is notified after an experiment is activated - Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); + isListenerCalled = false; + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); - // check that the argument that was captured by the mockEventBuilder attribute captor, - // was equal to the attributes passed in to activate - assertEquals(attributes, attributeCaptor.getValue()); - verify(activateNotification, never()) - .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); + FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) + ); + int notificationId = optimizely.addDecisionNotificationHandler(decisionNotification -> { + }); - // Check if listener is notified after a live variable is accessed - boolean activateExperiment = true; - verify(activateNotification, never()) - .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); + List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); + assertTrue(featureFlags.isEmpty()); - // Check if listener is notified after a event is tracked - EventType eventType = validProjectConfig.getEventTypes().get(0); - String eventKey = eventType.getKey(); + // Verify that listener not being called + assertFalse(isListenerCalled); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - OptimizelyTest.genericUserId, - attributes); - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(OptimizelyTest.genericUserId), - eq(eventType.getId()), - eq(eventKey), - eq(attributes), - anyMapOf(String.class, Object.class))) - .thenReturn(logEventToDispatch); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); - optimizely.track(eventKey, genericUserId, attributes); - verify(trackNotification, never()) - .onTrack(eventKey, genericUserId, attributes, Collections.EMPTY_MAP, logEventToDispatch); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } + //======IsFeatureEnabled Notification TESTS======// + /** - * Add notificaiton listener for track {@link com.optimizely.ab.notification.NotificationCenter}. Verify called and - * remove. - * @throws Exception + * Verify that the {@link Optimizely#isFeatureEnabled(String, String, Map<String, String>)} + * notification listener of isFeatureEnabled is called when feature is in experiment and feature is true */ @Test - @SuppressWarnings("unchecked") - public void trackEventWithListenerAttributes() throws Exception { - final Attribute attribute = validProjectConfig.getAttributes().get(0); - final EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } - - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); + public void isFeatureEnabledWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; - final Map<String, String> attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - attributes); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - anyMapOf(String.class, String.class), - eq(Collections.<String, Object>emptyMap()))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.build(); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); - - TrackNotificationListener trackNotification = new TrackNotificationListener() { - @Override - public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, String> _attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { - assertEquals(eventType.getKey(), eventKey); - assertEquals(genericUserId, userId); - assertEquals(attributes, _attributes); - assertTrue(eventTags.isEmpty()); - } - }; + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - int notificationId = optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Track, trackNotification); + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(VARIATION_KEY, "George"); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); - // call track - optimizely.track(eventType.getKey(), genericUserId, attributes); + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); - optimizely.notificationCenter.removeNotificationListener(notificationId); + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); + assertTrue(optimizely.isFeatureEnabled( + validFeatureKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE) + )); - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - attributeCaptor.capture(), - eq(Collections.<String, Object>emptyMap())); + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + eventHandler.expectImpression(activatedExperiment.getId(), "2099211198", genericUserId, testUserAttributes); - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? true" + ); - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } /** - * Track with listener and verify that {@link Optimizely#track(String, String)} ignores null attributes. + * Verify that the {@link Optimizely#isFeatureEnabled(String, String, Map<String, String>)} + * notification listener of isFeatureEnabled is called when feature is in experiment and feature is false */ @Test - @SuppressFBWarnings( - value="NP_NONNULL_PARAM_VIOLATION", - justification="testing nullness contract violation") - public void trackEventWithListenerNullAttributes() throws Exception { - final EventType eventType; - if (datafileVersion >= 4) { - eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); - } - else { - eventType = validProjectConfig.getEventTypes().get(0); - } + public void isFeatureEnabledWithListenerUserInExperimentFeatureOff() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - // setup a mock event builder to return expected conversion params - EventFactory mockEventFactory = mock(EventFactory.class); + isListenerCalled = false; - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withEventBuilder(mockEventFactory) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - mockDecisionService, - eventType.getKey(), - genericUserId, - Collections.<String, String>emptyMap()); - - when(mockEventFactory.createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - eq(Collections.<String, String>emptyMap()), - eq(Collections.<String, Object>emptyMap()))) - .thenReturn(logEventToDispatch); + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); - logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + - "\" for user \"" + genericUserId + "\"."); - logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + - testParams + " and payload \"\""); - - TrackNotificationListener trackNotification = new TrackNotificationListener() { - @Override - public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { - assertEquals(eventType.getKey(), eventKey); - assertEquals(genericUserId, userId); - assertTrue(attributes.isEmpty()); - assertTrue(eventTags.isEmpty()); - } - }; + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - int notificationId = optimizely.notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Track, trackNotification); + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(VARIATION_KEY, "variation_toggled_off"); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); - // call track - Map<String, String> attributes = null; - optimizely.track(eventType.getKey(), genericUserId, attributes); + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); - optimizely.notificationCenter.removeNotificationListener(notificationId); + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); - logbackVerifier.expectMessage(Level.WARN, "Attributes is null when non-null was expected. Defaulting to an empty attributes map."); + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + Variation variation = new Variation("2", "variation_toggled_off", false, null); - // setup the attribute map captor (so we can verify its content) - ArgumentCaptor<Map> attributeCaptor = ArgumentCaptor.forClass(Map.class); + FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) + ); - // verify that the event builder was called with the expected attributes - verify(mockEventFactory).createConversionEvent( - eq(validProjectConfig), - eq(experimentVariationMap), - eq(genericUserId), - eq(eventType.getId()), - eq(eventType.getKey()), - attributeCaptor.capture(), - eq(Collections.<String, Object>emptyMap())); + assertFalse(optimizely.isFeatureEnabled(validFeatureKey, genericUserId, testUserAttributes)); + eventHandler.expectImpression(activatedExperiment.getId(), variation.getId(), genericUserId, testUserAttributes); - Map<String, String> actualValue = attributeCaptor.getValue(); - assertThat(actualValue, is(Collections.<String, String>emptyMap())); + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? false" + ); - verify(mockEventHandler).dispatchEvent(logEventToDispatch); + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } - //======== Feature Accessor Tests ========// - /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} - * returns null and logs a message - * when it is called with a feature key that has no corresponding feature in the datafile. - * @throws ConfigParseException + * Verify that the {@link Optimizely#isFeatureEnabled(String, String, Map<String, String>)} + * notification listener of isFeatureEnabled is called when feature is not in experiment and not in rollout + * and it dispatch event + * returns false */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableValueForTypeReturnsNullWhenFeatureNotFound() throws ConfigParseException { + public void isFeatureEnabledWithListenerUserNotInExperimentAndNotInRollOut() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - String invalidFeatureKey = "nonexistent feature key"; - String invalidVariableKey = "nonexistent variable key"; - Map<String, String> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + isListenerCalled = false; + final String validFeatureKey = "boolean_feature"; - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); + final Map<String, String> testUserAttributes = new HashMap<>(); - String value = optimizely.getFeatureVariableValueForType( - invalidFeatureKey, - invalidVariableKey, + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.ROLLOUT.toString()); + testDecisionInfoMap.put(SOURCE_INFO, new HashMap<>()); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE.toString(), genericUserId, - Collections.<String, String>emptyMap(), - LiveVariable.VariableType.STRING); - assertNull(value); + testUserAttributes, + testDecisionInfoMap)); - value = optimizely.getFeatureVariableString(invalidFeatureKey, invalidVariableKey, genericUserId, attributes); - assertNull(value); + assertFalse(optimizely.isFeatureEnabled(validFeatureKey, genericUserId, null)); - logbackVerifier.expectMessage(Level.INFO, - "No feature flag was found for key \"" + invalidFeatureKey + "\".", - times(2)); + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? false" + ); + eventHandler.expectImpression(null, "", genericUserId); - verify(mockDecisionService, never()).getVariation( - any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class)); + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} - * returns null and logs a message - * when the feature key is valid, but no variable could be found for the variable key in the feature. - * @throws ConfigParseException + * Verify that the {@link Optimizely#isFeatureEnabled(String, String, Map<String, String>)} + * notification listener of isFeatureEnabled is called when feature is in rollout and featureEnabled is true */ @Test - public void getFeatureVariableValueForTypeReturnsNullWhenVariableNotFoundInValidFeature() throws ConfigParseException { + public void isFeatureEnabledWithListenerUserInRollOut() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + isListenerCalled = false; + final String validFeatureKey = "integer_single_variable_feature"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + testUserAttributes.put(testBucketingIdKey, testBucketingId); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(VARIATION_KEY, null); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.ROLLOUT.toString()); + testDecisionInfoMap.put(SOURCE_INFO, new HashMap<>()); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); + + assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId, testUserAttributes)); + + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? true" + ); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + + eventHandler.expectImpression("3794675122", "589640735", genericUserId, Collections.singletonMap("house", "Gryffindor")); + + } + + //======GetFeatureVariable Notification TESTS======// + + /** + * Verify that the {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in experiment and feature is true + */ + @Test + public void getFeatureVariableWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + String expectedValue = "F"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Fred"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.STRING_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, expectedValue); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + testUserId, + testUserAttributes, + testDecisionInfoMap)); + + assertEquals(optimizely.getFeatureVariableString( + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + expectedValue); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in experiment and feature enabled is false + * than default value will get returned and passing null attribute will send empty map instead of null + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getFeatureVariableWithListenerUserInExperimentFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + String expectedValue = "H"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Gred"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.STRING_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, expectedValue); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + userID, + testUserAttributes, + testDecisionInfoMap)); + + assertEquals(optimizely.getFeatureVariableString( + validFeatureKey, + validVariableKey, + userID, + null), + expectedValue); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in rollout and feature enabled is true + */ + @Test + public void getFeatureVariableWithListenerUserInRollOutFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + isListenerCalled = false; + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_STRING_KEY; + String validVariableKey = VARIABLE_STRING_VARIABLE_KEY; + String expectedValue = "lumos"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + + testDecisionInfoMap.put(EXPERIMENT_KEY, null); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.STRING_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, expectedValue); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.ROLLOUT.toString()); + testDecisionInfoMap.put(SOURCE_INFO, Collections.EMPTY_MAP); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); + + assertEquals(optimizely.getFeatureVariableString( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + expectedValue); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map)} + * notification listener of getFeatureVariableBoolean is called when feature is not in rollout and feature enabled is false + */ + @Test + public void getFeatureVariableWithListenerUserNotInRollOutFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + isListenerCalled = false; + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY; + String validVariableKey = VARIABLE_BOOLEAN_VARIABLE_KEY; + Boolean expectedValue = true; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + + testDecisionInfoMap.put(EXPERIMENT_KEY, null); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.BOOLEAN_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, expectedValue); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.ROLLOUT.toString()); + testDecisionInfoMap.put(SOURCE_INFO, Collections.EMPTY_MAP); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); + + assertEquals(optimizely.getFeatureVariableBoolean( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + expectedValue); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * notification listener of getFeatureVariableInteger is called when feature is in rollout and feature enabled is true + */ + @Test + public void getFeatureVariableIntegerWithListenerUserInRollOutFeatureOn() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + isListenerCalled = false; + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_INTEGER_KEY; + String validVariableKey = VARIABLE_INTEGER_VARIABLE_KEY; + int expectedValue = 7; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(EXPERIMENT_KEY, null); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.INTEGER_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, expectedValue); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.ROLLOUT.toString()); + testDecisionInfoMap.put(SOURCE_INFO, Collections.EMPTY_MAP); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); + + assertEquals( + expectedValue, + (long) optimizely.getFeatureVariableInteger( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)) + ); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} + * notification listener of getFeatureVariableDouble is called when feature is in experiment and feature enabled is true + */ + @SuppressFBWarnings("CNT_ROUGH_CONSTANT_VALUE") + @Test + public void getFeatureVariableDoubleWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + isListenerCalled = false; + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_DOUBLE_KEY; + String validVariableKey = VARIABLE_DOUBLE_VARIABLE_KEY; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "double_single_variable_feature_experiment"); + testSourceInfo.put(VARIATION_KEY, "pi_variation"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.DOUBLE_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, 3.14); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + genericUserId, + testUserAttributes, + testDecisionInfoMap)); + + assertEquals(optimizely.getFeatureVariableDouble( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE)), + Math.PI, 2); + + // Verify that listener being called + assertTrue(isListenerCalled); + + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in experiment and feature is true + */ + @Test + public void getFeatureVariableJSONWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Fred"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.JSON_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + testUserId, + testUserAttributes, + testDecisionInfoMap)); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * notification listener of getFeatureVariableString is called when feature is in experiment and feature enabled is false + * than default value will get returned and passing null attribute will send empty map instead of null + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getFeatureVariableJSONWithListenerUserInExperimentFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Gred"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(VARIABLE_KEY, validVariableKey); + testDecisionInfoMap.put(VARIABLE_TYPE, FeatureVariable.JSON_TYPE); + testDecisionInfoMap.put(VARIABLE_VALUE, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), + userID, + testUserAttributes, + testDecisionInfoMap)); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + userID, + null); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * notification listener of getAllFeatureVariables is called when feature is in experiment and feature is true + */ + @Test + public void getAllFeatureVariablesWithListenerUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"F\",\"rest_of_name\":\"red\",\"json_patched\":{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Fred"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, true); + testDecisionInfoMap.put(VARIABLE_VALUES, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.ALL_FEATURE_VARIABLES.toString(), + testUserId, + testUserAttributes, + testDecisionInfoMap)); + + String jsonString = optimizely.getAllFeatureVariables( + validFeatureKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)).toString(); + assertTrue(compareJsonStrings(jsonString, expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * notification listener of getAllFeatureVariables is called when feature is in experiment and feature enabled is false + * than default value will get returned and passing null attribute will send empty map instead of null + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getAllFeatureVariablesWithListenerUserInExperimentFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + isListenerCalled = false; + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"H\",\"rest_of_name\":\"arry\",\"json_patched\":{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}}"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> testUserAttributes = new HashMap<>(); + + final Map<String, String> testSourceInfo = new HashMap<>(); + testSourceInfo.put(EXPERIMENT_KEY, "multivariate_experiment"); + testSourceInfo.put(VARIATION_KEY, "Gred"); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FEATURE_KEY, validFeatureKey); + testDecisionInfoMap.put(FEATURE_ENABLED, false); + testDecisionInfoMap.put(VARIABLE_VALUES, parseJsonString(expectedString)); + testDecisionInfoMap.put(SOURCE, FeatureDecision.DecisionSource.FEATURE_TEST.toString()); + testDecisionInfoMap.put(SOURCE_INFO, testSourceInfo); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.ALL_FEATURE_VARIABLES.toString(), + userID, + testUserAttributes, + testDecisionInfoMap)); + + String jsonString = optimizely.getAllFeatureVariables( + validFeatureKey, + userID, + null).toString(); + assertTrue(compareJsonStrings(jsonString, expectedString)); + + // Verify that listener being called + assertTrue(isListenerCalled); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + /** + * Verify that the {@link Optimizely#activate(String, String, Map<String, String>)} call + * correctly builds an endpoint url and request params + * and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateWithListener() throws Exception { + final Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + final Variation bucketedVariation = activatedExperiment.getVariations().get(1); + + final Map<String, String> testUserAttributes = new HashMap<>(); + if (datafileVersion >= 4) { + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } else { + testUserAttributes.put("browser_type", "chrome"); + } + + NotificationHandler<ActivateNotification> activateNotification = message -> { + assertEquals(activatedExperiment.getKey(), message.getExperiment().getKey()); + assertEquals(bucketedVariation.getKey(), message.getVariation().getKey()); + assertEquals(testUserId, message.getUserId()); + assertEquals(testUserAttributes, message.getAttributes()); + + assertEquals(RequestMethod.POST, message.getEvent().getRequestMethod()); + }; + + Optimizely optimizely = optimizelyBuilder.build(); + int notificationId = optimizely.addNotificationHandler(ActivateNotification.class, activateNotification); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + assertThat(actualVariation, is(bucketedVariation)); + + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, testUserAttributes); + + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + + @Test + @SuppressFBWarnings( + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") + public void activateWithListenerNullAttributes() throws Exception { + final Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + final Variation bucketedVariation = activatedExperiment.getVariations().get(1); + + NotificationHandler<ActivateNotification> activateNotification = message -> { + assertEquals(activatedExperiment.getKey(), message.getExperiment().getKey()); + assertEquals(bucketedVariation.getKey(), message.getVariation().getKey()); + assertEquals(testUserId, message.getUserId()); + assertNull(message.getAttributes()); + + assertEquals(RequestMethod.POST, message.getEvent().getRequestMethod()); + }; + + Optimizely optimizely = optimizelyBuilder.build(); + int notificationId = optimizely.addNotificationHandler(ActivateNotification.class, activateNotification); + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, null); + assertThat(actualVariation, is(bucketedVariation)); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId); + + optimizely.notificationCenter.removeNotificationListener(notificationId); + } + + /** + * Verify that {@link com.optimizely.ab.notification.NotificationCenter#addNotificationListener( + *com.optimizely.ab.notification.NotificationCenter.NotificationType, + * com.optimizely.ab.notification.NotificationListener)} properly used + * and the listener is + * added and notified when an experiment is activated. + * <p> + * Feels redundant with the above tests + */ + @SuppressWarnings("unchecked") + @Test + public void addNotificationListenerFromNotificationCenter() throws Exception { + Experiment activatedExperiment; + EventType eventType; + if (datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_BASIC_EXPERIMENT_KEY); + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + eventType = validProjectConfig.getEventTypes().get(0); + } + Variation bucketedVariation = activatedExperiment.getVariations().get(1); + Map<String, String> attributes = Collections.emptyMap(); + + Optimizely optimizely = optimizelyBuilder.build(); + + // Add listener + ActivateNotificationListener listener = mock(ActivateNotificationListener.class); + optimizely.addNotificationHandler(ActivateNotification.class, listener); + + // Check if listener is notified when experiment is activated + Variation actualVariation = optimizely.activate(activatedExperiment, testUserId, attributes); + verify(listener, times(1)) + .onActivate(eq(activatedExperiment), eq(testUserId), eq(attributes), eq(bucketedVariation), any(LogEvent.class)); + + assertEquals(actualVariation.getKey(), bucketedVariation.getKey()); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, attributes); + + // Check if listener is notified after an event is tracked + String eventKey = eventType.getKey(); + + NotificationHandler<TrackNotification> trackNotification = mock(NotificationHandler.class); + optimizely.addTrackNotificationHandler(trackNotification); + + optimizely.track(eventKey, testUserId, attributes); + verify(trackNotification, times(1)).handle(any(TrackNotification.class)); + eventHandler.expectConversion(eventType.getKey(), testUserId); + } + + /** + * Verify that {@link com.optimizely.ab.notification.NotificationCenter} properly + * calls and the listener is removed and no longer notified when an experiment is activated. + * <p> + * TODO move this to NotificationCenter. + */ + @SuppressWarnings("unchecked") + @Test + public void removeNotificationListenerNotificationCenter() throws Exception { + Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + + Optimizely optimizely = optimizelyBuilder.build(); + + Map<String, String> attributes = new HashMap<>(); + if (datafileVersion >= 4) { + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } else { + attributes.put("browser_type", "chrome"); + } + + // Add and remove listener + ActivateNotificationListener activateNotification = mock(ActivateNotificationListener.class); + int notificationId = optimizely.addNotificationHandler(ActivateNotification.class, activateNotification); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + + NotificationHandler<TrackNotification> trackNotification = mock(NotificationHandler.class); + notificationId = optimizely.addTrackNotificationHandler(trackNotification); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + + // Check if listener is notified after an experiment is activated + Variation actualVariation = optimizely.activate(activatedExperiment, testUserId, attributes); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, attributes); + + verify(activateNotification, never()) + .onActivate(eq(activatedExperiment), eq(testUserId), eq(attributes), eq(actualVariation), any(LogEvent.class)); + + // Check if listener is notified after an event is tracked + EventType eventType = validProjectConfig.getEventTypes().get(0); + String eventKey = eventType.getKey(); + + optimizely.track(eventKey, testUserId, attributes); + eventHandler.expectConversion(eventKey, testUserId, attributes); + + verify(trackNotification, never()).handle(any(TrackNotification.class)); + } + + /** + * Verify that {@link com.optimizely.ab.notification.NotificationCenter} + * clearAllListerners removes all listeners + * and no longer notified when an experiment is activated. + * <p> + * TODO Should be part of NotificationCenter tests. + */ + @SuppressWarnings("unchecked") + @Test + public void clearNotificationListenersNotificationCenter() throws Exception { + Experiment activatedExperiment; + Map<String, String> attributes = new HashMap<>(); + if (datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + attributes.put("browser_type", "chrome"); + } + + Optimizely optimizely = optimizelyBuilder.build(); + + ActivateNotificationListener activateNotification = mock(ActivateNotificationListener.class); + NotificationHandler<TrackNotification> trackNotification = mock(NotificationHandler.class); + + optimizely.addNotificationHandler(ActivateNotification.class, activateNotification); + optimizely.addTrackNotificationHandler(trackNotification); + + optimizely.notificationCenter.clearAllNotificationListeners(); + + // Check if listener is notified after an experiment is activated + Variation actualVariation = optimizely.activate(activatedExperiment, testUserId, attributes); + eventHandler.expectImpression(activatedExperiment.getId(), actualVariation.getId(), testUserId, attributes); + + // Check if listener is notified after a feature variable is accessed + verify(activateNotification, never()) + .onActivate(eq(activatedExperiment), eq(testUserId), eq(attributes), eq(actualVariation), any(LogEvent.class)); + + // Check if listener is notified after a event is tracked + EventType eventType = validProjectConfig.getEventTypes().get(0); + String eventKey = eventType.getKey(); + + optimizely.track(eventKey, testUserId, attributes); + eventHandler.expectConversion(eventKey, testUserId, attributes); + + verify(trackNotification, never()).handle(any(TrackNotification.class)); + } + + /** + * Add notificaiton listener for track {@link com.optimizely.ab.notification.NotificationCenter}. Verify called and + * remove. + */ + @Test + @SuppressWarnings("unchecked") + public void trackEventWithListenerAttributes() throws Exception { + final Attribute attribute = validProjectConfig.getAttributes().get(0); + final EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } else { + eventType = validProjectConfig.getEventTypes().get(0); + } + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, String> attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); + + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + testUserId + "\"."); + + NotificationHandler<TrackNotification> trackNotification = message -> { + assertEquals(eventType.getKey(), message.getEventKey()); + assertEquals(testUserId, message.getUserId()); + assertEquals(attributes, message.getAttributes()); + assertTrue(message.getEventTags().isEmpty()); + }; + + int notificationId = optimizely.addTrackNotificationHandler(trackNotification); + + // call track + optimizely.track(eventType.getKey(), testUserId, attributes); + eventHandler.expectConversion(eventType.getKey(), testUserId, attributes); + + optimizely.notificationCenter.removeNotificationListener(notificationId); + } + + /** + * Track with listener and verify that {@link Optimizely#track(String, String)} returns null attributes. + * TODO I think these are the same tests, but now with an event handler... :/ perhaps we combine. + */ + @Test + @SuppressFBWarnings( + value = "NP_NONNULL_PARAM_VIOLATION", + justification = "testing nullness contract violation") + public void trackEventWithListenerNullAttributes() throws Exception { + final EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } else { + eventType = validProjectConfig.getEventTypes().get(0); + } + + Optimizely optimizely = optimizelyBuilder.build(); + + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + testUserId + "\"."); + + NotificationHandler<TrackNotification> trackNotification = message -> { + assertEquals(eventType.getKey(), message.getEventKey()); + assertEquals(testUserId, message.getUserId()); + assertNull(message.getAttributes()); + assertTrue(message.getEventTags().isEmpty()); + }; + + int notificationId = optimizely.addTrackNotificationHandler(trackNotification); + + // call track + optimizely.track(eventType.getKey(), testUserId, null); + eventHandler.expectConversion(eventType.getKey(), testUserId); + + optimizely.notificationCenter.removeNotificationListener(notificationId); + } + + //======== Feature Accessor Tests ========// + + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} + * returns null and logs a message + * when it is called with a feature key that has no corresponding feature in the datafile. + */ + @Test + public void getFeatureVariableValueForTypeReturnsNullWhenFeatureNotFound() throws Exception { + String invalidFeatureKey = "nonexistent feature key"; String invalidVariableKey = "nonexistent variable key"; + Map<String, String> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); String value = optimizely.getFeatureVariableValueForType( - validFeatureKey, - invalidVariableKey, - genericUserId, - Collections.<String, String>emptyMap(), - LiveVariable.VariableType.STRING); + invalidFeatureKey, + invalidVariableKey, + genericUserId, + Collections.<String, String>emptyMap(), + FeatureVariable.STRING_TYPE); + assertNull(value); + + value = optimizely.getFeatureVariableString(invalidFeatureKey, invalidVariableKey, genericUserId, attributes); assertNull(value); logbackVerifier.expectMessage(Level.INFO, - "No feature variable was found for key \"" + invalidVariableKey + "\" in feature flag \"" + - validFeatureKey + "\"."); + "No feature flag was found for key \"" + invalidFeatureKey + "\".", + 2); + } - verify(mockDecisionService, never()).getVariation( - any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class) - ); + /** + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} + * returns null and logs a message + * when the feature key is valid, but no variable could be found for the variable key in the feature. + */ + @Test + public void getFeatureVariableValueForTypeReturnsNullWhenVariableNotFoundInValidFeature() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String invalidVariableKey = "nonexistent variable key"; + Optimizely optimizely = optimizelyBuilder.build(); + + String value = optimizely.getFeatureVariableValueForType( + FEATURE_MULTI_VARIATE_FEATURE_KEY, + invalidVariableKey, + genericUserId, + Collections.<String, String>emptyMap(), + FeatureVariable.STRING_TYPE); + assertNull(value); + + logbackVerifier.expectMessage(Level.INFO, + "No feature variable was found for key \"" + invalidVariableKey + "\" in feature flag \"" + + validFeatureKey + "\"."); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null when the variable's type does not match the type with which it was attempted to be accessed. - * @throws ConfigParseException */ @Test - public void getFeatureVariableValueReturnsNullWhenVariableTypeDoesNotMatch() throws ConfigParseException { + public void getFeatureVariableValueReturnsNullWhenVariableTypeDoesNotMatch() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; String validVariableKey = VARIABLE_FIRST_LETTER_KEY; - - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); String value = optimizely.getFeatureVariableValueForType( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.<String, String>emptyMap(), - LiveVariable.VariableType.INTEGER + FEATURE_MULTI_VARIATE_FEATURE_KEY, + validVariableKey, + genericUserId, + Collections.<String, String>emptyMap(), + FeatureVariable.INTEGER_TYPE ); assertNull(value); logbackVerifier.expectMessage( - Level.INFO, - "The feature variable \"" + validVariableKey + - "\" is actually of type \"" + LiveVariable.VariableType.STRING.toString() + - "\" type. You tried to access it as type \"" + LiveVariable.VariableType.INTEGER.toString() + - "\". Please use the appropriate feature variable accessor." + Level.INFO, + "The feature variable \"" + validVariableKey + + "\" is actually of type \"" + FeatureVariable.STRING_TYPE + + "\" type. You tried to access it as type \"" + FeatureVariable.INTEGER_TYPE + + "\". Please use the appropriate feature variable accessor." ); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} - * returns the String default value of a live variable + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} + * returns the String default value of a feature variable * when the feature is not attached to an experiment or a rollout. - * @throws ConfigParseException */ @Test - public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttachedToExperimentOrRollout() throws ConfigParseException { + public void getFeatureVariableValueForTypeReturnsDefaultValueWhenFeatureIsNotAttachedToExperimentOrRollout() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY; String validVariableKey = VARIABLE_BOOLEAN_VARIABLE_KEY; - String defaultValue = VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE; + Boolean defaultValue = Boolean.parseBoolean(VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE); Map<String, String> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); - String value = optimizely.getFeatureVariableValueForType( - validFeatureKey, - validVariableKey, - genericUserId, - attributes, - LiveVariable.VariableType.BOOLEAN); + Boolean value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + attributes, + FeatureVariable.BOOLEAN_TYPE); assertEquals(defaultValue, value); logbackVerifier.expectMessage( - Level.INFO, - "The feature flag \"" + validFeatureKey + "\" is not used in any experiments." + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in any experiments." ); logbackVerifier.expectMessage( - Level.INFO, - "The feature flag \"" + validFeatureKey + "\" is not used in a rollout." + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in a rollout." ); logbackVerifier.expectMessage( - Level.INFO, - "User \"" + genericUserId + "\" was not bucketed into any variation for feature flag \"" + - validFeatureKey + "\". The default value \"" + - defaultValue + "\" for \"" + - validVariableKey + "\" is being returned." + Level.INFO, + "User \"" + genericUserId + "\" was not bucketed into any variation for feature flag \"" + + validFeatureKey + "\". The default value \"" + + defaultValue + "\" for \"" + + validVariableKey + "\" is being returned." ); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} - * returns the String default value for a live variable + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} + * returns the String default value for a feature variable * when the feature is attached to an experiment and no rollout, but the user is excluded from the experiment. - * @throws ConfigParseException */ @Test - public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOneExperimentButFailsTargeting() throws ConfigParseException { + public void getFeatureVariableValueReturnsDefaultValueWhenFeatureIsAttachedToOneExperimentButFailsTargeting() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_SINGLE_VARIABLE_DOUBLE_KEY; String validVariableKey = VARIABLE_DOUBLE_VARIABLE_KEY; - String expectedValue = VARIABLE_DOUBLE_DEFAULT_VALUE; + Double expectedValue = Double.parseDouble(VARIABLE_DOUBLE_DEFAULT_VALUE); FeatureFlag featureFlag = FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE; Experiment experiment = validProjectConfig.getExperimentIdMapping().get(featureFlag.getExperimentIds().get(0)); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); - String valueWithImproperAttributes = optimizely.getFeatureVariableValueForType( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Ravenclaw"), - LiveVariable.VariableType.DOUBLE + Double valueWithImproperAttributes = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, "Ravenclaw"), + FeatureVariable.DOUBLE_TYPE ); assertEquals(expectedValue, valueWithImproperAttributes); logbackVerifier.expectMessage( - Level.INFO, - "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" + - experiment.getKey() + "\"." + Level.INFO, + "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" + + experiment.getKey() + "\"." ); logbackVerifier.expectMessage( - Level.INFO, - "The feature flag \"" + validFeatureKey + "\" is not used in a rollout." + Level.INFO, + "The feature flag \"" + validFeatureKey + "\" is not used in a rollout." ); logbackVerifier.expectMessage( - Level.INFO, - "User \"" + genericUserId + - "\" was not bucketed into any variation for feature flag \"" + validFeatureKey + - "\". The default value \"" + expectedValue + - "\" for \"" + validVariableKey + "\" is being returned." + Level.INFO, + "User \"" + genericUserId + + "\" was not bucketed into any variation for feature flag \"" + validFeatureKey + + "\". The default value \"" + expectedValue + + "\" for \"" + validVariableKey + "\" is being returned." ); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} - * returns the variable value of the variation the user is bucketed into - * if the variation is not null and the variable has a usage within the variation. - * @throws ConfigParseException + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} + * is called when the variation is not null and feature enabled is false + * returns the default variable value */ @Test - public void getFeatureVariableValueReturnsVariationValueWhenUserGetsBucketedToVariation() throws ConfigParseException { + public void getFeatureVariableValueReturnsDefaultValueWhenFeatureEnabledIsFalse() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; String validVariableKey = VARIABLE_FIRST_LETTER_KEY; - LiveVariable variable = FEATURE_FLAG_MULTI_VARIATE_FEATURE.getVariableKeyToLiveVariableMap().get(validVariableKey); - String expectedValue = VARIATION_MULTIVARIATE_EXPERIMENT_GRED.getVariableIdToLiveVariableUsageInstanceMap().get(variable.getId()).getValue(); + String expectedValue = VARIABLE_FIRST_LETTER_DEFAULT_VALUE; Experiment multivariateExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build(); + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); - FeatureDecision featureDecision = new FeatureDecision(multivariateExperiment, VARIATION_MULTIVARIATE_EXPERIMENT_GRED, FeatureDecision.DecisionSource.EXPERIMENT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE) + FeatureDecision featureDecision = new FeatureDecision(multivariateExperiment, VARIATION_MULTIVARIATE_EXPERIMENT_GRED, FeatureDecision.DecisionSource.FEATURE_TEST); + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + validProjectConfig ); String value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE), + FeatureVariable.STRING_TYPE + ); + + logbackVerifier.expectMessage( + Level.INFO, + "Feature \"multi_variate_feature\" is not enabled for user \"genericUserId\". Returning the default variable value \"H\"." + ); + + assertEquals(expectedValue, value); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is true + * returns variable value + */ + @Test + public void getFeatureVariableUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + String expectedValue = "F"; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals(optimizely.getFeatureVariableString( + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + expectedValue); + + logbackVerifier.expectMessage( + Level.INFO, + "Got variable value \"F\" for variable \"first_letter\" of feature flag \"multi_variate_feature\"." + ); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is false + * than default value will gets returned + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getFeatureVariableUserInExperimentFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_FIRST_LETTER_KEY; + String expectedValue = "H"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals(optimizely.getFeatureVariableString( + validFeatureKey, + validVariableKey, + userID, + null), + expectedValue); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableString(String, String, String, Map)} + * is called when feature is in rollout and feature enabled is true + * returns variable value + */ + @Test + public void getFeatureVariableUserInRollOutFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_STRING_KEY; + String validVariableKey = VARIABLE_STRING_VARIABLE_KEY; + String expectedValue = "lumos"; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals(optimizely.getFeatureVariableString( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + expectedValue); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map)} + * is called when feature is not in rollout and feature enabled is false + * returns default value + */ + @Test + public void getFeatureVariableUserNotInRollOutFeatureOff() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY; + String validVariableKey = VARIABLE_BOOLEAN_VARIABLE_KEY; + Boolean expectedValue = true; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals(optimizely.getFeatureVariableBoolean( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + expectedValue); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * is called when feature is in rollout and feature enabled is true + * return rollout variable value + */ + @Test + public void getFeatureVariableIntegerUserInRollOutFeatureOn() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_INTEGER_KEY; + String validVariableKey = VARIABLE_INTEGER_VARIABLE_KEY; + int expectedValue = 7; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals( + expectedValue, + (int) optimizely.getFeatureVariableInteger( validFeatureKey, validVariableKey, genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE), - LiveVariable.VariableType.STRING + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)) ); + } - assertEquals(expectedValue, value); + /** + * Verify that the {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} + * is called when feature is in rollout and feature enabled is true + * return rollout variable value + */ + @Test + public void getFeatureVariableLongUserInRollOutFeatureOn() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_INTEGER_KEY; + String validVariableKey = VARIABLE_INTEGER_VARIABLE_KEY; + int expectedValue = 7; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals( + expectedValue, + (int) optimizely.getFeatureVariableInteger( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)) + ); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is true + * returns variable value + */ + @Test + public void getFeatureVariableDoubleUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_SINGLE_VARIABLE_DOUBLE_KEY; + String validVariableKey = VARIABLE_DOUBLE_VARIABLE_KEY; + + Optimizely optimizely = optimizelyBuilder.build(); + + assertEquals(optimizely.getFeatureVariableDouble( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_SLYTHERIN_VALUE)), + Math.PI, 2); } /** - * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * Verify {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns the default value for the feature variable * when there is no variable usage present for the variation the user is bucketed into. - * @throws ConfigParseException */ @Test - public void getFeatureVariableValueReturnsDefaultValueWhenNoVariationUsageIsPresent() throws ConfigParseException { + public void getFeatureVariableValueReturnsDefaultValueWhenNoVariationUsageIsPresent() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_SINGLE_VARIABLE_INTEGER_KEY; String validVariableKey = VARIABLE_INTEGER_VARIABLE_KEY; - LiveVariable variable = FEATURE_FLAG_SINGLE_VARIABLE_INTEGER.getVariableKeyToLiveVariableMap().get(validVariableKey); - String expectedValue = variable.getDefaultValue(); + FeatureVariable variable = FEATURE_FLAG_SINGLE_VARIABLE_INTEGER.getVariableKeyToFeatureVariableMap().get(validVariableKey); + Integer expectedValue = Integer.parseInt(variable.getDefaultValue()); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); - String value = optimizely.getFeatureVariableValueForType( - validFeatureKey, - validVariableKey, - genericUserId, - Collections.<String, String>emptyMap(), - LiveVariable.VariableType.INTEGER + Integer value = optimizely.getFeatureVariableValueForType( + validFeatureKey, + validVariableKey, + genericUserId, + Collections.<String, String>emptyMap(), + FeatureVariable.INTEGER_TYPE ); assertEquals(expectedValue, value); + + logbackVerifier.expectMessage( + Level.INFO, + "Value is not defined for variable \"integer_variable\". Returning default value \"7\"." + ); } /** @@ -3118,32 +3158,19 @@ public void getFeatureVariableValueReturnsDefaultValueWhenNoVariationUsageIsPres * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both * return False * when the APIs are called with a null value for the feature key parameter. - * @throws Exception */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test public void isFeatureEnabledReturnsFalseWhenFeatureKeyIsNull() throws Exception { - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); - + Optimizely spyOptimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); assertFalse(spyOptimizely.isFeatureEnabled(null, genericUserId)); - logbackVerifier.expectMessage( - Level.WARN, - "The featureKey parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.WARN, "The featureKey parameter must be nonnull."); - verify(spyOptimizely, times(1)).isFeatureEnabled( - isNull(String.class), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) - ); verify(mockDecisionService, never()).getVariationForFeature( - any(FeatureFlag.class), - any(String.class), - anyMapOf(String.class, String.class) + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); } @@ -3152,34 +3179,19 @@ public void isFeatureEnabledReturnsFalseWhenFeatureKeyIsNull() throws Exception * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both * return False * when the APIs are called with a null value for the user ID parameter. - * @throws Exception */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test public void isFeatureEnabledReturnsFalseWhenUserIdIsNull() throws Exception { - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); - - String featureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; - - assertFalse(spyOptimizely.isFeatureEnabled(featureKey, null)); + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); + assertFalse(optimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, null)); - logbackVerifier.expectMessage( - Level.WARN, - "The userId parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.WARN, "The userId parameter must be nonnull."); - verify(spyOptimizely, times(1)).isFeatureEnabled( - eq(featureKey), - isNull(String.class), - eq(Collections.<String, String>emptyMap()) - ); verify(mockDecisionService, never()).getVariationForFeature( - any(FeatureFlag.class), - any(String.class), - anyMapOf(String.class, String.class) + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); } @@ -3188,34 +3200,22 @@ public void isFeatureEnabledReturnsFalseWhenUserIdIsNull() throws Exception { * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both * return False * when the APIs are called with a feature key that is not in the datafile. - * @throws Exception */ @Test public void isFeatureEnabledReturnsFalseWhenFeatureFlagKeyIsInvalid() throws Exception { String invalidFeatureKey = "nonexistent feature key"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); - + Optimizely spyOptimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); assertFalse(spyOptimizely.isFeatureEnabled(invalidFeatureKey, genericUserId)); - logbackVerifier.expectMessage( - Level.INFO, - "No feature flag was found for key \"" + invalidFeatureKey + "\"." - ); - verify(spyOptimizely, times(1)).isFeatureEnabled( - eq(invalidFeatureKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) - ); + logbackVerifier.expectMessage(Level.INFO, "No feature flag was found for key \"" + invalidFeatureKey + "\"."); + verify(mockDecisionService, never()).getVariation( - any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class)); - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); + any(Experiment.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) + ); } /** @@ -3223,7 +3223,6 @@ public void isFeatureEnabledReturnsFalseWhenFeatureFlagKeyIsInvalid() throws Exc * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both * return False * when the user is not bucketed into any variation for the feature. - * @throws Exception */ @Test public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() throws Exception { @@ -3231,36 +3230,29 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); FeatureDecision featureDecision = new FeatureDecision(null, null, null); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( - any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); - assertFalse(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + assertFalse(optimizely.isFeatureEnabled(validFeatureKey, genericUserId)); logbackVerifier.expectMessage( - Level.INFO, - "Feature \"" + validFeatureKey + - "\" is not enabled for user \"" + genericUserId + "\"." - ); - verify(spyOptimizely).isFeatureEnabled( - eq(validFeatureKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? false" ); + eventHandler.expectImpression(null, "", genericUserId); + verify(mockDecisionService).getVariationForFeature( - eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), + eq(validProjectConfig) ); - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } /** @@ -3268,53 +3260,44 @@ public void isFeatureEnabledReturnsFalseWhenUserIsNotBucketedIntoAnyVariation() * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both * return True when the user is bucketed into a variation for the feature. * An impression event should not be dispatched since the user was not bucketed into an Experiment. - * @throws Exception */ @Test public void isFeatureEnabledReturnsTrueButDoesNotSendWhenUserIsBucketedIntoVariationWithoutExperiment() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; - - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); // Should be an experiment from the rollout associated with the feature, but for this test // it doesn't matter. Just use any valid experiment. Experiment experiment = validProjectConfig.getRolloutIdMapping().get(ROLLOUT_2_ID).getExperiments().get(0); Variation variation = new Variation("variationId", "variationKey", true, null); FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( - eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), + eq(validProjectConfig) ); - assertTrue(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId)); logbackVerifier.expectMessage( - Level.INFO, - "The user \"" + genericUserId + - "\" is not included in an experiment for feature \"" + validFeatureKey + "\"." + Level.INFO, + "The user \"" + genericUserId + + "\" is not included in an experiment for feature \"" + validFeatureKey + "\"." ); logbackVerifier.expectMessage( - Level.INFO, - "Feature \"" + validFeatureKey + - "\" is enabled for user \"" + genericUserId + "\"." - ); - verify(spyOptimizely).isFeatureEnabled( - eq(validFeatureKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? true" ); + eventHandler.expectImpression("3421010877", "variationId", genericUserId); + verify(mockDecisionService).getVariationForFeature( - eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), + eq(validProjectConfig) ); - verify(mockEventHandler, never()).dispatchEvent(any(LogEvent.class)); } /** @@ -3328,14 +3311,12 @@ public void isFeatureEnabledWithExperimentKeyForcedOffFeatureEnabledFalse() thro Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); Variation forcedVariation = activatedExperiment.getVariations().get(2); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey() ); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey()); assertFalse(optimizely.isFeatureEnabled(FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), testUserId)); + + eventHandler.expectImpression(activatedExperiment.getId(), forcedVariation.getId(), testUserId); } /** @@ -3349,14 +3330,12 @@ public void isFeatureEnabledWithExperimentKeyForcedWithNoFeatureEnabledSet() thr Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY); Variation forcedVariation = activatedExperiment.getVariations().get(1); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withBucketing(mockBucketer) - .withConfig(validProjectConfig) - .withErrorHandler(mockErrorHandler) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); - optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey() ); + optimizely.setForcedVariation(activatedExperiment.getKey(), testUserId, forcedVariation.getKey()); assertFalse(optimizely.isFeatureEnabled(FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, testUserId)); + + eventHandler.expectImpression(activatedExperiment.getId(), forcedVariation.getId(), testUserId); } /** @@ -3364,30 +3343,27 @@ public void isFeatureEnabledWithExperimentKeyForcedWithNoFeatureEnabledSet() thr * {@link Optimizely#isFeatureEnabled(String, String, Map)} sending FeatureEnabled true and they both * return True when the user is bucketed into a variation for the feature. * An impression event should not be dispatched since the user was not bucketed into an Experiment. - * @throws Exception */ @Test - public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exception{ + public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); // Should be an experiment from the rollout associated with the feature, but for this test // it doesn't matter. Just use any valid experiment. Experiment experiment = validProjectConfig.getRolloutIdMapping().get(ROLLOUT_2_ID).getExperiments().get(0); Variation variation = new Variation("variationId", "variationKey", true, null); FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( - eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(optimizely.createUserContext(genericUserId, Collections.emptyMap())), + eq(validProjectConfig) ); - assertTrue(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + eventHandler.expectImpression("3421010877", "variationId", genericUserId); } @@ -3397,30 +3373,26 @@ public void isFeatureEnabledTrueWhenFeatureEnabledOfVariationIsTrue() throws Exc * {@link Optimizely#isFeatureEnabled(String, String, Map)} sending FeatureEnabled false because of which and they both * return false even when the user is bucketed into a variation for the feature. * An impression event should not be dispatched since the user was not bucketed into an Experiment. - * @throws Exception */ @Test - public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws Exception{ + public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + Optimizely spyOptimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); // Should be an experiment from the rollout associated with the feature, but for this test // it doesn't matter. Just use any valid experiment. Experiment experiment = validProjectConfig.getRolloutIdMapping().get(ROLLOUT_2_ID).getExperiments().get(0); Variation variation = new Variation("variationId", "variationKey", false, null); FeatureDecision featureDecision = new FeatureDecision(experiment, variation, FeatureDecision.DecisionSource.ROLLOUT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( - eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + eq(FEATURE_FLAG_MULTI_VARIATE_FEATURE), + eq(spyOptimizely.createUserContext(genericUserId, Collections.emptyMap())), + eq(validProjectConfig) ); - assertFalse(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + assertFalse(spyOptimizely.isFeatureEnabled(FEATURE_MULTI_VARIATE_FEATURE_KEY, genericUserId)); + eventHandler.expectImpression("3421010877", "variationId", genericUserId); } @@ -3429,7 +3401,6 @@ public void isFeatureEnabledFalseWhenFeatureEnabledOfVariationIsFalse() throws E * {@link Optimizely#isFeatureEnabled(String, String, Map)} and they both * return False * when the user is bucketed an feature test variation that is turned off. - * @throws Exception */ @Test public void isFeatureEnabledReturnsFalseAndDispatchesWhenUserIsBucketedIntoAnExperimentVariationToggleOff() throws Exception { @@ -3437,60 +3408,72 @@ public void isFeatureEnabledReturnsFalseAndDispatchesWhenUserIsBucketedIntoAnExp String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .withDecisionService(mockDecisionService) - .build()); + Optimizely spyOptimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); Variation variation = new Variation("2", "variation_toggled_off", false, null); - FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.EXPERIMENT); - doReturn(featureDecision).when(mockDecisionService).getVariationForFeature( - any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class) + FeatureDecision featureDecision = new FeatureDecision(activatedExperiment, variation, FeatureDecision.DecisionSource.FEATURE_TEST); + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); assertFalse(spyOptimizely.isFeatureEnabled(validFeatureKey, genericUserId)); + eventHandler.expectImpression(activatedExperiment.getId(), variation.getId(), genericUserId); logbackVerifier.expectMessage( - Level.INFO, - "Feature \"" + validFeatureKey + - "\" is not enabled for user \"" + genericUserId + "\"." + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? false" ); - verify(mockEventHandler, times(1)).dispatchEvent(any(LogEvent.class)); } - /** Integration Test + /** + * Integration Test * Verify {@link Optimizely#isFeatureEnabled(String, String, Map)} * returns True * when the user is bucketed into a variation for the feature. * The user is also bucketed into an experiment, so we verify that an event is dispatched. - * @throws Exception */ @Test public void isFeatureEnabledReturnsTrueAndDispatchesEventWhenUserIsBucketedIntoAnExperiment() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + Experiment activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); - Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build(); + Optimizely optimizely = optimizelyBuilder.build(); - assertTrue(optimizely.isFeatureEnabled( - validFeatureKey, - genericUserId, - Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE) - )); + Map<String, Object> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + assertTrue(optimizely.isFeatureEnabled(validFeatureKey, genericUserId, attributes)); + + eventHandler.expectImpression(activatedExperiment.getId(), "2099211198", genericUserId, attributes); logbackVerifier.expectMessage( - Level.INFO, - "Feature \"" + validFeatureKey + - "\" is enabled for user \"" + genericUserId + "\"." + Level.INFO, + "Feature \"" + validFeatureKey + + "\" is enabled for user \"" + genericUserId + "\"? true" + ); + } + + /** + * Verify that we don't attempt to activate the user when the Optimizely instance is not valid + */ + @Test + public void isFeatureEnabledWithInvalidDatafile() throws Exception { + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); + Boolean isEnabled = optimizely.isFeatureEnabled("no_variable_feature", genericUserId); + assertFalse(isEnabled); + + // make sure we didn't even attempt to bucket the user + verify(mockDecisionService, never()).getVariationForFeature( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); - verify(mockEventHandler, times(1)).dispatchEvent(any(LogEvent.class)); } /** @@ -3499,16 +3482,21 @@ public void isFeatureEnabledReturnsTrueAndDispatchesEventWhenUserIsBucketedIntoA * return List of FeatureFlags that are enabled */ @Test - public void getEnabledFeatureWithValidUserId() throws ConfigParseException{ + public void getEnabledFeatureWithValidUserId() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - ArrayList<String> featureFlags = (ArrayList<String>) spyOptimizely.getEnabledFeatures(genericUserId, - new HashMap<String, String>()); + Optimizely optimizely = optimizelyBuilder.build(); + List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertFalse(featureFlags.isEmpty()); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression("3794675122", "589640735", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression("1785077004", "1566407342", genericUserId); + eventHandler.expectImpression("828245624", "3137445031", genericUserId); + eventHandler.expectImpression("828245624", "3137445031", genericUserId); + eventHandler.expectImpression("1786133852", "1619235542", genericUserId); } /** @@ -3518,17 +3506,21 @@ public void getEnabledFeatureWithValidUserId() throws ConfigParseException{ * return empty List of FeatureFlags without checking further. */ @Test - public void getEnabledFeatureWithEmptyUserId() throws ConfigParseException{ + public void getEnabledFeatureWithEmptyUserId() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - ArrayList<String> featureFlags = (ArrayList<String>) spyOptimizely.getEnabledFeatures("", - new HashMap<String, String>()); - logbackVerifier.expectMessage(Level.ERROR, "Non-empty user ID required"); - assertTrue(featureFlags.isEmpty()); + Optimizely optimizely = optimizelyBuilder.build(); + List<String> featureFlags = optimizely.getEnabledFeatures("", Collections.emptyMap()); + assertFalse(featureFlags.isEmpty()); + eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression("3794675122", "589640735", ""); + eventHandler.expectImpression(null, "", ""); + eventHandler.expectImpression("1785077004", "1566407342", ""); + eventHandler.expectImpression("828245624", "3137445031", ""); + eventHandler.expectImpression("828245624", "3137445031", ""); + eventHandler.expectImpression("4138322202", "1394671166", ""); } /** @@ -3540,44 +3532,49 @@ public void getEnabledFeatureWithEmptyUserId() throws ConfigParseException{ */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getEnabledFeatureWithNullUserID() throws ConfigParseException{ + public void getEnabledFeatureWithNullUserID() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); String userID = null; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - ArrayList<String> featureFlags = (ArrayList<String>) spyOptimizely.getEnabledFeatures(userID, - new HashMap<String, String>()); + Optimizely optimizely = optimizelyBuilder.build(); + List<String> featureFlags = optimizely.getEnabledFeatures(userID, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); logbackVerifier.expectMessage( - Level.ERROR, - "The user ID parameter must be nonnull." + Level.ERROR, + "The user ID parameter must be nonnull." ); } /** * Verify {@link Optimizely#getEnabledFeatures(String, Map)} calls into - * {@link Optimizely#isFeatureEnabled(String, String, Map)} for each featureFlag sending - * userId and emptyMap and Mocked {@link Optimizely#isFeatureEnabled(String, String, Map)} - * to return false so {@link Optimizely#getEnabledFeatures(String, Map)} will + * {@link DecisionService#getVariationForFeature} to return feature + * disabled so {@link Optimizely#getEnabledFeatures(String, Map)} will * return empty List of FeatureFlags. */ @Test - public void getEnabledFeatureWithMockIsFeatureEnabledToReturnFalse() throws ConfigParseException{ + public void getEnabledFeatureWithMockDecisionService() throws Exception { assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - doReturn(false).when(spyOptimizely).isFeatureEnabled( - any(String.class), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + Optimizely optimizely = optimizelyBuilder.withDecisionService(mockDecisionService).build(); + + FeatureDecision featureDecision = new FeatureDecision(null, null, FeatureDecision.DecisionSource.ROLLOUT); + doReturn(DecisionResponse.responseNoReasons(featureDecision)).when(mockDecisionService).getVariationForFeature( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); - ArrayList<String> featureFlags = (ArrayList<String>) spyOptimizely.getEnabledFeatures(genericUserId, - Collections.<String, String>emptyMap()); + + List<String> featureFlags = optimizely.getEnabledFeatures(genericUserId, Collections.emptyMap()); assertTrue(featureFlags.isEmpty()); + + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); + eventHandler.expectImpression(null, "", genericUserId); } /** @@ -3585,32 +3582,29 @@ public void getEnabledFeatureWithMockIsFeatureEnabledToReturnFalse() throws Conf * calls through to {@link Optimizely#getFeatureVariableString(String, String, String, Map)} * and returns null * when called with a null value for the feature Key parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableStringReturnsNullWhenFeatureKeyIsNull() throws ConfigParseException { + public void getFeatureVariableStringReturnsNullWhenFeatureKeyIsNull() throws Exception { String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); assertNull(spyOptimizely.getFeatureVariableString( - null, - variableKey, - genericUserId + null, + variableKey, + genericUserId )); logbackVerifier.expectMessage( - Level.WARN, - "The featureKey parameter must be nonnull." + Level.WARN, + "The featureKey parameter must be nonnull." ); verify(spyOptimizely, times(1)).getFeatureVariableString( - isNull(String.class), - any(String.class), - any(String.class), - anyMapOf(String.class, String.class) + isNull(String.class), + any(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -3619,32 +3613,22 @@ public void getFeatureVariableStringReturnsNullWhenFeatureKeyIsNull() throws Con * calls through to {@link Optimizely#getFeatureVariableString(String, String, String)} * and returns null * when called with a null value for the variableKey parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableStringReturnsNullWhenVariableKeyIsNull() throws ConfigParseException { + public void getFeatureVariableStringReturnsNullWhenVariableKeyIsNull() throws Exception { String featureKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - assertNull(spyOptimizely.getFeatureVariableString( - featureKey, - null, - genericUserId - )); + assertNull(spyOptimizely.getFeatureVariableString(featureKey, null, genericUserId)); - logbackVerifier.expectMessage( - Level.WARN, - "The variableKey parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.WARN, "The variableKey parameter must be nonnull."); verify(spyOptimizely, times(1)).getFeatureVariableString( - any(String.class), - isNull(String.class), - any(String.class), - anyMapOf(String.class, String.class) + any(String.class), + isNull(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -3653,71 +3637,55 @@ public void getFeatureVariableStringReturnsNullWhenVariableKeyIsNull() throws Co * calls through to {@link Optimizely#getFeatureVariableString(String, String, String)} * and returns null * when called with a null value for the userID parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableStringReturnsNullWhenUserIdIsNull() throws ConfigParseException { + public void getFeatureVariableStringReturnsNullWhenUserIdIsNull() throws Exception { String featureKey = ""; String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - assertNull(spyOptimizely.getFeatureVariableString( - featureKey, - variableKey, - null - )); + assertNull(spyOptimizely.getFeatureVariableString(featureKey, variableKey, null)); - logbackVerifier.expectMessage( - Level.WARN, - "The userId parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.WARN, "The userId parameter must be nonnull."); verify(spyOptimizely, times(1)).getFeatureVariableString( - any(String.class), - any(String.class), - isNull(String.class), - anyMapOf(String.class, String.class) + any(String.class), + any(String.class), + isNull(String.class), + anyMapOf(String.class, String.class) ); } + /** * Verify {@link Optimizely#getFeatureVariableString(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableString(String, String, String, Map<String, String>)} * and returns null - * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null - * @throws ConfigParseException */ @Test - public void getFeatureVariableStringReturnsNullFromInternal() throws ConfigParseException { + public void getFeatureVariableStringReturnsNullFromInternal() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.STRING) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.STRING_TYPE) ); - assertNull(spyOptimizely.getFeatureVariableString( - featureKey, - variableKey, - genericUserId - )); + assertNull(spyOptimizely.getFeatureVariableString(featureKey, variableKey, genericUserId)); verify(spyOptimizely).getFeatureVariableString( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); } @@ -3725,56 +3693,52 @@ public void getFeatureVariableStringReturnsNullFromInternal() throws ConfigParse * Verify {@link Optimizely#getFeatureVariableString(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableString(String, String, String, Map)} * and both return the value returned from - * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. - * @throws ConfigParseException + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)}. */ @Test - public void getFeatureVariableStringReturnsWhatInternalReturns() throws ConfigParseException { + public void getFeatureVariableStringReturnsWhatInternalReturns() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; String valueNoAttributes = "valueNoAttributes"; String valueWithAttributes = "valueWithAttributes"; Map<String, String> attributes = Collections.singletonMap("key", "value"); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(valueNoAttributes).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.STRING) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.STRING_TYPE) ); doReturn(valueWithAttributes).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(attributes), - eq(LiveVariable.VariableType.STRING) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(FeatureVariable.STRING_TYPE) ); assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableString( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableString( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableString( - featureKey, - variableKey, - genericUserId, - attributes + featureKey, + variableKey, + genericUserId, + attributes )); } @@ -3783,32 +3747,30 @@ public void getFeatureVariableStringReturnsWhatInternalReturns() throws ConfigPa * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map)} * and returns null * when called with a null value for the feature Key parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableBooleanReturnsNullWhenFeatureKeyIsNull() throws ConfigParseException { + public void getFeatureVariableBooleanReturnsNullWhenFeatureKeyIsNull() throws Exception { String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); assertNull(spyOptimizely.getFeatureVariableBoolean( - null, - variableKey, - genericUserId + null, + variableKey, + genericUserId )); logbackVerifier.expectMessage( - Level.WARN, - "The featureKey parameter must be nonnull." + Level.WARN, + "The featureKey parameter must be nonnull." ); + verify(spyOptimizely, times(1)).getFeatureVariableBoolean( - isNull(String.class), - any(String.class), - any(String.class), - anyMapOf(String.class, String.class) + isNull(String.class), + any(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -3817,32 +3779,29 @@ public void getFeatureVariableBooleanReturnsNullWhenFeatureKeyIsNull() throws Co * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String)} * and returns null * when called with a null value for the variableKey parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableBooleanReturnsNullWhenVariableKeyIsNull() throws ConfigParseException { + public void getFeatureVariableBooleanReturnsNullWhenVariableKeyIsNull() throws Exception { String featureKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); assertNull(spyOptimizely.getFeatureVariableBoolean( - featureKey, - null, - genericUserId + featureKey, + null, + genericUserId )); logbackVerifier.expectMessage( - Level.WARN, - "The variableKey parameter must be nonnull." + Level.WARN, + "The variableKey parameter must be nonnull." ); verify(spyOptimizely, times(1)).getFeatureVariableBoolean( - any(String.class), - isNull(String.class), - any(String.class), - anyMapOf(String.class, String.class) + any(String.class), + isNull(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -3851,33 +3810,30 @@ public void getFeatureVariableBooleanReturnsNullWhenVariableKeyIsNull() throws C * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String)} * and returns null * when called with a null value for the userID parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableBooleanReturnsNullWhenUserIdIsNull() throws ConfigParseException { + public void getFeatureVariableBooleanReturnsNullWhenUserIdIsNull() throws Exception { String featureKey = ""; String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); assertNull(spyOptimizely.getFeatureVariableBoolean( - featureKey, - variableKey, - null + featureKey, + variableKey, + null )); logbackVerifier.expectMessage( - Level.WARN, - "The userId parameter must be nonnull." + Level.WARN, + "The userId parameter must be nonnull." ); verify(spyOptimizely, times(1)).getFeatureVariableBoolean( - any(String.class), - any(String.class), - isNull(String.class), - anyMapOf(String.class, String.class) + any(String.class), + any(String.class), + isNull(String.class), + anyMapOf(String.class, String.class) ); } @@ -3886,38 +3842,35 @@ public void getFeatureVariableBooleanReturnsNullWhenUserIdIsNull() throws Config * Verify {@link Optimizely#getFeatureVariableBoolean(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map<String, String>)} * and returns null - * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null - * @throws ConfigParseException */ @Test - public void getFeatureVariableBooleanReturnsNullFromInternal() throws ConfigParseException { + public void getFeatureVariableBooleanReturnsNullFromInternal() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.BOOLEAN) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.BOOLEAN_TYPE) ); assertNull(spyOptimizely.getFeatureVariableBoolean( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableBoolean( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); } @@ -3925,56 +3878,52 @@ public void getFeatureVariableBooleanReturnsNullFromInternal() throws ConfigPars * Verify {@link Optimizely#getFeatureVariableBoolean(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableBoolean(String, String, String, Map)} * and both return a Boolean representation of the value returned from - * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. - * @throws ConfigParseException + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)}. */ @Test - public void getFeatureVariableBooleanReturnsWhatInternalReturns() throws ConfigParseException { + public void getFeatureVariableBooleanReturnsWhatInternalReturns() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; Boolean valueNoAttributes = false; Boolean valueWithAttributes = true; Map<String, String> attributes = Collections.singletonMap("key", "value"); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - doReturn(valueNoAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.BOOLEAN) + doReturn(valueNoAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.BOOLEAN_TYPE) ); - doReturn(valueWithAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(attributes), - eq(LiveVariable.VariableType.BOOLEAN) + doReturn(valueWithAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(FeatureVariable.BOOLEAN_TYPE) ); assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableBoolean( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableBoolean( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableBoolean( - featureKey, - variableKey, - genericUserId, - attributes + featureKey, + variableKey, + genericUserId, + attributes )); } @@ -3982,38 +3931,35 @@ public void getFeatureVariableBooleanReturnsWhatInternalReturns() throws ConfigP * Verify {@link Optimizely#getFeatureVariableDouble(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String, Map<String, String>)} * and returns null - * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null - * @throws ConfigParseException */ @Test - public void getFeatureVariableDoubleReturnsNullFromInternal() throws ConfigParseException { + public void getFeatureVariableDoubleReturnsNullFromInternal() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.DOUBLE) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.DOUBLE_TYPE) ); assertNull(spyOptimizely.getFeatureVariableDouble( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableDouble( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); } @@ -4021,56 +3967,52 @@ public void getFeatureVariableDoubleReturnsNullFromInternal() throws ConfigParse * Verify {@link Optimizely#getFeatureVariableDouble(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} * and both return the parsed Double from the value returned from - * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. - * @throws ConfigParseException + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)}. */ @Test - public void getFeatureVariableDoubleReturnsWhatInternalReturns() throws ConfigParseException { + public void getFeatureVariableDoubleReturnsWhatInternalReturns() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; Double valueNoAttributes = 0.1; Double valueWithAttributes = 0.2; Map<String, String> attributes = Collections.singletonMap("key", "value"); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - doReturn(valueNoAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.DOUBLE) + doReturn(valueNoAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.DOUBLE_TYPE) ); - doReturn(valueWithAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(attributes), - eq(LiveVariable.VariableType.DOUBLE) + doReturn(valueWithAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(FeatureVariable.DOUBLE_TYPE) ); assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableDouble( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableDouble( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableDouble( - featureKey, - variableKey, - genericUserId, - attributes + featureKey, + variableKey, + genericUserId, + attributes )); } @@ -4078,38 +4020,35 @@ public void getFeatureVariableDoubleReturnsWhatInternalReturns() throws ConfigPa * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map<String, String>)} * and returns null - * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)} + * when {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)} * returns null - * @throws ConfigParseException */ @Test - public void getFeatureVariableIntegerReturnsNullFromInternal() throws ConfigParseException { + public void getFeatureVariableIntegerReturnsNullFromInternal() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(null).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.INTEGER) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.INTEGER_TYPE) ); assertNull(spyOptimizely.getFeatureVariableInteger( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableInteger( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); } @@ -4118,32 +4057,22 @@ public void getFeatureVariableIntegerReturnsNullFromInternal() throws ConfigPars * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} * and returns null * when called with a null value for the feature Key parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableDoubleReturnsNullWhenFeatureKeyIsNull() throws ConfigParseException { + public void getFeatureVariableDoubleReturnsNullWhenFeatureKeyIsNull() throws Exception { String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - assertNull(spyOptimizely.getFeatureVariableDouble( - null, - variableKey, - genericUserId - )); + assertNull(spyOptimizely.getFeatureVariableDouble(null, variableKey, genericUserId)); - logbackVerifier.expectMessage( - Level.WARN, - "The featureKey parameter must be nonnull." - ); + logbackVerifier.expectMessage(Level.WARN, "The featureKey parameter must be nonnull."); verify(spyOptimizely, times(1)).getFeatureVariableDouble( - isNull(String.class), - any(String.class), - any(String.class), - anyMapOf(String.class, String.class) + isNull(String.class), + any(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -4152,32 +4081,22 @@ public void getFeatureVariableDoubleReturnsNullWhenFeatureKeyIsNull() throws Con * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String)} * and returns null * when called with a null value for the variableKey parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableDoubleReturnsNullWhenVariableKeyIsNull() throws ConfigParseException { + public void getFeatureVariableDoubleReturnsNullWhenVariableKeyIsNull() throws Exception { String featureKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - assertNull(spyOptimizely.getFeatureVariableDouble( - featureKey, - null, - genericUserId - )); + assertNull(spyOptimizely.getFeatureVariableDouble(featureKey, null, genericUserId)); + logbackVerifier.expectMessage(Level.WARN, "The variableKey parameter must be nonnull."); - logbackVerifier.expectMessage( - Level.WARN, - "The variableKey parameter must be nonnull." - ); verify(spyOptimizely, times(1)).getFeatureVariableDouble( - any(String.class), - isNull(String.class), - any(String.class), - anyMapOf(String.class, String.class) + any(String.class), + isNull(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -4186,33 +4105,23 @@ public void getFeatureVariableDoubleReturnsNullWhenVariableKeyIsNull() throws Co * calls through to {@link Optimizely#getFeatureVariableDouble(String, String, String)} * and returns null * when called with a null value for the userID parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableDoubleReturnsNullWhenUserIdIsNull() throws ConfigParseException { + public void getFeatureVariableDoubleReturnsNullWhenUserIdIsNull() throws Exception { String featureKey = ""; String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - assertNull(spyOptimizely.getFeatureVariableDouble( - featureKey, - variableKey, - null - )); + assertNull(spyOptimizely.getFeatureVariableDouble(featureKey, variableKey, null)); + logbackVerifier.expectMessage(Level.WARN, "The userId parameter must be nonnull."); - logbackVerifier.expectMessage( - Level.WARN, - "The userId parameter must be nonnull." - ); verify(spyOptimizely, times(1)).getFeatureVariableDouble( - any(String.class), - any(String.class), - isNull(String.class), - anyMapOf(String.class, String.class) + any(String.class), + any(String.class), + isNull(String.class), + anyMapOf(String.class, String.class) ); } @@ -4220,69 +4129,98 @@ public void getFeatureVariableDoubleReturnsNullWhenUserIdIsNull() throws ConfigP * Verify that {@link Optimizely#getFeatureVariableDouble(String, String, String)} * and {@link Optimizely#getFeatureVariableDouble(String, String, String, Map)} * do not throw errors when they are unable to parse the value into an Double. - * @throws ConfigParseException */ @Test - public void getFeatureVariableDoubleCatchesExceptionFromParsing() throws ConfigParseException { + public void getFeatureVariableDoubleCatchesExceptionFromParsing() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; String unParsableValue = "not_a_double"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(unParsableValue).when(spyOptimizely).getFeatureVariableValueForType( - anyString(), - anyString(), - anyString(), - anyMapOf(String.class, String.class), - eq(LiveVariable.VariableType.DOUBLE) + anyString(), + anyString(), + anyString(), + anyMapOf(String.class, String.class), + eq(FeatureVariable.DOUBLE_TYPE) ); - assertNull(spyOptimizely.getFeatureVariableDouble( - featureKey, - variableKey, - genericUserId - )); + assertNull(spyOptimizely.getFeatureVariableDouble(featureKey, variableKey, genericUserId)); + } + + /** + * Verify that {@link Optimizely#convertStringToType(String, String)} + * do not throw errors when they are unable to parse the value into an Double. + * + * @throws NumberFormatException + */ + @Test + public void convertStringToTypeDoubleCatchesExceptionFromParsing() throws NumberFormatException { + String unParsableValue = "not_a_double"; + + Optimizely optimizely = optimizelyBuilder.build(); + assertNull(optimizely.convertStringToType(unParsableValue, FeatureVariable.DOUBLE_TYPE)); + + logbackVerifier.expectMessage( + Level.ERROR, + "NumberFormatException while trying to parse \"" + unParsableValue + + "\" as Double." + ); + } + + /** + * Verify that {@link Optimizely#convertStringToType(String, String)} + * do not throw errors when they are unable to parse the value into an Integer. + * + * @throws NumberFormatException + */ + @Test + public void convertStringToTypeIntegerCatchesExceptionFromParsing() throws NumberFormatException { + String unParsableValue = "not_a_integer"; + + Optimizely optimizely = optimizelyBuilder.build(); + assertNull(optimizely.convertStringToType(unParsableValue, FeatureVariable.INTEGER_TYPE)); logbackVerifier.expectMessage( - Level.ERROR, - "NumberFormatException while trying to parse \"" + unParsableValue + - "\" as Double. " + Level.ERROR, + "NumberFormatException while trying to parse \"" + unParsableValue + + "\" as Integer." ); } + + /** + * Verify that {@link Optimizely#convertStringToType(String, String)} + * is able to parse Long. + */ + @Test + public void convertStringToTypeIntegerReturnsLongCorrectly() throws NumberFormatException { + String longValue = "8949425362117"; + + Optimizely optimizely = optimizelyBuilder.build(); + assertEquals(Long.valueOf(longValue), optimizely.convertStringToType(longValue, FeatureVariable.INTEGER_TYPE)); + } + /** * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} * and returns null * when called with a null value for the feature Key parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableIntegerReturnsNullWhenFeatureKeyIsNull() throws ConfigParseException { + public void getFeatureVariableIntegerReturnsNullWhenFeatureKeyIsNull() throws Exception { String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - - assertNull(spyOptimizely.getFeatureVariableInteger( - null, - variableKey, - genericUserId - )); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); + assertNull(spyOptimizely.getFeatureVariableInteger(null, variableKey, genericUserId)); + logbackVerifier.expectMessage(Level.WARN, "The featureKey parameter must be nonnull."); - logbackVerifier.expectMessage( - Level.WARN, - "The featureKey parameter must be nonnull." - ); verify(spyOptimizely, times(1)).getFeatureVariableInteger( - isNull(String.class), - any(String.class), - any(String.class), - anyMapOf(String.class, String.class) + isNull(String.class), + any(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -4291,32 +4229,21 @@ public void getFeatureVariableIntegerReturnsNullWhenFeatureKeyIsNull() throws Co * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String)} * and returns null * when called with a null value for the variableKey parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableIntegerReturnsNullWhenVariableKeyIsNull() throws ConfigParseException { + public void getFeatureVariableIntegerReturnsNullWhenVariableKeyIsNull() throws Exception { String featureKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - - assertNull(spyOptimizely.getFeatureVariableInteger( - featureKey, - null, - genericUserId - )); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); + assertNull(spyOptimizely.getFeatureVariableInteger(featureKey, null, genericUserId)); + logbackVerifier.expectMessage(Level.WARN, "The variableKey parameter must be nonnull."); - logbackVerifier.expectMessage( - Level.WARN, - "The variableKey parameter must be nonnull." - ); verify(spyOptimizely, times(1)).getFeatureVariableInteger( - any(String.class), - isNull(String.class), - any(String.class), - anyMapOf(String.class, String.class) + any(String.class), + isNull(String.class), + any(String.class), + anyMapOf(String.class, String.class) ); } @@ -4325,33 +4252,22 @@ public void getFeatureVariableIntegerReturnsNullWhenVariableKeyIsNull() throws C * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String)} * and returns null * when called with a null value for the userID parameter. - * @throws ConfigParseException */ @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") @Test - public void getFeatureVariableIntegerReturnsNullWhenUserIdIsNull() throws ConfigParseException { + public void getFeatureVariableIntegerReturnsNullWhenUserIdIsNull() throws Exception { String featureKey = ""; String variableKey = ""; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - - assertNull(spyOptimizely.getFeatureVariableInteger( - featureKey, - variableKey, - null - )); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); + assertNull(spyOptimizely.getFeatureVariableInteger(featureKey, variableKey, null)); + logbackVerifier.expectMessage(Level.WARN, "The userId parameter must be nonnull."); - logbackVerifier.expectMessage( - Level.WARN, - "The userId parameter must be nonnull." - ); verify(spyOptimizely, times(1)).getFeatureVariableInteger( - any(String.class), - any(String.class), - isNull(String.class), - anyMapOf(String.class, String.class) + any(String.class), + any(String.class), + isNull(String.class), + anyMapOf(String.class, String.class) ); } @@ -4359,56 +4275,52 @@ public void getFeatureVariableIntegerReturnsNullWhenUserIdIsNull() throws Config * Verify {@link Optimizely#getFeatureVariableInteger(String, String, String)} * calls through to {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} * and both return the parsed Integer value from the value returned from - * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, LiveVariable.VariableType)}. - * @throws ConfigParseException + * {@link Optimizely#getFeatureVariableValueForType(String, String, String, Map, String)}. */ @Test - public void getFeatureVariableIntegerReturnsWhatInternalReturns() throws ConfigParseException { + public void getFeatureVariableIntegerReturnsWhatInternalReturns() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; Integer valueNoAttributes = 1; Integer valueWithAttributes = 2; Map<String, String> attributes = Collections.singletonMap("key", "value"); - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); - + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); - doReturn(valueNoAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()), - eq(LiveVariable.VariableType.INTEGER) + doReturn(valueNoAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()), + eq(FeatureVariable.INTEGER_TYPE) ); - doReturn(valueWithAttributes.toString()).when(spyOptimizely).getFeatureVariableValueForType( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(attributes), - eq(LiveVariable.VariableType.INTEGER) + doReturn(valueWithAttributes).when(spyOptimizely).getFeatureVariableValueForType( + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(attributes), + eq(FeatureVariable.INTEGER_TYPE) ); assertEquals(valueNoAttributes, spyOptimizely.getFeatureVariableInteger( - featureKey, - variableKey, - genericUserId + featureKey, + variableKey, + genericUserId )); verify(spyOptimizely).getFeatureVariableInteger( - eq(featureKey), - eq(variableKey), - eq(genericUserId), - eq(Collections.<String, String>emptyMap()) + eq(featureKey), + eq(variableKey), + eq(genericUserId), + eq(Collections.<String, String>emptyMap()) ); assertEquals(valueWithAttributes, spyOptimizely.getFeatureVariableInteger( - featureKey, - variableKey, - genericUserId, - attributes + featureKey, + variableKey, + genericUserId, + attributes )); } @@ -4416,85 +4328,669 @@ public void getFeatureVariableIntegerReturnsWhatInternalReturns() throws ConfigP * Verify that {@link Optimizely#getFeatureVariableInteger(String, String, String)} * and {@link Optimizely#getFeatureVariableInteger(String, String, String, Map)} * do not throw errors when they are unable to parse the value into an Integer. - * @throws ConfigParseException */ @Test - public void getFeatureVariableIntegerCatchesExceptionFromParsing() throws ConfigParseException { + public void getFeatureVariableIntegerCatchesExceptionFromParsing() throws Exception { String featureKey = "featureKey"; String variableKey = "variableKey"; String unParsableValue = "not_an_integer"; - Optimizely spyOptimizely = spy(Optimizely.builder(validDatafile, mockEventHandler) - .withConfig(validProjectConfig) - .build()); + Optimizely spyOptimizely = spy(optimizelyBuilder.build()); doReturn(unParsableValue).when(spyOptimizely).getFeatureVariableValueForType( - anyString(), - anyString(), - anyString(), - anyMapOf(String.class, String.class), - eq(LiveVariable.VariableType.INTEGER) + anyString(), + anyString(), + anyString(), + anyMapOf(String.class, String.class), + eq(FeatureVariable.INTEGER_TYPE) ); - assertNull(spyOptimizely.getFeatureVariableInteger( - featureKey, - variableKey, - genericUserId - )); + assertNull(spyOptimizely.getFeatureVariableInteger(featureKey, variableKey, genericUserId)); + } - logbackVerifier.expectMessage( - Level.ERROR, - "NumberFormatException while trying to parse \"" + unParsableValue + - "\" as Integer. " - ); + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is true + * returns variable value + */ + @Test + public void getFeatureVariableJSONUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("k1"), "s1"); + assertEquals(json.toMap().get("k2"), 103.5); + assertEquals(json.toMap().get("k3"), false); + assertEquals(((Map) json.toMap().get("k4")).get("kk1"), "ss1"); + assertEquals(((Map) json.toMap().get("k4")).get("kk2"), true); + + assertEquals(json.getValue("k1", String.class), "s1"); + assertEquals(json.getValue("k4.kk2", Boolean.class), true); + } + + /** + * Verify that the {@link Optimizely#getFeatureVariableJSON(String, String, String, Map)} + * is called when feature is in experiment and feature enabled is false + * than default value will gets returned + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getFeatureVariableJSONUserInExperimentFeatureOff() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String validVariableKey = VARIABLE_JSON_PATCHED_TYPE_KEY; + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getFeatureVariableJSON( + validFeatureKey, + validVariableKey, + userID, + null); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("k1"), "v1"); + assertEquals(json.toMap().get("k2"), 3.5); + assertEquals(json.toMap().get("k3"), true); + assertEquals(((Map) json.toMap().get("k4")).get("kk1"), "vv1"); + assertEquals(((Map) json.toMap().get("k4")).get("kk2"), false); + + assertEquals(json.getValue("k1", String.class), "v1"); + assertEquals(json.getValue("k4.kk2", Boolean.class), false); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * is called when feature is in experiment and feature enabled is true + * returns variable value + */ + @Test + public void getAllFeatureVariablesUserInExperimentFeatureOn() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"F\",\"rest_of_name\":\"red\",\"json_patched\":{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}}"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getAllFeatureVariables( + validFeatureKey, + testUserId, + Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("first_letter"), "F"); + assertEquals(json.toMap().get("rest_of_name"), "red"); + Map subMap = (Map) json.toMap().get("json_patched"); + assertEquals(subMap.get("k1"), "s1"); + assertEquals(subMap.get("k2"), 103.5); + assertEquals(subMap.get("k3"), false); + assertEquals(((Map) subMap.get("k4")).get("kk1"), "ss1"); + assertEquals(((Map) subMap.get("k4")).get("kk2"), true); + + assertEquals(json.getValue("first_letter", String.class), "F"); + assertEquals(json.getValue("json_patched.k1", String.class), "s1"); + assertEquals(json.getValue("json_patched.k4.kk2", Boolean.class), true); + } + + /** + * Verify that the {@link Optimizely#getAllFeatureVariables(String, String, Map)} + * is called when feature is in experiment and feature enabled is false + * than default value will gets returned + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getAllFeatureVariablesUserInExperimentFeatureOff() throws Exception { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + final String validFeatureKey = FEATURE_MULTI_VARIATE_FEATURE_KEY; + String expectedString = "{\"first_letter\":\"H\",\"rest_of_name\":\"arry\",\"json_patched\":{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}}"; + String userID = "Gred"; + + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON json = optimizely.getAllFeatureVariables( + validFeatureKey, + userID, + null); + + assertTrue(compareJsonStrings(json.toString(), expectedString)); + + assertEquals(json.toMap().get("first_letter"), "H"); + assertEquals(json.toMap().get("rest_of_name"), "arry"); + Map subMap = (Map) json.toMap().get("json_patched"); + assertEquals(subMap.get("k1"), "v1"); + assertEquals(subMap.get("k2"), 3.5); + assertEquals(subMap.get("k3"), true); + assertEquals(((Map) subMap.get("k4")).get("kk1"), "vv1"); + assertEquals(((Map) subMap.get("k4")).get("kk2"), false); + + assertEquals(json.getValue("first_letter", String.class), "H"); + assertEquals(json.getValue("json_patched.k1", String.class), "v1"); + assertEquals(json.getValue("json_patched.k4.kk2", Boolean.class), false); + } + + /** + * Verify {@link Optimizely#getAllFeatureVariables(String, String, Map)} with invalid parameters + */ + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getAllFeatureVariablesWithInvalidParameters() throws Exception { + Optimizely optimizely = optimizelyBuilder.build(); + + OptimizelyJSON value; + value = optimizely.getAllFeatureVariables(null, testUserId); + assertNull(value); + + value = optimizely.getAllFeatureVariables(FEATURE_MULTI_VARIATE_FEATURE_KEY, null); + assertNull(value); + + value = optimizely.getAllFeatureVariables("invalid-feature-flag", testUserId); + assertNull(value); + + Optimizely optimizelyInvalid = Optimizely.builder(invalidProjectConfigV5(), mockEventHandler).build(); + value = optimizelyInvalid.getAllFeatureVariables(FEATURE_MULTI_VARIATE_FEATURE_KEY, testUserId); + assertNull(value); } /** * Verify that {@link Optimizely#getVariation(String, String)} returns a variation when given an experiment - * with no audiences and no user attributes. + * with no audiences and no user attributes and verify that listener is getting called. */ @Test public void getVariationBucketingIdAttribute() throws Exception { - Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - Variation bucketedVariation = experiment.getVariations().get(0); - String bucketingKey = testBucketingIdKey; - String bucketingId = "blah"; - String userId = testUserId; - Map<String, String> testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - testUserAttributes.put(bucketingKey, bucketingId); + Experiment experiment = validProjectConfig.getExperiments().get(0); + Variation bucketedVariation = experiment.getVariations().get(1); + Map<String, String> testUserAttributes = Collections.singletonMap("browser_type", "chrome"); + + Optimizely optimizely = optimizelyBuilder.build(); + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(EXPERIMENT_KEY, experiment.getKey()); + testDecisionInfoMap.put(VARIATION_KEY, bucketedVariation.getKey()); + + int notificationId = optimizely.addDecisionNotificationHandler( + getDecisionListener(NotificationCenter.DecisionNotificationType.AB_TEST.toString(), + testUserId, + testUserAttributes, + testDecisionInfoMap)); + + Variation actualVariation = optimizely.getVariation(experiment.getKey(), testUserId, testUserAttributes); + assertThat(actualVariation, is(bucketedVariation)); + assertTrue(optimizely.notificationCenter.removeNotificationListener(notificationId)); + } + //======== isValid calls ========// + + /** + * Verify that {@link Optimizely#isValid()} returns false when the Optimizely instance is not valid + */ + @Test + public void isValidReturnsFalseWhenClientIsInvalid() throws Exception { + Optimizely optimizely = Optimizely.builder(invalidProjectConfigV5(), mockEventHandler).build(); - when(mockBucketer.bucket(experiment, bucketingId)).thenReturn(bucketedVariation); + assertFalse(optimizely.isValid()); + } - Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) - .withConfig(noAudienceProjectConfig) - .withBucketing(mockBucketer) - .withErrorHandler(mockErrorHandler) - .build(); + /** + * Verify that {@link Optimizely#isValid()} returns false when the Optimizely instance is not valid + */ + @Test + public void isValidReturnsTrueWhenClientIsValid() throws Exception { + Optimizely optimizely = optimizelyBuilder.build(); + assertTrue(optimizely.isValid()); + } - Variation actualVariation = optimizely.getVariation(experiment.getKey(), userId, testUserAttributes); + //======== Test Notification APIs ========// - verify(mockBucketer).bucket(experiment, bucketingId); + @Test + public void testGetNotificationCenter() { + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); + assertEquals(optimizely.notificationCenter, optimizely.getNotificationCenter()); + } - assertThat(actualVariation, is(bucketedVariation)); + @Test + public void testAddTrackNotificationHandler() { + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); + NotificationManager<TrackNotification> manager = optimizely.getNotificationCenter() + .getNotificationManager(TrackNotification.class); + + int notificationId = optimizely.addTrackNotificationHandler(message -> { + }); + assertTrue(manager.remove(notificationId)); + } + + @Test + public void testAddDecisionNotificationHandler() { + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); + NotificationManager<DecisionNotification> manager = optimizely.getNotificationCenter() + .getNotificationManager(DecisionNotification.class); + + int notificationId = optimizely.addDecisionNotificationHandler(message -> { + }); + assertTrue(manager.remove(notificationId)); + } + + @Test + public void testAddUpdateConfigNotificationHandler() { + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); + NotificationManager<UpdateConfigNotification> manager = optimizely.getNotificationCenter() + .getNotificationManager(UpdateConfigNotification.class); + + int notificationId = optimizely.addUpdateConfigNotificationHandler(message -> { + }); + assertTrue(manager.remove(notificationId)); + } + + @Test + public void testAddLogEventNotificationHandler() { + Optimizely optimizely = optimizelyBuilder.withConfigManager(projectConfigManagerReturningNull).build(); + NotificationManager<LogEvent> manager = optimizely.getNotificationCenter() + .getNotificationManager(LogEvent.class); + + int notificationId = optimizely.addLogEventNotificationHandler(message -> { + }); + assertTrue(manager.remove(notificationId)); } //======== Helper methods ========// private Experiment createUnknownExperiment() { return new Experiment("0987", "unknown_experiment", "Running", "1", - Collections.<String>emptyList(), - Collections.singletonList( - new Variation("8765", "unknown_variation", Collections.<LiveVariableUsageInstance>emptyList())), - Collections.<String, String>emptyMap(), - Collections.singletonList(new TrafficAllocation("8765", 4999))); + Collections.<String>emptyList(), + null, + Collections.singletonList( + new Variation("8765", "unknown_variation", Collections.<FeatureVariableUsageInstance>emptyList())), + Collections.<String, String>emptyMap(), + Collections.singletonList(new TrafficAllocation("8765", 4999))); } private EventType createUnknownEventType() { List<String> experimentIds = asList( - "223" + "223" ); return new EventType("8765", "unknown_event_type", experimentIds); } + + private boolean compareJsonStrings(String str1, String str2) { + JsonParser parser = new JsonParser(); + + JsonElement j1 = parser.parse(str1); + JsonElement j2 = parser.parse(str2); + return j1.equals(j2); + } + + private Map parseJsonString(String str) { + return new Gson().fromJson(str, Map.class); + } + + /* Invalid Experiment */ + @Test + @SuppressFBWarnings("NP") + public void setForcedVariationNullExperimentKey() { + Optimizely optimizely = optimizelyBuilder.build(); + assertFalse(optimizely.setForcedVariation(null, "testUser1", "vtag1")); + } + + @Test + @SuppressFBWarnings("NP") + public void getForcedVariationNullExperimentKey() { + Optimizely optimizely = optimizelyBuilder.build(); + assertNull(optimizely.getForcedVariation(null, "testUser1")); + } + + + @Test + public void setForcedVariationWrongExperimentKey() { + Optimizely optimizely = optimizelyBuilder.build(); + assertFalse(optimizely.setForcedVariation("wrongKey", "testUser1", "vtag1")); + + } + + @Test + public void getForcedVariationWrongExperimentKey() { + Optimizely optimizely = optimizelyBuilder.build(); + assertNull(optimizely.getForcedVariation("wrongKey", "testUser1")); + } + + @Test + public void setForcedVariationEmptyExperimentKey() { + Optimizely optimizely = optimizelyBuilder.build(); + assertFalse(optimizely.setForcedVariation("", "testUser1", "vtag1")); + + } + + @Test + public void getForcedVariationEmptyExperimentKey() { + Optimizely optimizely = optimizelyBuilder.build(); + assertNull(optimizely.getForcedVariation("", "testUser1")); + } + + @Test + public void getOptimizelyConfigValidDatafile() { + Optimizely optimizely = optimizelyBuilder.build(); + assertEquals(optimizely.getOptimizelyConfig().getDatafile(), validDatafile); + } + + // OptimizelyUserContext + + @Test + public void createUserContext_withAttributes() { + String userId = "testUser1"; + Map<String, Object> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertEquals(user.getAttributes(), attributes); + } + + @Test + public void createUserContext_noAttributes() { + String userId = "testUser1"; + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertTrue(user.getAttributes().isEmpty()); + } + + @Test + public void createUserContext_multiple() { + String userId1 = "testUser1"; + String userId2 = "testUser1"; + Map<String, Object> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Optimizely optimizely = optimizelyBuilder.build(); + OptimizelyUserContext user1 = optimizely.createUserContext(userId1, attributes); + OptimizelyUserContext user2 = optimizely.createUserContext(userId2); + + assertEquals(user1.getUserId(), userId1); + assertEquals(user1.getAttributes(), attributes); + assertEquals(user2.getUserId(), userId2); + assertTrue(user2.getAttributes().isEmpty()); + } + + @Test + public void getFlagVariationByKey() throws IOException { + String flagKey = "double_single_variable_feature"; + String variationKey = "pi_variation"; + Optimizely optimizely = Optimizely.builder().withDatafile(validConfigJsonV4()).build(); + Variation variation = optimizely.getProjectConfig().getFlagVariationByKey(flagKey, variationKey); + + assertNotNull(variation); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void initODPManagerWithoutProjectConfig() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + verify(mockODPManager, never()).updateSettings(any(), any(), any()); + } + + @Test + public void initODPManagerWithProjectConfig() throws IOException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely.builder() + .withDatafile(validConfigJsonV4()) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + verify(mockODPManager, times(1)).updateSettings(any(), any(), any()); + } + + @Test + public void sendODPEvent() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "identify", identifiers, data); + ArgumentCaptor<ODPEvent> eventArgument = ArgumentCaptor.forClass(ODPEvent.class); + verify(mockODPEventManager).sendEvent(eventArgument.capture()); + + assertEquals("fullstack", eventArgument.getValue().getType()); + assertEquals("identify", eventArgument.getValue().getAction()); + assertEquals(identifiers, eventArgument.getValue().getIdentifiers()); + assertEquals(data, eventArgument.getValue().getData()); + } + + @Test + public void sendODPEventInvalidConfig() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "identify", identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing sendODPEvent call."); + } + + @Test + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") + public void sendODPEventErrorNullAction() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", null, identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "ODP action is not valid (cannot be empty)."); + } + + @Test + public void sendODPEventErrorEmptyAction() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "", identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "ODP action is not valid (cannot be empty)."); + } + + @Test + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") + public void sendODPEventNullType() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent(null, "identify", identifiers, data); + ArgumentCaptor<ODPEvent> eventArgument = ArgumentCaptor.forClass(ODPEvent.class); + verify(mockODPEventManager).sendEvent(eventArgument.capture()); + + assertEquals("fullstack", eventArgument.getValue().getType()); + assertEquals("identify", eventArgument.getValue().getAction()); + assertEquals(identifiers, eventArgument.getValue().getIdentifiers()); + assertEquals(data, eventArgument.getValue().getData()); + } + + @Test + public void sendODPEventEmptyType() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + verify(mockODPEventManager).start(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("", "identify", identifiers, data); + ArgumentCaptor<ODPEvent> eventArgument = ArgumentCaptor.forClass(ODPEvent.class); + verify(mockODPEventManager).sendEvent(eventArgument.capture()); + + assertEquals("fullstack", eventArgument.getValue().getType()); + assertEquals("identify", eventArgument.getValue().getAction()); + assertEquals(identifiers, eventArgument.getValue().getIdentifiers()); + assertEquals(data, eventArgument.getValue().getData()); + } + + @Test + public void sendODPEventError() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .build(); + + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("id1", "value1"); + identifiers.put("id2", "value2"); + + Map<String, Object> data = new HashMap<>(); + data.put("sdk", "java"); + data.put("revision", 52); + + optimizely.sendODPEvent("fullstack", "identify", identifiers, data); + logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (ODP is not enabled)"); + } + + @Test + public void identifyUser() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(validProjectConfig); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + optimizely.identifyUser("the-user"); + Mockito.verify(mockODPEventManager, times(1)).identifyUser("the-user"); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java new file mode 100644 index 000000000..bb2d36192 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyUserContextTest.java @@ -0,0 +1,2087 @@ +/** + * + * Copyright 2021-2024, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import ch.qos.logback.classic.Level; +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.bucketing.UserProfile; +import com.optimizely.ab.bucketing.UserProfileService; +import com.optimizely.ab.bucketing.UserProfileUtils; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.event.EventProcessor; +import com.optimizely.ab.event.ForwardingEventProcessor; +import com.optimizely.ab.event.internal.ImpressionEvent; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.odp.*; +import com.optimizely.ab.optimizelydecision.DecisionMessage; +import com.optimizely.ab.optimizelydecision.OptimizelyDecideOption; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import junit.framework.TestCase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.CountDownLatch; + +import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; +import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; +import static com.optimizely.ab.notification.DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY; +import static com.optimizely.ab.notification.DecisionNotification.FlagDecisionNotificationBuilder.*; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class OptimizelyUserContextTest { + @Rule + public EventHandlerRule eventHandler = new EventHandlerRule(); + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + String userId = "tester"; + boolean isListenerCalled = false; + + Optimizely optimizely; + String datafile; + ProjectConfig config; + Map<String, Experiment> experimentIdMapping; + Map<String, FeatureFlag> featureKeyMapping; + Map<String, Group> groupIdMapping; + + @Before + public void setUp() throws Exception { + datafile = Resources.toString(Resources.getResource("config/decide-project-config.json"), Charsets.UTF_8); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .build(); + } + + @Test + public void optimizelyUserContext_withAttributes() { + Map<String, Object> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + assertEquals(user.getAttributes(), attributes); + } + + @Test + public void optimizelyUserContext_noAttributes() { + OptimizelyUserContext user_1 = new OptimizelyUserContext(optimizely, userId); + OptimizelyUserContext user_2 = new OptimizelyUserContext(optimizely, userId); + + assertEquals(user_1.getOptimizely(), optimizely); + assertEquals(user_1.getUserId(), userId); + assertTrue(user_1.getAttributes().isEmpty()); + assertEquals(user_1.hashCode(), user_2.hashCode()); + } + + @Test + public void setAttribute() { + Map<String, Object> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + user.setAttribute("k1", "v1"); + user.setAttribute("k2", true); + user.setAttribute("k3", 100); + user.setAttribute("k4", 3.5); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + Map<String, Object> newAttributes = user.getAttributes(); + assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), AUDIENCE_GRYFFINDOR_VALUE); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get("k2"), true); + assertEquals(newAttributes.get("k3"), 100); + assertEquals(newAttributes.get("k4"), 3.5); + } + + @Test + public void setAttribute_noAttribute() { + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId); + + user.setAttribute("k1", "v1"); + user.setAttribute("k2", true); + + assertEquals(user.getOptimizely(), optimizely); + assertEquals(user.getUserId(), userId); + Map<String, Object> newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get("k2"), true); + } + + @Test + public void setAttribute_override() { + Map<String, Object> attributes = Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + user.setAttribute("k1", "v1"); + user.setAttribute(ATTRIBUTE_HOUSE_KEY, "v2"); + + Map<String, Object> newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), "v1"); + assertEquals(newAttributes.get(ATTRIBUTE_HOUSE_KEY), "v2"); + } + + @Test + public void setAttribute_nullValue() { + Map<String, Object> attributes = Collections.singletonMap("k1", null); + OptimizelyUserContext user = new OptimizelyUserContext(optimizely, userId, attributes); + + Map<String, Object> newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), null); + + user.setAttribute("k1", true); + newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), true); + + user.setAttribute("k1", null); + newAttributes = user.getAttributes(); + assertEquals(newAttributes.get("k1"), null); + } + + // decide + + @Test + public void decide_featureTest() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decide_rollout() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_1"; + String experimentKey = "18322080788"; + String variationKey = "18257766532"; + String experimentId = "18322080788"; + String variationId = "18257766532"; + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + assertTrue(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), experimentKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.ROLLOUT.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decide_nullVariation() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_3"; + OptimizelyJSON variablesExpected = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), null); + assertFalse(decision.getEnabled()); + assertEquals(decision.getVariables().toMap(), variablesExpected.toMap()); + assertEquals(decision.getRuleKey(), null); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + assertTrue(decision.getReasons().isEmpty()); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey("") + .setRuleType(FeatureDecision.DecisionSource.ROLLOUT.toString()) + .setVariationKey("") + .setEnabled(false) + .build(); + eventHandler.expectImpression(null, "", userId, Collections.emptyMap(), metadata); + } + + // decideAll + + @Test + public void decideAll_oneFlag() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + + List<String> flagKeys = Arrays.asList(flagKey); + OptimizelyJSON variablesExpected = optimizely.getAllFeatureVariables(flagKey, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map<String, OptimizelyDecision> decisions = user.decideForKeys(flagKeys); + + assertTrue(decisions.size() == 1); + OptimizelyDecision decision = decisions.get(flagKey); + + OptimizelyDecision expDecision = new OptimizelyDecision( + variationKey, + true, + variablesExpected, + experimentKey, + flagKey, + user, + Collections.emptyList()); + assertEquals(decision, expDecision); + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey(experimentKey) + .setRuleType(FeatureDecision.DecisionSource.FEATURE_TEST.toString()) + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + } + + @Test + public void decideAll_twoFlags() { + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + + List<String> flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map<String, OptimizelyDecision> decisions = user.decideForKeys(flagKeys); + + assertTrue(decisions.size() == 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision("a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision("variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + } + + @Test + public void decideAll_allFlags() { + EventProcessor mockEventProcessor = mock(EventProcessor.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(mockEventProcessor) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + OptimizelyJSON variablesExpected2 = optimizely.getAllFeatureVariables(flagKey2, userId); + OptimizelyJSON variablesExpected3 = new OptimizelyJSON(Collections.emptyMap()); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map<String, OptimizelyDecision> decisions = user.decideAll(); + assertEquals(decisions.size(), 3); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected2, + "exp_no_audience", + flagKey2, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey3), + new OptimizelyDecision( + null, + false, + variablesExpected3, + null, + flagKey3, + user, + Collections.emptyList())); + + ArgumentCaptor<ImpressionEvent> argumentCaptor = ArgumentCaptor.forClass(ImpressionEvent.class); + verify(mockEventProcessor, times(3)).process(argumentCaptor.capture()); + + List<ImpressionEvent> sentEvents = argumentCaptor.getAllValues(); + assertEquals(sentEvents.size(), 3); + + assertEquals(sentEvents.get(0).getExperimentKey(), "exp_with_audience"); + assertEquals(sentEvents.get(0).getVariationKey(), "a"); + assertEquals(sentEvents.get(0).getUserContext().getUserId(), userId); + + + assertEquals(sentEvents.get(1).getExperimentKey(), "exp_no_audience"); + assertEquals(sentEvents.get(1).getVariationKey(), "variation_with_traffic"); + assertEquals(sentEvents.get(1).getUserContext().getUserId(), userId); + + assertEquals(sentEvents.get(2).getExperimentKey(), ""); + assertEquals(sentEvents.get(2).getUserContext().getUserId(), userId); + } + + @Test + public void decideForKeys_ups_batching() throws Exception { + UserProfileService ups = mock(UserProfileService.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + String flagKey1 = "feature_1"; + String flagKey2 = "feature_2"; + String flagKey3 = "feature_3"; + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map<String, OptimizelyDecision> decisions = user.decideForKeys(Arrays.asList( + flagKey1, flagKey2, flagKey3 + )); + + assertEquals(decisions.size(), 3); + + ArgumentCaptor<Map> argumentCaptor = ArgumentCaptor.forClass(Map.class); + + + verify(ups, times(1)).lookup(userId); + verify(ups, times(1)).save(argumentCaptor.capture()); + + Map<String, Object> savedUps = argumentCaptor.getValue(); + UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); + + assertEquals(savedProfile.userId, userId); + } + + @Test + public void decideAll_ups_batching() throws Exception { + UserProfileService ups = mock(UserProfileService.class); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + Map<String, OptimizelyDecision> decisions = user.decideAll(); + + assertEquals(decisions.size(), 3); + + ArgumentCaptor<Map> argumentCaptor = ArgumentCaptor.forClass(Map.class); + + + verify(ups, times(1)).lookup(userId); + verify(ups, times(1)).save(argumentCaptor.capture()); + + Map<String, Object> savedUps = argumentCaptor.getValue(); + UserProfile savedProfile = UserProfileUtils.convertMapToUserProfile(savedUps); + + assertEquals(savedProfile.userId, userId); + } + + @Test + public void decideAll_allFlags_enabledFlagsOnly() { + String flagKey1 = "feature_1"; + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId, Collections.singletonMap("gender", "f")); + Map<String, OptimizelyDecision> decisions = user.decideAll(Arrays.asList(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)); + + assertTrue(decisions.size() == 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "a", + true, + variablesExpected1, + "exp_with_audience", + flagKey1, + user, + Collections.emptyList())); + } + + // trackEvent + + @Test + public void trackEvent() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + String eventKey = "event1"; + Map<String, Object> eventTags = Collections.singletonMap("name", "carrot"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + user.trackEvent(eventKey, eventTags); + + eventHandler.expectConversion(eventKey, userId, attributes, eventTags); + } + + @Test + public void trackEvent_noEventTags() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + String eventKey = "event1"; + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + user.trackEvent(eventKey); + + eventHandler.expectConversion(eventKey, userId, attributes); + } + + @Test + public void trackEvent_emptyAttributes() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String eventKey = "event1"; + Map<String, ?> eventTags = Collections.singletonMap("name", "carrot"); + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.trackEvent(eventKey, eventTags); + + eventHandler.expectConversion(eventKey, userId, Collections.emptyMap(), eventTags); + } + + // send events + + @Test + public void decide_sendEvent() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + String variationKey = "variation_with_traffic"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getVariationKey(), variationKey); + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + } + + @Test + public void decide_doNotSendEvent_withOption() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + String flagKey = "feature_2"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + + assertEquals(decision.getVariationKey(), "variation_with_traffic"); + + // impression event not expected here + } + + @Test + public void decide_sendEvent_featureTest_withSendFlagDecisionsOn() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + String flagKey = "feature_2"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + eventHandler.expectImpression(experimentId, variationId, userId, attributes); + } + + @Test + public void decide_sendEvent_rollout_withSendFlagDecisionsOn() { + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + String flagKey = "feature_3"; + String experimentId = null; + String variationId = null; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + eventHandler.expectImpression(null, "", userId, attributes); + } + + @Test + public void decide_sendEvent_featureTest_withSendFlagDecisionsOff() { + String datafileWithSendFlagDecisionsOff = datafile.replace("\"sendFlagDecisions\": true", "\"sendFlagDecisions\": false"); + optimizely = new Optimizely.Builder() + .withDatafile(datafileWithSendFlagDecisionsOff) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + String flagKey = "feature_2"; + String experimentId = "10420810910"; + String variationId = "10418551353"; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + eventHandler.expectImpression(experimentId, variationId, userId, attributes); + } + + @Test + public void decide_sendEvent_rollout_withSendFlagDecisionsOff() { + String datafileWithSendFlagDecisionsOff = datafile.replace("\"sendFlagDecisions\": true", "\"sendFlagDecisions\": false"); + optimizely = new Optimizely.Builder() + .withDatafile(datafileWithSendFlagDecisionsOff) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), false); + isListenerCalled = true; + }); + + String flagKey = "feature_3"; + isListenerCalled = false; + user.decide(flagKey); + assertTrue(isListenerCalled); + + // impression event not expected here + } + + // notifications + + @Test + public void decisionNotification() { + String flagKey = "feature_2"; + String variationKey = "variation_with_traffic"; + boolean enabled = true; + OptimizelyJSON variables = optimizely.getAllFeatureVariables(flagKey, userId); + String ruleKey = "exp_no_audience"; + List<String> reasons = Collections.emptyList(); + String experimentId = "10420810910"; + String variationId = "10418551353"; + + final Map<String, Object> testDecisionInfoMap = new HashMap<>(); + testDecisionInfoMap.put(FLAG_KEY, flagKey); + testDecisionInfoMap.put(VARIATION_KEY, variationKey); + testDecisionInfoMap.put(ENABLED, enabled); + testDecisionInfoMap.put(VARIABLES, variables.toMap()); + testDecisionInfoMap.put(RULE_KEY, ruleKey); + testDecisionInfoMap.put(REASONS, reasons); + testDecisionInfoMap.put(EXPERIMENT_ID, experimentId); + testDecisionInfoMap.put(VARIATION_ID, variationId); + + Map<String, Object> attributes = Collections.singletonMap("gender", "f"); + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getType(), NotificationCenter.DecisionNotificationType.FLAG.toString()); + Assert.assertEquals(decisionNotification.getUserId(), userId); + Assert.assertEquals(decisionNotification.getAttributes(), attributes); + Assert.assertEquals(decisionNotification.getDecisionInfo(), testDecisionInfoMap); + isListenerCalled = true; + }); + + isListenerCalled = false; + testDecisionInfoMap.put(DECISION_EVENT_DISPATCHED, true); + user.decide(flagKey); + assertTrue(isListenerCalled); + + isListenerCalled = false; + testDecisionInfoMap.put(DECISION_EVENT_DISPATCHED, false); + user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.DISABLE_DECISION_EVENT)); + assertTrue(isListenerCalled); + } + + // options + + @Test + public void decideOptions_bypassUPS() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String variationId1 = "10418551353"; + String variationId2 = "10418510624"; + String variationKey1 = "variation_with_traffic"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + // should return variationId2 set by UPS + assertEquals(decision.getVariationKey(), variationKey2); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE)); + // should ignore variationId2 set by UPS and return variationId1 + assertEquals(decision.getVariationKey(), variationKey1); + // also should not save either + verify(ups, never()).save(anyObject()); + } + + @Test + public void decideOptions_excludeVariables() { + String flagKey = "feature_1"; + OptimizelyUserContext user = optimizely.createUserContext(userId); + + OptimizelyDecision decision = user.decide(flagKey); + assertTrue(decision.getVariables().toMap().size() > 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.EXCLUDE_VARIABLES)); + assertTrue(decision.getVariables().toMap().size() == 0); + } + + @Test + public void decideOptions_includeReasons() { + OptimizelyUserContext user = optimizely.createUserContext(userId); + + String flagKey = "invalid_key"; + OptimizelyDecision decision = user.decide(flagKey); + assertEquals(decision.getReasons().size(), 1); + TestCase.assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + + flagKey = "feature_1"; + decision = user.decide(flagKey); + assertEquals(decision.getReasons().size(), 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + assertTrue(decision.getReasons().size() > 0); + } + + public void decideOptions_disableDispatchEvent() { + // tested already with decide_doNotSendEvent() above + } + + public void decideOptions_enabledFlagsOnly() { + // tested already with decideAll_allFlags_enabledFlagsOnly() above + } + + @Test + public void decideOptions_defaultDecideOptions() { + List<OptimizelyDecideOption> options = Arrays.asList( + OptimizelyDecideOption.EXCLUDE_VARIABLES + ); + + optimizely = Optimizely.builder() + .withDatafile(datafile) + .withDefaultDecideOptions(options) + .build(); + + String flagKey = "feature_1"; + OptimizelyUserContext user = optimizely.createUserContext(userId); + + // should be excluded by DefaultDecideOption + OptimizelyDecision decision = user.decide(flagKey); + assertTrue(decision.getVariables().toMap().size() == 0); + + decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS, OptimizelyDecideOption.EXCLUDE_VARIABLES)); + // other options should work as well + assertTrue(decision.getReasons().size() > 0); + // redundant setting ignored + assertTrue(decision.getVariables().toMap().size() == 0); + } + + // errors + + @Test + public void decide_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), user); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decide_invalidFeatureKey() { + String flagKey = "invalid_key"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertNull(decision.getVariationKey()); + assertFalse(decision.getEnabled()); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + } + + @Test + public void decideAll_sdkNotReady() { + List<String> flagKeys = Arrays.asList("feature_1"); + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map<String, OptimizelyDecision> decisions = user.decideForKeys(flagKeys); + + assertEquals(decisions.size(), 0); + } + + @Test + public void decideAll_errorDecisionIncluded() { + String flagKey1 = "feature_2"; + String flagKey2 = "invalid_key"; + + List<String> flagKeys = Arrays.asList(flagKey1, flagKey2); + OptimizelyJSON variablesExpected1 = optimizely.getAllFeatureVariables(flagKey1, userId); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + Map<String, OptimizelyDecision> decisions = user.decideForKeys(flagKeys); + + assertEquals(decisions.size(), 2); + + assertEquals( + decisions.get(flagKey1), + new OptimizelyDecision( + "variation_with_traffic", + true, + variablesExpected1, + "exp_no_audience", + flagKey1, + user, + Collections.emptyList())); + assertEquals( + decisions.get(flagKey2), + OptimizelyDecision.newErrorDecision( + flagKey2, + user, + DecisionMessage.FLAG_KEY_INVALID.reason(flagKey2))); + } + + // reasons (errors) + + @Test + public void decideReasons_sdkNotReady() { + String flagKey = "feature_1"; + + Optimizely optimizely = new Optimizely.Builder().build(); + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.SDK_NOT_READY.reason()); + } + + @Test + public void decideReasons_featureKeyInvalid() { + String flagKey = "invalid_key"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), DecisionMessage.FLAG_KEY_INVALID.reason(flagKey)); + } + + @Test + public void decideReasons_variableValueInvalid() { + String flagKey = "feature_1"; + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + List<FeatureVariable> variables = Arrays.asList(new FeatureVariable("any-id", "any-key", "invalid", null, "integer", null)); + when(flag.getVariables()).thenReturn(variables); + addSpyFeatureFlag(flag); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey); + + assertEquals(decision.getReasons().get(0), DecisionMessage.VARIABLE_VALUE_INVALID.reason("any-key")); + } + + // reasons (infos with includeReasons) + + @Test + public void decideReasons_experimentNotRunning() { + String flagKey = "feature_1"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.isActive()).thenReturn(false); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Experiment \"exp_with_audience\" is not running.") + )); + } + + @Test + public void decideReasons_gotVariationFromUserProfile() throws Exception { + String flagKey = "feature_2"; // embedding experiment: "exp_no_audience" + String experimentId = "10420810910"; // "exp_no_audience" + String experimentKey = "exp_no_audience"; + String variationId2 = "10418510624"; + String variationKey2 = "variation_no_traffic"; + + UserProfileService ups = mock(UserProfileService.class); + when(ups.lookup(userId)).thenReturn(createUserProfileMap(experimentId, variationId2)); + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withUserProfileService(ups) + .build(); + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("Returning previously activated variation \"%s\" of experiment \"%s\" for user \"%s\" from user profile.", variationKey2, experimentKey, userId) + )); + } + + @Test + public void decideReasons_forcedVariationFound() { + String flagKey = "feature_1"; + String variationKey = "b"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getUserIdToVariationKeyMap()).thenReturn(Collections.singletonMap(userId, variationKey)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" is forced in variation \"%s\".", userId, variationKey) + )); + } + + @Test + public void decideReasons_forcedVariationFoundButInvalid() { + String flagKey = "feature_1"; + String variationKey = "invalid-key"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getUserIdToVariationKeyMap()).thenReturn(Collections.singletonMap(userId, variationKey)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Variation \"%s\" is not in the datafile. Not activating user \"%s\".", variationKey, userId) + )); + } + + @Test + public void decideReasons_userMeetsConditionsForTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "US"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, flagKey) + )); + } + + @Test + public void decideReasons_userDoesntMeetConditionsForTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "CA"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" does not meet conditions for targeting rule \"%d\".", userId, 1) + )); + } + + @Test + public void decideReasons_userBucketedIntoTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "US"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".", userId, flagKey) + )); + } + + @Test + public void decideReasons_userBucketedIntoEveryoneTargetingRule() { + String flagKey = "feature_1"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("country", "KO"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" meets conditions for targeting rule \"Everyone Else\".", userId) + )); + } + + @Test + public void decideReasons_userNotBucketedIntoTargetingRule() { + String flagKey = "feature_1"; + String experimentKey = "3332020494"; // experimentKey of rollout[2] + + OptimizelyUserContext user = optimizely.createUserContext(userId); + user.setAttribute("browser", "safari"); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is not in any variation of experiment \"%s\".", userId, experimentKey) + )); + } + + @Test + public void decideReasons_userBucketedIntoVariationInExperiment() { + String flagKey = "feature_2"; + String experimentKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is in variation \"%s\" of experiment \"%s\".", userId, variationKey, experimentKey) + )); + } + + @Test + public void decideReasons_userNotBucketedIntoVariation() { + String flagKey = "feature_2"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getTrafficAllocation()).thenReturn(Arrays.asList(new TrafficAllocation("any-id", 0))); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"%s\" is not in any variation of experiment \"exp_no_audience\".", userId) + )); + } + + @Test + public void decideReasons_userBucketedIntoExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10390965532"; // "group_exp_1" + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is in experiment \"group_exp_1\" of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotBucketedIntoExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10420843432"; // "group_exp_2" + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is not in experiment \"group_exp_2\" of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotBucketedIntoAnyExperimentInGroup() { + String flagKey = "feature_3"; + String experimentId = "10390965532"; // "group_exp_1" + String groupId = "13142870430"; + + FeatureFlag flag = getSpyFeatureFlag(flagKey); + when(flag.getExperimentIds()).thenReturn(Arrays.asList(experimentId)); + addSpyFeatureFlag(flag); + + Group group = getSpyGroup(groupId); + when(group.getTrafficAllocation()).thenReturn(Collections.emptyList()); + addSpyGroup(group); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("User with bucketingId \"tester\" is not in any experiment of group 13142870430.") + )); + } + + @Test + public void decideReasons_userNotInExperiment() { + String flagKey = "feature_1"; + String experimentKey = "exp_with_audience"; + + OptimizelyUserContext user = optimizely.createUserContext(userId); + OptimizelyDecision decision = user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("User \"%s\" does not meet conditions to be in experiment \"%s\".", userId, experimentKey) + )); + } + + @Test + public void decideReasons_conditionNoMatchingAudience() throws ConfigParseException { + String flagKey = "feature_1"; + String audienceId = "invalid_id"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_evaluateAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "13389130056"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("country", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_evaluateAttributeValueOutOfRange() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", (float)Math.pow(2, 54))); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_userAttributeInvalidType() { + String flagKey = "feature_1"; + String audienceId = "invalid_type"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_userAttributeInvalidMatch() { + String flagKey = "feature_1"; + String audienceId = "invalid_match"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_userAttributeNilValue() { + String flagKey = "feature_1"; + String audienceId = "nil_value"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey, Collections.singletonMap("age", 25)); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void decideReasons_missingAttributeValue() { + String flagKey = "feature_1"; + String audienceId = "age_18"; + + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + OptimizelyDecision decision = callDecideWithIncludeReasons(flagKey); + + assertTrue(decision.getReasons().contains( + String.format("Audiences for experiment \"%s\" collectively evaluated to null.", experiment.getKey()) + )); + } + + @Test + public void setForcedDecisionWithRuleKeyTest() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + String foundVariationKey = optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey(); + assertEquals(variationKey, foundVariationKey); + } + + @Test + public void setForcedDecisionsWithRuleKeyTest() { + String flagKey = "feature_2"; + String ruleKey = "exp_no_audience"; + String ruleKey2 = "88888"; + String variationKey = "33333"; + String variationKey2 = "variation_with_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + OptimizelyDecisionContext optimizelyDecisionContext2 = new OptimizelyDecisionContext(flagKey, ruleKey2); + OptimizelyForcedDecision optimizelyForcedDecision2 = new OptimizelyForcedDecision(variationKey2); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext2, optimizelyForcedDecision2); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + assertEquals(variationKey2, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext2).getVariationKey()); + + // Update first forcedDecision + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision2); + assertEquals(variationKey2, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey2, flagKey, ruleKey, userId) + )); + } + + @Test + public void setForcedDecisionWithoutRuleKeyTest() { + String flagKey = "55555"; + String variationKey = "33333"; + String updatedVariationKey = "55555"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + OptimizelyForcedDecision updatedOptimizelyForcedDecision = new OptimizelyForcedDecision(updatedVariationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Update forcedDecision + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, updatedOptimizelyForcedDecision); + assertEquals(updatedVariationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + + @Test + public void getForcedVariationWithRuleKey() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + @Test + public void failedGetForcedDecisionWithRuleKey() { + String flagKey = "55555"; + String invalidFlagKey = "11"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyDecisionContext invalidOptimizelyDecisionContext = new OptimizelyDecisionContext(invalidFlagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(invalidOptimizelyDecisionContext)); + } + + @Test + public void getForcedVariationWithoutRuleKey() { + String flagKey = "55555"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + } + + + @Test + public void failedGetForcedDecisionWithoutRuleKey() { + String flagKey = "55555"; + String invalidFlagKey = "11"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext invalidOptimizelyDecisionContext = new OptimizelyDecisionContext(invalidFlagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertNull(optimizelyUserContext.getForcedDecision(invalidOptimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithRuleKey() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithoutRuleKey() { + String flagKey = "55555"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContext)); + } + + @Test + public void removeForcedDecisionWithNullRuleKeyAfterAddingWithRuleKey() { + String flagKey = "flag2"; + String ruleKey = "default-rollout-3045-20390585493"; + String variationKey = "variation2"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext optimizelyDecisionContextNonNull = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(optimizelyDecisionContextNonNull)); + } + + @Test + public void removeForcedDecisionWithIncorrectFlagKey() { + String flagKey = "55555"; + String variationKey = "variation2"; + String incorrectFlagKey = "flag1"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyDecisionContext incorrectOptimizelyDecisionContext = new OptimizelyDecisionContext(incorrectFlagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(incorrectOptimizelyDecisionContext)); + } + + + @Test + public void removeForcedDecisionWithIncorrectFlagKeyButSimilarRuleKey() { + String flagKey = "flag2"; + String incorrectFlagKey = "flag3"; + String ruleKey = "default-rollout-3045-20390585493"; + String variationKey = "variation2"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyDecisionContext similarOptimizelyDecisionContext = new OptimizelyDecisionContext(incorrectFlagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertFalse(optimizelyUserContext.removeForcedDecision(similarOptimizelyDecisionContext)); + } + + @Test + public void removeAllForcedDecisions() { + String flagKey = "55555"; + String ruleKey = "77777"; + String variationKey = "33333"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertTrue(optimizelyUserContext.removeAllForcedDecisions()); + } + + @Test + public void setForcedDecisionsAndCallDecide() { + String flagKey = "feature_2"; + String ruleKey = "exp_no_audience"; + String variationKey = "variation_with_traffic"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + /******************************************[START DECIDE TESTS WITH FDs]******************************************/ + @Test + public void setForcedDecisionsAndCallDecideFlagToDecision() { + String flagKey = "feature_1"; + String variationKey = "a"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "10389729780"; + String experimentId = ""; + + + DecisionMetadata metadata = new DecisionMetadata.Builder() + .setFlagKey(flagKey) + .setRuleKey("") + .setRuleType("feature-test") + .setVariationKey(variationKey) + .setEnabled(true) + .build(); + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap(), metadata); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s) and user (%s) in the forced decision map.", variationKey, flagKey, userId) + )); + } + @Test + public void setForcedDecisionsAndCallDecideExperimentRuleToDecision() { + String flagKey = "feature_1"; + String ruleKey = "exp_with_audience"; + String variationKey = "a"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "10389729780"; + String experimentId = "10390977673"; + + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + + @Test + public void setForcedDecisionsAndCallDecideDeliveryRuleToDecision() { + String flagKey = "feature_1"; + String ruleKey = "3332020515"; + String variationKey = "3324490633"; + + optimizely = new Optimizely.Builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + assertEquals(variationKey, optimizelyUserContext.getForcedDecision(optimizelyDecisionContext).getVariationKey()); + + optimizely.addDecisionNotificationHandler( + decisionNotification -> { + Assert.assertEquals(decisionNotification.getDecisionInfo().get(DECISION_EVENT_DISPATCHED), true); + isListenerCalled = true; + }); + + isListenerCalled = false; + + // Test to confirm decide uses proper FD + OptimizelyDecision decision = optimizelyUserContext.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + + assertTrue(isListenerCalled); + + String variationId = "3324490633"; + String experimentId = "3332020515"; + + + eventHandler.expectImpression(experimentId, variationId, userId, Collections.emptyMap()); + + assertNotNull(decision); + assertTrue(decision.getReasons().contains( + String.format("Variation (%s) is mapped to flag (%s), rule (%s) and user (%s) in the forced decision map.", variationKey, flagKey, ruleKey, userId) + )); + } + /********************************************[END DECIDE TESTS WITH FDs]******************************************/ + + @Test + public void fetchQualifiedSegments() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + assertTrue(userContext.fetchQualifiedSegments()); + verify(mockODPSegmentManager).getQualifiedSegments("test-user", Collections.emptyList()); + + assertTrue(userContext.fetchQualifiedSegments(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + verify(mockODPSegmentManager).getQualifiedSegments("test-user", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + } + + @Test + public void fetchQualifiedSegmentsErrorWhenConfigIsInvalid() { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + assertFalse(userContext.fetchQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing fetchQualifiedSegments call."); + } + + @Test + public void fetchQualifiedSegmentsError() { + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + assertFalse(userContext.fetchQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)."); + } + + @Test + public void fetchQualifiedSegmentsAsync() throws InterruptedException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + doAnswer( + invocation -> { + ODPSegmentManager.ODPSegmentFetchCallback callback = invocation.getArgumentAt(1, ODPSegmentManager.ODPSegmentFetchCallback.class); + callback.onCompleted(Arrays.asList("segment1", "segment2")); + return null; + } + ).when(mockODPSegmentManager).getQualifiedSegments(any(), (ODPSegmentManager.ODPSegmentFetchCallback) any(), any()); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq("test-user"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.emptyList())); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + + // reset qualified segments + userContext.setQualifiedSegments(Collections.emptyList()); + CountDownLatch countDownLatch2 = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch2.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch2.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq("test-user"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + } + + @Test + public void fetchQualifiedSegmentsAsyncWithVUID() throws InterruptedException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPApiManager mockAPIManager = mock(ODPApiManager.class); + ODPSegmentManager mockODPSegmentManager = spy(new ODPSegmentManager(mockAPIManager)); + ODPManager mockODPManager = mock(ODPManager.class); + + doAnswer( + invocation -> { + ODPSegmentManager.ODPSegmentFetchCallback callback = invocation.getArgumentAt(2, ODPSegmentManager.ODPSegmentFetchCallback.class); + callback.onCompleted(Arrays.asList("segment1", "segment2")); + return null; + } + ).when(mockODPSegmentManager).getQualifiedSegments(any(), eq("vuid_f6db3d60ba3a493d8e41bc995bb"), (ODPSegmentManager.ODPSegmentFetchCallback) any(), any()); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("vuid_f6db3d60ba3a493d8e41bc995bb"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.VUID), eq("vuid_f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.emptyList())); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + + // reset qualified segments + userContext.setQualifiedSegments(Collections.emptyList()); + CountDownLatch countDownLatch2 = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch2.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch2.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.VUID) ,eq("vuid_f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + } + + + @Test + public void fetchQualifiedSegmentsAsyncWithUserID() throws InterruptedException { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPApiManager mockAPIManager = mock(ODPApiManager.class); + ODPSegmentManager mockODPSegmentManager = spy(new ODPSegmentManager(mockAPIManager)); + ODPManager mockODPManager = mock(ODPManager.class); + + doAnswer( + invocation -> { + ODPSegmentManager.ODPSegmentFetchCallback callback = invocation.getArgumentAt(2, ODPSegmentManager.ODPSegmentFetchCallback.class); + callback.onCompleted(Arrays.asList("segment1", "segment2")); + return null; + } + ).when(mockODPSegmentManager).getQualifiedSegments(any(), eq("f6db3d60ba3a493d8e41bc995bb"), (ODPSegmentManager.ODPSegmentFetchCallback) any(), any()); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("f6db3d60ba3a493d8e41bc995bb"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.FS_USER_ID), eq("f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.emptyList())); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + + // reset qualified segments + userContext.setQualifiedSegments(Collections.emptyList()); + CountDownLatch countDownLatch2 = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertTrue(isFetchSuccessful); + countDownLatch2.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch2.await(); + verify(mockODPSegmentManager).getQualifiedSegments(eq(ODPUserKey.FS_USER_ID) ,eq("f6db3d60ba3a493d8e41bc995bb"), any(ODPSegmentManager.ODPSegmentFetchCallback.class), eq(Collections.singletonList(ODPSegmentOption.RESET_CACHE))); + assertEquals(Arrays.asList("segment1", "segment2"), userContext.getQualifiedSegments()); + } + + @Test + public void fetchQualifiedSegmentsAsyncError() throws InterruptedException { + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertFalse(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + assertEquals(null, userContext.getQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)."); + } + + @Test + public void fetchQualifiedSegmentsAsyncErrorWhenConfigIsInvalid() throws InterruptedException { + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + + CountDownLatch countDownLatch = new CountDownLatch(1); + userContext.fetchQualifiedSegments((Boolean isFetchSuccessful) -> { + assertFalse(isFetchSuccessful); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + assertEquals(null, userContext.getQualifiedSegments()); + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing fetchQualifiedSegments call."); + } + + @Test + public void identifyUserErrorWhenConfigIsInvalid() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + ProjectConfigManager mockProjectConfigManager = mock(ProjectConfigManager.class); + Mockito.when(mockProjectConfigManager.getConfig()).thenReturn(null); + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withConfigManager(mockProjectConfigManager) + .withODPManager(mockODPManager) + .build(); + + optimizely.createUserContext("test-user"); + verify(mockODPEventManager, never()).identifyUser("test-user"); + Mockito.reset(mockODPEventManager); + + logbackVerifier.expectMessage(Level.ERROR, "Optimizely instance is not valid, failing identifyUser call."); + } + + @Test + public void identifyUser() { + ODPEventManager mockODPEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockODPSegmentManager = mock(ODPSegmentManager.class); + ODPManager mockODPManager = mock(ODPManager.class); + + Mockito.when(mockODPManager.getEventManager()).thenReturn(mockODPEventManager); + Mockito.when(mockODPManager.getSegmentManager()).thenReturn(mockODPSegmentManager); + + Optimizely optimizely = Optimizely.builder() + .withDatafile(datafile) + .withEventProcessor(new ForwardingEventProcessor(eventHandler, null)) + .withODPManager(mockODPManager) + .build(); + + OptimizelyUserContext userContext = optimizely.createUserContext("test-user"); + verify(mockODPEventManager).identifyUser("test-user"); + + Mockito.reset(mockODPEventManager); + OptimizelyUserContext userContextClone = userContext.copy(); + + // identifyUser should not be called the new userContext is created through copy + verify(mockODPEventManager, never()).identifyUser("test-user"); + + assertNotSame(userContextClone, userContext); + } + + // utils + + Map<String, Object> createUserProfileMap(String experimentId, String variationId) { + Map<String, Object> userProfileMap = new HashMap<String, Object>(); + userProfileMap.put(UserProfileService.userIdKey, userId); + + Map<String, String> decisionMap = new HashMap<String, String>(1); + decisionMap.put(UserProfileService.variationIdKey, variationId); + + Map<String, Map<String, String>> decisionsMap = new HashMap<String, Map<String, String>>(); + decisionsMap.put(experimentId, decisionMap); + userProfileMap.put(UserProfileService.experimentBucketMapKey, decisionsMap); + + return userProfileMap; + } + + void setAudienceForFeatureTest(String flagKey, String audienceId) throws ConfigParseException { + Experiment experiment = getSpyExperiment(flagKey); + when(experiment.getAudienceIds()).thenReturn(Arrays.asList(audienceId)); + addSpyExperiment(experiment); + } + + Experiment getSpyExperiment(String flagKey) { + setMockConfig(); + String experimentId = config.getFeatureKeyMapping().get(flagKey).getExperimentIds().get(0); + return spy(experimentIdMapping.get(experimentId)); + } + + FeatureFlag getSpyFeatureFlag(String flagKey) { + setMockConfig(); + return spy(config.getFeatureKeyMapping().get(flagKey)); + } + + Group getSpyGroup(String groupId) { + setMockConfig(); + return spy(groupIdMapping.get(groupId)); + } + + void addSpyExperiment(Experiment experiment) { + experimentIdMapping.put(experiment.getId(), experiment); + when(config.getExperimentIdMapping()).thenReturn(experimentIdMapping); + } + + void addSpyFeatureFlag(FeatureFlag flag) { + featureKeyMapping.put(flag.getKey(), flag); + when(config.getFeatureKeyMapping()).thenReturn(featureKeyMapping); + } + + void addSpyGroup(Group group) { + groupIdMapping.put(group.getId(), group); + when(config.getGroupIdMapping()).thenReturn(groupIdMapping); + } + + void setMockConfig() { + if (config != null) return; + + ProjectConfig configReal = null; + try { + configReal = new DatafileProjectConfig.Builder().withDatafile(datafile).build(); + config = spy(configReal); + optimizely = Optimizely.builder().withConfig(config).build(); + experimentIdMapping = new HashMap<>(config.getExperimentIdMapping()); + groupIdMapping = new HashMap<>(config.getGroupIdMapping()); + featureKeyMapping = new HashMap<>(config.getFeatureKeyMapping()); + } catch (ConfigParseException e) { + fail("ProjectConfig build failed"); + } + } + + OptimizelyDecision callDecideWithIncludeReasons(String flagKey, Map<String, Object> attributes) { + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + return user.decide(flagKey, Arrays.asList(OptimizelyDecideOption.INCLUDE_REASONS)); + } + + OptimizelyDecision callDecideWithIncludeReasons(String flagKey) { + return callDecideWithIncludeReasons(flagKey, Collections.emptyMap()); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java index 19ffed65f..5a67d1841 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/BucketerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import com.optimizely.ab.internal.LogbackVerifier; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Assume; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.experimental.categories.Category; @@ -36,7 +37,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicInteger; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertNotNull; @@ -55,15 +56,23 @@ public class BucketerTest { @Rule public LogbackVerifier logbackVerifier = new LogbackVerifier(); + private Bucketer algorithm; + private ProjectConfig projectConfig; + + @Before + public void setUp() { + algorithm = new Bucketer(); + projectConfig = validProjectConfigV2(); + } + /** * Verify that {@link Bucketer#generateBucketValue(int)} correctly handles negative hashCodes. */ @Test public void generateBucketValueForNegativeHashCodes() throws Exception { - Bucketer algorithm = new Bucketer(validProjectConfigV2()); int actual = algorithm.generateBucketValue(-1); assertTrue("generated bucket value is not in range: " + actual, - actual > 0 && actual < Bucketer.MAX_TRAFFIC_VALUE); + actual > 0 && actual < Bucketer.MAX_TRAFFIC_VALUE); } /** @@ -78,7 +87,6 @@ public void generateBucketValueDistribution() throws Exception { long totalCount = 0; int outOfRangeCount = 0; - Bucketer algorithm = new Bucketer(validProjectConfigV2()); for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) { int bucketValue = algorithm.generateBucketValue(i); @@ -105,8 +113,6 @@ public void bucketNumberGeneration() throws Exception { int experimentId = 1886780721; int hashCode; - Bucketer algorithm = new Bucketer(validProjectConfigV2()); - String combinedBucketId; combinedBucketId = "ppid1" + experimentId; @@ -126,7 +132,7 @@ public void bucketNumberGeneration() throws Exception { assertThat(algorithm.generateBucketValue(hashCode), is(5439)); combinedBucketId = "a very very very very very very very very very very very very very very very long ppd " + - "string" + experimentId; + "string" + experimentId; hashCode = MurmurHash3.murmurhash3_x86_32(combinedBucketId, 0, combinedBucketId.length(), MURMUR_HASH_SEED); assertThat(algorithm.generateBucketValue(hashCode), is(6128)); } @@ -140,46 +146,46 @@ public void bucketToMultipleVariations() throws Exception { // create an experiment with 4 variations using ranges: [0 -> 999, 1000 -> 4999, 5000 -> 5999, 6000 -> 9999] List<Variation> variations = Arrays.asList( - new Variation("1", "var1"), - new Variation("2", "var2"), - new Variation("3", "var3"), - new Variation("4", "var4") + new Variation("1", "var1"), + new Variation("2", "var2"), + new Variation("3", "var3"), + new Variation("4", "var4") ); List<TrafficAllocation> trafficAllocations = Arrays.asList( - new TrafficAllocation("1", 1000), - new TrafficAllocation("2", 5000), - new TrafficAllocation("3", 6000), - new TrafficAllocation("4", 10000) + new TrafficAllocation("1", 1000), + new TrafficAllocation("2", 5000), + new TrafficAllocation("3", 6000), + new TrafficAllocation("4", 10000) ); - Experiment experiment = new Experiment("1234", "exp_key", "Running", "1", audienceIds, variations, - Collections.<String, String>emptyMap(), trafficAllocations, ""); + Experiment experiment = new Experiment("1234", "exp_key", "Running", "1", audienceIds, null, variations, + Collections.<String, String>emptyMap(), trafficAllocations, ""); final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); // verify bucketing to the first variation bucketValue.set(0); - assertThat(algorithm.bucket(experiment, "user1"), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, "user1", projectConfig).getResult(), is(variations.get(0))); bucketValue.set(500); - assertThat(algorithm.bucket(experiment, "user2"), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, "user2", projectConfig).getResult(), is(variations.get(0))); bucketValue.set(999); - assertThat(algorithm.bucket(experiment, "user3"), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, "user3", projectConfig).getResult(), is(variations.get(0))); // verify the second variation bucketValue.set(1000); - assertThat(algorithm.bucket(experiment, "user4"), is(variations.get(1))); + assertThat(algorithm.bucket(experiment, "user4", projectConfig).getResult(), is(variations.get(1))); bucketValue.set(4000); - assertThat(algorithm.bucket(experiment, "user5"), is(variations.get(1))); + assertThat(algorithm.bucket(experiment, "user5", projectConfig).getResult(), is(variations.get(1))); bucketValue.set(4999); - assertThat(algorithm.bucket(experiment, "user6"), is(variations.get(1))); + assertThat(algorithm.bucket(experiment, "user6", projectConfig).getResult(), is(variations.get(1))); // ...and the rest bucketValue.set(5100); - assertThat(algorithm.bucket(experiment, "user7"), is(variations.get(2))); + assertThat(algorithm.bucket(experiment, "user7", projectConfig).getResult(), is(variations.get(2))); bucketValue.set(6500); - assertThat(algorithm.bucket(experiment, "user8"), is(variations.get(3))); + assertThat(algorithm.bucket(experiment, "user8", projectConfig).getResult(), is(variations.get(3))); } /** @@ -193,67 +199,67 @@ public void bucketToControl() throws Exception { List<String> audienceIds = Collections.emptyList(); List<Variation> variations = Collections.singletonList( - new Variation("1", "var1") + new Variation("1", "var1") ); List<TrafficAllocation> trafficAllocations = Collections.singletonList( - new TrafficAllocation("1", 999) + new TrafficAllocation("1", 999) ); - Experiment experiment = new Experiment("1234", "exp_key", "Running", "1", audienceIds, variations, - Collections.<String, String>emptyMap(), trafficAllocations, ""); + Experiment experiment = new Experiment("1234", "exp_key", "Running", "1", audienceIds, null, variations, + Collections.<String, String>emptyMap(), trafficAllocations, ""); final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket 0 to user with bucketingId \"" + bucketingId + "\" when bucketing to a variation."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"" + bucketingId + "\" is in variation \"var1\" of experiment \"exp_key\"."); // verify bucketing to the first variation bucketValue.set(0); - assertThat(algorithm.bucket(experiment, bucketingId), is(variations.get(0))); + assertThat(algorithm.bucket(experiment, bucketingId, projectConfig).getResult(), is(variations.get(0))); logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket 1000 to user with bucketingId \"" + bucketingId + "\" when bucketing to a variation."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"" + bucketingId + "\" is not in any variation of experiment \"exp_key\"."); // verify bucketing to no variation (null) bucketValue.set(1000); - assertNull(algorithm.bucket(experiment, bucketingId)); + assertNull(algorithm.bucket(experiment, bucketingId, projectConfig).getResult()); } //========== Tests for Grouped experiments ==========// /** - * Verify that {@link Bucketer#bucket(Experiment, String)} returns the proper variation when a user is + * Verify that {@link Bucketer#bucket(Experiment, String, ProjectConfig)} returns the proper variation when a user is * in the group experiment. */ @Test public void bucketUserInExperiment() throws Exception { final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(3000); ProjectConfig projectConfig = validProjectConfigV2(); List<Experiment> groupExperiments = projectConfig.getGroups().get(0).getExperiments(); Experiment groupExperiment = groupExperiments.get(0); logbackVerifier.expectMessage(Level.DEBUG, - "Assigned bucket 3000 to user with bucketingId \"blah\" during experiment bucketing."); + "Assigned bucket 3000 to user with bucketingId \"blah\" during experiment bucketing."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"blah\" is in experiment \"group_etag2\" of group 42."); logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket 3000 to user with bucketingId \"blah\" when bucketing to a variation."); logbackVerifier.expectMessage(Level.INFO, - "User with bucketingId \"blah\" is in variation \"e2_vtag1\" of experiment \"group_etag2\"."); - assertThat(algorithm.bucket(groupExperiment, "blah"), is(groupExperiment.getVariations().get(0))); + "User with bucketingId \"blah\" is in variation \"e2_vtag1\" of experiment \"group_etag2\"."); + assertThat(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult(), is(groupExperiment.getVariations().get(0))); } /** - * Verify that {@link Bucketer#bucket(Experiment, String)} doesn't return a variation when a user isn't bucketed + * Verify that {@link Bucketer#bucket(Experiment, String, ProjectConfig)} doesn't return a variation when a user isn't bucketed * into the group experiment. */ @Test public void bucketUserNotInExperiment() throws Exception { final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(3000); ProjectConfig projectConfig = validProjectConfigV2(); @@ -262,21 +268,21 @@ public void bucketUserNotInExperiment() throws Exception { // the user should be bucketed to a different experiment than the one provided, resulting in no variation being // returned. logbackVerifier.expectMessage(Level.DEBUG, - "Assigned bucket 3000 to user with bucketingId \"blah\" during experiment bucketing."); + "Assigned bucket 3000 to user with bucketingId \"blah\" during experiment bucketing."); logbackVerifier.expectMessage(Level.INFO, - "User with bucketingId \"blah\" is not in experiment \"group_etag1\" of group 42"); - assertNull(algorithm.bucket(groupExperiment, "blah")); + "User with bucketingId \"blah\" is not in experiment \"group_etag1\" of group 42"); + assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult()); } /** - * Verify that {@link Bucketer#bucket(Experiment, String)} doesn't return a variation when the user is bucketed to + * Verify that {@link Bucketer#bucket(Experiment, String, ProjectConfig)} doesn't return a variation when the user is bucketed to * the traffic space of a deleted experiment within a random group. */ @Test public void bucketUserToDeletedExperimentSpace() throws Exception { final AtomicInteger bucketValue = new AtomicInteger(); final int bucketIntVal = 9000; - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(bucketIntVal); ProjectConfig projectConfig = validProjectConfigV2(); @@ -285,17 +291,17 @@ public void bucketUserToDeletedExperimentSpace() throws Exception { logbackVerifier.expectMessage(Level.DEBUG, "Assigned bucket " + bucketIntVal + " to user with bucketingId \"blah\" during experiment bucketing."); logbackVerifier.expectMessage(Level.INFO, "User with bucketingId \"blah\" is not in any experiment of group 42."); - assertNull(algorithm.bucket(groupExperiment, "blah")); + assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult()); } /** - * Verify that {@link Bucketer#bucket(Experiment, String)} returns a variation when the user falls into an + * Verify that {@link Bucketer#bucket(Experiment, String, ProjectConfig)} returns a variation when the user falls into an * experiment within an overlapping group. */ @Test public void bucketUserToVariationInOverlappingGroupExperiment() throws Exception { final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(0); ProjectConfig projectConfig = validProjectConfigV2(); @@ -304,19 +310,19 @@ public void bucketUserToVariationInOverlappingGroupExperiment() throws Exception Variation expectedVariation = groupExperiment.getVariations().get(0); logbackVerifier.expectMessage( - Level.INFO, - "User with bucketingId \"blah\" is in variation \"e1_vtag1\" of experiment \"overlapping_etag1\"."); - assertThat(algorithm.bucket(groupExperiment, "blah"), is(expectedVariation)); + Level.INFO, + "User with bucketingId \"blah\" is in variation \"e1_vtag1\" of experiment \"overlapping_etag1\"."); + assertThat(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult(), is(expectedVariation)); } /** - * Verify that {@link Bucketer#bucket(Experiment, String)} doesn't return a variation when the user doesn't fall + * Verify that {@link Bucketer#bucket(Experiment, String, ProjectConfig)} doesn't return a variation when the user doesn't fall * into an experiment within an overlapping group. */ @Test public void bucketUserNotInOverlappingGroupExperiment() throws Exception { final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(3000); ProjectConfig projectConfig = validProjectConfigV2(); @@ -324,15 +330,15 @@ public void bucketUserNotInOverlappingGroupExperiment() throws Exception { Experiment groupExperiment = groupExperiments.get(0); logbackVerifier.expectMessage(Level.INFO, - "User with bucketingId \"blah\" is not in any variation of experiment \"overlapping_etag1\"."); + "User with bucketingId \"blah\" is not in any variation of experiment \"overlapping_etag1\"."); - assertNull(algorithm.bucket(groupExperiment, "blah")); + assertNull(algorithm.bucket(groupExperiment, "blah", projectConfig).getResult()); } @Test public void testBucketWithBucketingId() { final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(0); String bucketingId = "blah"; String userId = "blahUser"; @@ -343,9 +349,9 @@ public void testBucketWithBucketingId() { Variation expectedVariation = groupExperiment.getVariations().get(0); logbackVerifier.expectMessage( - Level.INFO, - "User with bucketingId \"" + bucketingId + "\" is in variation \"e1_vtag1\" of experiment \"overlapping_etag1\"."); - assertThat(algorithm.bucket(groupExperiment, bucketingId), is(expectedVariation)); + Level.INFO, + "User with bucketingId \"" + bucketingId + "\" is in variation \"e1_vtag1\" of experiment \"overlapping_etag1\"."); + assertThat(algorithm.bucket(groupExperiment, bucketingId, projectConfig).getResult(), is(expectedVariation)); } @@ -353,17 +359,15 @@ public void testBucketWithBucketingId() { @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") public void testBucketWithNullBucketingId() { final AtomicInteger bucketValue = new AtomicInteger(); - Bucketer algorithm = mockBucketAlgorithm(bucketValue); + Bucketer algorithm = testBucketAlgorithm(bucketValue); bucketValue.set(0); - ProjectConfig projectConfig = validProjectConfigV2(); List<Experiment> groupExperiments = projectConfig.getGroups().get(1).getExperiments(); Experiment groupExperiment = groupExperiments.get(0); try { - algorithm.bucket(groupExperiment, null); - } - catch (IllegalArgumentException e) { + algorithm.bucket(groupExperiment, null, projectConfig); + } catch (IllegalArgumentException e) { assertNotNull(e); } } @@ -376,8 +380,8 @@ public void testBucketWithNullBucketingId() { * @param bucketValue the expected bucket value holder * @return the mock bucket algorithm */ - private static Bucketer mockBucketAlgorithm(final AtomicInteger bucketValue) { - return new Bucketer(validProjectConfigV2()) { + private static Bucketer testBucketAlgorithm(final AtomicInteger bucketValue) { + return new Bucketer() { @Override int generateBucketValue(int hashCode) { return bucketValue.get(); diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java index 0b65e9f91..d818826d4 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/DecisionServiceTest.java @@ -1,5 +1,5 @@ /**************************************************************************** - * Copyright 2017-2018, Optimizely, Inc. and contributors * + * Copyright 2017-2022, 2024, Optimizely, Inc. and contributors * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * @@ -15,67 +15,35 @@ ***************************************************************************/ package com.optimizely.ab.bucketing; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.FeatureFlag; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.ProjectConfigTestUtils; -import com.optimizely.ab.config.Rollout; -import com.optimizely.ab.config.TrafficAllocation; -import com.optimizely.ab.config.ValidProjectConfigV4; -import com.optimizely.ab.config.Variation; +import ch.qos.logback.classic.Level; +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyDecisionContext; +import com.optimizely.ab.OptimizelyForcedDecision; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.config.*; import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.internal.LogbackVerifier; - import com.optimizely.ab.internal.ControlAttribute; -import org.junit.BeforeClass; +import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.optimizelydecision.DecisionReasons; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; -import ch.qos.logback.classic.Level; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - -import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_HOUSE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_NATIONALITY_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_ENGLISH_CITIZENS_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_GRYFFINDOR_VALUE; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_MULTI_VARIATE_FEATURE; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; -import static com.optimizely.ab.config.ValidProjectConfigV4.FEATURE_MULTI_VARIATE_FEATURE_KEY; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_2; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE; -import static com.optimizely.ab.config.ValidProjectConfigV4.ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; +import static com.optimizely.ab.config.ValidProjectConfigV4.*; +import static junit.framework.TestCase.assertEquals; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyMapOf; -import static org.mockito.Matchers.anyString; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.atMost; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; public class DecisionServiceTest { @@ -90,177 +58,165 @@ public class DecisionServiceTest { @Mock private ErrorHandler mockErrorHandler; - private static ProjectConfig noAudienceProjectConfig; - private static ProjectConfig v4ProjectConfig; - private static ProjectConfig validProjectConfig; - private static Experiment whitelistedExperiment; - private static Variation whitelistedVariation; + private ProjectConfig noAudienceProjectConfig; + private ProjectConfig v4ProjectConfig; + private ProjectConfig validProjectConfig; + private Experiment whitelistedExperiment; + private Variation whitelistedVariation; + private DecisionService decisionService; + + private Optimizely optimizely; - @BeforeClass - public static void setUp() throws Exception { + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Before + public void setUp() throws Exception { validProjectConfig = validProjectConfigV3(); v4ProjectConfig = validProjectConfigV4(); noAudienceProjectConfig = noAudienceProjectConfigV3(); whitelistedExperiment = validProjectConfig.getExperimentIdMapping().get("223"); whitelistedVariation = whitelistedExperiment.getVariationKeyToVariationMap().get("vtag1"); + Bucketer bucketer = new Bucketer(); + decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); + this.optimizely = Optimizely.builder().build(); } - @Rule - public LogbackVerifier logbackVerifier = new LogbackVerifier(); //========= getVariation tests =========/ /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over audience evaluation. */ @Test public void getVariationWhitelistedPrecedesAudienceEval() throws Exception { - Bucketer bucketer = spy(new Bucketer(validProjectConfig)); - DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap())); + assertNull(decisionService.getVariation( + experiment, + optimizely.createUserContext( + genericUserId, + Collections.emptyMap()), + validProjectConfig).getResult()); logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"vtag1\"."); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.<String, String>emptyMap()), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); - verify(decisionService).getWhitelistedVariation(experiment, whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class)); + verify(decisionService).getWhitelistedVariation(eq(experiment), eq(whitelistedUserId)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over whitelisting. */ @Test public void getForcedVariationBeforeWhitelisting() throws Exception { - Bucketer bucketer = new Bucketer(validProjectConfig); - DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation whitelistVariation = experiment.getVariations().get(0); Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap())); - - logbackVerifier.expectMessage(Level.INFO, "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"etag1\"."); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); // set the runtimeForcedVariation - validProjectConfig.setForcedVariation(experiment.getKey(), whitelistedUserId, expectedVariation.getKey()); + decisionService.setForcedVariation(experiment, whitelistedUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.<String, String>emptyMap()), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); //verify(decisionService).getForcedVariation(experiment.getKey(), whitelistedUserId); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class)); - assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId), whitelistVariation); - assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), whitelistedUserId, null)); - assertNull(validProjectConfig.getForcedVariation(experiment.getKey(), whitelistedUserId)); - assertThat(decisionService.getVariation(experiment, whitelistedUserId, Collections.<String, String>emptyMap()), is(whitelistVariation)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), any(ProjectConfig.class)); + assertEquals(decisionService.getWhitelistedVariation(experiment, whitelistedUserId).getResult(), whitelistVariation); + assertTrue(decisionService.setForcedVariation(experiment, whitelistedUserId, null)); + assertNull(decisionService.getForcedVariation(experiment, whitelistedUserId).getResult()); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(whitelistedUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult(), is(whitelistVariation)); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over audience evaluation. */ @Test public void getVariationForcedPrecedesAudienceEval() throws Exception { - Bucketer bucketer = spy(new Bucketer(validProjectConfig)); - DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(1); // user excluded without audiences and whitelisting - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap())); - - logbackVerifier.expectMessage(Level.INFO, "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"etag1\"."); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); // set the runtimeForcedVariation - validProjectConfig.setForcedVariation(experiment.getKey(), genericUserId, expectedVariation.getKey()); + decisionService.setForcedVariation(experiment, genericUserId, expectedVariation.getKey()); // no attributes provided for a experiment that has an audience - assertThat(decisionService.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap()), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult(), is(expectedVariation)); - verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class)); - assertEquals(validProjectConfig.setForcedVariation(experiment.getKey(), genericUserId, null), true); - assertNull(validProjectConfig.getForcedVariation(experiment.getKey(), genericUserId)); + verify(decisionService, never()).getStoredVariation(eq(experiment), any(UserProfile.class), eq(validProjectConfig)); + assertEquals(decisionService.setForcedVariation(experiment, genericUserId, null), true); + assertNull(decisionService.getForcedVariation(experiment, genericUserId).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to forced variation bucketing over user profile. */ @Test public void getVariationForcedBeforeUserProfile() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation variation = experiment.getVariations().get(0); - Bucketer bucketer = spy(new Bucketer(validProjectConfig)); Decision decision = new Decision(variation.getId()); UserProfile userProfile = new UserProfile(userProfileId, - Collections.singletonMap(experiment.getId(), decision)); + Collections.singletonMap(experiment.getId(), decision)); UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = spy(new DecisionService(bucketer, - mockErrorHandler, validProjectConfig, userProfileService)); + DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap())); - - logbackVerifier.expectMessage(Level.INFO, - "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" - + experiment.getKey() + "\"."); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.<String, String>emptyMap())); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); Variation forcedVariation = experiment.getVariations().get(1); - validProjectConfig.setForcedVariation(experiment.getKey(), userProfileId, forcedVariation.getKey()); + decisionService.setForcedVariation(experiment, userProfileId, forcedVariation.getKey()); assertEquals(forcedVariation, - decisionService.getVariation(experiment, userProfileId, Collections.<String, String>emptyMap())); - assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), userProfileId, null)); - assertNull(validProjectConfig.getForcedVariation(experiment.getKey(), userProfileId)); - - + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); + assertTrue(decisionService.setForcedVariation(experiment, userProfileId, null)); + assertNull(decisionService.getForcedVariation(experiment, userProfileId).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives precedence to user profile over audience evaluation. */ @Test public void getVariationEvaluatesUserProfileBeforeAudienceTargeting() throws Exception { Experiment experiment = validProjectConfig.getExperiments().get(0); Variation variation = experiment.getVariations().get(0); - Bucketer bucketer = spy(new Bucketer(validProjectConfig)); Decision decision = new Decision(variation.getId()); UserProfile userProfile = new UserProfile(userProfileId, - Collections.singletonMap(experiment.getId(), decision)); + Collections.singletonMap(experiment.getId(), decision)); UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = spy(new DecisionService(bucketer, - mockErrorHandler, validProjectConfig, userProfileService)); + DecisionService decisionService = spy(new DecisionService(new Bucketer(), mockErrorHandler, userProfileService)); // ensure that normal users still get excluded from the experiment when they fail audience evaluation - assertNull(decisionService.getVariation(experiment, genericUserId, Collections.<String, String>emptyMap())); - - logbackVerifier.expectMessage(Level.INFO, - "User \"" + genericUserId + "\" does not meet conditions to be in experiment \"" - + experiment.getKey() + "\"."); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); // ensure that a user with a saved user profile, sees the same variation regardless of audience evaluation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.<String, String>emptyMap())); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * gives a null variation on a Experiment that is not running. Set the forced variation. * And, test to make sure that after setting forced variation, the getVariation still returns * null. @@ -270,29 +226,25 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { Experiment experiment = validProjectConfig.getExperiments().get(1); assertFalse(experiment.isRunning()); Variation variation = experiment.getVariations().get(0); - Bucketer bucketer = new Bucketer(validProjectConfig); - - DecisionService decisionService = spy(new DecisionService(bucketer, - mockErrorHandler, validProjectConfig, null)); // ensure that the not running variation returns null with no forced variation set. - assertNull(decisionService.getVariation(experiment, "userId", Collections.<String, String>emptyMap())); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userId", Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); // we call getVariation 3 times on an experiment that is not running. logbackVerifier.expectMessage(Level.INFO, - "Experiment \"etag2\" is not running.", times(3)); + "Experiment \"etag2\" is not running.", 3); // set a forced variation on the user that got back null - assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), "userId", variation.getKey())); + assertTrue(decisionService.setForcedVariation(experiment, "userId", variation.getKey())); // ensure that a user with a forced variation set // still gets back a null variation if the variation is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.<String, String>emptyMap())); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userId", Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); // set the forced variation back to null - assertTrue(validProjectConfig.setForcedVariation(experiment.getKey(), "userId", null)); + assertTrue(decisionService.setForcedVariation(experiment, "userId", null)); // test one more time that the getVariation returns null for the experiment that is not running. - assertNull(decisionService.getVariation(experiment, "userId", Collections.<String, String>emptyMap())); + assertNull(decisionService.getVariation(experiment, optimizely.createUserContext("userid", Collections.<String, Object>emptyMap()), validProjectConfig).getResult()); } @@ -300,7 +252,7 @@ public void getVariationOnNonRunningExperimentWithForcedVariation() { //========== get Variation for Feature tests ==========// /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when the {@link FeatureFlag} is not used in any experiments or rollouts. */ @Test @@ -312,24 +264,18 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty when(emptyFeatureFlag.getKey()).thenReturn(featureKey); when(emptyFeatureFlag.getRolloutId()).thenReturn(""); - DecisionService decisionService = new DecisionService( - mock(Bucketer.class), - mockErrorHandler, - validProjectConfig, - null); - logbackVerifier.expectMessage(Level.INFO, - "The feature flag \"" + featureKey + "\" is not used in any experiments."); + "The feature flag \"" + featureKey + "\" is not used in any experiments."); logbackVerifier.expectMessage(Level.INFO, - "The feature flag \"" + featureKey + "\" is not used in a rollout."); + "The feature flag \"" + featureKey + "\" is not used in a rollout."); logbackVerifier.expectMessage(Level.INFO, - "The user \"" + genericUserId + "\" was not bucketed into a rollout for feature flag \"" + - featureKey + "\"."); + "The user \"" + genericUserId + "\" was not bucketed into a rollout for feature flag \"" + + featureKey + "\"."); FeatureDecision featureDecision = decisionService.getVariationForFeature( - emptyFeatureFlag, - genericUserId, - Collections.<String, String>emptyMap()); + emptyFeatureFlag, + optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), + validProjectConfig).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); @@ -339,7 +285,7 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty } /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when the user is not bucketed into any experiments or rollouts for the {@link FeatureFlag}. */ @Test @@ -347,45 +293,41 @@ public void getVariationForFeatureReturnsNullWhenFeatureFlagExperimentIdsIsEmpty public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperimentsAndRollouts() { FeatureFlag spyFeatureFlag = spy(FEATURE_FLAG_MULTI_VARIATE_FEATURE); - DecisionService spyDecisionService = spy(new DecisionService( - mock(Bucketer.class), - mockErrorHandler, - validProjectConfig, - null) - ); - // do not bucket to any experiments - doReturn(null).when(spyDecisionService).getVariation( - any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( + any(Experiment.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject(), + anyObject(), + any(DecisionReasons.class) ); // do not bucket to any rollouts - doReturn(new FeatureDecision(null, null, null)).when(spyDecisionService).getVariationForFeatureInRollout( - any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(null, null, null))).when(decisionService).getVariationForFeatureInRollout( + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); // try to get a variation back from the decision service for the feature flag - FeatureDecision featureDecision = spyDecisionService.getVariationForFeature( - spyFeatureFlag, - genericUserId, - Collections.<String, String>emptyMap() - ); + FeatureDecision featureDecision = decisionService.getVariationForFeature( + spyFeatureFlag, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + validProjectConfig + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); logbackVerifier.expectMessage(Level.INFO, - "The user \"" + genericUserId + "\" was not bucketed into a rollout for feature flag \"" + - FEATURE_MULTI_VARIATE_FEATURE_KEY + "\"."); + "The user \"" + genericUserId + "\" was not bucketed into a rollout for feature flag \"" + + FEATURE_MULTI_VARIATE_FEATURE_KEY + "\"."); verify(spyFeatureFlag, times(2)).getExperimentIds(); - verify(spyFeatureFlag, times(1)).getKey(); + verify(spyFeatureFlag, times(2)).getKey(); } /** - * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of the experiment a user gets bucketed into for an experiment. */ @Test @@ -393,40 +335,35 @@ public void getVariationForFeatureReturnsNullWhenItGetsNoVariationsForExperiment public void getVariationForFeatureReturnsVariationReturnedFromGetVariation() { FeatureFlag spyFeatureFlag = spy(ValidProjectConfigV4.FEATURE_FLAG_MUTEX_GROUP_FEATURE); - DecisionService spyDecisionService = spy(new DecisionService( - mock(Bucketer.class), - mockErrorHandler, - validProjectConfigV4(), - null) + doReturn(DecisionResponse.nullNoReasons()).when(decisionService).getVariation( + eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject() ); - doReturn(null).when(spyDecisionService).getVariation( - eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.responseNoReasons(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1)).when(decisionService).getVariation( + eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject() ); - doReturn(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1).when(spyDecisionService).getVariation( - eq(ValidProjectConfigV4.EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2), - anyString(), - anyMapOf(String.class, String.class) - ); - - FeatureDecision featureDecision = spyDecisionService.getVariationForFeature( - spyFeatureFlag, - genericUserId, - Collections.<String, String>emptyMap() - ); + FeatureDecision featureDecision = decisionService.getVariationForFeature( + spyFeatureFlag, + optimizely.createUserContext(genericUserId, Collections.emptyMap()), + v4ProjectConfig + ).getResult(); assertEquals(ValidProjectConfigV4.VARIATION_MUTEX_GROUP_EXP_2_VAR_1, featureDecision.variation); - assertEquals(FeatureDecision.DecisionSource.EXPERIMENT, featureDecision.decisionSource); + assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST, featureDecision.decisionSource); verify(spyFeatureFlag, times(2)).getExperimentIds(); - verify(spyFeatureFlag, never()).getKey(); + verify(spyFeatureFlag, times(2)).getKey(); } /** * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in - * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)}, + * {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)}, * check first if the user is bucketed to an {@link Experiment} * then check if the user is not bucketed to an experiment, * check for a {@link Rollout}. @@ -441,57 +378,55 @@ public void getVariationForFeatureReturnsVariationFromExperimentBeforeRollout() Experiment rolloutExperiment = featureRollout.getExperiments().get(0); Variation rolloutVariation = rolloutExperiment.getVariations().get(0); - DecisionService decisionService = spy(new DecisionService( - mock(Bucketer.class), - mockErrorHandler, - v4ProjectConfig, - null - ) - ); - // return variation for experiment - doReturn(experimentVariation) - .when(decisionService).getVariation( - eq(featureExperiment), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.responseNoReasons(experimentVariation)) + .when(decisionService).getVariation( + eq(featureExperiment), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject(), + anyObject(), + any(DecisionReasons.class) ); // return variation for rollout - doReturn(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT)) - .when(decisionService).getVariationForFeatureInRollout( - eq(featureFlag), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) + .when(decisionService).getVariationForFeatureInRollout( + eq(featureFlag), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); // make sure we get the right variation back FeatureDecision featureDecision = decisionService.getVariationForFeature( - featureFlag, - genericUserId, - Collections.<String, String>emptyMap() - ); + featureFlag, + optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), + v4ProjectConfig + ).getResult(); assertEquals(experimentVariation, featureDecision.variation); - assertEquals(FeatureDecision.DecisionSource.EXPERIMENT, featureDecision.decisionSource); + assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST, featureDecision.decisionSource); // make sure we do not even check for rollout bucketing verify(decisionService, never()).getVariationForFeatureInRollout( - any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class) + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once verify(decisionService, times(1)).getVariation( - any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class) + any(Experiment.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject(), + anyObject(), + any(DecisionReasons.class) ); } /** * Verify that when getting a {@link Variation} for a {@link FeatureFlag} in - * {@link DecisionService#getVariationForFeature(FeatureFlag, String, Map)}, + * {@link DecisionService#getVariationForFeature(FeatureFlag, OptimizelyUserContext, ProjectConfig)}, * check first if the user is bucketed to an {@link Rollout} * if the user is not bucketed to an experiment. */ @@ -504,64 +439,89 @@ public void getVariationForFeatureReturnsVariationFromRolloutWhenExperimentFails Experiment rolloutExperiment = featureRollout.getExperiments().get(0); Variation rolloutVariation = rolloutExperiment.getVariations().get(0); - DecisionService decisionService = spy(new DecisionService( - mock(Bucketer.class), - mockErrorHandler, - v4ProjectConfig, - null - ) - ); - // return variation for experiment - doReturn(null) - .when(decisionService).getVariation( - eq(featureExperiment), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.nullNoReasons()) + .when(decisionService).getVariation( + eq(featureExperiment), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject(), + anyObject(), + any(DecisionReasons.class) ); // return variation for rollout - doReturn(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT)) - .when(decisionService).getVariationForFeatureInRollout( - eq(featureFlag), - anyString(), - anyMapOf(String.class, String.class) + doReturn(DecisionResponse.responseNoReasons(new FeatureDecision(rolloutExperiment, rolloutVariation, FeatureDecision.DecisionSource.ROLLOUT))) + .when(decisionService).getVariationForFeatureInRollout( + eq(featureFlag), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); // make sure we get the right variation back FeatureDecision featureDecision = decisionService.getVariationForFeature( - featureFlag, - genericUserId, - Collections.<String, String>emptyMap() - ); + featureFlag, + optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), + v4ProjectConfig + ).getResult(); assertEquals(rolloutVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // make sure we do not even check for rollout bucketing verify(decisionService, times(1)).getVariationForFeatureInRollout( - any(FeatureFlag.class), - anyString(), - anyMapOf(String.class, String.class) + any(FeatureFlag.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class) ); // make sure we ask for experiment bucketing once verify(decisionService, times(1)).getVariation( - any(Experiment.class), - anyString(), - anyMapOf(String.class, String.class) + any(Experiment.class), + any(OptimizelyUserContext.class), + any(ProjectConfig.class), + anyObject(), + anyObject(), + any(DecisionReasons.class) ); logbackVerifier.expectMessage( - Level.INFO, - "The user \"" + genericUserId + "\" was bucketed into a rollout for feature flag \"" + - featureFlag.getKey() + "\"." + Level.INFO, + "The user \"" + genericUserId + "\" was bucketed into a rollout for feature flag \"" + + featureFlag.getKey() + "\"." + ); + } + + //========== getVariationForFeatureList tests ==========// + + @Test + public void getVariationsForFeatureListBatchesUpsLoadAndSave() throws Exception { + Bucketer bucketer = new Bucketer(); + ErrorHandler mockErrorHandler = mock(ErrorHandler.class); + UserProfileService mockUserProfileService = mock(UserProfileService.class); + + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, mockUserProfileService); + + FeatureFlag featureFlag1 = FEATURE_FLAG_MULTI_VARIATE_FEATURE; + FeatureFlag featureFlag2 = FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE; + FeatureFlag featureFlag3 = FEATURE_FLAG_MUTEX_GROUP_FEATURE; + + List<DecisionResponse<FeatureDecision>> decisions = decisionService.getVariationsForFeatureList( + Arrays.asList(featureFlag1, featureFlag2, featureFlag3), + optimizely.createUserContext(genericUserId), + v4ProjectConfig, + new ArrayList<>() ); + + assertEquals(decisions.size(), 3); + verify(mockUserProfileService, times(1)).lookup(genericUserId); + verify(mockUserProfileService, times(1)).save(anyObject()); } + //========== getVariationForFeatureInRollout tests ==========// /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when trying to bucket a user into a {@link FeatureFlag} * that does not have a {@link Rollout} attached. */ @@ -572,90 +532,75 @@ public void getVariationForFeatureInRolloutReturnsNullWhenFeatureIsNotAttachedTo String featureKey = "featureKey"; when(mockFeatureFlag.getKey()).thenReturn(featureKey); - DecisionService decisionService = new DecisionService( - mock(Bucketer.class), - mockErrorHandler, - validProjectConfig, - null - ); - FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - mockFeatureFlag, - genericUserId, - Collections.<String, String>emptyMap() - ); + mockFeatureFlag, + optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), + validProjectConfig + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); logbackVerifier.expectMessage( - Level.INFO, - "The feature flag \"" + featureKey + "\" is not used in a rollout." + Level.INFO, + "The feature flag \"" + featureKey + "\" is not used in a rollout." ); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * return null when a user is excluded from every rule of a rollout due to traffic allocation. */ @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserIsExcludedFromAllTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); DecisionService decisionService = new DecisionService( - mockBucketer, - mockErrorHandler, - v4ProjectConfig, - null + mockBucketer, + mockErrorHandler, + null ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE - ) - ); + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + v4ProjectConfig + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); // with fall back bucketing, the user has at most 2 chances to get bucketed with traffic allocation // one chance with the audience rollout rule // one chance with the everyone else rule - verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString()); + verify(mockBucketer, atMost(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns null when a user is excluded from every rule of a rollout due to targeting * and also fails traffic allocation in the everyone else rollout. */ @Test public void getVariationForFeatureInRolloutReturnsNullWhenUserFailsAllAudiencesAndTraffic() { Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); - DecisionService decisionService = new DecisionService( - mockBucketer, - mockErrorHandler, - v4ProjectConfig, - null - ); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.<String, String>emptyMap() - ); + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), + v4ProjectConfig + ).getResult(); assertNull(featureDecision.variation); assertNull(featureDecision.decisionSource); // user is only bucketed once for the everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString()); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user fails targeting for all rules, but is bucketed into the "Everyone Else" rule. */ @@ -665,29 +610,36 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsAllAudie Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(expectedVariation); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); DecisionService decisionService = new DecisionService( - mockBucketer, - mockErrorHandler, - v4ProjectConfig, - null + mockBucketer, + mockErrorHandler, + null ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.<String, String>emptyMap() - ); + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, Collections.<String, Object>emptyMap()), + v4ProjectConfig + ).getResult(); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"1\": [3468206642]."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"1\" collectively evaluated to null."); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"2\": [3988293898]."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"2\" collectively evaluated to null."); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"3\": [4194404272]."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to null."); + logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); + assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString()); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user passes targeting for a rule, but was failed the traffic allocation for that rule, * and is bucketed successfully into the "Everyone Else" rule. @@ -698,37 +650,36 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Rollout rollout = ROLLOUT_2; Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(expectedVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); DecisionService decisionService = new DecisionService( - mockBucketer, - mockErrorHandler, - v4ProjectConfig, - null + mockBucketer, + mockErrorHandler, + null ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE - ) - ); + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE)), + v4ProjectConfig + ).getResult(); assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); + logbackVerifier.expectMessage(Level.DEBUG, "User \"genericUserId\" meets conditions for targeting rule \"Everyone Else\"."); + // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString()); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "Everyone Else" rule * when the user passes targeting for a rule, but was failed the traffic allocation for that rule, * and is bucketed successfully into the "Everyone Else" rule. * Fallback bucketing should not evaluate any other audiences. - * Even though the user would satisfy a later rollout rule, they are never evaluated for it or bucketed into it. + * Even though the user would satisfy a later rollout rule, they are never evaluated for it or bucketed into it. */ @Test public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficInRuleButWouldPassForAnotherRuleAndPassesInEveryoneElse() { @@ -738,38 +689,37 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTrafficI Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation expectedVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(expectedVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString())).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(englishCitizenVariation)); DecisionService decisionService = new DecisionService( - mockBucketer, - mockErrorHandler, - v4ProjectConfig, - null + mockBucketer, + mockErrorHandler, + null ); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - ProjectConfigTestUtils.createMapOfObjects( - ProjectConfigTestUtils.createListOfObjects( - ATTRIBUTE_HOUSE_KEY, ATTRIBUTE_NATIONALITY_KEY - ), - ProjectConfigTestUtils.createListOfObjects( - AUDIENCE_GRYFFINDOR_VALUE, AUDIENCE_ENGLISH_CITIZENS_VALUE - ) + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, DatafileProjectConfigTestUtils.createMapOfObjects( + DatafileProjectConfigTestUtils.createListOfObjects( + ATTRIBUTE_HOUSE_KEY, ATTRIBUTE_NATIONALITY_KEY + ), + DatafileProjectConfigTestUtils.createListOfObjects( + AUDIENCE_GRYFFINDOR_VALUE, AUDIENCE_ENGLISH_CITIZENS_VALUE ) - ); + )), + v4ProjectConfig + ).getResult(); assertEquals(expectedVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString()); + verify(mockBucketer, times(2)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); } /** - * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} * returns the variation of "English Citizens" rule * when the user fails targeting for previous rules, but passes targeting and traffic for Rule 3. */ @@ -781,29 +731,93 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin Variation englishCitizenVariation = englishCitizensRule.getVariations().get(0); Experiment everyoneElseRule = rollout.getExperiments().get(rollout.getExperiments().size() - 1); Variation everyoneElseVariation = everyoneElseRule.getVariations().get(0); - when(mockBucketer.bucket(any(Experiment.class), anyString())).thenReturn(null); - when(mockBucketer.bucket(eq(everyoneElseRule), anyString())).thenReturn(everyoneElseVariation); - when(mockBucketer.bucket(eq(englishCitizensRule), anyString())).thenReturn(englishCitizenVariation); + when(mockBucketer.bucket(any(Experiment.class), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.nullNoReasons()); + when(mockBucketer.bucket(eq(everyoneElseRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(everyoneElseVariation)); + when(mockBucketer.bucket(eq(englishCitizensRule), anyString(), any(ProjectConfig.class))).thenReturn(DecisionResponse.responseNoReasons(englishCitizenVariation)); - DecisionService decisionService = new DecisionService( - mockBucketer, - mockErrorHandler, - v4ProjectConfig, - null - ); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, null); FeatureDecision featureDecision = decisionService.getVariationForFeatureInRollout( - FEATURE_FLAG_MULTI_VARIATE_FEATURE, - genericUserId, - Collections.singletonMap( - ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE - ) - ); + FEATURE_FLAG_MULTI_VARIATE_FEATURE, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)), + v4ProjectConfig + ).getResult(); assertEquals(englishCitizenVariation, featureDecision.variation); assertEquals(FeatureDecision.DecisionSource.ROLLOUT, featureDecision.decisionSource); - + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"2\" collectively evaluated to null"); + logbackVerifier.expectMessage(Level.DEBUG, "Evaluating audiences for rule \"3\": [4194404272]."); + logbackVerifier.expectMessage(Level.DEBUG, "Starting to evaluate audience \"4194404272\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='exact', value='English'}]]]."); + logbackVerifier.expectMessage(Level.DEBUG, "Audience \"4194404272\" evaluated to true."); + logbackVerifier.expectMessage(Level.INFO, "Audiences for rule \"3\" collectively evaluated to true"); // verify user is only bucketed once for everyone else rule - verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString()); + verify(mockBucketer, times(1)).bucket(any(Experiment.class), anyString(), any(ProjectConfig.class)); + } + + @Test + public void getVariationFromDeliveryRuleTest() { + int index = 3; + List<Experiment> rules = ROLLOUT_2.getExperiments(); + Experiment experiment = ROLLOUT_2.getExperiments().get(index); + Variation expectedVariation = null; + for (Variation variation : experiment.getVariations()) { + if (variation.getKey().equals("3137445031")) { + expectedVariation = variation; + } + } + DecisionResponse<AbstractMap.SimpleEntry> decisionResponse = decisionService.getVariationFromDeliveryRule( + v4ProjectConfig, + FEATURE_FLAG_MULTI_VARIATE_FEATURE.getKey(), + rules, + index, + optimizely.createUserContext(genericUserId, Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, AUDIENCE_ENGLISH_CITIZENS_VALUE)) + ); + + Variation variation = (Variation) decisionResponse.getResult().getKey(); + Boolean skipToEveryoneElse = (Boolean) decisionResponse.getResult().getValue(); + assertNotNull(decisionResponse.getResult()); + assertNotNull(variation); + assertNotNull(expectedVariation); + assertEquals(expectedVariation, variation); + assertFalse(skipToEveryoneElse); + } + + @Test + public void validatedForcedDecisionWithRuleKey() { + String userId = "testUser1"; + String ruleKey = "2637642575"; + String flagKey = "multi_variate_feature"; + String variationKey = "2346257680"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse<Variation> response = decisionService.validatedForcedDecision(optimizelyDecisionContext, v4ProjectConfig, optimizelyUserContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); + } + + @Test + public void validatedForcedDecisionWithoutRuleKey() { + String userId = "testUser1"; + String flagKey = "multi_variate_feature"; + String variationKey = "521740985"; + OptimizelyUserContext optimizelyUserContext = new OptimizelyUserContext( + optimizely, + userId, + Collections.emptyMap()); + + OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, null); + OptimizelyForcedDecision optimizelyForcedDecision = new OptimizelyForcedDecision(variationKey); + + optimizelyUserContext.setForcedDecision(optimizelyDecisionContext, optimizelyForcedDecision); + DecisionResponse<Variation> response = decisionService.validatedForcedDecision(optimizelyDecisionContext, v4ProjectConfig, optimizelyUserContext); + Variation variation = response.getResult(); + assertEquals(variationKey, variation.getKey()); } //========= white list tests ==========/ @@ -813,12 +827,9 @@ public void getVariationForFeatureInRolloutReturnsVariationWhenUserFailsTargetin */ @Test public void getWhitelistedReturnsForcedVariation() { - Bucketer bucketer = new Bucketer(validProjectConfig); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null); - logbackVerifier.expectMessage(Level.INFO, "User \"" + whitelistedUserId + "\" is forced in variation \"" - + whitelistedVariation.getKey() + "\"."); - assertEquals(whitelistedVariation, decisionService.getWhitelistedVariation(whitelistedExperiment, whitelistedUserId)); + + whitelistedVariation.getKey() + "\"."); + assertEquals(whitelistedVariation, decisionService.getWhitelistedVariation(whitelistedExperiment, whitelistedUserId).getResult()); } /** @@ -830,27 +841,24 @@ public void getWhitelistedWithInvalidVariation() throws Exception { String userId = "testUser1"; String invalidVariationKey = "invalidVarKey"; - Bucketer bucketer = new Bucketer(validProjectConfig); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null); - List<Variation> variations = Collections.singletonList( - new Variation("1", "var1") + new Variation("1", "var1") ); List<TrafficAllocation> trafficAllocations = Collections.singletonList( - new TrafficAllocation("1", 1000) + new TrafficAllocation("1", 1000) ); Map<String, String> userIdToVariationKeyMap = Collections.singletonMap(userId, invalidVariationKey); Experiment experiment = new Experiment("1234", "exp_key", "Running", "1", Collections.<String>emptyList(), - variations, userIdToVariationKeyMap, trafficAllocations); + null, variations, userIdToVariationKeyMap, trafficAllocations); logbackVerifier.expectMessage( - Level.ERROR, - "Variation \"" + invalidVariationKey + "\" is not in the datafile. Not activating user \"" + userId + "\"."); + Level.ERROR, + "Variation \"" + invalidVariationKey + "\" is not in the datafile. Not activating user \"" + userId + "\"."); - assertNull(decisionService.getWhitelistedVariation(experiment, userId)); + assertNull(decisionService.getWhitelistedVariation(experiment, userId).getResult()); } /** @@ -858,16 +866,13 @@ public void getWhitelistedWithInvalidVariation() throws Exception { */ @Test public void getWhitelistedReturnsNullWhenUserIsNotWhitelisted() throws Exception { - Bucketer bucketer = new Bucketer(validProjectConfig); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null); - - assertNull(decisionService.getWhitelistedVariation(whitelistedExperiment, genericUserId)); + assertNull(decisionService.getWhitelistedVariation(whitelistedExperiment, genericUserId).getResult()); } //======== User Profile tests =========// /** - * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile)} returns a variation that is + * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile, ProjectConfig)} returns a variation that is * stored in the provided {@link UserProfile}. */ @SuppressFBWarnings @@ -878,52 +883,47 @@ public void bucketReturnsVariationStoredInUserProfile() throws Exception { Decision decision = new Decision(variation.getId()); UserProfile userProfile = new UserProfile(userProfileId, - Collections.singletonMap(experiment.getId(), decision)); + Collections.singletonMap(experiment.getId(), decision)); UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - Bucketer bucketer = new Bucketer(noAudienceProjectConfig); - DecisionService decisionService = new DecisionService(bucketer, - mockErrorHandler, - noAudienceProjectConfig, - userProfileService); + Bucketer bucketer = new Bucketer(); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); logbackVerifier.expectMessage(Level.INFO, - "Returning previously activated variation \"" + variation.getKey() + "\" of experiment \"" + experiment.getKey() + "\"" - + " for user \"" + userProfileId + "\" from user profile."); + "Returning previously activated variation \"" + variation.getKey() + "\" of experiment \"" + experiment.getKey() + "\"" + + " for user \"" + userProfileId + "\" from user profile."); // ensure user with an entry in the user profile is bucketed into the corresponding stored variation assertEquals(variation, - decisionService.getVariation(experiment, userProfileId, Collections.<String, String>emptyMap())); + decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); verify(userProfileService).lookup(userProfileId); } /** - * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile)} returns null and logs properly + * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile, ProjectConfig)} returns null and logs properly * when there is no stored variation for that user in that {@link Experiment} in the {@link UserProfileService}. */ @Test public void getStoredVariationLogsWhenLookupReturnsNull() throws Exception { final Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); - Bucketer bucketer = new Bucketer(noAudienceProjectConfig); + Bucketer bucketer = new Bucketer(); UserProfileService userProfileService = mock(UserProfileService.class); - UserProfile userProfile = new UserProfile(userProfileId, - Collections.<String, Decision>emptyMap()); + UserProfile userProfile = new UserProfile(userProfileId, Collections.<String, Decision>emptyMap()); when(userProfileService.lookup(userProfileId)).thenReturn(userProfile.toMap()); - DecisionService decisionService = new DecisionService(bucketer, - mockErrorHandler, noAudienceProjectConfig, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); logbackVerifier.expectMessage(Level.INFO, "No previously activated variation of experiment " + - "\"" + experiment.getKey() + "\" for user \"" + userProfileId + "\" found in user profile."); + "\"" + experiment.getKey() + "\" for user \"" + userProfileId + "\" found in user profile."); - assertNull(decisionService.getStoredVariation(experiment, userProfile)); + assertNull(decisionService.getStoredVariation(experiment, userProfile, noAudienceProjectConfig).getResult()); } /** - * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile)} returns null + * Verify that {@link DecisionService#getStoredVariation(Experiment, UserProfile, ProjectConfig)} returns null * when a {@link UserProfile} is present, contains a decision for the experiment in question, * but the variation ID for that decision does not exist in the datafile. */ @@ -935,25 +935,24 @@ public void getStoredVariationReturnsNullWhenVariationIsNoLongerInConfig() throw final Map<String, Decision> storedDecisions = new HashMap<String, Decision>(); storedDecisions.put(experiment.getId(), storedDecision); final UserProfile storedUserProfile = new UserProfile(userProfileId, - storedDecisions); + storedDecisions); Bucketer bucketer = mock(Bucketer.class); UserProfileService userProfileService = mock(UserProfileService.class); when(userProfileService.lookup(userProfileId)).thenReturn(storedUserProfile.toMap()); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, noAudienceProjectConfig, - userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); logbackVerifier.expectMessage(Level.INFO, - "User \"" + userProfileId + "\" was previously bucketed into variation with ID \"" + storedVariationId + "\" for " + - "experiment \"" + experiment.getKey() + "\", but no matching variation " + - "was found for that user. We will re-bucket the user."); + "User \"" + userProfileId + "\" was previously bucketed into variation with ID \"" + storedVariationId + "\" for " + + "experiment \"" + experiment.getKey() + "\", but no matching variation " + + "was found for that user. We will re-bucket the user."); - assertNull(decisionService.getStoredVariation(experiment, storedUserProfile)); + assertNull(decisionService.getStoredVariation(experiment, storedUserProfile, noAudienceProjectConfig).getResult()); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} * saves a {@link Variation}of an {@link Experiment} for a user when a {@link UserProfileService} is present. */ @SuppressFBWarnings @@ -965,27 +964,31 @@ public void getVariationSavesBucketedVariationIntoUserProfile() throws Exception UserProfileService userProfileService = mock(UserProfileService.class); UserProfile originalUserProfile = new UserProfile(userProfileId, - new HashMap<String, Decision>()); + new HashMap<String, Decision>()); when(userProfileService.lookup(userProfileId)).thenReturn(originalUserProfile.toMap()); UserProfile expectedUserProfile = new UserProfile(userProfileId, - Collections.singletonMap(experiment.getId(), decision)); + Collections.singletonMap(experiment.getId(), decision)); Bucketer mockBucketer = mock(Bucketer.class); - when(mockBucketer.bucket(experiment, userProfileId)).thenReturn(variation); + when(mockBucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); - DecisionService decisionService = new DecisionService(mockBucketer, - mockErrorHandler, noAudienceProjectConfig, userProfileService); + DecisionService decisionService = new DecisionService(mockBucketer, mockErrorHandler, userProfileService); - assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.<String, String>emptyMap())); + assertEquals(variation, decisionService.getVariation( + experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult() + ); logbackVerifier.expectMessage(Level.INFO, - String.format("Saved variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), - experiment.getId())); + String.format("Updated variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), + experiment.getId())); + + logbackVerifier.expectMessage(Level.INFO, + String.format("Saved user profile of user \"%s\".", userProfileId)); verify(userProfileService).save(eq(expectedUserProfile.toMap())); } /** - * Verify that {@link DecisionService#getVariation(Experiment, String, Map)} logs correctly + * Verify that {@link DecisionService#getVariation(Experiment, OptimizelyUserContext, ProjectConfig)} logs correctly * when a {@link UserProfileService} is present but fails to save an activation. */ @Test @@ -993,26 +996,25 @@ public void bucketLogsCorrectlyWhenUserProfileFailsToSave() throws Exception { final Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); final Variation variation = experiment.getVariations().get(0); Decision decision = new Decision(variation.getId()); - Bucketer bucketer = new Bucketer(noAudienceProjectConfig); + Bucketer bucketer = new Bucketer(); UserProfileService userProfileService = mock(UserProfileService.class); doThrow(new Exception()).when(userProfileService).save(anyMapOf(String.class, Object.class)); Map<String, Decision> experimentBucketMap = new HashMap<String, Decision>(); experimentBucketMap.put(experiment.getId(), decision); UserProfile expectedUserProfile = new UserProfile(userProfileId, - experimentBucketMap); + experimentBucketMap); UserProfile saveUserProfile = new UserProfile(userProfileId, - new HashMap<String, Decision>()); + new HashMap<String, Decision>()); - DecisionService decisionService = new DecisionService(bucketer, - mockErrorHandler, noAudienceProjectConfig, userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); decisionService.saveVariation(experiment, variation, saveUserProfile); logbackVerifier.expectMessage(Level.WARN, - String.format("Failed to save variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), - experiment.getId())); + String.format("Failed to save variation \"%s\" of experiment \"%s\" for user \"" + userProfileId + "\".", variation.getId(), + experiment.getId())); verify(userProfileService).save(eq(expectedUserProfile.toMap())); } @@ -1027,39 +1029,38 @@ public void getVariationSavesANewUserProfile() throws Exception { final Variation variation = experiment.getVariations().get(0); final Decision decision = new Decision(variation.getId()); final UserProfile expectedUserProfile = new UserProfile(userProfileId, - Collections.singletonMap(experiment.getId(), decision)); + Collections.singletonMap(experiment.getId(), decision)); Bucketer bucketer = mock(Bucketer.class); UserProfileService userProfileService = mock(UserProfileService.class); - DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, noAudienceProjectConfig, - userProfileService); + DecisionService decisionService = new DecisionService(bucketer, mockErrorHandler, userProfileService); - when(bucketer.bucket(experiment, userProfileId)).thenReturn(variation); + when(bucketer.bucket(eq(experiment), eq(userProfileId), eq(noAudienceProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(variation)); when(userProfileService.lookup(userProfileId)).thenReturn(null); - assertEquals(variation, decisionService.getVariation(experiment, userProfileId, Collections.<String, String>emptyMap())); + assertEquals(variation, decisionService.getVariation(experiment, optimizely.createUserContext(userProfileId, Collections.emptyMap()), noAudienceProjectConfig).getResult()); verify(userProfileService).save(expectedUserProfile.toMap()); } @Test public void getVariationBucketingId() throws Exception { Bucketer bucketer = mock(Bucketer.class); - DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, validProjectConfig, null)); + DecisionService decisionService = spy(new DecisionService(bucketer, mockErrorHandler, null)); Experiment experiment = validProjectConfig.getExperiments().get(0); Variation expectedVariation = experiment.getVariations().get(0); - when(bucketer.bucket(experiment, "bucketId")).thenReturn(expectedVariation); + when(bucketer.bucket(eq(experiment), eq("bucketId"), eq(validProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(expectedVariation)); - Map<String, String> attr = new HashMap<String, String>(); + Map<String, Object> attr = new HashMap(); attr.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "bucketId"); // user excluded without audiences and whitelisting - assertThat(decisionService.getVariation(experiment, genericUserId, attr), is(expectedVariation)); + assertThat(decisionService.getVariation(experiment, optimizely.createUserContext(genericUserId, attr), validProjectConfig).getResult(), is(expectedVariation)); } /** - Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, String, Map)} - uses bucketing ID to bucket the user into rollouts. + * Verify that {@link DecisionService#getVariationForFeatureInRollout(FeatureFlag, OptimizelyUserContext, ProjectConfig)} + * uses bucketing ID to bucket the user into rollouts. */ @Test public void getVariationForRolloutWithBucketingId() { @@ -1068,28 +1069,163 @@ public void getVariationForRolloutWithBucketingId() { FeatureFlag featureFlag = FEATURE_FLAG_SINGLE_VARIABLE_INTEGER; String bucketingId = "user_bucketing_id"; String userId = "user_id"; - Map<String, String> attributes = new HashMap<String, String>(); + Map<String, Object> attributes = new HashMap(); attributes.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); Bucketer bucketer = mock(Bucketer.class); - when(bucketer.bucket(rolloutRuleExperiment, userId)).thenReturn(null); - when(bucketer.bucket(rolloutRuleExperiment, bucketingId)).thenReturn(rolloutVariation); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(userId), eq(v4ProjectConfig))).thenReturn(DecisionResponse.nullNoReasons()); + when(bucketer.bucket(eq(rolloutRuleExperiment), eq(bucketingId), eq(v4ProjectConfig))).thenReturn(DecisionResponse.responseNoReasons(rolloutVariation)); DecisionService decisionService = spy(new DecisionService( - bucketer, - mockErrorHandler, - v4ProjectConfig, - null + bucketer, + mockErrorHandler, + null )); FeatureDecision expectedFeatureDecision = new FeatureDecision( - rolloutRuleExperiment, - rolloutVariation, - FeatureDecision.DecisionSource.ROLLOUT); + rolloutRuleExperiment, + rolloutVariation, + FeatureDecision.DecisionSource.ROLLOUT); - FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, userId, attributes); + FeatureDecision featureDecision = decisionService.getVariationForFeature(featureFlag, optimizely.createUserContext(userId, attributes), v4ProjectConfig).getResult(); assertEquals(expectedFeatureDecision, featureDecision); } + /** + * Invalid User IDs + * <p> + * User ID is null + * User ID is an empty string + * Invalid Experiment IDs + * <p> + * Experiment key does not exist in the datafile + * Experiment key is null + * Experiment key is an empty string + * Invalid Variation IDs [set only] + * <p> + * Variation key does not exist in the datafile + * Variation key is null + * Variation key is an empty string + * Multiple set calls [set only] + * <p> + * Call set variation with different variations on one user/experiment to confirm that each set is expected. + * Set variation on multiple variations for one user. + * Set variations for multiple users. + */ + /* UserID test */ + @Test + @SuppressFBWarnings("NP") + public void setForcedVariationNullUserId() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + boolean b = decisionService.setForcedVariation(experiment, null, "vtag1"); + assertFalse(b); + } + + @Test + @SuppressFBWarnings("NP") + public void getForcedVariationNullUserId() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertNull(decisionService.getForcedVariation(experiment, null).getResult()); + } + + @Test + public void setForcedVariationEmptyUserId() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertTrue(decisionService.setForcedVariation(experiment, "", "vtag1")); + } + + @Test + public void getForcedVariationEmptyUserId() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertNull(decisionService.getForcedVariation(experiment, "").getResult()); + } + + /* Invalid Variation Id (set only */ + @Test + public void setForcedVariationWrongVariationKey() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertFalse(decisionService.setForcedVariation(experiment, "testUser1", "vtag3")); + } + + @Test + public void setForcedVariationNullVariationKey() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertFalse(decisionService.setForcedVariation(experiment, "testUser1", null)); + assertNull(decisionService.getForcedVariation(experiment, "testUser1").getResult()); + } + + @Test + public void setForcedVariationEmptyVariationKey() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertFalse(decisionService.setForcedVariation(experiment, "testUser1", "")); + } + + /* Multiple set calls (set only */ + @Test + public void setForcedVariationDifferentVariations() { + Experiment experiment = validProjectConfig.getExperimentKeyMapping().get("etag1"); + assertTrue(decisionService.setForcedVariation(experiment, "testUser1", "vtag1")); + assertTrue(decisionService.setForcedVariation(experiment, "testUser1", "vtag2")); + assertEquals(decisionService.getForcedVariation(experiment, "testUser1").getResult().getKey(), "vtag2"); + assertTrue(decisionService.setForcedVariation(experiment, "testUser1", null)); + } + + @Test + public void setForcedVariationMultipleVariationsExperiments() { + Experiment experiment1 = validProjectConfig.getExperimentKeyMapping().get("etag1"); + Experiment experiment2 = validProjectConfig.getExperimentKeyMapping().get("etag2"); + + assertTrue(decisionService.setForcedVariation(experiment1, "testUser1", "vtag1")); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser2", "vtag2")); + + assertTrue(decisionService.setForcedVariation(experiment2, "testUser1", "vtag3")); + assertTrue(decisionService.setForcedVariation(experiment2, "testUser2", "vtag4")); + + assertEquals(decisionService.getForcedVariation(experiment1, "testUser1").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser2").getResult().getKey(), "vtag2"); + + assertEquals(decisionService.getForcedVariation(experiment2, "testUser1").getResult().getKey(), "vtag3"); + assertEquals(decisionService.getForcedVariation(experiment2, "testUser2").getResult().getKey(), "vtag4"); + + assertTrue(decisionService.setForcedVariation(experiment1, "testUser1", null)); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser2", null)); + + assertTrue(decisionService.setForcedVariation(experiment2, "testUser1", null)); + assertTrue(decisionService.setForcedVariation(experiment2, "testUser2", null)); + + assertNull(decisionService.getForcedVariation(experiment1, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment1, "testUser2").getResult()); + + assertNull(decisionService.getForcedVariation(experiment2, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment2, "testUser2").getResult()); + } + + @Test + public void setForcedVariationMultipleUsers() { + Experiment experiment1 = validProjectConfig.getExperimentKeyMapping().get("etag1"); + Experiment experiment2 = validProjectConfig.getExperimentKeyMapping().get("etag2"); + + assertTrue(decisionService.setForcedVariation(experiment1, "testUser1", "vtag1")); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser2", "vtag1")); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser3", "vtag1")); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser4", "vtag1")); + + assertEquals(decisionService.getForcedVariation(experiment1, "testUser1").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser2").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser3").getResult().getKey(), "vtag1"); + assertEquals(decisionService.getForcedVariation(experiment1, "testUser4").getResult().getKey(), "vtag1"); + + assertTrue(decisionService.setForcedVariation(experiment1, "testUser1", null)); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser2", null)); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser3", null)); + assertTrue(decisionService.setForcedVariation(experiment1, "testUser4", null)); + + assertNull(decisionService.getForcedVariation(experiment1, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment1, "testUser2").getResult()); + + assertNull(decisionService.getForcedVariation(experiment2, "testUser1").getResult()); + assertNull(decisionService.getForcedVariation(experiment2, "testUser2").getResult()); + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/bucketing/internal/MurmurHash3Test.java b/core-api/src/test/java/com/optimizely/ab/bucketing/internal/MurmurHash3Test.java index bf86a31f9..076eab966 100644 --- a/core-api/src/test/java/com/optimizely/ab/bucketing/internal/MurmurHash3Test.java +++ b/core-api/src/test/java/com/optimizely/ab/bucketing/internal/MurmurHash3Test.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,19 +51,19 @@ private void doString(String s) { private void doString(String s, int pre, int post) { byte[] utf8 = s.getBytes(utf8Charset); - int hash1 = MurmurHash3.murmurhash3_x86_32(utf8, pre, utf8.length-pre-post, 123456789); - int hash2 = MurmurHash3.murmurhash3_x86_32(s, pre, s.length()-pre-post, 123456789); + int hash1 = MurmurHash3.murmurhash3_x86_32(utf8, pre, utf8.length - pre - post, 123456789); + int hash2 = MurmurHash3.murmurhash3_x86_32(s, pre, s.length() - pre - post, 123456789); if (hash1 != hash2) { // second time for debugging... - hash2 = MurmurHash3.murmurhash3_x86_32(s, pre, s.length()-pre-post, 123456789); + hash2 = MurmurHash3.murmurhash3_x86_32(s, pre, s.length() - pre - post, 123456789); } assertEquals(hash1, hash2); } @Test @SuppressFBWarnings( - value={"SF_SWITCH_FALLTHROUGH","SF_SWITCH_NO_DEFAULT"}, - justification="deliberate") + value = {"SF_SWITCH_FALLTHROUGH", "SF_SWITCH_NO_DEFAULT"}, + justification = "deliberate") public void testStringHash() { doString("hello!"); doString("ABCD"); @@ -73,41 +73,49 @@ public void testStringHash() { Random r = new Random(); StringBuilder sb = new StringBuilder(40); - for (int i=0; i<100000; i++) { + for (int i = 0; i < 100000; i++) { sb.setLength(0); int pre = r.nextInt(3); int post = r.nextInt(3); int len = r.nextInt(16); - for (int j=0; j<pre; j++) { + for (int j = 0; j < pre; j++) { int codePoint = r.nextInt(0x80); sb.appendCodePoint(codePoint); } - for (int j=0; j<len; j++) { + for (int j = 0; j < len; j++) { int codePoint; do { int max = 0; switch (r.nextInt() & 0x3) { - case 0: max=0x80; break; // 1 UTF8 bytes - case 1: max=0x800; break; // up to 2 bytes - case 2: max=0xffff+1; break; // up to 3 bytes - case 3: max=Character.MAX_CODE_POINT+1; // up to 4 bytes + case 0: + max = 0x80; + break; // 1 UTF8 bytes + case 1: + max = 0x800; + break; // up to 2 bytes + case 2: + max = 0xffff + 1; + break; // up to 3 bytes + case 3: + max = Character.MAX_CODE_POINT + 1; // up to 4 bytes } codePoint = r.nextInt(max); - } while (codePoint < 0xffff && (Character.isHighSurrogate((char)codePoint) || Character.isLowSurrogate((char)codePoint))); + } + while (codePoint < 0xffff && (Character.isHighSurrogate((char) codePoint) || Character.isLowSurrogate((char) codePoint))); sb.appendCodePoint(codePoint); } - for (int j=0; j<post; j++) { + for (int j = 0; j < post; j++) { int codePoint = r.nextInt(0x80); sb.appendCodePoint(codePoint); } String s = sb.toString(); - String middle = s.substring(pre, s.length()-post); + String middle = s.substring(pre, s.length() - post); doString(s); doString(middle); diff --git a/core-api/src/test/java/com/optimizely/ab/categories/ExhaustiveTest.java b/core-api/src/test/java/com/optimizely/ab/categories/ExhaustiveTest.java index 484112a79..6a49fe803 100644 --- a/core-api/src/test/java/com/optimizely/ab/categories/ExhaustiveTest.java +++ b/core-api/src/test/java/com/optimizely/ab/categories/ExhaustiveTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,4 +19,5 @@ /** * Tests that may be <i>extremely</i> slow because they're exhaustively testing some parameter space. */ -public interface ExhaustiveTest { } +public interface ExhaustiveTest { +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/AtomicProjectConfigManagerTest.java b/core-api/src/test/java/com/optimizely/ab/config/AtomicProjectConfigManagerTest.java new file mode 100644 index 000000000..22d479310 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/AtomicProjectConfigManagerTest.java @@ -0,0 +1,42 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import org.junit.Before; +import org.junit.Test; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static org.junit.Assert.*; + +public class AtomicProjectConfigManagerTest { + + private AtomicProjectConfigManager projectConfigManager; + + @Before + public void setUp() { + projectConfigManager = new AtomicProjectConfigManager(); + } + + @Test + public void testGetAndSetConfig() throws Exception { + assertNull(projectConfigManager.getConfig()); + + ProjectConfig projectConfig = new DatafileProjectConfig.Builder().withDatafile(validConfigJsonV4()).build(); + projectConfigManager.setConfig(projectConfig); + assertEquals(projectConfig, projectConfigManager.getConfig()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java new file mode 100644 index 000000000..533be8be2 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigBuilderTest.java @@ -0,0 +1,71 @@ +/** + * + * Copyright 2018-2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.config.parser.ConfigParseException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.invalidProjectConfigV5; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Tests for {@link DatafileProjectConfig.Builder}. + */ +public class DatafileProjectConfigBuilderTest { + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void withNullDatafile() throws Exception { + thrown.expect(ConfigParseException.class); + new DatafileProjectConfig.Builder() + .withDatafile(null) + .build(); + } + + @Test + public void withEmptyDatafile() throws Exception { + thrown.expect(ConfigParseException.class); + new DatafileProjectConfig.Builder() + .withDatafile("") + .build(); + } + + @Test + public void withValidDatafile() throws Exception { + ProjectConfig projectConfig = new DatafileProjectConfig.Builder() + .withDatafile(validConfigJsonV4()) + .build(); + + assertEquals(projectConfig.toDatafile(), validConfigJsonV4()); + assertNotNull(projectConfig); + assertEquals("4", projectConfig.getVersion()); + } + + @Test + public void withUnsupportedDatafile() throws Exception { + thrown.expect(ConfigParseException.class); + new DatafileProjectConfig.Builder() + .withDatafile(invalidProjectConfigV5()) + .build(); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java new file mode 100644 index 000000000..41b02ea91 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTest.java @@ -0,0 +1,190 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import ch.qos.logback.classic.Level; +import com.google.errorprone.annotations.Var; +import com.optimizely.ab.config.audience.AndCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.NotCondition; +import com.optimizely.ab.config.audience.OrCondition; +import com.optimizely.ab.config.audience.UserAttribute; + +import java.util.*; + +import static java.util.Arrays.asList; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.*; + + +import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.internal.ControlAttribute; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +/** + * Tests for {@link DatafileProjectConfig}. + */ +public class DatafileProjectConfigTest { + + private ProjectConfig projectConfig; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Before + public void initialize() { + projectConfig = DatafileProjectConfigTestUtils.validProjectConfigV3(); + } + + /** + * Verify that {@link DatafileProjectConfig#toString()} doesn't throw an exception. + */ + @Test + @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") + public void toStringDoesNotFail() throws Exception { + projectConfig.toString(); + } + + /** + * Asserts that {@link DatafileProjectConfig#getExperimentsForEventKey(String)} + * returns the respective experiment ids for experiments using an event, + * provided that the event parameter is valid. + */ + @Test + public void verifyGetExperimentsForValidEvent() throws Exception { + Experiment experiment223 = projectConfig.getExperimentIdMapping().get("223"); + Experiment experiment118 = projectConfig.getExperimentIdMapping().get("118"); + List<Experiment> expectedSingleExperiment = asList(experiment223); + List<Experiment> actualSingleExperiment = projectConfig.getExperimentsForEventKey("clicked_cart"); + assertThat(actualSingleExperiment, is(expectedSingleExperiment)); + + List<Experiment> expectedMultipleExperiments = asList(experiment118, experiment223); + List<Experiment> actualMultipleExperiments = projectConfig.getExperimentsForEventKey("clicked_purchase"); + assertThat(actualMultipleExperiments, is(expectedMultipleExperiments)); + } + + /** + * Asserts that {@link DatafileProjectConfig#getExperimentsForEventKey(String)} returns an empty List + * when given an invalid event key. + */ + @Test + public void verifyGetExperimentsForInvalidEvent() throws Exception { + List<Experiment> expectedExperiments = Collections.emptyList(); + List<Experiment> actualExperiments = projectConfig.getExperimentsForEventKey("a_fake_event"); + assertThat(actualExperiments, is(expectedExperiments)); + } + + /** + * Asserts that getAudience returns the respective audience, provided the + * audience ID parameter is valid. + */ + @Test + public void verifyGetAudienceConditionsFromValidId() throws Exception { + List<Condition> userAttributes = new ArrayList<Condition>(); + userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null, "firefox")); + + OrCondition orInner = new OrCondition(userAttributes); + + NotCondition notCondition = new NotCondition(orInner); + List<Condition> outerOrList = new ArrayList<Condition>(); + outerOrList.add(notCondition); + + OrCondition orOuter = new OrCondition(outerOrList); + List<Condition> andList = new ArrayList<Condition>(); + andList.add(orOuter); + + Condition expectedConditions = new AndCondition(andList); + Condition actualConditions = projectConfig.getAudience("100").getConditions(); + assertThat(actualConditions, is(expectedConditions)); + } + + /** + * Asserts that getAudience returns null given an invalid audience ID parameter. + */ + @Test + public void verifyGetAudienceFromInvalidId() throws Exception { + assertNull(projectConfig.getAudience("invalid_id")); + } + + /** + * Asserts that anonymizeIP is set to false if not explicitly passed into the constructor (in the case of V2 + * projects). + * + * @throws Exception + */ + @Test + public void verifyAnonymizeIPIsFalseByDefault() throws Exception { + ProjectConfig v2ProjectConfig = DatafileProjectConfigTestUtils.validProjectConfigV2(); + assertFalse(v2ProjectConfig.getAnonymizeIP()); + } + + @Test + public void getAttributeIDWhenAttributeKeyIsFromAttributeKeyMapping() { + ProjectConfig projectConfig = DatafileProjectConfigTestUtils.validProjectConfigV4(); + String attributeID = projectConfig.getAttributeId(projectConfig, "house"); + assertEquals(attributeID, "553339214"); + } + + @Test + public void getAttributeIDWhenAttributeKeyIsUsingReservedKey() { + ProjectConfig projectConfig = DatafileProjectConfigTestUtils.validProjectConfigV4(); + String attributeID = projectConfig.getAttributeId(projectConfig, "$opt_user_agent"); + assertEquals(attributeID, ControlAttribute.USER_AGENT_ATTRIBUTE.toString()); + } + + @Test + public void getAttributeIDWhenAttributeKeyUnrecognizedAttribute() { + ProjectConfig projectConfig = DatafileProjectConfigTestUtils.validProjectConfigV4(); + String invalidAttribute = "empty"; + String attributeID = projectConfig.getAttributeId(projectConfig, invalidAttribute); + assertNull(attributeID); + logbackVerifier.expectMessage(Level.DEBUG, "Unrecognized Attribute \"" + invalidAttribute + "\""); + } + + @Test + public void getAttributeIDWhenAttributeKeyPrefixIsMatched() { + ProjectConfig projectConfig = DatafileProjectConfigTestUtils.validProjectConfigV4(); + String attributeWithReservedPrefix = "$opt_test"; + String attributeID = projectConfig.getAttributeId(projectConfig, attributeWithReservedPrefix); + assertEquals(attributeID, "583394100"); + logbackVerifier.expectMessage(Level.WARN, "Attribute " + attributeWithReservedPrefix + " unexpectedly" + + " has reserved prefix $opt_; using attribute ID instead of reserved attribute name."); + } + + @Test + public void confirmUniqueVariationsInFlagVariationsMapTest() { + // Test to confirm no duplicate variations are added for each flag + // This should never happen as a Map is used for each flag based on variation ID as the key + Map<String, List<Variation>> flagVariationsMap = projectConfig.getFlagVariationsMap(); + for (List<Variation> variationsList : flagVariationsMap.values()) { + Boolean duplicate = false; + Map<String, Variation> variationIdToVariationsMap = new HashMap<>(); + for (Variation variation : variationsList) { + if (variationIdToVariationsMap.containsKey(variation.getId())) { + duplicate = true; + } + variationIdToVariationsMap.put(variation.getId(), variation); + } + assertFalse(duplicate); + } + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java similarity index 51% rename from core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java rename to core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java index c072d79ee..9b65421bb 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/config/DatafileProjectConfigTestUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,37 +45,41 @@ import static org.junit.Assert.assertThat; /** - * Helper class that provides common functionality and resources for testing {@link ProjectConfig}. + * Helper class that provides common functionality and resources for testing {@link DatafileProjectConfig}. */ -public final class ProjectConfigTestUtils { +public final class DatafileProjectConfigTestUtils { private static final ProjectConfig VALID_PROJECT_CONFIG_V2 = generateValidProjectConfigV2(); + private static ProjectConfig generateValidProjectConfigV2() { List<Experiment> experiments = asList( new Experiment("223", "etag1", "Running", "1", - singletonList("100"), - asList(new Variation("276", "vtag1"), - new Variation("277", "vtag2")), - Collections.singletonMap("testUser1", "vtag1"), - asList(new TrafficAllocation("276", 3500), - new TrafficAllocation("277", 9000)), - ""), + singletonList("100"), + null, + asList(new Variation("276", "vtag1"), + new Variation("277", "vtag2")), + Collections.singletonMap("testUser1", "vtag1"), + asList(new TrafficAllocation("276", 3500), + new TrafficAllocation("277", 9000)), + ""), new Experiment("118", "etag2", "Not started", "2", - singletonList("100"), - asList(new Variation("278", "vtag3"), - new Variation("279", "vtag4")), - Collections.singletonMap("testUser3", "vtag3"), - asList(new TrafficAllocation("278", 4500), - new TrafficAllocation("279", 9000)), - ""), + singletonList("100"), + null, + asList(new Variation("278", "vtag3"), + new Variation("279", "vtag4")), + Collections.singletonMap("testUser3", "vtag3"), + asList(new TrafficAllocation("278", 4500), + new TrafficAllocation("279", 9000)), + ""), new Experiment("119", "etag3", "Not started", null, - singletonList("100"), - asList(new Variation("280", "vtag5"), - new Variation("281", "vtag6")), - Collections.singletonMap("testUser4", "vtag5"), - asList(new TrafficAllocation("280", 4500), - new TrafficAllocation("281", 9000)), - "") + singletonList("100"), + null, + asList(new Variation("280", "vtag5"), + new Variation("281", "vtag6")), + Collections.singletonMap("testUser4", "vtag5"), + asList(new TrafficAllocation("280", 4500), + new TrafficAllocation("281", 9000)), + "") ); List<Attribute> attributes = singletonList(new Attribute("134", "browser_type")); @@ -83,12 +87,12 @@ private static ProjectConfig generateValidProjectConfigV2() { List<String> singleExperimentId = singletonList("223"); List<String> multipleExperimentIds = asList("118", "223"); List<EventType> events = asList(new EventType("971", "clicked_cart", singleExperimentId), - new EventType("098", "Total Revenue", singleExperimentId), - new EventType("099", "clicked_purchase", multipleExperimentIds), - new EventType("100", "no_running_experiments", singletonList("118"))); + new EventType("098", "Total Revenue", singleExperimentId), + new EventType("099", "clicked_purchase", multipleExperimentIds), + new EventType("100", "no_running_experiments", singletonList("118"))); List<Condition> userAttributes = new ArrayList<Condition>(); - userAttributes.add(new UserAttribute("browser_type", "custom_dimension", "firefox")); + userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null, "firefox")); OrCondition orInner = new OrCondition(userAttributes); @@ -110,48 +114,52 @@ private static ProjectConfig generateValidProjectConfigV2() { List<Experiment> randomGroupExperiments = asList( new Experiment("301", "group_etag2", "Running", "3", - singletonList("100"), - asList(new Variation("282", "e2_vtag1"), - new Variation("283", "e2_vtag2")), - Collections.<String, String>emptyMap(), - asList(new TrafficAllocation("282", 5000), - new TrafficAllocation("283", 10000)), - "42"), + singletonList("100"), + null, + asList(new Variation("282", "e2_vtag1"), + new Variation("283", "e2_vtag2")), + Collections.<String, String>emptyMap(), + asList(new TrafficAllocation("282", 5000), + new TrafficAllocation("283", 10000)), + "42"), new Experiment("300", "group_etag1", "Running", "4", - singletonList("100"), - asList(new Variation("280", "e1_vtag1"), - new Variation("281", "e1_vtag2")), - userIdToVariationKeyMap, - asList(new TrafficAllocation("280", 3000), - new TrafficAllocation("281", 10000)), - "42") + singletonList("100"), + null, + asList(new Variation("280", "e1_vtag1"), + new Variation("281", "e1_vtag2")), + userIdToVariationKeyMap, + asList(new TrafficAllocation("280", 3000), + new TrafficAllocation("281", 10000)), + "42") ); List<Experiment> overlappingGroupExperiments = asList( new Experiment("302", "overlapping_etag1", "Running", "5", - singletonList("100"), - asList(new Variation("284", "e1_vtag1"), - new Variation("285", "e1_vtag2")), - userIdToVariationKeyMap, - asList(new TrafficAllocation("284", 1500), - new TrafficAllocation("285", 3000)), - "43") + singletonList("100"), + null, + asList(new Variation("284", "e1_vtag1"), + new Variation("285", "e1_vtag2")), + userIdToVariationKeyMap, + asList(new TrafficAllocation("284", 1500), + new TrafficAllocation("285", 3000)), + "43") ); Group randomPolicyGroup = new Group("42", "random", - randomGroupExperiments, - asList(new TrafficAllocation("300", 3000), - new TrafficAllocation("301", 9000), - new TrafficAllocation("", 10000))); + randomGroupExperiments, + asList(new TrafficAllocation("300", 3000), + new TrafficAllocation("301", 9000), + new TrafficAllocation("", 10000))); Group overlappingPolicyGroup = new Group("43", "overlapping", - overlappingGroupExperiments, - Collections.<TrafficAllocation>emptyList()); + overlappingGroupExperiments, + Collections.<TrafficAllocation>emptyList()); List<Group> groups = asList(randomPolicyGroup, overlappingPolicyGroup); - return new ProjectConfig("789", "1234", "2", "42", groups, experiments, attributes, events, audiences); + return new DatafileProjectConfig("789", "1234", "2", "42", groups, experiments, attributes, events, audiences); } private static final ProjectConfig NO_AUDIENCE_PROJECT_CONFIG_V2 = generateNoAudienceProjectConfigV2(); + private static ProjectConfig generateNoAudienceProjectConfigV2() { Map<String, String> userIdToVariationKeyMap = new HashMap<String, String>(); userIdToVariationKeyMap.put("testUser1", "vtag1"); @@ -159,29 +167,32 @@ private static ProjectConfig generateNoAudienceProjectConfigV2() { List<Experiment> experiments = asList( new Experiment("223", "etag1", "Running", "1", - Collections.<String>emptyList(), - asList(new Variation("276", "vtag1"), - new Variation("277", "vtag2")), - userIdToVariationKeyMap, - asList(new TrafficAllocation("276", 3500), - new TrafficAllocation("277", 9000)), - ""), + Collections.<String>emptyList(), + null, + asList(new Variation("276", "vtag1"), + new Variation("277", "vtag2")), + userIdToVariationKeyMap, + asList(new TrafficAllocation("276", 3500), + new TrafficAllocation("277", 9000)), + ""), new Experiment("118", "etag2", "Not started", "2", - Collections.<String>emptyList(), - asList(new Variation("278", "vtag3"), - new Variation("279", "vtag4")), - Collections.<String, String>emptyMap(), - asList(new TrafficAllocation("278", 4500), - new TrafficAllocation("279", 9000)), - ""), + Collections.<String>emptyList(), + null, + asList(new Variation("278", "vtag3"), + new Variation("279", "vtag4")), + Collections.<String, String>emptyMap(), + asList(new TrafficAllocation("278", 4500), + new TrafficAllocation("279", 9000)), + ""), new Experiment("119", "etag3", "Launched", "3", - Collections.<String>emptyList(), - asList(new Variation("280", "vtag5"), - new Variation("281", "vtag6")), - Collections.<String, String>emptyMap(), - asList(new TrafficAllocation("280", 5000), - new TrafficAllocation("281", 10000)), - "") + Collections.<String>emptyList(), + null, + asList(new Variation("280", "vtag5"), + new Variation("281", "vtag6")), + Collections.<String, String>emptyMap(), + asList(new TrafficAllocation("280", 5000), + new TrafficAllocation("281", 10000)), + "") ); List<Attribute> attributes = singletonList(new Attribute("134", "browser_type")); @@ -189,50 +200,53 @@ private static ProjectConfig generateNoAudienceProjectConfigV2() { List<String> singleExperimentId = singletonList("223"); List<String> multipleExperimentIds = asList("118", "223"); List<EventType> events = asList( - new EventType("971", "clicked_cart", singleExperimentId), - new EventType("098", "Total Revenue", singleExperimentId), - new EventType("099", "clicked_purchase", multipleExperimentIds), - new EventType("100", "launched_exp_event", singletonList("119")), - new EventType("101", "event_with_launched_and_running_experiments", Arrays.asList("119", "223")) + new EventType("971", "clicked_cart", singleExperimentId), + new EventType("098", "Total Revenue", singleExperimentId), + new EventType("099", "clicked_purchase", multipleExperimentIds), + new EventType("100", "launched_exp_event", singletonList("119")), + new EventType("101", "event_with_launched_and_running_experiments", Arrays.asList("119", "223")) ); - return new ProjectConfig("789", "1234", "2", "42", Collections.<Group>emptyList(), experiments, attributes, - events, Collections.<Audience>emptyList()); + return new DatafileProjectConfig("789", "1234", "2", "42", Collections.<Group>emptyList(), experiments, attributes, + events, Collections.<Audience>emptyList()); } private static final ProjectConfig VALID_PROJECT_CONFIG_V3 = generateValidProjectConfigV3(); + private static ProjectConfig generateValidProjectConfigV3() { - List<LiveVariableUsageInstance> variationVtag1VariableUsageInstances = asList( - new LiveVariableUsageInstance("6", "True"), - new LiveVariableUsageInstance("2", "10"), - new LiveVariableUsageInstance("3", "string_var_vtag1"), - new LiveVariableUsageInstance("4", "5.3") + List<FeatureVariableUsageInstance> variationVtag1VariableUsageInstances = asList( + new FeatureVariableUsageInstance("6", "True"), + new FeatureVariableUsageInstance("2", "10"), + new FeatureVariableUsageInstance("3", "string_var_vtag1"), + new FeatureVariableUsageInstance("4", "5.3") ); - List<LiveVariableUsageInstance> variationVtag2VariableUsageInstances = asList( - new LiveVariableUsageInstance("6", "False"), - new LiveVariableUsageInstance("2", "20"), - new LiveVariableUsageInstance("3", "string_var_vtag2"), - new LiveVariableUsageInstance("4", "6.3") + List<FeatureVariableUsageInstance> variationVtag2VariableUsageInstances = asList( + new FeatureVariableUsageInstance("6", "False"), + new FeatureVariableUsageInstance("2", "20"), + new FeatureVariableUsageInstance("3", "string_var_vtag2"), + new FeatureVariableUsageInstance("4", "6.3") ); List<Experiment> experiments = asList( new Experiment("223", "etag1", "Running", "1", - singletonList("100"), - asList(new Variation("276", "vtag1", variationVtag1VariableUsageInstances), - new Variation("277", "vtag2", variationVtag2VariableUsageInstances)), - Collections.singletonMap("testUser1", "vtag1"), - asList(new TrafficAllocation("276", 3500), - new TrafficAllocation("277", 9000)), - ""), + singletonList("100"), + null, + asList(new Variation("276", "vtag1", variationVtag1VariableUsageInstances), + new Variation("277", "vtag2", variationVtag2VariableUsageInstances)), + Collections.singletonMap("testUser1", "vtag1"), + asList(new TrafficAllocation("276", 3500), + new TrafficAllocation("277", 9000)), + ""), new Experiment("118", "etag2", "Not started", "2", - singletonList("100"), - asList(new Variation("278", "vtag3", Collections.<LiveVariableUsageInstance>emptyList()), - new Variation("279", "vtag4", Collections.<LiveVariableUsageInstance>emptyList())), - Collections.singletonMap("testUser3", "vtag3"), - asList(new TrafficAllocation("278", 4500), - new TrafficAllocation("279", 9000)), - "") + singletonList("100"), + null, + asList(new Variation("278", "vtag3", Collections.<FeatureVariableUsageInstance>emptyList()), + new Variation("279", "vtag4", Collections.<FeatureVariableUsageInstance>emptyList())), + Collections.singletonMap("testUser3", "vtag3"), + asList(new TrafficAllocation("278", 4500), + new TrafficAllocation("279", 9000)), + "") ); List<Attribute> attributes = singletonList(new Attribute("134", "browser_type")); @@ -240,12 +254,12 @@ private static ProjectConfig generateValidProjectConfigV3() { List<String> singleExperimentId = singletonList("223"); List<String> multipleExperimentIds = asList("118", "223"); List<EventType> events = asList(new EventType("971", "clicked_cart", singleExperimentId), - new EventType("098", "Total Revenue", singleExperimentId), - new EventType("099", "clicked_purchase", multipleExperimentIds), - new EventType("100", "no_running_experiments", singletonList("118"))); + new EventType("098", "Total Revenue", singleExperimentId), + new EventType("099", "clicked_purchase", multipleExperimentIds), + new EventType("100", "no_running_experiments", singletonList("118"))); List<Condition> userAttributes = new ArrayList<Condition>(); - userAttributes.add(new UserAttribute("browser_type", "custom_dimension", "firefox")); + userAttributes.add(new UserAttribute("browser_type", "custom_attribute", null, "firefox")); OrCondition orInner = new OrCondition(userAttributes); @@ -267,100 +281,88 @@ private static ProjectConfig generateValidProjectConfigV3() { List<Experiment> randomGroupExperiments = asList( new Experiment("301", "group_etag2", "Running", "3", - singletonList("100"), - asList(new Variation("282", "e2_vtag1", Collections.<LiveVariableUsageInstance>emptyList()), - new Variation("283", "e2_vtag2", Collections.<LiveVariableUsageInstance>emptyList())), - Collections.<String, String>emptyMap(), - asList(new TrafficAllocation("282", 5000), - new TrafficAllocation("283", 10000)), - "42"), + singletonList("100"), + null, + asList(new Variation("282", "e2_vtag1", Collections.<FeatureVariableUsageInstance>emptyList()), + new Variation("283", "e2_vtag2", Collections.<FeatureVariableUsageInstance>emptyList())), + Collections.<String, String>emptyMap(), + asList(new TrafficAllocation("282", 5000), + new TrafficAllocation("283", 10000)), + "42"), new Experiment("300", "group_etag1", "Running", "4", - singletonList("100"), - asList(new Variation("280", "e1_vtag1", - Collections.singletonList(new LiveVariableUsageInstance("7", "True"))), - new Variation("281", "e1_vtag2", - Collections.singletonList(new LiveVariableUsageInstance("7", "False")))), - userIdToVariationKeyMap, - asList(new TrafficAllocation("280", 3000), - new TrafficAllocation("281", 10000)), - "42") + singletonList("100"), + null, + asList(new Variation("280", "e1_vtag1", + Collections.singletonList(new FeatureVariableUsageInstance("7", "True"))), + new Variation("281", "e1_vtag2", + Collections.singletonList(new FeatureVariableUsageInstance("7", "False")))), + userIdToVariationKeyMap, + asList(new TrafficAllocation("280", 3000), + new TrafficAllocation("281", 10000)), + "42") ); List<Experiment> overlappingGroupExperiments = asList( new Experiment("302", "overlapping_etag1", "Running", "5", - singletonList("100"), - asList(new Variation("284", "e1_vtag1", Collections.<LiveVariableUsageInstance>emptyList()), - new Variation("285", "e1_vtag2", Collections.<LiveVariableUsageInstance>emptyList())), - userIdToVariationKeyMap, - asList(new TrafficAllocation("284", 1500), - new TrafficAllocation("285", 3000)), - "43") + singletonList("100"), + null, + asList(new Variation("284", "e1_vtag1", Collections.<FeatureVariableUsageInstance>emptyList()), + new Variation("285", "e1_vtag2", Collections.<FeatureVariableUsageInstance>emptyList())), + userIdToVariationKeyMap, + asList(new TrafficAllocation("284", 1500), + new TrafficAllocation("285", 3000)), + "43") ); Group randomPolicyGroup = new Group("42", "random", - randomGroupExperiments, - asList(new TrafficAllocation("300", 3000), - new TrafficAllocation("301", 9000), - new TrafficAllocation("", 10000))); + randomGroupExperiments, + asList(new TrafficAllocation("300", 3000), + new TrafficAllocation("301", 9000), + new TrafficAllocation("", 10000))); Group overlappingPolicyGroup = new Group("43", "overlapping", - overlappingGroupExperiments, - Collections.<TrafficAllocation>emptyList()); + overlappingGroupExperiments, + Collections.<TrafficAllocation>emptyList()); List<Group> groups = asList(randomPolicyGroup, overlappingPolicyGroup); - List<LiveVariable> liveVariables = asList( - new LiveVariable("1", "boolean_variable", "False", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.BOOLEAN), - new LiveVariable("2", "integer_variable", "5", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.INTEGER), - new LiveVariable("3", "string_variable", "string_live_variable", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.STRING), - new LiveVariable("4", "double_variable", "13.37", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.DOUBLE), - new LiveVariable("5", "archived_variable", "True", LiveVariable.VariableStatus.ARCHIVED, - LiveVariable.VariableType.BOOLEAN), - new LiveVariable("6", "etag1_variable", "False", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.BOOLEAN), - new LiveVariable("7", "group_etag1_variable", "False", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.BOOLEAN), - new LiveVariable("8", "unused_string_variable", "unused_variable", LiveVariable.VariableStatus.ACTIVE, - LiveVariable.VariableType.STRING) - ); - - return new ProjectConfig("789", "1234", "3", "42", groups, experiments, attributes, events, audiences, - true, liveVariables); + return new DatafileProjectConfig("789", "1234", "3", "42", groups, experiments, attributes, events, audiences, + true); } private static final ProjectConfig NO_AUDIENCE_PROJECT_CONFIG_V3 = generateNoAudienceProjectConfigV3(); + private static ProjectConfig generateNoAudienceProjectConfigV3() { Map<String, String> userIdToVariationKeyMap = new HashMap<String, String>(); userIdToVariationKeyMap.put("testUser1", "vtag1"); userIdToVariationKeyMap.put("testUser2", "vtag2"); List<Experiment> experiments = asList( - new Experiment("223", "etag1", "Running", "1", - Collections.<String>emptyList(), - asList(new Variation("276", "vtag1", Collections.<LiveVariableUsageInstance>emptyList()), - new Variation("277", "vtag2", Collections.<LiveVariableUsageInstance>emptyList())), - userIdToVariationKeyMap, - asList(new TrafficAllocation("276", 3500), - new TrafficAllocation("277", 9000)), - ""), - new Experiment("118", "etag2", "Not started", "2", - Collections.<String>emptyList(), - asList(new Variation("278", "vtag3", Collections.<LiveVariableUsageInstance>emptyList()), - new Variation("279", "vtag4", Collections.<LiveVariableUsageInstance>emptyList())), - Collections.<String, String>emptyMap(), - asList(new TrafficAllocation("278", 4500), - new TrafficAllocation("279", 9000)), - ""), - new Experiment("119", "etag3", "Launched", "3", - Collections.<String>emptyList(), - asList(new Variation("280", "vtag5"), - new Variation("281", "vtag6")), - Collections.<String, String>emptyMap(), - asList(new TrafficAllocation("280", 5000), - new TrafficAllocation("281", 10000)), - "") + new Experiment("223", "etag1", "Running", "1", + Collections.<String>emptyList(), + null, + asList(new Variation("276", "vtag1", Collections.<FeatureVariableUsageInstance>emptyList()), + new Variation("277", "vtag2", Collections.<FeatureVariableUsageInstance>emptyList())), + userIdToVariationKeyMap, + asList(new TrafficAllocation("276", 3500), + new TrafficAllocation("277", 9000)), + ""), + new Experiment("118", "etag2", "Not started", "2", + Collections.<String>emptyList(), + null, + asList(new Variation("278", "vtag3", Collections.<FeatureVariableUsageInstance>emptyList()), + new Variation("279", "vtag4", Collections.<FeatureVariableUsageInstance>emptyList())), + Collections.<String, String>emptyMap(), + asList(new TrafficAllocation("278", 4500), + new TrafficAllocation("279", 9000)), + ""), + new Experiment("119", "etag3", "Launched", "3", + Collections.<String>emptyList(), + null, + asList(new Variation("280", "vtag5"), + new Variation("281", "vtag6")), + Collections.<String, String>emptyMap(), + asList(new TrafficAllocation("280", 5000), + new TrafficAllocation("281", 10000)), + "") ); List<Attribute> attributes = singletonList(new Attribute("134", "browser_type")); @@ -368,23 +370,25 @@ private static ProjectConfig generateNoAudienceProjectConfigV3() { List<String> singleExperimentId = singletonList("223"); List<String> multipleExperimentIds = asList("118", "223"); List<EventType> events = asList( - new EventType("971", "clicked_cart", singleExperimentId), - new EventType("098", "Total Revenue", singleExperimentId), - new EventType("099", "clicked_purchase", multipleExperimentIds), - new EventType("100", "launched_exp_event", singletonList("119")), - new EventType("101", "event_with_launched_and_running_experiments", Arrays.asList("119", "223")) + new EventType("971", "clicked_cart", singleExperimentId), + new EventType("098", "Total Revenue", singleExperimentId), + new EventType("099", "clicked_purchase", multipleExperimentIds), + new EventType("100", "launched_exp_event", singletonList("119")), + new EventType("101", "event_with_launched_and_running_experiments", Arrays.asList("119", "223")) ); - return new ProjectConfig("789", "1234", "3", "42", Collections.<Group>emptyList(), experiments, attributes, - events, Collections.<Audience>emptyList(), true, Collections.<LiveVariable>emptyList()); + return new DatafileProjectConfig("789", "1234", "3", "42", Collections.<Group>emptyList(), experiments, attributes, + events, Collections.<Audience>emptyList(), true); } private static final ProjectConfig VALID_PROJECT_CONFIG_V4 = generateValidProjectConfigV4(); + private static ProjectConfig generateValidProjectConfigV4() { return ValidProjectConfigV4.generateValidProjectConfigV4(); } - private ProjectConfigTestUtils() { } + private DatafileProjectConfigTestUtils() { + } public static String validConfigJsonV2() throws IOException { return Resources.toString(Resources.getResource("config/valid-project-config-v2.json"), Charsets.UTF_8); @@ -406,29 +410,33 @@ public static String validConfigJsonV4() throws IOException { return Resources.toString(Resources.getResource("config/valid-project-config-v4.json"), Charsets.UTF_8); } + public static String nullFeatureEnabledConfigJsonV4() throws IOException { + return Resources.toString(Resources.getResource("config/null-featureEnabled-config-v4.json"), Charsets.UTF_8); + } + /** - * @return the expected {@link ProjectConfig} for the json produced by {@link #validConfigJsonV2()} ()} + * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #validConfigJsonV2()} ()} */ public static ProjectConfig validProjectConfigV2() { return VALID_PROJECT_CONFIG_V2; } /** - * @return the expected {@link ProjectConfig} for the json produced by {@link #noAudienceProjectConfigJsonV2()} + * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #noAudienceProjectConfigJsonV2()} */ public static ProjectConfig noAudienceProjectConfigV2() { return NO_AUDIENCE_PROJECT_CONFIG_V2; } /** - * @return the expected {@link ProjectConfig} for the json produced by {@link #validConfigJsonV3()} ()} + * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #validConfigJsonV3()} ()} */ public static ProjectConfig validProjectConfigV3() { return VALID_PROJECT_CONFIG_V3; } /** - * @return the expected {@link ProjectConfig} for the json produced by {@link #noAudienceProjectConfigJsonV3()} + * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #noAudienceProjectConfigJsonV3()} */ public static ProjectConfig noAudienceProjectConfigV3() { return NO_AUDIENCE_PROJECT_CONFIG_V3; @@ -438,8 +446,16 @@ public static ProjectConfig validProjectConfigV4() { return VALID_PROJECT_CONFIG_V4; } + /** + * @return the expected {@link DatafileProjectConfig} for the json produced by {@link #invalidProjectConfigV5()} + */ + public static String invalidProjectConfigV5() throws IOException { + return Resources.toString(Resources.getResource("config/invalid-project-config-v5.json"), Charsets.UTF_8); + } + /** * Asserts that the provided project configs are equivalent. + * TODO this signature is backwards should be (ProjectConfig expected, ProjectConfig actual) */ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonnull ProjectConfig expected) { assertNotNull(actual); @@ -452,12 +468,13 @@ public static void verifyProjectConfig(@CheckForNull ProjectConfig actual, @Nonn verifyAttributes(actual.getAttributes(), expected.getAttributes()); verifyAudiences(actual.getAudiences(), expected.getAudiences()); + verifyAudiences(actual.getTypedAudiences(), expected.getTypedAudiences()); verifyEvents(actual.getEventTypes(), expected.getEventTypes()); verifyExperiments(actual.getExperiments(), expected.getExperiments()); verifyFeatureFlags(actual.getFeatureFlags(), expected.getFeatureFlags()); - verifyLiveVariables(actual.getLiveVariables(), expected.getLiveVariables()); verifyGroups(actual.getGroups(), expected.getGroups()); verifyRollouts(actual.getRollouts(), expected.getRollouts()); + verifyIntegrations(actual.getIntegrations(), expected.getIntegrations()); } /** @@ -475,18 +492,19 @@ private static void verifyExperiments(List<Experiment> actual, List<Experiment> assertThat(actualExperiment.getGroupId(), is(expectedExperiment.getGroupId())); assertThat(actualExperiment.getStatus(), is(expectedExperiment.getStatus())); assertThat(actualExperiment.getAudienceIds(), is(expectedExperiment.getAudienceIds())); + assertThat(actualExperiment.getAudienceConditions(), is(expectedExperiment.getAudienceConditions())); assertThat(actualExperiment.getUserIdToVariationKeyMap(), - is(expectedExperiment.getUserIdToVariationKeyMap())); + is(expectedExperiment.getUserIdToVariationKeyMap())); verifyVariations(actualExperiment.getVariations(), expectedExperiment.getVariations()); verifyTrafficAllocations(actualExperiment.getTrafficAllocation(), - expectedExperiment.getTrafficAllocation()); + expectedExperiment.getTrafficAllocation()); } } private static void verifyFeatureFlags(List<FeatureFlag> actual, List<FeatureFlag> expected) { assertEquals(expected.size(), actual.size()); - for (int i = 0; i < actual.size(); i ++) { + for (int i = 0; i < actual.size(); i++) { FeatureFlag actualFeatureFlag = actual.get(i); FeatureFlag expectedFeatureFlag = expected.get(i); @@ -506,8 +524,8 @@ private static void verifyVariations(List<Variation> actual, List<Variation> exp assertThat(actualVariation.getId(), is(expectedVariation.getId())); assertThat(actualVariation.getKey(), is(expectedVariation.getKey())); - verifyLiveVariableInstances(actualVariation.getLiveVariableUsageInstances(), - expectedVariation.getLiveVariableUsageInstances()); + verifyFeatureVariableInstances(actualVariation.getFeatureVariableUsageInstances(), + expectedVariation.getFeatureVariableUsageInstances()); } } @@ -515,7 +533,7 @@ private static void verifyVariations(List<Variation> actual, List<Variation> exp * Asserts that the provided traffic allocation configs are equivalent. */ private static void verifyTrafficAllocations(List<TrafficAllocation> actual, - List<TrafficAllocation> expected) { + List<TrafficAllocation> expected) { assertThat(actual.size(), is(expected.size())); for (int i = 0; i < actual.size(); i++) { @@ -524,8 +542,8 @@ private static void verifyTrafficAllocations(List<TrafficAllocation> actual, assertThat(actualDistribution.getEntityId(), is(expectedDistribution.getEntityId())); assertEquals("expectedDistribution: " + expectedDistribution.toString() + - "is not equal to the actualDistribution: " + actualDistribution.toString(), - expectedDistribution.getEndOfRange(), actualDistribution.getEndOfRange()); + "is not equal to the actualDistribution: " + actualDistribution.toString(), + expectedDistribution.getEndOfRange(), actualDistribution.getEndOfRange()); } } @@ -574,7 +592,6 @@ private static void verifyAudiences(List<Audience> actual, List<Audience> expect assertThat(actualAudience.getId(), is(expectedAudience.getId())); assertThat(actualAudience.getKey(), is(expectedAudience.getKey())); assertThat(actualAudience.getConditions(), is(expectedAudience.getConditions())); - assertThat(actualAudience.getConditions(), is(expectedAudience.getConditions())); } } @@ -595,68 +612,61 @@ private static void verifyGroups(List<Group> actual, List<Group> expected) { } } - /** - * Verify that the provided live variable definitions are equivalent. - */ - private static void verifyLiveVariables(List<LiveVariable> actual, List<LiveVariable> expected) { - // if using V2, live variables will be null + private static void verifyRollouts(List<Rollout> actual, List<Rollout> expected) { if (expected == null) { assertNull(actual); } else { - assertThat(actual.size(), is(expected.size())); + assertEquals(expected.size(), actual.size()); for (int i = 0; i < actual.size(); i++) { - LiveVariable actualLiveVariable = actual.get(i); - LiveVariable expectedLiveVariable = expected.get(i); - - assertThat(actualLiveVariable.getId(), is(expectedLiveVariable.getId())); - assertThat(actualLiveVariable.getKey(), is(expectedLiveVariable.getKey())); - assertThat(actualLiveVariable.getDefaultValue(), is(expectedLiveVariable.getDefaultValue())); - assertThat(actualLiveVariable.getType(), is(expectedLiveVariable.getType())); - assertThat(actualLiveVariable.getStatus(), is(expectedLiveVariable.getStatus())); + Rollout actualRollout = actual.get(i); + Rollout expectedRollout = expected.get(i); + + assertEquals(expectedRollout.getId(), actualRollout.getId()); + verifyExperiments(actualRollout.getExperiments(), expectedRollout.getExperiments()); } } } - private static void verifyRollouts(List<Rollout> actual, List<Rollout> expected) { + private static void verifyIntegrations(List<Integration> actual, List<Integration> expected) { if (expected == null) { assertNull(actual); - } - else { + } else { assertEquals(expected.size(), actual.size()); for (int i = 0; i < actual.size(); i++) { - Rollout actualRollout = actual.get(i); - Rollout expectedRollout = expected.get(i); + Integration actualIntegrations = actual.get(i); + Integration expectedIntegration = expected.get(i); - assertEquals(expectedRollout.getId(), actualRollout.getId()); - verifyExperiments(actualRollout.getExperiments(), expectedRollout.getExperiments()); + assertEquals(expectedIntegration.getKey(), actualIntegrations.getKey()); + assertEquals(expectedIntegration.getHost(), actualIntegrations.getHost()); + assertEquals(expectedIntegration.getPublicKey(), actualIntegrations.getPublicKey()); } } } /** - * Verify that the provided variation-level live variable usage instances are equivalent. + * Verify that the provided variation-level feature variable usage instances are equivalent. */ - private static void verifyLiveVariableInstances(List<LiveVariableUsageInstance> actual, - List<LiveVariableUsageInstance> expected) { - // if using V2, live variable instances will be null + private static void verifyFeatureVariableInstances(List<FeatureVariableUsageInstance> actual, + List<FeatureVariableUsageInstance> expected) { + // if using V2, feature variable instances will be null if (expected == null) { assertNull(actual); } else { assertThat(actual.size(), is(expected.size())); for (int i = 0; i < actual.size(); i++) { - LiveVariableUsageInstance actualLiveVariableUsageInstance = actual.get(i); - LiveVariableUsageInstance expectedLiveVariableUsageInstance = expected.get(i); + FeatureVariableUsageInstance actualFeatureVariableUsageInstance = actual.get(i); + FeatureVariableUsageInstance expectedFeatureVariableUsageInstance = expected.get(i); - assertThat(actualLiveVariableUsageInstance.getId(), is(expectedLiveVariableUsageInstance.getId())); - assertThat(actualLiveVariableUsageInstance.getValue(), is(expectedLiveVariableUsageInstance.getValue())); + assertThat(actualFeatureVariableUsageInstance.getId(), is(expectedFeatureVariableUsageInstance.getId())); + assertThat(actualFeatureVariableUsageInstance.getValue(), is(expectedFeatureVariableUsageInstance.getValue())); } } } - public static <T> List<T> createListOfObjects(T ... elements) { + public static <T> List<T> createListOfObjects(T... elements) { ArrayList<T> list = new ArrayList<T>(elements.length); for (T element : elements) { list.add(element); @@ -664,7 +674,7 @@ public static <T> List<T> createListOfObjects(T ... elements) { return list; } - public static <K, V> Map<K, V> createMapOfObjects(List<K>keys, List<V>values) { + public static <K, V> Map<K, V> createMapOfObjects(List<K> keys, List<V> values) { HashMap<K, V> map = new HashMap<K, V>(keys.size()); if (keys.size() == values.size()) { Iterator<K> keysIterator = keys.iterator(); diff --git a/core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java b/core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java new file mode 100644 index 000000000..334e76067 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/ExperimentTest.java @@ -0,0 +1,205 @@ +/** + * + * Copyright 2021, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.config.audience.*; +import org.junit.Test; +import static org.junit.Assert.*; + +import java.util.*; + +public class ExperimentTest { + + @Test + public void testStringifyConditionScenarios() { + List<Condition> audienceConditionsScenarios = getAudienceConditionsList(); + Map<Integer, String> expectedScenarioStringsMap = getExpectedScenariosMap(); + Map<String, String> audiencesMap = new HashMap<>(); + audiencesMap.put("1", "us"); + audiencesMap.put("2", "female"); + audiencesMap.put("3", "adult"); + audiencesMap.put("11", "fr"); + audiencesMap.put("12", "male"); + audiencesMap.put("13", "kid"); + + if (expectedScenarioStringsMap.size() == audienceConditionsScenarios.size()) { + for (int i = 0; i < audienceConditionsScenarios.size() - 1; i++) { + Experiment experiment = makeMockExperimentWithStatus(Experiment.ExperimentStatus.RUNNING, + audienceConditionsScenarios.get(i)); + String audiences = experiment.serializeConditions(audiencesMap); + assertEquals(expectedScenarioStringsMap.get(i+1), audiences); + } + } + + } + + public Map<Integer, String> getExpectedScenariosMap() { + Map<Integer, String> expectedScenarioStringsMap = new HashMap<>(); + expectedScenarioStringsMap.put(1, ""); + expectedScenarioStringsMap.put(2, "\"us\" OR \"female\""); + expectedScenarioStringsMap.put(3, "\"us\" AND \"female\" AND \"adult\""); + expectedScenarioStringsMap.put(4, "NOT \"us\""); + expectedScenarioStringsMap.put(5, "\"us\""); + expectedScenarioStringsMap.put(6, "\"us\""); + expectedScenarioStringsMap.put(7, "\"us\""); + expectedScenarioStringsMap.put(8, "\"us\" OR \"female\""); + expectedScenarioStringsMap.put(9, "(\"us\" OR \"female\") AND \"adult\""); + expectedScenarioStringsMap.put(10, "(\"us\" OR (\"female\" AND \"adult\")) AND (\"fr\" AND (\"male\" OR \"kid\"))"); + expectedScenarioStringsMap.put(11, "NOT (\"us\" AND \"female\")"); + expectedScenarioStringsMap.put(12, "\"us\" OR \"100000\""); + expectedScenarioStringsMap.put(13, ""); + + return expectedScenarioStringsMap; + } + + public List<Condition> getAudienceConditionsList() { + AudienceIdCondition one = new AudienceIdCondition("1"); + AudienceIdCondition two = new AudienceIdCondition("2"); + AudienceIdCondition three = new AudienceIdCondition("3"); + AudienceIdCondition eleven = new AudienceIdCondition("11"); + AudienceIdCondition twelve = new AudienceIdCondition("12"); + AudienceIdCondition thirteen = new AudienceIdCondition("13"); + + // Scenario 1 - [] + EmptyCondition scenario1 = new EmptyCondition(); + + // Scenario 2 - ["or", "1", "2"] + List<Condition> scenario2List = new ArrayList<>(); + scenario2List.add(one); + scenario2List.add(two); + OrCondition scenario2 = new OrCondition(scenario2List); + + // Scenario 3 - ["and", "1", "2", "3"] + List<Condition> scenario3List = new ArrayList<>(); + scenario3List.add(one); + scenario3List.add(two); + scenario3List.add(three); + AndCondition scenario3 = new AndCondition(scenario3List); + + // Scenario 4 - ["not", "1"] + NotCondition scenario4 = new NotCondition(one); + + // Scenario 5 - ["or", "1"] + List<Condition> scenario5List = new ArrayList<>(); + scenario5List.add(one); + OrCondition scenario5 = new OrCondition(scenario5List); + + // Scenario 6 - ["and", "1"] + List<Condition> scenario6List = new ArrayList<>(); + scenario6List.add(one); + AndCondition scenario6 = new AndCondition(scenario6List); + + // Scenario 7 - ["1"] + AudienceIdCondition scenario7 = one; + + // Scenario 8 - ["1", "2"] + // Defaults to Or in Datafile Parsing resulting in an OrCondition + // Same as Scenario 2 + + OrCondition scenario8 = scenario2; + + // Scenario 9 - ["and", ["or", "1", "2"], "3"] + List<Condition> Scenario9List = new ArrayList<>(); + Scenario9List.add(scenario2); + Scenario9List.add(three); + AndCondition scenario9 = new AndCondition(Scenario9List); + + // Scenario 10 - ["and", ["or", "1", ["and", "2", "3"]], ["and", "11, ["or", "12", "13"]]] + List<Condition> scenario10List = new ArrayList<>(); + + List<Condition> or1213List = new ArrayList<>(); + or1213List.add(twelve); + or1213List.add(thirteen); + OrCondition or1213 = new OrCondition(or1213List); + + List<Condition> and11Or1213List = new ArrayList<>(); + and11Or1213List.add(eleven); + and11Or1213List.add(or1213); + AndCondition and11Or1213 = new AndCondition(and11Or1213List); + + List<Condition> and23List = new ArrayList<>(); + and23List.add(two); + and23List.add(three); + AndCondition and23 = new AndCondition(and23List); + + List<Condition> or1And23List = new ArrayList<>(); + or1And23List.add(one); + or1And23List.add(and23); + OrCondition or1And23 = new OrCondition(or1And23List); + + scenario10List.add(or1And23); + scenario10List.add(and11Or1213); + AndCondition scenario10 = new AndCondition(scenario10List); + + // Scenario 11 - ["not", ["and", "1", "2"]] + List<Condition> and12List = new ArrayList<>(); + and12List.add(one); + and12List.add(two); + AndCondition and12 = new AndCondition(and12List); + + NotCondition scenario11 = new NotCondition(and12); + + // Scenario 12 - ["or", "1", "100000"] + List<Condition> scenario12List = new ArrayList<>(); + scenario12List.add(one); + AudienceIdCondition unknownAudience = new AudienceIdCondition("100000"); + scenario12List.add(unknownAudience); + + OrCondition scenario12 = new OrCondition(scenario12List); + + // Scenario 13 - ["and", ["and", invalidAudienceIdCondition]] which becomes + // the scenario of ["and", "and"] and results in empty string. + AudienceIdCondition invalidAudience = new AudienceIdCondition("5"); + List<Condition> invalidIdList = new ArrayList<>(); + invalidIdList.add(invalidAudience); + AndCondition andCondition = new AndCondition(invalidIdList); + List<Condition> andInvalidAudienceId = new ArrayList<>(); + andInvalidAudienceId.add(andCondition); + AndCondition scenario13 = new AndCondition(andInvalidAudienceId); + + + List<Condition> conditionTestScenarios = new ArrayList<>(); + conditionTestScenarios.add(scenario1); + conditionTestScenarios.add(scenario2); + conditionTestScenarios.add(scenario3); + conditionTestScenarios.add(scenario4); + conditionTestScenarios.add(scenario5); + conditionTestScenarios.add(scenario6); + conditionTestScenarios.add(scenario7); + conditionTestScenarios.add(scenario8); + conditionTestScenarios.add(scenario9); + conditionTestScenarios.add(scenario10); + conditionTestScenarios.add(scenario11); + conditionTestScenarios.add(scenario12); + conditionTestScenarios.add(scenario13); + + return conditionTestScenarios; + } + + private Experiment makeMockExperimentWithStatus(Experiment.ExperimentStatus status, Condition audienceConditions) { + return new Experiment("12345", + "mockExperimentKey", + status.toString(), + "layerId", + Collections.<String>emptyList(), + audienceConditions, + Collections.<Variation>emptyList(), + Collections.<String, String>emptyMap(), + Collections.<TrafficAllocation>emptyList() + ); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java new file mode 100644 index 000000000..91a9b8715 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/PollingProjectConfigManagerTest.java @@ -0,0 +1,313 @@ +/** + * + * Copyright 2019-2021, 2023, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.internal.NotificationRegistry; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.notification.UpdateConfigNotification; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.*; +import org.junit.rules.ExpectedException; +import org.junit.rules.RuleChain; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PollingProjectConfigManagerTest { + + private static final long POLLING_PERIOD = 10; + private static final TimeUnit POLLING_UNIT = TimeUnit.MILLISECONDS; + private static final int PROJECT_CONFIG_DELAY = 100; + + public ExpectedException thrown = ExpectedException.none(); + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Rule + @SuppressFBWarnings("URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD") + public RuleChain ruleChain = RuleChain.outerRule(thrown) + .around(logbackVerifier); + private TestProjectConfigManager testProjectConfigManager; + private ProjectConfig projectConfig; + + @Before + public void setUp() throws Exception { + projectConfig = new DatafileProjectConfig.Builder().withDatafile(validConfigJsonV4()).build(); + testProjectConfigManager = new TestProjectConfigManager(projectConfig); + testProjectConfigManager.start(); + } + + @After + public void tearDown() throws Exception { + testProjectConfigManager.close(); + } + + @Test + public void testPollingUpdates() throws Exception { + int maxAttempts = 100; + int desiredCount = 10; + + testProjectConfigManager.release(); + + for (int i = 0; i < maxAttempts; i++) { + Thread.sleep(PROJECT_CONFIG_DELAY); + if (desiredCount <= testProjectConfigManager.getCount()) { + return; + } + } + + fail(String.format("Max number of attempts exceeded: %s", maxAttempts)); + } + + @Test + public void testStop() throws Exception { + int maxAttempts = 10; + + testProjectConfigManager.release(); + testProjectConfigManager.stop(); + assertFalse(testProjectConfigManager.isRunning()); + + int desiredCount = testProjectConfigManager.getCount(); + + for (int i = 0; i < maxAttempts; i++) { + Thread.sleep(PROJECT_CONFIG_DELAY); + if (desiredCount <= testProjectConfigManager.getCount()) { + assertEquals(desiredCount, testProjectConfigManager.getCount()); + } + } + } + + @Test + public void testBlockingGetConfig() throws Exception { + testProjectConfigManager.release(); + TimeUnit.MILLISECONDS.sleep(PROJECT_CONFIG_DELAY); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + assertEquals(projectConfig.getSdkKey(), testProjectConfigManager.getSDKKey()); + } + + @Test + public void testBlockingGetConfigWithDefault() throws Exception { + testProjectConfigManager.setConfig(projectConfig); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + assertEquals(projectConfig.getSdkKey(), testProjectConfigManager.getSDKKey()); + } + + @Test + @Ignore("flaky") + public void testBlockingGetConfigWithTimeout() throws Exception { + testProjectConfigManager.start(); + assertNull(testProjectConfigManager.getConfig()); + } + + @Test + public void testGetConfigNotStarted() throws Exception { + testProjectConfigManager.release(); + testProjectConfigManager.close(); + assertFalse(testProjectConfigManager.isRunning()); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + } + + @Test + public void testGetConfigNotStartedDefault() throws Exception { + testProjectConfigManager.setConfig(projectConfig); + testProjectConfigManager.close(); + assertFalse(testProjectConfigManager.isRunning()); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + assertEquals(projectConfig.getSdkKey(), testProjectConfigManager.getSDKKey()); + } + + @Test + public void testSetConfig() { + testProjectConfigManager = new TestProjectConfigManager() { + @Override + public ProjectConfig poll() { + return null; + } + }; + + assertNull(testProjectConfigManager.getConfig()); + + testProjectConfigManager.setConfig(projectConfig); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + + testProjectConfigManager.setConfig(null); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + + ProjectConfig newerProjectConfig = mock(ProjectConfig.class); + when(newerProjectConfig.getRevision()).thenReturn("new"); + + testProjectConfigManager.setConfig(newerProjectConfig); + assertEquals(newerProjectConfig, testProjectConfigManager.getConfig()); + } + + @Test + public void testSetOptimizelyConfig(){ + assertNull(testProjectConfigManager.getOptimizelyConfig()); + + testProjectConfigManager.setConfig(projectConfig); + assertEquals("1480511547", testProjectConfigManager.getOptimizelyConfig().getRevision()); + assertEquals("ValidProjectConfigV4", testProjectConfigManager.getOptimizelyConfig().getSdkKey()); + assertEquals("production", testProjectConfigManager.getOptimizelyConfig().getEnvironmentKey()); + + // cached config because project config is null + testProjectConfigManager.setConfig(null); + assertEquals("1480511547", testProjectConfigManager.getOptimizelyConfig().getRevision()); + + // created config with new revision + ProjectConfig newerProjectConfig = mock(ProjectConfig.class); + when(newerProjectConfig.getRevision()).thenReturn("new"); + + // verify the new optimizely config + testProjectConfigManager.setConfig(newerProjectConfig); + assertEquals("new", testProjectConfigManager.getOptimizelyConfig().getRevision()); + } + + @Test + public void testErroringProjectConfigManagerWithTimeout() throws Exception { + testProjectConfigManager = new TestProjectConfigManager() { + @Override + public ProjectConfig poll() { + throw new RuntimeException(); + } + }; + + testProjectConfigManager.start(); + assertNull(testProjectConfigManager.getConfig()); + } + + @Test + public void testRecoveringProjectConfigManagerWithTimeout() throws Exception { + AtomicBoolean throwError = new AtomicBoolean(true); + + testProjectConfigManager = new TestProjectConfigManager() { + @Override + public ProjectConfig poll() { + if (throwError.get()) { + throw new RuntimeException("Test class, expected failure"); + } + + return projectConfig; + } + }; + + testProjectConfigManager.start(); + assertNull(testProjectConfigManager.getConfig()); + + throwError.set(false); + Thread.sleep(2 * PROJECT_CONFIG_DELAY); + assertEquals(projectConfig, testProjectConfigManager.getConfig()); + } + + @Test + public void testUpdateConfigNotificationGetsTriggered() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(2); + NotificationCenter registryDefaultNotificationCenter = NotificationRegistry.getInternalNotificationCenter("ValidProjectConfigV4"); + NotificationCenter userNotificationCenter = testProjectConfigManager.getNotificationCenter(); + assertNotEquals(registryDefaultNotificationCenter, userNotificationCenter); + + testProjectConfigManager.getNotificationCenter() + .<UpdateConfigNotification>getNotificationManager(UpdateConfigNotification.class) + .addHandler(message -> {countDownLatch.countDown();}); + NotificationRegistry.getInternalNotificationCenter("ValidProjectConfigV4") + .<UpdateConfigNotification>getNotificationManager(UpdateConfigNotification.class) + .addHandler(message -> {countDownLatch.countDown();}); + assertTrue(countDownLatch.await(500, TimeUnit.MILLISECONDS)); + } + + @Test + public void testSettingUpLowerPollingPeriodResultsInWarning() throws InterruptedException { + long pollingPeriod = 29; + new TestProjectConfigManager(projectConfig, pollingPeriod, TimeUnit.SECONDS, pollingPeriod / 2, TimeUnit.SECONDS, new NotificationCenter()); + logbackVerifier.expectMessage(Level.WARN, "Polling intervals below 30 seconds are not recommended."); + } + + @Test + public void testUpdateConfigNotificationDoesNotResultInDeadlock() throws Exception { + NotificationCenter notificationCenter = new NotificationCenter(); + + TestProjectConfigManager testProjectConfigManager = new TestProjectConfigManager(projectConfig, TimeUnit.SECONDS.toMillis(10), notificationCenter); + notificationCenter.getNotificationManager(UpdateConfigNotification.class) + .addHandler(message -> { + assertNotNull(testProjectConfigManager.getConfig()); + }); + + testProjectConfigManager.start(); + CompletableFuture.runAsync(testProjectConfigManager::getConfig).get(5, TimeUnit.SECONDS); + } + + private static class TestProjectConfigManager extends PollingProjectConfigManager { + private final AtomicInteger counter = new AtomicInteger(); + + private final CountDownLatch countDownLatch = new CountDownLatch(1); + private final ProjectConfig projectConfig; + + private TestProjectConfigManager() { + this(null); + } + + private TestProjectConfigManager(ProjectConfig projectConfig) { + this(projectConfig, POLLING_PERIOD / 2, new NotificationCenter()); + } + + private TestProjectConfigManager(ProjectConfig projectConfig, long blockPeriod, NotificationCenter notificationCenter) { + this(projectConfig, POLLING_PERIOD, POLLING_UNIT, blockPeriod, POLLING_UNIT, notificationCenter); + } + + private TestProjectConfigManager(ProjectConfig projectConfig, long pollingPeriod, TimeUnit pollingUnit, long blockPeriod, TimeUnit blockingUnit, NotificationCenter notificationCenter) { + super(pollingPeriod, pollingUnit, blockPeriod, blockingUnit, notificationCenter); + this.projectConfig = projectConfig; + } + + @Override + public ProjectConfig poll() { + try { + countDownLatch.await(10, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + + counter.incrementAndGet(); + return projectConfig; + } + + public int getCount() { + return counter.get(); + } + + public void release() { + countDownLatch.countDown(); + } + + @Override + public String getSDKKey() { + if (projectConfig == null) { + return null; + } + return projectConfig.getSdkKey(); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java deleted file mode 100644 index 2d64e71fe..000000000 --- a/core-api/src/test/java/com/optimizely/ab/config/ProjectConfigTest.java +++ /dev/null @@ -1,395 +0,0 @@ -/** - * - * Copyright 2016-2018, Optimizely and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.optimizely.ab.config; - -import ch.qos.logback.classic.Level; -import com.optimizely.ab.config.audience.AndCondition; -import com.optimizely.ab.config.audience.Condition; -import com.optimizely.ab.config.audience.NotCondition; -import com.optimizely.ab.config.audience.OrCondition; -import com.optimizely.ab.config.audience.UserAttribute; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static java.util.Arrays.asList; -import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThat; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertEquals; - - -import com.optimizely.ab.internal.LogbackVerifier; -import com.optimizely.ab.internal.ControlAttribute; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - -/** - * Tests for {@link ProjectConfig}. - */ -public class ProjectConfigTest { - - private ProjectConfig projectConfig; - - @Rule - public LogbackVerifier logbackVerifier = new LogbackVerifier(); - - @Before - public void initialize() { - projectConfig = ProjectConfigTestUtils.validProjectConfigV3(); - } - - /** - * Verify that {@link ProjectConfig#toString()} doesn't throw an exception. - */ - @Test - @SuppressFBWarnings("RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT") - public void toStringDoesNotFail() throws Exception { - projectConfig.toString(); - } - - /** - * Asserts that {@link ProjectConfig#getExperimentsForEventKey(String)} - * returns the respective experiment ids for experiments using an event, - * provided that the event parameter is valid. - */ - @Test - public void verifyGetExperimentsForValidEvent() throws Exception { - Experiment experiment223 = projectConfig.getExperimentIdMapping().get("223"); - Experiment experiment118 = projectConfig.getExperimentIdMapping().get("118"); - List<Experiment> expectedSingleExperiment = asList(experiment223); - List<Experiment> actualSingleExperiment = projectConfig.getExperimentsForEventKey("clicked_cart"); - assertThat(actualSingleExperiment, is(expectedSingleExperiment)); - - List<Experiment> expectedMultipleExperiments = asList(experiment118, experiment223); - List<Experiment> actualMultipleExperiments = projectConfig.getExperimentsForEventKey("clicked_purchase"); - assertThat(actualMultipleExperiments, is(expectedMultipleExperiments)); - } - - /** - * Asserts that {@link ProjectConfig#getExperimentsForEventKey(String)} returns an empty List - * when given an invalid event key. - */ - @Test - public void verifyGetExperimentsForInvalidEvent() throws Exception { - List<Experiment> expectedExperiments = Collections.emptyList(); - List<Experiment> actualExperiments = projectConfig.getExperimentsForEventKey("a_fake_event"); - assertThat(actualExperiments, is(expectedExperiments)); - } - - /** - * Asserts that getAudienceConditionsFromId returns the respective conditions for an audience, provided the - * audience ID parameter is valid. - */ - @Test - public void verifyGetAudienceConditionsFromValidId() throws Exception { - List<Condition> userAttributes = new ArrayList<Condition>(); - userAttributes.add(new UserAttribute("browser_type", "custom_dimension", "firefox")); - - OrCondition orInner = new OrCondition(userAttributes); - - NotCondition notCondition = new NotCondition(orInner); - List<Condition> outerOrList = new ArrayList<Condition>(); - outerOrList.add(notCondition); - - OrCondition orOuter = new OrCondition(outerOrList); - List<Condition> andList = new ArrayList<Condition>(); - andList.add(orOuter); - - Condition expectedConditions = new AndCondition(andList); - Condition actualConditions = projectConfig.getAudienceConditionsFromId("100"); - assertThat(actualConditions, is(expectedConditions)); - } - - /** - * Asserts that getAudienceConditionsFromId returns null given an invalid audience ID parameter. - */ - @Test - public void verifyGetAudienceConditionsFromInvalidId() throws Exception { - assertNull(projectConfig.getAudienceConditionsFromId("invalid_id")); - } - - /** - * Asserts that getLiveVariableIdToExperimentsMapping returns a correct mapping between live variable IDs and - * corresponding experiments using these live variables. - */ - @Test - public void verifyGetLiveVariableIdToExperimentsMapping() throws Exception { - Experiment ungroupedExpWithVariables = projectConfig.getExperiments().get(0); - Experiment groupedExpWithVariables = projectConfig.getGroups().get(0).getExperiments().get(1); - - Map<String, List<Experiment>> expectedLiveVariableIdToExperimentsMapping = - new HashMap<String, List<Experiment>>(); - expectedLiveVariableIdToExperimentsMapping.put("6", Collections.singletonList(ungroupedExpWithVariables)); - expectedLiveVariableIdToExperimentsMapping.put("2", Collections.singletonList(ungroupedExpWithVariables)); - expectedLiveVariableIdToExperimentsMapping.put("3", Collections.singletonList(ungroupedExpWithVariables)); - expectedLiveVariableIdToExperimentsMapping.put("4", Collections.singletonList(ungroupedExpWithVariables)); - - expectedLiveVariableIdToExperimentsMapping.put("7", Collections.singletonList(groupedExpWithVariables)); - - assertThat(projectConfig.getLiveVariableIdToExperimentsMapping(), - is(expectedLiveVariableIdToExperimentsMapping)); - } - - /** - * Asserts that getVariationToLiveVariableUsageInstanceMapping returns a correct mapping between variation IDs and - * the values of the live variables for the variation. - */ - @Test - public void verifyGetVariationToLiveVariableUsageInstanceMapping() throws Exception { - Map<String, Map<String, LiveVariableUsageInstance>> expectedVariationToLiveVariableUsageInstanceMapping = - new HashMap<String, Map<String, LiveVariableUsageInstance>>(); - - Map<String, LiveVariableUsageInstance> ungroupedVariation276VariableValues = - new HashMap<String, LiveVariableUsageInstance>(); - ungroupedVariation276VariableValues.put("6", new LiveVariableUsageInstance("6", "True")); - ungroupedVariation276VariableValues.put("2", new LiveVariableUsageInstance("2", "10")); - ungroupedVariation276VariableValues.put("3", new LiveVariableUsageInstance("3", "string_var_vtag1")); - ungroupedVariation276VariableValues.put("4", new LiveVariableUsageInstance("4", "5.3")); - - - Map<String, LiveVariableUsageInstance> ungroupedVariation277VariableValues = - new HashMap<String, LiveVariableUsageInstance>(); - ungroupedVariation277VariableValues.put("6", new LiveVariableUsageInstance("6", "False")); - ungroupedVariation277VariableValues.put("2", new LiveVariableUsageInstance("2", "20")); - ungroupedVariation277VariableValues.put("3", new LiveVariableUsageInstance("3", "string_var_vtag2")); - ungroupedVariation277VariableValues.put("4", new LiveVariableUsageInstance("4", "6.3")); - - expectedVariationToLiveVariableUsageInstanceMapping.put("276", ungroupedVariation276VariableValues); - expectedVariationToLiveVariableUsageInstanceMapping.put("277", ungroupedVariation277VariableValues); - - Map<String, LiveVariableUsageInstance> groupedVariation280VariableValues = - new HashMap<String, LiveVariableUsageInstance>(); - groupedVariation280VariableValues.put("7", new LiveVariableUsageInstance("7", "True")); - - Map<String, LiveVariableUsageInstance> groupedVariation281VariableValues = - new HashMap<String, LiveVariableUsageInstance>(); - groupedVariation281VariableValues.put("7", new LiveVariableUsageInstance("7", "False")); - - expectedVariationToLiveVariableUsageInstanceMapping.put("280", groupedVariation280VariableValues); - expectedVariationToLiveVariableUsageInstanceMapping.put("281", groupedVariation281VariableValues); - - assertThat(projectConfig.getVariationToLiveVariableUsageInstanceMapping(), - is(expectedVariationToLiveVariableUsageInstanceMapping)); - } - - /** - * Asserts that anonymizeIP is set to false if not explicitly passed into the constructor (in the case of V2 - * projects). - * - * @throws Exception - */ - @Test - public void verifyAnonymizeIPIsFalseByDefault() throws Exception { - ProjectConfig v2ProjectConfig = ProjectConfigTestUtils.validProjectConfigV2(); - assertFalse(v2ProjectConfig.getAnonymizeIP()); - } - - /** - * Invalid User IDs - - User ID is null - User ID is an empty string - Invalid Experiment IDs - - Experiment key does not exist in the datafile - Experiment key is null - Experiment key is an empty string - Invalid Variation IDs [set only] - - Variation key does not exist in the datafile - Variation key is null - Variation key is an empty string - Multiple set calls [set only] - - Call set variation with different variations on one user/experiment to confirm that each set is expected. - Set variation on multiple variations for one user. - Set variations for multiple users. - */ - /* UserID test */ - @Test - @SuppressFBWarnings("NP") - public void setForcedVariationNullUserId() { - boolean b = projectConfig.setForcedVariation("etag1", null, "vtag1"); - assertFalse(b); - } - - @Test - @SuppressFBWarnings("NP") - public void getForcedVariationNullUserId() { - assertNull(projectConfig.getForcedVariation("etag1", null)); - } - - @Test - public void setForcedVariationEmptyUserId() { - assertFalse(projectConfig.setForcedVariation("etag1", "", "vtag1")); - } - - @Test - public void getForcedVariationEmptyUserId() { - assertNull(projectConfig.getForcedVariation("etag1", "")); - } - - /* Invalid Experiement */ - @Test - @SuppressFBWarnings("NP") - public void setForcedVariationNullExperimentKey() { - assertFalse(projectConfig.setForcedVariation(null, "testUser1", "vtag1")); - } - - @Test - @SuppressFBWarnings("NP") - public void getForcedVariationNullExperimentKey() { - assertNull(projectConfig.getForcedVariation(null, "testUser1")); - } - - @Test - public void setForcedVariationWrongExperimentKey() { - assertFalse(projectConfig.setForcedVariation("wrongKey", "testUser1", "vtag1")); - - } - - @Test - public void getForcedVariationWrongExperimentKey() { - assertNull(projectConfig.getForcedVariation("wrongKey", "testUser1")); - } - - @Test - public void setForcedVariationEmptyExperimentKey() { - assertFalse(projectConfig.setForcedVariation("", "testUser1", "vtag1")); - - } - - @Test - public void getForcedVariationEmptyExperimentKey() { - assertNull(projectConfig.getForcedVariation("", "testUser1")); - } - - /* Invalid Variation Id (set only */ - @Test - public void setForcedVariationWrongVariationKey() { - assertFalse(projectConfig.setForcedVariation("etag1", "testUser1", "vtag3")); - } - - @Test - public void setForcedVariationNullVariationKey() { - assertFalse(projectConfig.setForcedVariation("etag1", "testUser1", null)); - assertNull(projectConfig.getForcedVariation("etag1", "testUser1")); - } - - @Test - public void setForcedVariationEmptyVariationKey() { - assertFalse(projectConfig.setForcedVariation("etag1", "testUser1", "")); - } - - /* Multiple set calls (set only */ - @Test - public void setForcedVariationDifferentVariations() { - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag1")); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag2")); - assertEquals(projectConfig.getForcedVariation("etag1", "testUser1").getKey(), "vtag2"); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", null)); - } - - @Test - public void setForcedVariationMultipleVariationsExperiments() { - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag1")); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", "vtag2")); - assertTrue(projectConfig.setForcedVariation("etag2", "testUser1", "vtag3")); - assertTrue(projectConfig.setForcedVariation("etag2", "testUser2", "vtag4")); - assertEquals(projectConfig.getForcedVariation("etag1", "testUser1").getKey(), "vtag1"); - assertEquals(projectConfig.getForcedVariation("etag1", "testUser2").getKey(), "vtag2"); - assertEquals(projectConfig.getForcedVariation("etag2", "testUser1").getKey(), "vtag3"); - assertEquals(projectConfig.getForcedVariation("etag2", "testUser2").getKey(), "vtag4"); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", null)); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", null)); - assertTrue(projectConfig.setForcedVariation("etag2", "testUser1", null)); - assertTrue(projectConfig.setForcedVariation("etag2", "testUser2", null)); - assertNull(projectConfig.getForcedVariation("etag1", "testUser1")); - assertNull(projectConfig.getForcedVariation("etag1", "testUser2")); - assertNull(projectConfig.getForcedVariation("etag2", "testUser1")); - assertNull(projectConfig.getForcedVariation("etag2", "testUser2")); - - - } - - @Test - public void setForcedVariationMultipleUsers() { - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", "vtag1")); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", "vtag1")); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser3", "vtag1")); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser4", "vtag1")); - - assertEquals(projectConfig.getForcedVariation("etag1", "testUser1").getKey(), "vtag1"); - assertEquals(projectConfig.getForcedVariation("etag1", "testUser2").getKey(), "vtag1"); - assertEquals(projectConfig.getForcedVariation("etag1", "testUser3").getKey(), "vtag1"); - assertEquals(projectConfig.getForcedVariation("etag1", "testUser4").getKey(), "vtag1"); - - assertTrue(projectConfig.setForcedVariation("etag1", "testUser1", null)); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser2", null)); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser3", null)); - assertTrue(projectConfig.setForcedVariation("etag1", "testUser4", null)); - - assertNull(projectConfig.getForcedVariation("etag1", "testUser1")); - assertNull(projectConfig.getForcedVariation("etag1", "testUser2")); - assertNull(projectConfig.getForcedVariation("etag2", "testUser1")); - assertNull(projectConfig.getForcedVariation("etag2", "testUser2")); - - } - - @Test - public void getAttributeIDWhenAttributeKeyIsFromAttributeKeyMapping() { - ProjectConfig projectConfig = ProjectConfigTestUtils.validProjectConfigV4(); - String attributeID = projectConfig.getAttributeId(projectConfig, "house"); - assertEquals(attributeID, "553339214"); - } - - @Test - public void getAttributeIDWhenAttributeKeyIsUsingReservedKey() { - ProjectConfig projectConfig = ProjectConfigTestUtils.validProjectConfigV4(); - String attributeID = projectConfig.getAttributeId(projectConfig, "$opt_user_agent"); - assertEquals(attributeID, ControlAttribute.USER_AGENT_ATTRIBUTE.toString()); - } - - @Test - public void getAttributeIDWhenAttributeKeyUnrecognizedAttribute() { - ProjectConfig projectConfig = ProjectConfigTestUtils.validProjectConfigV4(); - String invalidAttribute = "empty"; - String attributeID = projectConfig.getAttributeId(projectConfig, invalidAttribute); - assertNull(attributeID); - logbackVerifier.expectMessage(Level.DEBUG, "Unrecognized Attribute \""+invalidAttribute+"\""); - } - - @Test - public void getAttributeIDWhenAttributeKeyPrefixIsMatched() { - ProjectConfig projectConfig = ProjectConfigTestUtils.validProjectConfigV4(); - String attributeWithReservedPrefix = "$opt_test"; - String attributeID = projectConfig.getAttributeId(projectConfig, attributeWithReservedPrefix); - assertEquals(attributeID,"583394100"); - logbackVerifier.expectMessage(Level.WARN, "Attribute "+attributeWithReservedPrefix +" unexpectedly" + - " has reserved prefix $opt_; using attribute ID instead of reserved attribute name."); - } - -} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java index 19a65dcba..faacfda76 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java +++ b/core-api/src/test/java/com/optimizely/ab/config/ValidProjectConfigV4.java @@ -1,6 +1,6 @@ /** * - * Copyright 2017-2018, Optimizely and contributors + * Copyright 2017-2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,284 +18,459 @@ import com.optimizely.ab.config.audience.AndCondition; import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.AudienceIdCondition; import com.optimizely.ab.config.audience.Condition; import com.optimizely.ab.config.audience.OrCondition; import com.optimizely.ab.config.audience.UserAttribute; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; public class ValidProjectConfigV4 { // simple properties - private static final String ACCOUNT_ID = "2360254204"; - private static final boolean ANONYMIZE_IP = true; - private static final boolean BOT_FILTERING = true; - private static final String PROJECT_ID = "3918735994"; - private static final String REVISION = "1480511547"; - private static final String VERSION = "4"; + private static final String ACCOUNT_ID = "2360254204"; + private static final boolean ANONYMIZE_IP = true; + private static final boolean BOT_FILTERING = true; + private static final String PROJECT_ID = "3918735994"; + private static final String REVISION = "1480511547"; + private static final String SDK_KEY = "ValidProjectConfigV4"; + private static final String ENVIRONMENT_KEY = "production"; + private static final String VERSION = "4"; + private static final Boolean SEND_FLAG_DECISIONS = true; // attributes - private static final String ATTRIBUTE_HOUSE_ID= "553339214"; - public static final String ATTRIBUTE_HOUSE_KEY = "house"; - private static final Attribute ATTRIBUTE_HOUSE = new Attribute(ATTRIBUTE_HOUSE_ID, ATTRIBUTE_HOUSE_KEY); + private static final String ATTRIBUTE_HOUSE_ID = "553339214"; + public static final String ATTRIBUTE_HOUSE_KEY = "house"; + private static final Attribute ATTRIBUTE_HOUSE = new Attribute(ATTRIBUTE_HOUSE_ID, ATTRIBUTE_HOUSE_KEY); - private static final String ATTRIBUTE_NATIONALITY_ID = "58339410"; - public static final String ATTRIBUTE_NATIONALITY_KEY = "nationality"; - private static final Attribute ATTRIBUTE_NATIONALITY = new Attribute(ATTRIBUTE_NATIONALITY_ID, ATTRIBUTE_NATIONALITY_KEY); + private static final String ATTRIBUTE_NATIONALITY_ID = "58339410"; + public static final String ATTRIBUTE_NATIONALITY_KEY = "nationality"; + private static final Attribute ATTRIBUTE_NATIONALITY = new Attribute(ATTRIBUTE_NATIONALITY_ID, ATTRIBUTE_NATIONALITY_KEY); - private static final String ATTRIBUTE_OPT_ID = "583394100"; - public static final String ATTRIBUTE_OPT_KEY = "$opt_test"; - private static final Attribute ATTRIBUTE_OPT = new Attribute(ATTRIBUTE_OPT_ID, ATTRIBUTE_OPT_KEY); + private static final String ATTRIBUTE_OPT_ID = "583394100"; + public static final String ATTRIBUTE_OPT_KEY = "$opt_test"; + private static final Attribute ATTRIBUTE_OPT = new Attribute(ATTRIBUTE_OPT_ID, ATTRIBUTE_OPT_KEY); + + private static final String ATTRIBUTE_BOOLEAN_ID = "323434545"; + public static final String ATTRIBUTE_BOOLEAN_KEY = "booleanKey"; + private static final Attribute ATTRIBUTE_BOOLEAN = new Attribute(ATTRIBUTE_BOOLEAN_ID, ATTRIBUTE_BOOLEAN_KEY); + + private static final String ATTRIBUTE_INTEGER_ID = "616727838"; + public static final String ATTRIBUTE_INTEGER_KEY = "integerKey"; + private static final Attribute ATTRIBUTE_INTEGER = new Attribute(ATTRIBUTE_INTEGER_ID, ATTRIBUTE_INTEGER_KEY); + + private static final String ATTRIBUTE_DOUBLE_ID = "808797686"; + public static final String ATTRIBUTE_DOUBLE_KEY = "doubleKey"; + private static final Attribute ATTRIBUTE_DOUBLE = new Attribute(ATTRIBUTE_DOUBLE_ID, ATTRIBUTE_DOUBLE_KEY); + + private static final String ATTRIBUTE_EMPTY_KEY_ID = "808797686"; + public static final String ATTRIBUTE_EMPTY_KEY = ""; + private static final Attribute ATTRIBUTE_EMPTY = new Attribute(ATTRIBUTE_EMPTY_KEY_ID, ATTRIBUTE_EMPTY_KEY); // audiences - private static final String CUSTOM_DIMENSION_TYPE = "custom_dimension"; - private static final String AUDIENCE_GRYFFINDOR_ID = "3468206642"; - private static final String AUDIENCE_GRYFFINDOR_KEY = "Gryffindors"; - public static final String AUDIENCE_GRYFFINDOR_VALUE = "Gryffindor"; - private static final Audience AUDIENCE_GRYFFINDOR = new Audience( - AUDIENCE_GRYFFINDOR_ID, - AUDIENCE_GRYFFINDOR_KEY, - new AndCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, - CUSTOM_DIMENSION_TYPE, - AUDIENCE_GRYFFINDOR_VALUE))))))) - ); - private static final String AUDIENCE_SLYTHERIN_ID = "3988293898"; - private static final String AUDIENCE_SLYTHERIN_KEY = "Slytherins"; - public static final String AUDIENCE_SLYTHERIN_VALUE = "Slytherin"; - private static final Audience AUDIENCE_SLYTHERIN = new Audience( - AUDIENCE_SLYTHERIN_ID, - AUDIENCE_SLYTHERIN_KEY, - new AndCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, - CUSTOM_DIMENSION_TYPE, - AUDIENCE_SLYTHERIN_VALUE))))))) + private static final String CUSTOM_ATTRIBUTE_TYPE = "custom_attribute"; + private static final String AUDIENCE_BOOL_ID = "3468206643"; + private static final String AUDIENCE_BOOL_KEY = "BOOL"; + public static final Boolean AUDIENCE_BOOL_VALUE = true; + private static final Audience TYPED_AUDIENCE_BOOL = new Audience( + AUDIENCE_BOOL_ID, + AUDIENCE_BOOL_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_BOOLEAN_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", + AUDIENCE_BOOL_VALUE))))))) + ); + private static final String AUDIENCE_INT_ID = "3468206644"; + private static final String AUDIENCE_INT_KEY = "INT"; + public static final Number AUDIENCE_INT_VALUE = 1.0; + private static final Audience TYPED_AUDIENCE_INT = new Audience( + AUDIENCE_INT_ID, + AUDIENCE_INT_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_INTEGER_KEY, + CUSTOM_ATTRIBUTE_TYPE, "gt", + AUDIENCE_INT_VALUE))))))) + ); + + private static final String AUDIENCE_INT_EXACT_ID = "3468206646"; + private static final String AUDIENCE_INT_EXACT_KEY = "INTEXACT"; + private static final Audience TYPED_AUDIENCE_EXACT_INT = new Audience( + AUDIENCE_INT_EXACT_ID, + AUDIENCE_INT_EXACT_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_INTEGER_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", + AUDIENCE_INT_VALUE))))))) + ); + private static final String AUDIENCE_DOUBLE_ID = "3468206645"; + private static final String AUDIENCE_DOUBLE_KEY = "DOUBLE"; + public static final Double AUDIENCE_DOUBLE_VALUE = 100.0; + private static final Audience TYPED_AUDIENCE_DOUBLE = new Audience( + AUDIENCE_DOUBLE_ID, + AUDIENCE_DOUBLE_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_DOUBLE_KEY, + CUSTOM_ATTRIBUTE_TYPE, "lt", + AUDIENCE_DOUBLE_VALUE))))))) + ); + private static final String AUDIENCE_GRYFFINDOR_ID = "3468206642"; + private static final String AUDIENCE_GRYFFINDOR_KEY = "Gryffindors"; + public static final String AUDIENCE_GRYFFINDOR_VALUE = "Gryffindor"; + private static final Audience AUDIENCE_GRYFFINDOR = new Audience( + AUDIENCE_GRYFFINDOR_ID, + AUDIENCE_GRYFFINDOR_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_ATTRIBUTE_TYPE, null, + AUDIENCE_GRYFFINDOR_VALUE))))))) + ); + private static final Audience TYPED_AUDIENCE_GRYFFINDOR = new Audience( + AUDIENCE_GRYFFINDOR_ID, + AUDIENCE_GRYFFINDOR_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", + AUDIENCE_GRYFFINDOR_VALUE))))))) + ); + + private static final String AUDIENCE_SLYTHERIN_ID = "3988293898"; + private static final String AUDIENCE_SLYTHERIN_KEY = "Slytherins"; + public static final String AUDIENCE_SLYTHERIN_VALUE = "Slytherin"; + private static final Audience AUDIENCE_SLYTHERIN = new Audience( + AUDIENCE_SLYTHERIN_ID, + AUDIENCE_SLYTHERIN_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_ATTRIBUTE_TYPE, null, + AUDIENCE_SLYTHERIN_VALUE))))))) + ); + + private static final Audience TYPED_AUDIENCE_SLYTHERIN = new Audience( + AUDIENCE_SLYTHERIN_ID, + AUDIENCE_SLYTHERIN_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_HOUSE_KEY, + CUSTOM_ATTRIBUTE_TYPE, "substring", + AUDIENCE_SLYTHERIN_VALUE))))))) ); - private static final String AUDIENCE_ENGLISH_CITIZENS_ID = "4194404272"; - private static final String AUDIENCE_ENGLISH_CITIZENS_KEY = "english_citizens"; - public static final String AUDIENCE_ENGLISH_CITIZENS_VALUE = "English"; - private static final Audience AUDIENCE_ENGLISH_CITIZENS = new Audience( - AUDIENCE_ENGLISH_CITIZENS_ID, - AUDIENCE_ENGLISH_CITIZENS_KEY, - new AndCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_NATIONALITY_KEY, - CUSTOM_DIMENSION_TYPE, - AUDIENCE_ENGLISH_CITIZENS_VALUE))))))) - ); - private static final String AUDIENCE_WITH_MISSING_VALUE_ID = "2196265320"; - private static final String AUDIENCE_WITH_MISSING_VALUE_KEY = "audience_with_missing_value"; - public static final String AUDIENCE_WITH_MISSING_VALUE_VALUE = "English"; + private static final String AUDIENCE_ENGLISH_CITIZENS_ID = "4194404272"; + private static final String AUDIENCE_ENGLISH_CITIZENS_KEY = "english_citizens"; + public static final String AUDIENCE_ENGLISH_CITIZENS_VALUE = "English"; + private static final Audience AUDIENCE_ENGLISH_CITIZENS = new Audience( + AUDIENCE_ENGLISH_CITIZENS_ID, + AUDIENCE_ENGLISH_CITIZENS_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_NATIONALITY_KEY, + CUSTOM_ATTRIBUTE_TYPE, null, + AUDIENCE_ENGLISH_CITIZENS_VALUE))))))) + ); + private static final Audience TYPED_AUDIENCE_ENGLISH_CITIZENS = new Audience( + AUDIENCE_ENGLISH_CITIZENS_ID, + AUDIENCE_ENGLISH_CITIZENS_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.singletonList((Condition) new UserAttribute(ATTRIBUTE_NATIONALITY_KEY, + CUSTOM_ATTRIBUTE_TYPE, "exact", + AUDIENCE_ENGLISH_CITIZENS_VALUE))))))) + ); + private static final String AUDIENCE_WITH_MISSING_VALUE_ID = "2196265320"; + private static final String AUDIENCE_WITH_MISSING_VALUE_KEY = "audience_with_missing_value"; + public static final String AUDIENCE_WITH_MISSING_VALUE_VALUE = "English"; private static final UserAttribute ATTRIBUTE_WITH_VALUE = new UserAttribute( - ATTRIBUTE_NATIONALITY_KEY, - CUSTOM_DIMENSION_TYPE, - AUDIENCE_WITH_MISSING_VALUE_VALUE + ATTRIBUTE_NATIONALITY_KEY, + CUSTOM_ATTRIBUTE_TYPE, null, + AUDIENCE_WITH_MISSING_VALUE_VALUE ); private static final UserAttribute ATTRIBUTE_WITHOUT_VALUE = new UserAttribute( - ATTRIBUTE_NATIONALITY_KEY, - CUSTOM_DIMENSION_TYPE, - null - ); - private static final Audience AUDIENCE_WITH_MISSING_VALUE = new Audience( - AUDIENCE_WITH_MISSING_VALUE_ID, - AUDIENCE_WITH_MISSING_VALUE_KEY, - new AndCondition(Collections.<Condition>singletonList( - new OrCondition(Collections.<Condition>singletonList( - new OrCondition(ProjectConfigTestUtils.<Condition>createListOfObjects( - ATTRIBUTE_WITH_VALUE, - ATTRIBUTE_WITHOUT_VALUE - )) - )) + ATTRIBUTE_NATIONALITY_KEY, + CUSTOM_ATTRIBUTE_TYPE, + null, + null + ); + private static final Audience AUDIENCE_WITH_MISSING_VALUE = new Audience( + AUDIENCE_WITH_MISSING_VALUE_ID, + AUDIENCE_WITH_MISSING_VALUE_KEY, + new AndCondition(Collections.<Condition>singletonList( + new OrCondition(Collections.<Condition>singletonList( + new OrCondition(DatafileProjectConfigTestUtils.<Condition>createListOfObjects( + ATTRIBUTE_WITH_VALUE, + ATTRIBUTE_WITHOUT_VALUE + )) )) + )) ); + private static final Condition AUDIENCE_COMBINATION_WITH_AND_CONDITION = new AndCondition(Arrays.<Condition>asList( + new AudienceIdCondition(AUDIENCE_BOOL_ID), + new AudienceIdCondition(AUDIENCE_INT_ID), + new AudienceIdCondition(AUDIENCE_DOUBLE_ID))); + + // audienceConditions + private static final Condition AUDIENCE_COMBINATION_LEAF_CONDITION = + new AudienceIdCondition(AUDIENCE_BOOL_ID); + + // audienceConditions + private static final Condition AUDIENCE_COMBINATION = + new OrCondition(Arrays.<Condition>asList( + new AudienceIdCondition(AUDIENCE_BOOL_ID), + new AudienceIdCondition(AUDIENCE_INT_ID), + new AudienceIdCondition(AUDIENCE_INT_EXACT_ID), + new AudienceIdCondition(AUDIENCE_DOUBLE_ID))); + // features - private static final String FEATURE_BOOLEAN_FEATURE_ID = "4195505407"; - private static final String FEATURE_BOOLEAN_FEATURE_KEY = "boolean_feature"; + private static final String FEATURE_BOOLEAN_FEATURE_ID = "4195505407"; + private static final String FEATURE_BOOLEAN_FEATURE_KEY = "boolean_feature"; private static final FeatureFlag FEATURE_FLAG_BOOLEAN_FEATURE = new FeatureFlag( - FEATURE_BOOLEAN_FEATURE_ID, - FEATURE_BOOLEAN_FEATURE_KEY, - "", - Collections.<String>emptyList(), - Collections.<LiveVariable>emptyList() - ); - private static final String FEATURE_SINGLE_VARIABLE_DOUBLE_ID = "3926744821"; - public static final String FEATURE_SINGLE_VARIABLE_DOUBLE_KEY = "double_single_variable_feature"; - private static final String VARIABLE_DOUBLE_VARIABLE_ID = "4111654444"; - public static final String VARIABLE_DOUBLE_VARIABLE_KEY = "double_variable"; - public static final String VARIABLE_DOUBLE_DEFAULT_VALUE = "14.99"; - private static final LiveVariable VARIABLE_DOUBLE_VARIABLE = new LiveVariable( - VARIABLE_DOUBLE_VARIABLE_ID, - VARIABLE_DOUBLE_VARIABLE_KEY, - VARIABLE_DOUBLE_DEFAULT_VALUE, - null, - LiveVariable.VariableType.DOUBLE - ); - private static final String FEATURE_SINGLE_VARIABLE_INTEGER_ID = "3281420120"; - public static final String FEATURE_SINGLE_VARIABLE_INTEGER_KEY = "integer_single_variable_feature"; - private static final String VARIABLE_INTEGER_VARIABLE_ID = "593964691"; - public static final String VARIABLE_INTEGER_VARIABLE_KEY = "integer_variable"; - private static final String VARIABLE_INTEGER_DEFAULT_VALUE = "7"; - private static final LiveVariable VARIABLE_INTEGER_VARIABLE = new LiveVariable( - VARIABLE_INTEGER_VARIABLE_ID, - VARIABLE_INTEGER_VARIABLE_KEY, - VARIABLE_INTEGER_DEFAULT_VALUE, - null, - LiveVariable.VariableType.INTEGER - ); - private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_ID = "2591051011"; - public static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; - private static final String VARIABLE_BOOLEAN_VARIABLE_ID = "3974680341"; - public static final String VARIABLE_BOOLEAN_VARIABLE_KEY = "boolean_variable"; - public static final String VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE = "true"; - private static final LiveVariable VARIABLE_BOOLEAN_VARIABLE = new LiveVariable( - VARIABLE_BOOLEAN_VARIABLE_ID, - VARIABLE_BOOLEAN_VARIABLE_KEY, - VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE, - null, - LiveVariable.VariableType.BOOLEAN + FEATURE_BOOLEAN_FEATURE_ID, + FEATURE_BOOLEAN_FEATURE_KEY, + "", + Collections.<String>emptyList(), + Collections.<FeatureVariable>emptyList() + ); + private static final String FEATURE_SINGLE_VARIABLE_DOUBLE_ID = "3926744821"; + public static final String FEATURE_SINGLE_VARIABLE_DOUBLE_KEY = "double_single_variable_feature"; + private static final String VARIABLE_DOUBLE_VARIABLE_ID = "4111654444"; + public static final String VARIABLE_DOUBLE_VARIABLE_KEY = "double_variable"; + public static final String VARIABLE_DOUBLE_DEFAULT_VALUE = "14.99"; + private static final FeatureVariable VARIABLE_DOUBLE_VARIABLE = new FeatureVariable( + VARIABLE_DOUBLE_VARIABLE_ID, + VARIABLE_DOUBLE_VARIABLE_KEY, + VARIABLE_DOUBLE_DEFAULT_VALUE, + null, + FeatureVariable.DOUBLE_TYPE, + null + ); + private static final String FEATURE_SINGLE_VARIABLE_INTEGER_ID = "3281420120"; + public static final String FEATURE_SINGLE_VARIABLE_INTEGER_KEY = "integer_single_variable_feature"; + private static final String VARIABLE_INTEGER_VARIABLE_ID = "593964691"; + public static final String VARIABLE_INTEGER_VARIABLE_KEY = "integer_variable"; + private static final String VARIABLE_INTEGER_DEFAULT_VALUE = "7"; + private static final FeatureVariable VARIABLE_INTEGER_VARIABLE = new FeatureVariable( + VARIABLE_INTEGER_VARIABLE_ID, + VARIABLE_INTEGER_VARIABLE_KEY, + VARIABLE_INTEGER_DEFAULT_VALUE, + null, + FeatureVariable.INTEGER_TYPE, + null + ); + private static final String FEATURE_SINGLE_VARIABLE_LONG_ID = "964006971"; + public static final String FEATURE_SINGLE_VARIABLE_LONG_KEY = "long_single_variable_feature"; + private static final String VARIABLE_LONG_VARIABLE_ID = "4339640697"; + public static final String VARIABLE_LONG_VARIABLE_KEY = "long_variable"; + private static final String VARIABLE_LONG_DEFAULT_VALUE = "379993881340"; + private static final FeatureVariable VARIABLE_LONG_VARIABLE = new FeatureVariable( + VARIABLE_LONG_VARIABLE_ID, + VARIABLE_LONG_VARIABLE_KEY, + VARIABLE_LONG_DEFAULT_VALUE, + null, + FeatureVariable.INTEGER_TYPE, + null + ); + private static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_ID = "2591051011"; + public static final String FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY = "boolean_single_variable_feature"; + private static final String VARIABLE_BOOLEAN_VARIABLE_ID = "3974680341"; + public static final String VARIABLE_BOOLEAN_VARIABLE_KEY = "boolean_variable"; + public static final String VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE = "true"; + private static final FeatureVariable VARIABLE_BOOLEAN_VARIABLE = new FeatureVariable( + VARIABLE_BOOLEAN_VARIABLE_ID, + VARIABLE_BOOLEAN_VARIABLE_KEY, + VARIABLE_BOOLEAN_VARIABLE_DEFAULT_VALUE, + null, + FeatureVariable.BOOLEAN_TYPE, + null ); private static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN = new FeatureFlag( - FEATURE_SINGLE_VARIABLE_BOOLEAN_ID, - FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY, - "", - Collections.<String>emptyList(), - Collections.singletonList( - VARIABLE_BOOLEAN_VARIABLE - ) + FEATURE_SINGLE_VARIABLE_BOOLEAN_ID, + FEATURE_SINGLE_VARIABLE_BOOLEAN_KEY, + "", + Collections.<String>emptyList(), + Collections.singletonList( + VARIABLE_BOOLEAN_VARIABLE + ) ); - private static final String FEATURE_SINGLE_VARIABLE_STRING_ID = "2079378557"; - public static final String FEATURE_SINGLE_VARIABLE_STRING_KEY = "string_single_variable_feature"; - private static final String VARIABLE_STRING_VARIABLE_ID = "2077511132"; - public static final String VARIABLE_STRING_VARIABLE_KEY = "string_variable"; - public static final String VARIABLE_STRING_VARIABLE_DEFAULT_VALUE = "wingardium leviosa"; - private static final LiveVariable VARIABLE_STRING_VARIABLE = new LiveVariable( - VARIABLE_STRING_VARIABLE_ID, - VARIABLE_STRING_VARIABLE_KEY, - VARIABLE_STRING_VARIABLE_DEFAULT_VALUE, - null, - LiveVariable.VariableType.STRING - ); - private static final String ROLLOUT_1_ID = "1058508303"; - private static final String ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID = "1785077004"; - private static final String ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID = "1566407342"; - private static final String ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_STRING_VALUE = "lumos"; - private static final Boolean ROLLOUT_1_FEATURE_ENABLED_VALUE = true; - private static final Variation ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION = new Variation( - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - ROLLOUT_1_FEATURE_ENABLED_VALUE, - Collections.singletonList( - new LiveVariableUsageInstance( - VARIABLE_STRING_VARIABLE_ID, - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_STRING_VALUE - ) + private static final String FEATURE_SINGLE_VARIABLE_STRING_ID = "2079378557"; + public static final String FEATURE_SINGLE_VARIABLE_STRING_KEY = "string_single_variable_feature"; + private static final String VARIABLE_STRING_VARIABLE_ID = "2077511132"; + public static final String VARIABLE_STRING_VARIABLE_KEY = "string_variable"; + public static final String VARIABLE_STRING_VARIABLE_DEFAULT_VALUE = "wingardium leviosa"; + private static final FeatureVariable VARIABLE_STRING_VARIABLE = new FeatureVariable( + VARIABLE_STRING_VARIABLE_ID, + VARIABLE_STRING_VARIABLE_KEY, + VARIABLE_STRING_VARIABLE_DEFAULT_VALUE, + null, + FeatureVariable.STRING_TYPE, + null + ); + private static final String ROLLOUT_1_ID = "1058508303"; + private static final String ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID = "1785077004"; + private static final String ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID = "1566407342"; + private static final String ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_STRING_VALUE = "lumos"; + private static final Boolean ROLLOUT_1_FEATURE_ENABLED_VALUE = true; + private static final Variation ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION = new Variation( + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + ROLLOUT_1_FEATURE_ENABLED_VALUE, + Collections.singletonList( + new FeatureVariableUsageInstance( + VARIABLE_STRING_VARIABLE_ID, + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_STRING_VALUE ) + ) ); private static final Experiment ROLLOUT_1_EVERYONE_ELSE_RULE = new Experiment( - ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID, - ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID, - Experiment.ExperimentStatus.RUNNING.toString(), - ROLLOUT_1_ID, - Collections.<String>emptyList(), - Collections.singletonList( - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION - ), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - 5000 - ) + ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID, + ROLLOUT_1_EVERYONE_ELSE_EXPERIMENT_ID, + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_1_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList( + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION + ), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + ROLLOUT_1_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + 5000 ) + ) ); - public static final Rollout ROLLOUT_1 = new Rollout( - ROLLOUT_1_ID, - Collections.singletonList( - ROLLOUT_1_EVERYONE_ELSE_RULE - ) + public static final Rollout ROLLOUT_1 = new Rollout( + ROLLOUT_1_ID, + Collections.singletonList( + ROLLOUT_1_EVERYONE_ELSE_RULE + ) ); - public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( - FEATURE_SINGLE_VARIABLE_STRING_ID, - FEATURE_SINGLE_VARIABLE_STRING_KEY, - ROLLOUT_1_ID, - Collections.<String>emptyList(), - Collections.singletonList( - VARIABLE_STRING_VARIABLE - ) + public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_STRING = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_STRING_ID, + FEATURE_SINGLE_VARIABLE_STRING_KEY, + ROLLOUT_1_ID, + Collections.<String>emptyList(), + Collections.singletonList( + VARIABLE_STRING_VARIABLE + ) ); - private static final String ROLLOUT_3_ID = "2048875663"; - private static final String ROLLOUT_3_EVERYONE_ELSE_EXPERIMENT_ID = "3794675122"; - private static final String ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID = "589640735"; - private static final Boolean ROLLOUT_3_FEATURE_ENABLED_VALUE = true; - public static final Variation ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION = new Variation( - ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - ROLLOUT_3_FEATURE_ENABLED_VALUE, - Collections.<LiveVariableUsageInstance>emptyList() - ); - public static final Experiment ROLLOUT_3_EVERYONE_ELSE_RULE = new Experiment( - ROLLOUT_3_EVERYONE_ELSE_EXPERIMENT_ID, - ROLLOUT_3_EVERYONE_ELSE_EXPERIMENT_ID, - Experiment.ExperimentStatus.RUNNING.toString(), - ROLLOUT_3_ID, - Collections.<String>emptyList(), - Collections.singletonList( - ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION - ), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, - 10000 - ) - ) + private static final String ROLLOUT_3_ID = "2048875663"; + private static final String ROLLOUT_3_EVERYONE_ELSE_EXPERIMENT_ID = "3794675122"; + private static final String ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID = "589640735"; + private static final Boolean ROLLOUT_3_FEATURE_ENABLED_VALUE = true; + public static final Variation ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION = new Variation( + ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + ROLLOUT_3_FEATURE_ENABLED_VALUE, + Collections.<FeatureVariableUsageInstance>emptyList() ); - public static final Rollout ROLLOUT_3 = new Rollout( - ROLLOUT_3_ID, - Collections.singletonList( - ROLLOUT_3_EVERYONE_ELSE_RULE + public static final Experiment ROLLOUT_3_EVERYONE_ELSE_RULE = new Experiment( + ROLLOUT_3_EVERYONE_ELSE_EXPERIMENT_ID, + ROLLOUT_3_EVERYONE_ELSE_EXPERIMENT_ID, + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_3_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList( + ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION + ), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + ROLLOUT_3_EVERYONE_ELSE_RULE_ENABLED_VARIATION_ID, + 10000 ) + ) + ); + public static final Rollout ROLLOUT_3 = new Rollout( + ROLLOUT_3_ID, + Collections.singletonList( + ROLLOUT_3_EVERYONE_ELSE_RULE + ) + ); + + private static final String FEATURE_MULTI_VARIATE_FEATURE_ID = "3263342226"; + public static final String FEATURE_MULTI_VARIATE_FEATURE_KEY = "multi_variate_feature"; + private static final String VARIABLE_FIRST_LETTER_ID = "675244127"; + public static final String VARIABLE_FIRST_LETTER_KEY = "first_letter"; + public static final String VARIABLE_FIRST_LETTER_DEFAULT_VALUE = "H"; + private static final FeatureVariable VARIABLE_FIRST_LETTER_VARIABLE = new FeatureVariable( + VARIABLE_FIRST_LETTER_ID, + VARIABLE_FIRST_LETTER_KEY, + VARIABLE_FIRST_LETTER_DEFAULT_VALUE, + null, + FeatureVariable.STRING_TYPE, + null + ); + private static final String VARIABLE_REST_OF_NAME_ID = "4052219963"; + private static final String VARIABLE_REST_OF_NAME_KEY = "rest_of_name"; + private static final String VARIABLE_REST_OF_NAME_DEFAULT_VALUE = "arry"; + private static final FeatureVariable VARIABLE_REST_OF_NAME_VARIABLE = new FeatureVariable( + VARIABLE_REST_OF_NAME_ID, + VARIABLE_REST_OF_NAME_KEY, + VARIABLE_REST_OF_NAME_DEFAULT_VALUE, + null, + FeatureVariable.STRING_TYPE, + null + ); + private static final String VARIABLE_JSON_PATCHED_TYPE_ID = "4111661000"; + public static final String VARIABLE_JSON_PATCHED_TYPE_KEY = "json_patched"; + public static final String VARIABLE_JSON_PATCHED_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + private static final FeatureVariable VARIABLE_JSON_PATCHED_TYPE_VARIABLE = new FeatureVariable( + VARIABLE_JSON_PATCHED_TYPE_ID, + VARIABLE_JSON_PATCHED_TYPE_KEY, + VARIABLE_JSON_PATCHED_TYPE_DEFAULT_VALUE, + null, + FeatureVariable.STRING_TYPE, + FeatureVariable.JSON_TYPE ); - private static final String FEATURE_MULTI_VARIATE_FEATURE_ID = "3263342226"; - public static final String FEATURE_MULTI_VARIATE_FEATURE_KEY = "multi_variate_feature"; - private static final String VARIABLE_FIRST_LETTER_ID = "675244127"; - public static final String VARIABLE_FIRST_LETTER_KEY = "first_letter"; - public static final String VARIABLE_FIRST_LETTER_DEFAULT_VALUE = "H"; - private static final LiveVariable VARIABLE_FIRST_LETTER_VARIABLE = new LiveVariable( - VARIABLE_FIRST_LETTER_ID, - VARIABLE_FIRST_LETTER_KEY, - VARIABLE_FIRST_LETTER_DEFAULT_VALUE, - null, - LiveVariable.VariableType.STRING - ); - private static final String VARIABLE_REST_OF_NAME_ID = "4052219963"; - private static final String VARIABLE_REST_OF_NAME_KEY = "rest_of_name"; - private static final String VARIABLE_REST_OF_NAME_DEFAULT_VALUE = "arry"; - private static final LiveVariable VARIABLE_REST_OF_NAME_VARIABLE = new LiveVariable( - VARIABLE_REST_OF_NAME_ID, - VARIABLE_REST_OF_NAME_KEY, - VARIABLE_REST_OF_NAME_DEFAULT_VALUE, - null, - LiveVariable.VariableType.STRING - ); - private static final String FEATURE_MUTEX_GROUP_FEATURE_ID = "3263342226"; - public static final String FEATURE_MUTEX_GROUP_FEATURE_KEY = "mutex_group_feature"; - private static final String VARIABLE_CORRELATING_VARIATION_NAME_ID = "2059187672"; - private static final String VARIABLE_CORRELATING_VARIATION_NAME_KEY = "correlating_variation_name"; - private static final String VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE = "null"; - private static final LiveVariable VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE = new LiveVariable( - VARIABLE_CORRELATING_VARIATION_NAME_ID, - VARIABLE_CORRELATING_VARIATION_NAME_KEY, - VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE, - null, - LiveVariable.VariableType.STRING + private static final String FEATURE_MULTI_VARIATE_FUTURE_FEATURE_ID = "3263342227"; + public static final String FEATURE_MULTI_VARIATE_FUTURE_FEATURE_KEY = "multi_variate_future_feature"; + private static final String VARIABLE_JSON_NATIVE_TYPE_ID = "4111661001"; + public static final String VARIABLE_JSON_NATIVE_TYPE_KEY = "json_native"; + public static final String VARIABLE_JSON_NATIVE_TYPE_DEFAULT_VALUE = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}"; + private static final FeatureVariable VARIABLE_JSON_NATIVE_TYPE_VARIABLE = new FeatureVariable( + VARIABLE_JSON_NATIVE_TYPE_ID, + VARIABLE_JSON_NATIVE_TYPE_KEY, + VARIABLE_JSON_NATIVE_TYPE_DEFAULT_VALUE, + null, + FeatureVariable.JSON_TYPE, + null + ); + private static final String VARIABLE_FUTURE_TYPE_ID = "4111661002"; + public static final String VARIABLE_FUTURE_TYPE_KEY = "future_variable"; + public static final String VARIABLE_FUTURE_TYPE_DEFAULT_VALUE = "future_value"; + private static final FeatureVariable VARIABLE_FUTURE_TYPE_VARIABLE = new FeatureVariable( + VARIABLE_FUTURE_TYPE_ID, + VARIABLE_FUTURE_TYPE_KEY, + VARIABLE_FUTURE_TYPE_DEFAULT_VALUE, + null, + "future_type", + null + ); + + private static final String FEATURE_MUTEX_GROUP_FEATURE_ID = "3263342226"; + public static final String FEATURE_MUTEX_GROUP_FEATURE_KEY = "mutex_group_feature"; + private static final String VARIABLE_CORRELATING_VARIATION_NAME_ID = "2059187672"; + private static final String VARIABLE_CORRELATING_VARIATION_NAME_KEY = "correlating_variation_name"; + private static final String VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE = "null"; + private static final FeatureVariable VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE = new FeatureVariable( + VARIABLE_CORRELATING_VARIATION_NAME_ID, + VARIABLE_CORRELATING_VARIATION_NAME_KEY, + VARIABLE_CORRELATING_VARIATION_NAME_DEFAULT_VALUE, + null, + FeatureVariable.STRING_TYPE, + null ); // group IDs @@ -303,728 +478,902 @@ public class ValidProjectConfigV4 { private static final String GROUP_2_ID = "2606208781"; // experiments - private static final String LAYER_BASIC_EXPERIMENT_ID = "1630555626"; - private static final String EXPERIMENT_BASIC_EXPERIMENT_ID = "1323241596"; - public static final String EXPERIMENT_BASIC_EXPERIMENT_KEY = "basic_experiment"; - private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID = "1423767502"; - private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY = "A"; - private static final Variation VARIATION_BASIC_EXPERIMENT_VARIATION_A = new Variation( - VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID, - VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID = "3433458314"; - private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY = "B"; - private static final Variation VARIATION_BASIC_EXPERIMENT_VARIATION_B = new Variation( - VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID, - VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - private static final String BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Harry Potter"; - private static final String BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Tom Riddle"; + private static final String LAYER_BASIC_EXPERIMENT_ID = "1630555626"; + private static final String EXPERIMENT_BASIC_EXPERIMENT_ID = "1323241596"; + public static final String EXPERIMENT_BASIC_EXPERIMENT_KEY = "basic_experiment"; + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID = "1423767502"; + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY = "A"; + private static final Variation VARIATION_BASIC_EXPERIMENT_VARIATION_A = new Variation( + VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID, + VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID = "3433458314"; + private static final String VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY = "B"; + private static final Variation VARIATION_BASIC_EXPERIMENT_VARIATION_B = new Variation( + VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID, + VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Harry Potter"; + private static final String BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Tom Riddle"; private static final Experiment EXPERIMENT_BASIC_EXPERIMENT = new Experiment( - EXPERIMENT_BASIC_EXPERIMENT_ID, - EXPERIMENT_BASIC_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_BASIC_EXPERIMENT_ID, - Collections.<String>emptyList(), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_BASIC_EXPERIMENT_VARIATION_A, - VARIATION_BASIC_EXPERIMENT_VARIATION_B + EXPERIMENT_BASIC_EXPERIMENT_ID, + EXPERIMENT_BASIC_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_BASIC_EXPERIMENT_ID, + Collections.<String>emptyList(), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_BASIC_EXPERIMENT_VARIATION_A, + VARIATION_BASIC_EXPERIMENT_VARIATION_B + ), + DatafileProjectConfigTestUtils.createMapOfObjects( + DatafileProjectConfigTestUtils.createListOfObjects( + BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, + BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B ), - ProjectConfigTestUtils.createMapOfObjects( - ProjectConfigTestUtils.createListOfObjects( - BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, - BASIC_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B - ), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, - VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY - ) + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_BASIC_EXPERIMENT_VARIATION_A_KEY, + VARIATION_BASIC_EXPERIMENT_VARIATION_B_KEY + ) + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID, + 5000 ), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - VARIATION_BASIC_EXPERIMENT_VARIATION_A_ID, - 5000 - ), - new TrafficAllocation( - VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID, - 10000 - ) + new TrafficAllocation( + VARIATION_BASIC_EXPERIMENT_VARIATION_B_ID, + 10000 ) + ) ); - private static final String LAYER_FIRST_GROUPED_EXPERIMENT_ID = "3301900159"; - private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID = "2738374745"; - private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY = "first_grouped_experiment"; - private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID = "2377378132"; - private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY = "A"; - private static final Variation VARIATION_FIRST_GROUPED_EXPERIMENT_A = new Variation( - VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID, - VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID = "1179171250"; - private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY = "B"; - private static final Variation VARIATION_FIRST_GROUPED_EXPERIMENT_B = new Variation( - VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID, - VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - private static final String FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Harry Potter"; - private static final String FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Tom Riddle"; - private static final Experiment EXPERIMENT_FIRST_GROUPED_EXPERIMENT = new Experiment( - EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, - EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_FIRST_GROUPED_EXPERIMENT_ID, - Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_FIRST_GROUPED_EXPERIMENT_A, - VARIATION_FIRST_GROUPED_EXPERIMENT_B + private static final String LAYER_TYPEDAUDIENCE_EXPERIMENT_ID = "1630555627"; + private static final String EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_ID = "1323241597"; + public static final String EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY = "typed_audience_experiment"; + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_ID = "1423767503"; + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_KEY = "A"; + private static final Variation VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A = new Variation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_ID, + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_ID = "3433458315"; + private static final String VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_KEY = "B"; + private static final Variation VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B = new Variation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_ID, + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + + private static final Experiment EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT = new Experiment( + EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_ID, + EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_TYPEDAUDIENCE_EXPERIMENT_ID, + DatafileProjectConfigTestUtils.createListOfObjects( + AUDIENCE_BOOL_ID, + AUDIENCE_INT_ID, + AUDIENCE_INT_EXACT_ID, + AUDIENCE_DOUBLE_ID + ), + AUDIENCE_COMBINATION, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A, + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B + ), + Collections.EMPTY_MAP, + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_A_ID, + 5000 ), - ProjectConfigTestUtils.createMapOfObjects( - ProjectConfigTestUtils.createListOfObjects( - FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, - FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B - ), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY, - VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY - ) + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_EXPERIMENT_VARIATION_B_ID, + 10000 + ) + ) + ); + private static final String LAYER_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_ID = "1630555628"; + private static final String EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_ID = "1323241598"; + public static final String EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_KEY = "typed_audience_experiment_with_and"; + private static final String VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A_ID = "1423767504"; + private static final String VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A_KEY = "A"; + private static final Variation VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A = new Variation( + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A_ID, + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B_ID = "3433458316"; + private static final String VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B_KEY = "B"; + private static final Variation VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B = new Variation( + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B_ID, + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + + private static final Experiment EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT = new Experiment( + EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_ID, + EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_ID, + DatafileProjectConfigTestUtils.createListOfObjects( + AUDIENCE_BOOL_ID, + AUDIENCE_INT_ID, + AUDIENCE_DOUBLE_ID + ), + AUDIENCE_COMBINATION_WITH_AND_CONDITION, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A, + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B + ), + Collections.EMPTY_MAP, + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_A_ID, + 5000 ), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID, - 5000 - ), - new TrafficAllocation( - VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID, - 10000 - ) + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_WITH_AND_EXPERIMENT_VARIATION_B_ID, + 10000 + ) + ) + ); + private static final String LAYER_TYPEDAUDIENCE_LEAF_EXPERIMENT_ID = "1630555629"; + private static final String EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT_ID = "1323241599"; + public static final String EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT_KEY = "typed_audience_experiment_leaf_condition"; + private static final String VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A_ID = "1423767505"; + private static final String VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A_KEY = "A"; + private static final Variation VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A = new Variation( + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A_ID, + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B_ID = "3433458317"; + private static final String VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B_KEY = "B"; + private static final Variation VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B = new Variation( + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B_ID, + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + + private static final Experiment EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT = new Experiment( + EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT_ID, + EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_TYPEDAUDIENCE_LEAF_EXPERIMENT_ID, + Collections.<String>emptyList(), + AUDIENCE_COMBINATION_LEAF_CONDITION, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A, + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B + ), + Collections.EMPTY_MAP, + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_A_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_TYPEDAUDIENCE_LEAF_EXPERIMENT_VARIATION_B_ID, + 10000 + ) + ) + ); + private static final String LAYER_FIRST_GROUPED_EXPERIMENT_ID = "3301900159"; + private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID = "2738374745"; + private static final String EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY = "first_grouped_experiment"; + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID = "2377378132"; + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY = "A"; + private static final Variation VARIATION_FIRST_GROUPED_EXPERIMENT_A = new Variation( + VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID, + VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID = "1179171250"; + private static final String VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY = "B"; + private static final Variation VARIATION_FIRST_GROUPED_EXPERIMENT_B = new Variation( + VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID, + VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Harry Potter"; + private static final String FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Tom Riddle"; + private static final Experiment EXPERIMENT_FIRST_GROUPED_EXPERIMENT = new Experiment( + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_FIRST_GROUPED_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_FIRST_GROUPED_EXPERIMENT_A, + VARIATION_FIRST_GROUPED_EXPERIMENT_B + ), + DatafileProjectConfigTestUtils.createMapOfObjects( + DatafileProjectConfigTestUtils.createListOfObjects( + FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, + FIRST_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B ), - GROUP_1_ID - ); - private static final String LAYER_SECOND_GROUPED_EXPERIMENT_ID = "2625300442"; - private static final String EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID = "3042640549"; - private static final String EXPERIMENT_SECOND_GROUPED_EXPERIMENT_KEY = "second_grouped_experiment"; - private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID = "1558539439"; - private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY = "A"; - private static final Variation VARIATION_SECOND_GROUPED_EXPERIMENT_A = new Variation( - VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID, - VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID = "2142748370"; - private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY = "B"; - private static final Variation VARIATION_SECOND_GROUPED_EXPERIMENT_B = new Variation( - VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID, - VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - private static final String SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Hermione Granger"; - private static final String SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Ronald Weasley"; + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_FIRST_GROUPED_EXPERIMENT_A_KEY, + VARIATION_FIRST_GROUPED_EXPERIMENT_B_KEY + ) + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_FIRST_GROUPED_EXPERIMENT_A_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_FIRST_GROUPED_EXPERIMENT_B_ID, + 10000 + ) + ), + GROUP_1_ID + ); + private static final String LAYER_SECOND_GROUPED_EXPERIMENT_ID = "2625300442"; + private static final String EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID = "3042640549"; + private static final String EXPERIMENT_SECOND_GROUPED_EXPERIMENT_KEY = "second_grouped_experiment"; + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID = "1558539439"; + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY = "A"; + private static final Variation VARIATION_SECOND_GROUPED_EXPERIMENT_A = new Variation( + VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID, + VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID = "2142748370"; + private static final String VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY = "B"; + private static final Variation VARIATION_SECOND_GROUPED_EXPERIMENT_B = new Variation( + VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID, + VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + private static final String SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A = "Hermione Granger"; + private static final String SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B = "Ronald Weasley"; private static final Experiment EXPERIMENT_SECOND_GROUPED_EXPERIMENT = new Experiment( - EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, - EXPERIMENT_SECOND_GROUPED_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_SECOND_GROUPED_EXPERIMENT_ID, - Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_SECOND_GROUPED_EXPERIMENT_A, - VARIATION_SECOND_GROUPED_EXPERIMENT_B + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_SECOND_GROUPED_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_SECOND_GROUPED_EXPERIMENT_A, + VARIATION_SECOND_GROUPED_EXPERIMENT_B + ), + DatafileProjectConfigTestUtils.createMapOfObjects( + DatafileProjectConfigTestUtils.createListOfObjects( + SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, + SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B ), - ProjectConfigTestUtils.createMapOfObjects( - ProjectConfigTestUtils.createListOfObjects( - SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_A, - SECOND_GROUPED_EXPERIMENT_FORCED_VARIATION_USER_ID_VARIATION_B - ), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY, - VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY - ) + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_SECOND_GROUPED_EXPERIMENT_A_KEY, + VARIATION_SECOND_GROUPED_EXPERIMENT_B_KEY + ) + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID, + 5000 ), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - VARIATION_SECOND_GROUPED_EXPERIMENT_A_ID, - 5000 - ), - new TrafficAllocation( - VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID, - 10000 - ) + new TrafficAllocation( + VARIATION_SECOND_GROUPED_EXPERIMENT_B_ID, + 10000 + ) + ), + GROUP_1_ID + ); + private static final String LAYER_MULTIVARIATE_EXPERIMENT_ID = "3780747876"; + private static final String EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID = "3262035800"; + public static final String EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY = "multivariate_experiment"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID = "1880281238"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY = "Fred"; + private static final Boolean VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE = true; + private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_FRED = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY, + VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "F" ), - GROUP_1_ID - ); - private static final String LAYER_MULTIVARIATE_EXPERIMENT_ID = "3780747876"; - private static final String EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID = "3262035800"; - public static final String EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY = "multivariate_experiment"; - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID = "1880281238"; - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY = "Fred"; - private static final Boolean VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE = true; - private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_FRED = new Variation( - VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID, - VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY, - VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - VARIABLE_FIRST_LETTER_ID, - "F" - ), - new LiveVariableUsageInstance( - VARIABLE_REST_OF_NAME_ID, - "red" - ) + new FeatureVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "red" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}" ) + ) ); - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID = "3631049532"; - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY = "Feorge"; - private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE = new Variation( - VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID, - VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY, - VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - VARIABLE_FIRST_LETTER_ID, - "F" - ), - new LiveVariableUsageInstance( - VARIABLE_REST_OF_NAME_ID, - "eorge" - ) + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID = "3631049532"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY = "Feorge"; + private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY, + VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "F" + ), + new FeatureVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "eorge" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s2\",\"k2\":203.5,\"k3\":true,\"k4\":{\"kk1\":\"ss2\",\"kk2\":true}}" ) + ) ); - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID = "4204375027"; - public static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY = "Gred"; - public static final Boolean VARIATION_MULTIVARIATE_VARIATION_FEATURE_ENABLED_GRED_KEY = false; - public static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GRED = new Variation( - VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, - VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, - VARIATION_MULTIVARIATE_VARIATION_FEATURE_ENABLED_GRED_KEY, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - VARIABLE_FIRST_LETTER_ID, - "G" - ), - new LiveVariableUsageInstance( - VARIABLE_REST_OF_NAME_ID, - "red" - ) + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID = "4204375027"; + public static final String VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY = "Gred"; + public static final Boolean VARIATION_MULTIVARIATE_VARIATION_FEATURE_ENABLED_GRED_KEY = false; + public static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GRED = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, + VARIATION_MULTIVARIATE_VARIATION_FEATURE_ENABLED_GRED_KEY, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "G" + ), + new FeatureVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "red" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s3\",\"k2\":303.5,\"k3\":true,\"k4\":{\"kk1\":\"ss3\",\"kk2\":false}}" ) + ) ); - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID = "2099211198"; - private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY = "George"; - private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE = new Variation( - VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID, - VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY, - VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - VARIABLE_FIRST_LETTER_ID, - "G" - ), - new LiveVariableUsageInstance( - VARIABLE_REST_OF_NAME_ID, - "eorge" - ) + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID = "2099211198"; + private static final String VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY = "George"; + private static final Variation VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE = new Variation( + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID, + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY, + VARIATION_MULTIVARIATE_FEATURE_ENABLED_VALUE, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + VARIABLE_FIRST_LETTER_ID, + "G" + ), + new FeatureVariableUsageInstance( + VARIABLE_REST_OF_NAME_ID, + "eorge" + ), + new FeatureVariableUsageInstance( + VARIABLE_JSON_PATCHED_TYPE_ID, + "{\"k1\":\"s4\",\"k2\":403.5,\"k3\":false,\"k4\":{\"kk1\":\"ss4\",\"kk2\":true}}" ) + ) ); - private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FRED = "Fred"; - private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FEORGE = "Feorge"; - public static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED = "Gred"; - private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GEORGE = "George"; + private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FRED = "Fred"; + private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FEORGE = "Feorge"; + public static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED = "Gred"; + private static final String MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GEORGE = "George"; private static final Experiment EXPERIMENT_MULTIVARIATE_EXPERIMENT = new Experiment( - EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID, - EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_MULTIVARIATE_EXPERIMENT_ID, - Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_MULTIVARIATE_EXPERIMENT_FRED, - VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE, - VARIATION_MULTIVARIATE_EXPERIMENT_GRED, - VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE + EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID, + EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_MULTIVARIATE_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED, + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE, + VARIATION_MULTIVARIATE_EXPERIMENT_GRED, + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE + ), + DatafileProjectConfigTestUtils.createMapOfObjects( + DatafileProjectConfigTestUtils.createListOfObjects( + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FRED, + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FEORGE, + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED, + MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GEORGE ), - ProjectConfigTestUtils.createMapOfObjects( - ProjectConfigTestUtils.createListOfObjects( - MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FRED, - MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_FEORGE, - MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED, - MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GEORGE - ), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY, - VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY, - VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, - VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY - ) + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_KEY, + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_KEY, + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_KEY, + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_KEY + ) + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID, + 2500 ), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - VARIATION_MULTIVARIATE_EXPERIMENT_FRED_ID, - 2500 - ), - new TrafficAllocation( - VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID, - 5000 - ), - new TrafficAllocation( - VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, - 7500 - ), - new TrafficAllocation( - VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID, - 10000 - ) + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_FEORGE_ID, + 5000 + ), + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_GRED_ID, + 7500 + ), + new TrafficAllocation( + VARIATION_MULTIVARIATE_EXPERIMENT_GEORGE_ID, + 10000 ) + ) ); - private static final String LAYER_DOUBLE_FEATURE_EXPERIMENT_ID = "1278722008"; - private static final String EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID = "2201520193"; - public static final String EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY = "double_single_variable_feature_experiment"; - private static final String VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID = "1505457580"; - private static final String VARIATION_DOUBLE_FEATURE_PI_VARIATION_KEY = "pi_variation"; - private static final Boolean VARIATION_DOUBLE_FEATURE_ENABLED_VALUE = true; - private static final Variation VARIATION_DOUBLE_FEATURE_PI_VARIATION = new Variation( - VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID, - VARIATION_DOUBLE_FEATURE_PI_VARIATION_KEY, - VARIATION_DOUBLE_FEATURE_ENABLED_VALUE, - Collections.singletonList( - new LiveVariableUsageInstance( - VARIABLE_DOUBLE_VARIABLE_ID, - "3.14" - ) + private static final String LAYER_DOUBLE_FEATURE_EXPERIMENT_ID = "1278722008"; + private static final String EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID = "2201520193"; + public static final String EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY = "double_single_variable_feature_experiment"; + private static final String VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID = "1505457580"; + private static final String VARIATION_DOUBLE_FEATURE_PI_VARIATION_KEY = "pi_variation"; + private static final Boolean VARIATION_DOUBLE_FEATURE_ENABLED_VALUE = true; + private static final Variation VARIATION_DOUBLE_FEATURE_PI_VARIATION = new Variation( + VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID, + VARIATION_DOUBLE_FEATURE_PI_VARIATION_KEY, + VARIATION_DOUBLE_FEATURE_ENABLED_VALUE, + Collections.singletonList( + new FeatureVariableUsageInstance( + VARIABLE_DOUBLE_VARIABLE_ID, + "3.14" ) + ) ); - private static final String VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID = "119616179"; - private static final String VARIATION_DOUBLE_FEATURE_EULER_VARIATION_KEY = "euler_variation"; - private static final Variation VARIATION_DOUBLE_FEATURE_EULER_VARIATION = new Variation( - VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID, - VARIATION_DOUBLE_FEATURE_EULER_VARIATION_KEY, - Collections.singletonList( - new LiveVariableUsageInstance( - VARIABLE_DOUBLE_VARIABLE_ID, - "2.718" - ) + private static final String VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID = "119616179"; + private static final String VARIATION_DOUBLE_FEATURE_EULER_VARIATION_KEY = "euler_variation"; + private static final Variation VARIATION_DOUBLE_FEATURE_EULER_VARIATION = new Variation( + VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID, + VARIATION_DOUBLE_FEATURE_EULER_VARIATION_KEY, + Collections.singletonList( + new FeatureVariableUsageInstance( + VARIABLE_DOUBLE_VARIABLE_ID, + "2.718" ) + ) ); private static final Experiment EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT = new Experiment( - EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID, - EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_DOUBLE_FEATURE_EXPERIMENT_ID, - Collections.singletonList(AUDIENCE_SLYTHERIN_ID), - ProjectConfigTestUtils.createListOfObjects( - VARIATION_DOUBLE_FEATURE_PI_VARIATION, - VARIATION_DOUBLE_FEATURE_EULER_VARIATION + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID, + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_DOUBLE_FEATURE_EXPERIMENT_ID, + Collections.singletonList(AUDIENCE_SLYTHERIN_ID), + null, + DatafileProjectConfigTestUtils.createListOfObjects( + VARIATION_DOUBLE_FEATURE_PI_VARIATION, + VARIATION_DOUBLE_FEATURE_EULER_VARIATION + ), + Collections.<String, String>emptyMap(), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID, + 4000 ), - Collections.<String, String>emptyMap(), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - VARIATION_DOUBLE_FEATURE_PI_VARIATION_ID, - 4000 - ), - new TrafficAllocation( - VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID, - 8000 - ) + new TrafficAllocation( + VARIATION_DOUBLE_FEATURE_EULER_VARIATION_ID, + 8000 ) + ) ); - private static final String LAYER_PAUSED_EXPERIMENT_ID = "3949273892"; - private static final String EXPERIMENT_PAUSED_EXPERIMENT_ID = "2667098701"; - public static final String EXPERIMENT_PAUSED_EXPERIMENT_KEY = "paused_experiment"; - private static final String VARIATION_PAUSED_EXPERIMENT_CONTROL_ID = "391535909"; - private static final String VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY = "Control"; - private static final Variation VARIATION_PAUSED_EXPERIMENT_CONTROL = new Variation( - VARIATION_PAUSED_EXPERIMENT_CONTROL_ID, - VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY, - Collections.<LiveVariableUsageInstance>emptyList() - ); - public static final String PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL = "Harry Potter"; + private static final String LAYER_PAUSED_EXPERIMENT_ID = "3949273892"; + private static final String EXPERIMENT_PAUSED_EXPERIMENT_ID = "2667098701"; + public static final String EXPERIMENT_PAUSED_EXPERIMENT_KEY = "paused_experiment"; + private static final String VARIATION_PAUSED_EXPERIMENT_CONTROL_ID = "391535909"; + private static final String VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY = "Control"; + private static final Variation VARIATION_PAUSED_EXPERIMENT_CONTROL = new Variation( + VARIATION_PAUSED_EXPERIMENT_CONTROL_ID, + VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() + ); + public static final String PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL = "Harry Potter"; private static final Experiment EXPERIMENT_PAUSED_EXPERIMENT = new Experiment( - EXPERIMENT_PAUSED_EXPERIMENT_ID, - EXPERIMENT_PAUSED_EXPERIMENT_KEY, - Experiment.ExperimentStatus.PAUSED.toString(), - LAYER_PAUSED_EXPERIMENT_ID, - Collections.<String>emptyList(), - Collections.singletonList(VARIATION_PAUSED_EXPERIMENT_CONTROL), - Collections.singletonMap(PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL, - VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY), - Collections.singletonList( - new TrafficAllocation( - VARIATION_PAUSED_EXPERIMENT_CONTROL_ID, - 10000 - ) + EXPERIMENT_PAUSED_EXPERIMENT_ID, + EXPERIMENT_PAUSED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.PAUSED.toString(), + LAYER_PAUSED_EXPERIMENT_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList(VARIATION_PAUSED_EXPERIMENT_CONTROL), + Collections.singletonMap(PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL, + VARIATION_PAUSED_EXPERIMENT_CONTROL_KEY), + Collections.singletonList( + new TrafficAllocation( + VARIATION_PAUSED_EXPERIMENT_CONTROL_ID, + 10000 ) + ) ); - private static final String LAYER_LAUNCHED_EXPERIMENT_ID = "3587821424"; - private static final String EXPERIMENT_LAUNCHED_EXPERIMENT_ID = "3072915611"; - public static final String EXPERIMENT_LAUNCHED_EXPERIMENT_KEY = "launched_experiment"; - private static final String VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID = "1647582435"; - private static final String VARIATION_LAUNCHED_EXPERIMENT_CONTROL_KEY = "launch_control"; - private static final Variation VARIATION_LAUNCHED_EXPERIMENT_CONTROL = new Variation( - VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID, - VARIATION_LAUNCHED_EXPERIMENT_CONTROL_KEY, - Collections.<LiveVariableUsageInstance>emptyList() + private static final String LAYER_LAUNCHED_EXPERIMENT_ID = "3587821424"; + private static final String EXPERIMENT_LAUNCHED_EXPERIMENT_ID = "3072915611"; + public static final String EXPERIMENT_LAUNCHED_EXPERIMENT_KEY = "launched_experiment"; + private static final String VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID = "1647582435"; + private static final String VARIATION_LAUNCHED_EXPERIMENT_CONTROL_KEY = "launch_control"; + private static final Variation VARIATION_LAUNCHED_EXPERIMENT_CONTROL = new Variation( + VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID, + VARIATION_LAUNCHED_EXPERIMENT_CONTROL_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() ); private static final Experiment EXPERIMENT_LAUNCHED_EXPERIMENT = new Experiment( - EXPERIMENT_LAUNCHED_EXPERIMENT_ID, - EXPERIMENT_LAUNCHED_EXPERIMENT_KEY, - Experiment.ExperimentStatus.LAUNCHED.toString(), - LAYER_LAUNCHED_EXPERIMENT_ID, - Collections.<String>emptyList(), - Collections.singletonList(VARIATION_LAUNCHED_EXPERIMENT_CONTROL), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID, - 8000 - ) + EXPERIMENT_LAUNCHED_EXPERIMENT_ID, + EXPERIMENT_LAUNCHED_EXPERIMENT_KEY, + Experiment.ExperimentStatus.LAUNCHED.toString(), + LAYER_LAUNCHED_EXPERIMENT_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList(VARIATION_LAUNCHED_EXPERIMENT_CONTROL), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_LAUNCHED_EXPERIMENT_CONTROL_ID, + 8000 ) + ) ); - private static final String LAYER_MUTEX_GROUP_EXPERIMENT_1_LAYER_ID = "3755588495"; - private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID = "4138322202"; - private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_KEY = "mutex_group_2_experiment_1"; - private static final String VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID = "1394671166"; - private static final String VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY = "mutex_group_2_experiment_1_variation_1"; - private static final Boolean VARIATION_MUTEX_GROUP_EXP_FEATURE_ENABLED_VALUE = true; - private static final Variation VARIATION_MUTEX_GROUP_EXP_1_VAR_1 = new Variation( - VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID, - VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY, - VARIATION_MUTEX_GROUP_EXP_FEATURE_ENABLED_VALUE, - Collections.singletonList( - new LiveVariableUsageInstance( - VARIABLE_CORRELATING_VARIATION_NAME_ID, - VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY - ) + private static final String LAYER_MUTEX_GROUP_EXPERIMENT_1_LAYER_ID = "3755588495"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID = "4138322202"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_KEY = "mutex_group_2_experiment_1"; + private static final String VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID = "1394671166"; + private static final String VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY = "mutex_group_2_experiment_1_variation_1"; + private static final Boolean VARIATION_MUTEX_GROUP_EXP_FEATURE_ENABLED_VALUE = true; + private static final Variation VARIATION_MUTEX_GROUP_EXP_1_VAR_1 = new Variation( + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID, + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY, + VARIATION_MUTEX_GROUP_EXP_FEATURE_ENABLED_VALUE, + Collections.singletonList( + new FeatureVariableUsageInstance( + VARIABLE_CORRELATING_VARIATION_NAME_ID, + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_KEY ) + ) ); - public static final Experiment EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1 = new Experiment( - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_MUTEX_GROUP_EXPERIMENT_1_LAYER_ID, - Collections.<String>emptyList(), - Collections.singletonList(VARIATION_MUTEX_GROUP_EXP_1_VAR_1), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID, - 10000 - ) - ), - GROUP_2_ID - ); - private static final String LAYER_MUTEX_GROUP_EXPERIMENT_2_LAYER_ID = "3818002538"; - private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID = "1786133852"; - private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_KEY = "mutex_group_2_experiment_2"; - private static final String VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID = "1619235542"; - private static final String VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY = "mutex_group_2_experiment_2_variation_2"; - private static final Boolean VARIATION_MUTEX_GROUP_EXP_2_FEATURE_ENABLED_VALUE = true; - public static final Variation VARIATION_MUTEX_GROUP_EXP_2_VAR_1 = new Variation( - VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID, - VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY, - VARIATION_MUTEX_GROUP_EXP_2_FEATURE_ENABLED_VALUE, - Collections.singletonList( - new LiveVariableUsageInstance( - VARIABLE_CORRELATING_VARIATION_NAME_ID, - VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY - ) + public static final Experiment EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1 = new Experiment( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_MUTEX_GROUP_EXPERIMENT_1_LAYER_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList(VARIATION_MUTEX_GROUP_EXP_1_VAR_1), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_MUTEX_GROUP_EXP_1_VAR_1_ID, + 10000 ) + ), + GROUP_2_ID ); - public static final Experiment EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2 = new Experiment( - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID, - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_MUTEX_GROUP_EXPERIMENT_2_LAYER_ID, - Collections.<String>emptyList(), - Collections.singletonList(VARIATION_MUTEX_GROUP_EXP_2_VAR_1), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID, - 10000 - ) - ), - GROUP_2_ID + private static final String LAYER_MUTEX_GROUP_EXPERIMENT_2_LAYER_ID = "3818002538"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID = "1786133852"; + private static final String EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_KEY = "mutex_group_2_experiment_2"; + private static final String VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID = "1619235542"; + private static final String VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY = "mutex_group_2_experiment_2_variation_2"; + private static final Boolean VARIATION_MUTEX_GROUP_EXP_2_FEATURE_ENABLED_VALUE = true; + public static final Variation VARIATION_MUTEX_GROUP_EXP_2_VAR_1 = new Variation( + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID, + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY, + VARIATION_MUTEX_GROUP_EXP_2_FEATURE_ENABLED_VALUE, + Collections.singletonList( + new FeatureVariableUsageInstance( + VARIABLE_CORRELATING_VARIATION_NAME_ID, + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_KEY + ) + ) + ); + public static final Experiment EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2 = new Experiment( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_MUTEX_GROUP_EXPERIMENT_2_LAYER_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList(VARIATION_MUTEX_GROUP_EXP_2_VAR_1), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_MUTEX_GROUP_EXP_2_VAR_1_ID, + 10000 + ) + ), + GROUP_2_ID ); - private static final String EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID = "748215081"; - public static final String EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY = "experiment_with_malformed_audience"; - private static final String LAYER_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID = "1238149537"; - private static final String VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID = "535538389"; - public static final String VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY = "var1"; - private static final Variation VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE = new Variation( - VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, - VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY, - Collections.<LiveVariableUsageInstance>emptyList() + private static final String EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID = "748215081"; + public static final String EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY = "experiment_with_malformed_audience"; + private static final String LAYER_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID = "1238149537"; + private static final String VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID = "535538389"; + public static final String VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY = "var1"; + private static final Variation VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE = new Variation( + VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, + VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY, + Collections.<FeatureVariableUsageInstance>emptyList() ); private static final Experiment EXPERIMENT_WITH_MALFORMED_AUDIENCE = new Experiment( - EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, - EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY, - Experiment.ExperimentStatus.RUNNING.toString(), - LAYER_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, - Collections.singletonList(AUDIENCE_WITH_MISSING_VALUE_ID), - Collections.singletonList(VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, - 10000 - ) + EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, + EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY, + Experiment.ExperimentStatus.RUNNING.toString(), + LAYER_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, + Collections.singletonList(AUDIENCE_WITH_MISSING_VALUE_ID), + null, + Collections.singletonList(VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + VARIATION_EXPERIMENT_WITH_MALFORMED_AUDIENCE_ID, + 10000 ) + ) ); // generate groups - private static final Group GROUP_1 = new Group( - GROUP_1_ID, - Group.RANDOM_POLICY, - ProjectConfigTestUtils.createListOfObjects( - EXPERIMENT_FIRST_GROUPED_EXPERIMENT, - EXPERIMENT_SECOND_GROUPED_EXPERIMENT + private static final Group GROUP_1 = new Group( + GROUP_1_ID, + Group.RANDOM_POLICY, + DatafileProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_FIRST_GROUPED_EXPERIMENT, + EXPERIMENT_SECOND_GROUPED_EXPERIMENT + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, + 4000 ), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, - 4000 - ), - new TrafficAllocation( - EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, - 8000 - ) + new TrafficAllocation( + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, + 8000 ) + ) ); - private static final Group GROUP_2 = new Group( - GROUP_2_ID, - Group.RANDOM_POLICY, - ProjectConfigTestUtils.createListOfObjects( - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1, - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2 + private static final Group GROUP_2 = new Group( + GROUP_2_ID, + Group.RANDOM_POLICY, + DatafileProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2 + ), + DatafileProjectConfigTestUtils.createListOfObjects( + new TrafficAllocation( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, + 5000 ), - ProjectConfigTestUtils.createListOfObjects( - new TrafficAllocation( - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, - 5000 - ), - new TrafficAllocation( - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID, - 10000 - ) + new TrafficAllocation( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID, + 10000 ) + ) ); // events - private static final String EVENT_BASIC_EVENT_ID = "3785620495"; - public static final String EVENT_BASIC_EVENT_KEY = "basic_event"; - private static final EventType EVENT_BASIC_EVENT = new EventType( - EVENT_BASIC_EVENT_ID, - EVENT_BASIC_EVENT_KEY, - ProjectConfigTestUtils.createListOfObjects( - EXPERIMENT_BASIC_EXPERIMENT_ID, - EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, - EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, - EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID, - EXPERIMENT_LAUNCHED_EXPERIMENT_ID - ) + private static final String EVENT_BASIC_EVENT_ID = "3785620495"; + public static final String EVENT_BASIC_EVENT_KEY = "basic_event"; + private static final EventType EVENT_BASIC_EVENT = new EventType( + EVENT_BASIC_EVENT_ID, + EVENT_BASIC_EVENT_KEY, + DatafileProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_BASIC_EXPERIMENT_ID, + EXPERIMENT_FIRST_GROUPED_EXPERIMENT_ID, + EXPERIMENT_SECOND_GROUPED_EXPERIMENT_ID, + EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID, + EXPERIMENT_LAUNCHED_EXPERIMENT_ID + ) ); - private static final String EVENT_PAUSED_EXPERIMENT_ID = "3195631717"; - public static final String EVENT_PAUSED_EXPERIMENT_KEY = "event_with_paused_experiment"; - private static final EventType EVENT_PAUSED_EXPERIMENT = new EventType( - EVENT_PAUSED_EXPERIMENT_ID, - EVENT_PAUSED_EXPERIMENT_KEY, - Collections.singletonList( - EXPERIMENT_PAUSED_EXPERIMENT_ID - ) + private static final String EVENT_PAUSED_EXPERIMENT_ID = "3195631717"; + public static final String EVENT_PAUSED_EXPERIMENT_KEY = "event_with_paused_experiment"; + private static final EventType EVENT_PAUSED_EXPERIMENT = new EventType( + EVENT_PAUSED_EXPERIMENT_ID, + EVENT_PAUSED_EXPERIMENT_KEY, + Collections.singletonList( + EXPERIMENT_PAUSED_EXPERIMENT_ID + ) ); - private static final String EVENT_LAUNCHED_EXPERIMENT_ONLY_ID = "1987018666"; - public static final String EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY = "event_with_launched_experiments_only"; - private static final EventType EVENT_LAUNCHED_EXPERIMENT_ONLY = new EventType( - EVENT_LAUNCHED_EXPERIMENT_ONLY_ID, - EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY, - Collections.singletonList( - EXPERIMENT_LAUNCHED_EXPERIMENT_ID - ) + private static final String EVENT_LAUNCHED_EXPERIMENT_ONLY_ID = "1987018666"; + public static final String EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY = "event_with_launched_experiments_only"; + private static final EventType EVENT_LAUNCHED_EXPERIMENT_ONLY = new EventType( + EVENT_LAUNCHED_EXPERIMENT_ONLY_ID, + EVENT_LAUNCHED_EXPERIMENT_ONLY_KEY, + Collections.singletonList( + EXPERIMENT_LAUNCHED_EXPERIMENT_ID + ) ); // rollouts - public static final String ROLLOUT_2_ID = "813411034"; + public static final String ROLLOUT_2_ID = "813411034"; private static final Experiment ROLLOUT_2_RULE_1 = new Experiment( - "3421010877", - "3421010877", - Experiment.ExperimentStatus.RUNNING.toString(), - ROLLOUT_2_ID, - Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), - Collections.singletonList( - new Variation( - "521740985", - "521740985", - true, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - "675244127", - "G" - ), - new LiveVariableUsageInstance( - "4052219963", - "odric" - ) - ) - ) - ), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - "521740985", - 5000 + "3421010877", + "3421010877", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.singletonList(AUDIENCE_GRYFFINDOR_ID), + null, + Collections.singletonList( + new Variation( + "521740985", + "521740985", + true, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + "675244127", + "G" + ), + new FeatureVariableUsageInstance( + "4052219963", + "odric" ) + ) + ) + ), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "521740985", + 5000 ) + ) ); private static final Experiment ROLLOUT_2_RULE_2 = new Experiment( - "600050626", - "600050626", - Experiment.ExperimentStatus.RUNNING.toString(), - ROLLOUT_2_ID, - Collections.singletonList(AUDIENCE_SLYTHERIN_ID), - Collections.singletonList( - new Variation( - "180042646", - "180042646", - true, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - "675244127", - "S" - ), - new LiveVariableUsageInstance( - "4052219963", - "alazar" - ) - ) - ) - ), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - "180042646", - 5000 + "600050626", + "600050626", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.singletonList(AUDIENCE_SLYTHERIN_ID), + null, + Collections.singletonList( + new Variation( + "180042646", + "180042646", + true, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + "675244127", + "S" + ), + new FeatureVariableUsageInstance( + "4052219963", + "alazar" ) + ) + ) + ), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "180042646", + 5000 ) + ) ); private static final Experiment ROLLOUT_2_RULE_3 = new Experiment( - "2637642575", - "2637642575", - Experiment.ExperimentStatus.RUNNING.toString(), - ROLLOUT_2_ID, - Collections.singletonList(AUDIENCE_ENGLISH_CITIZENS_ID), - Collections.singletonList( - new Variation( - "2346257680", - "2346257680", - true, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - "675244127", - "D" - ), - new LiveVariableUsageInstance( - "4052219963", - "udley" - ) - ) - ) - ), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - "2346257680", - 5000 + "2637642575", + "2637642575", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.singletonList(AUDIENCE_ENGLISH_CITIZENS_ID), + null, + Collections.singletonList( + new Variation( + "2346257680", + "2346257680", + true, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + "675244127", + "D" + ), + new FeatureVariableUsageInstance( + "4052219963", + "udley" ) + ) + ) + ), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "2346257680", + 5000 ) + ) ); private static final Experiment ROLLOUT_2_EVERYONE_ELSE_RULE = new Experiment( - "828245624", - "828245624", - Experiment.ExperimentStatus.RUNNING.toString(), - ROLLOUT_2_ID, - Collections.<String>emptyList(), - Collections.singletonList( - new Variation( - "3137445031", - "3137445031", - true, - ProjectConfigTestUtils.createListOfObjects( - new LiveVariableUsageInstance( - "675244127", - "M" - ), - new LiveVariableUsageInstance( - "4052219963", - "uggle" - ) - ) - ) - ), - Collections.<String, String>emptyMap(), - Collections.singletonList( - new TrafficAllocation( - "3137445031", - 5000 + "828245624", + "828245624", + Experiment.ExperimentStatus.RUNNING.toString(), + ROLLOUT_2_ID, + Collections.<String>emptyList(), + null, + Collections.singletonList( + new Variation( + "3137445031", + "3137445031", + true, + DatafileProjectConfigTestUtils.createListOfObjects( + new FeatureVariableUsageInstance( + "675244127", + "M" + ), + new FeatureVariableUsageInstance( + "4052219963", + "uggle" ) + ) ) - ); - public static final Rollout ROLLOUT_2 = new Rollout( - ROLLOUT_2_ID, - ProjectConfigTestUtils.createListOfObjects( - ROLLOUT_2_RULE_1, - ROLLOUT_2_RULE_2, - ROLLOUT_2_RULE_3, - ROLLOUT_2_EVERYONE_ELSE_RULE + ), + Collections.<String, String>emptyMap(), + Collections.singletonList( + new TrafficAllocation( + "3137445031", + 5000 ) + ) + ); + public static final Rollout ROLLOUT_2 = new Rollout( + ROLLOUT_2_ID, + DatafileProjectConfigTestUtils.createListOfObjects( + ROLLOUT_2_RULE_1, + ROLLOUT_2_RULE_2, + ROLLOUT_2_RULE_3, + ROLLOUT_2_EVERYONE_ELSE_RULE + ) ); // finish features - public static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( - FEATURE_MULTI_VARIATE_FEATURE_ID, - FEATURE_MULTI_VARIATE_FEATURE_KEY, - ROLLOUT_2_ID, - Collections.singletonList(EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID), - ProjectConfigTestUtils.createListOfObjects( - VARIABLE_FIRST_LETTER_VARIABLE, - VARIABLE_REST_OF_NAME_VARIABLE - ) + public static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FEATURE = new FeatureFlag( + FEATURE_MULTI_VARIATE_FEATURE_ID, + FEATURE_MULTI_VARIATE_FEATURE_KEY, + ROLLOUT_2_ID, + Collections.singletonList(EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID), + DatafileProjectConfigTestUtils.createListOfObjects( + VARIABLE_FIRST_LETTER_VARIABLE, + VARIABLE_REST_OF_NAME_VARIABLE, + VARIABLE_JSON_PATCHED_TYPE_VARIABLE + ) ); - public static final FeatureFlag FEATURE_FLAG_MUTEX_GROUP_FEATURE = new FeatureFlag( - FEATURE_MUTEX_GROUP_FEATURE_ID, - FEATURE_MUTEX_GROUP_FEATURE_KEY, - "", - ProjectConfigTestUtils.createListOfObjects( - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, - EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID - ), - Collections.singletonList( - VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE - ) + public static final FeatureFlag FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE = new FeatureFlag( + FEATURE_MULTI_VARIATE_FUTURE_FEATURE_ID, + FEATURE_MULTI_VARIATE_FUTURE_FEATURE_KEY, + ROLLOUT_2_ID, + Collections.singletonList(EXPERIMENT_MULTIVARIATE_EXPERIMENT_ID), + DatafileProjectConfigTestUtils.createListOfObjects( + VARIABLE_JSON_NATIVE_TYPE_VARIABLE, + VARIABLE_FUTURE_TYPE_VARIABLE + ) ); - public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( - FEATURE_SINGLE_VARIABLE_DOUBLE_ID, - FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, - "", - Collections.singletonList( - EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID - ), - Collections.singletonList( - VARIABLE_DOUBLE_VARIABLE - ) + public static final FeatureFlag FEATURE_FLAG_MUTEX_GROUP_FEATURE = new FeatureFlag( + FEATURE_MUTEX_GROUP_FEATURE_ID, + FEATURE_MUTEX_GROUP_FEATURE_KEY, + "", + DatafileProjectConfigTestUtils.createListOfObjects( + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_1_EXPERIMENT_ID, + EXPERIMENT_MUTEX_GROUP_EXPERIMENT_2_EXPERIMENT_ID + ), + Collections.singletonList( + VARIABLE_CORRELATING_VARIATION_NAME_VARIABLE + ) ); - public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_INTEGER = new FeatureFlag( - FEATURE_SINGLE_VARIABLE_INTEGER_ID, - FEATURE_SINGLE_VARIABLE_INTEGER_KEY, - ROLLOUT_3_ID, - Collections.<String>emptyList(), - Collections.singletonList( - VARIABLE_INTEGER_VARIABLE - ) + public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_DOUBLE = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_DOUBLE_ID, + FEATURE_SINGLE_VARIABLE_DOUBLE_KEY, + "", + Collections.singletonList( + EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT_ID + ), + Collections.singletonList( + VARIABLE_DOUBLE_VARIABLE + ) + ); + public static final FeatureFlag FEATURE_FLAG_SINGLE_VARIABLE_INTEGER = new FeatureFlag( + FEATURE_SINGLE_VARIABLE_INTEGER_ID, + FEATURE_SINGLE_VARIABLE_INTEGER_KEY, + ROLLOUT_3_ID, + Collections.<String>emptyList(), + Collections.singletonList( + VARIABLE_INTEGER_VARIABLE + ) ); + public static final Integration odpIntegration = new Integration("odp", "https://example.com", "test-key"); public static ProjectConfig generateValidProjectConfigV4() { @@ -1033,6 +1382,10 @@ public static ProjectConfig generateValidProjectConfigV4() { attributes.add(ATTRIBUTE_HOUSE); attributes.add(ATTRIBUTE_NATIONALITY); attributes.add(ATTRIBUTE_OPT); + attributes.add(ATTRIBUTE_BOOLEAN); + attributes.add(ATTRIBUTE_INTEGER); + attributes.add(ATTRIBUTE_DOUBLE); + attributes.add(ATTRIBUTE_EMPTY); // list audiences List<Audience> audiences = new ArrayList<Audience>(); @@ -1041,6 +1394,16 @@ public static ProjectConfig generateValidProjectConfigV4() { audiences.add(AUDIENCE_ENGLISH_CITIZENS); audiences.add(AUDIENCE_WITH_MISSING_VALUE); + List<Audience> typedAudiences = new ArrayList<Audience>(); + typedAudiences.add(TYPED_AUDIENCE_BOOL); + typedAudiences.add(TYPED_AUDIENCE_EXACT_INT); + typedAudiences.add(TYPED_AUDIENCE_INT); + typedAudiences.add(TYPED_AUDIENCE_DOUBLE); + typedAudiences.add(TYPED_AUDIENCE_GRYFFINDOR); + typedAudiences.add(TYPED_AUDIENCE_SLYTHERIN); + typedAudiences.add(TYPED_AUDIENCE_ENGLISH_CITIZENS); + typedAudiences.add(AUDIENCE_WITH_MISSING_VALUE); + // list events List<EventType> events = new ArrayList<EventType>(); events.add(EVENT_BASIC_EVENT); @@ -1050,6 +1413,9 @@ public static ProjectConfig generateValidProjectConfigV4() { // list experiments List<Experiment> experiments = new ArrayList<Experiment>(); experiments.add(EXPERIMENT_BASIC_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_WITH_AND_EXPERIMENT); + experiments.add(EXPERIMENT_TYPEDAUDIENCE_LEAF_EXPERIMENT); experiments.add(EXPERIMENT_MULTIVARIATE_EXPERIMENT); experiments.add(EXPERIMENT_DOUBLE_FEATURE_EXPERIMENT); experiments.add(EXPERIMENT_PAUSED_EXPERIMENT); @@ -1064,6 +1430,7 @@ public static ProjectConfig generateValidProjectConfigV4() { featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_BOOLEAN); featureFlags.add(FEATURE_FLAG_SINGLE_VARIABLE_STRING); featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FEATURE); + featureFlags.add(FEATURE_FLAG_MULTI_VARIATE_FUTURE_FEATURE); featureFlags.add(FEATURE_FLAG_MUTEX_GROUP_FEATURE); List<Group> groups = new ArrayList<Group>(); @@ -1076,21 +1443,28 @@ public static ProjectConfig generateValidProjectConfigV4() { rollouts.add(ROLLOUT_2); rollouts.add(ROLLOUT_3); - return new ProjectConfig( - ACCOUNT_ID, - ANONYMIZE_IP, - BOT_FILTERING, - PROJECT_ID, - REVISION, - VERSION, - attributes, - audiences, - events, - experiments, - featureFlags, - groups, - Collections.<LiveVariable>emptyList(), - rollouts + List<Integration> integrations = new ArrayList<>(); + integrations.add(odpIntegration); + + return new DatafileProjectConfig( + ACCOUNT_ID, + ANONYMIZE_IP, + SEND_FLAG_DECISIONS, + BOT_FILTERING, + PROJECT_ID, + REVISION, + SDK_KEY, + ENVIRONMENT_KEY, + VERSION, + attributes, + audiences, + typedAudiences, + events, + experiments, + featureFlags, + groups, + rollouts, + integrations ); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/VariationTest.java b/core-api/src/test/java/com/optimizely/ab/config/VariationTest.java index 5a76f51ca..e9632b02d 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/VariationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/VariationTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,4 +41,4 @@ public void isUsesVariationKey() throws Exception { // we *should* be comparing keys assertThat(variation.is("key"), is(true)); } -} \ No newline at end of file +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java index d15bd70aa..5a88e8ad9 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/AudienceConditionEvaluationTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,201 +16,1864 @@ */ package com.optimizely.ab.config.audience; -import com.optimizely.ab.config.Experiment; +import ch.qos.logback.classic.Level; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.testutils.OTUtils; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.mockito.Mock; +import org.mockito.internal.matchers.Or; +import org.mockito.internal.util.reflection.Whitebox; + +import java.math.BigInteger; +import java.util.*; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Tests for the evaluation of different audience condition types (And, Or, Not, and UserAttribute) + */ +@SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT", + justification = "mockito verify calls do have a side-effect") +public class AudienceConditionEvaluationTest { + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + Map<String, String> testUserAttributes; + Map<String, Object> testTypedUserAttributes; + + @Before + public void initialize() { + testUserAttributes = new HashMap<>(); + testUserAttributes.put("browser_type", "chrome"); + testUserAttributes.put("device_type", "Android"); + + testTypedUserAttributes = new HashMap<>(); + testTypedUserAttributes.put("is_firefox", true); + testTypedUserAttributes.put("num_counts", 3.55); + testTypedUserAttributes.put("num_size", 3); + testTypedUserAttributes.put("meta_data", testUserAttributes); + testTypedUserAttributes.put("null_val", null); + } + + @Test + public void nullConditionTest() throws Exception { + NullCondition nullCondition = new NullCondition(); + assertEquals(null, nullCondition.toJson()); + assertEquals(null, nullCondition.getOperandOrId()); + } + + @Test + public void emptyConditionTest() throws Exception { + EmptyCondition emptyCondition = new EmptyCondition(); + assertEquals(null, emptyCondition.toJson()); + assertEquals(null, emptyCondition.getOperandOrId()); + assertEquals(true, emptyCondition.evaluate(null, null)); + } + + /** + * Verify that UserAttribute.toJson returns a json represented string of conditions. + */ + @Test + public void userAttributeConditionsToJson() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + String expectedConditionJsonString = "{\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}"; + assertEquals(testInstance.toJson(), expectedConditionJsonString); + } + + /** + * Verify that AndCondition.toJson returns a json represented string of conditions. + */ + @Test + public void andConditionsToJsonWithComma() throws Exception { + UserAttribute testInstance1 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + UserAttribute testInstance2 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + String expectedConditionJsonString = "[\"and\", [\"or\", {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}, {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}]]"; + List<Condition> userConditions = new ArrayList<>(); + userConditions.add(testInstance1); + userConditions.add(testInstance2); + OrCondition orCondition = new OrCondition(userConditions); + List<Condition> orConditions = new ArrayList<>(); + orConditions.add(orCondition); + AndCondition andCondition = new AndCondition(orConditions); + assertEquals(andCondition.toJson(), expectedConditionJsonString); + } + + /** + * Verify that orCondition.toJson returns a json represented string of conditions. + */ + @Test + public void orConditionsToJsonWithComma() throws Exception { + UserAttribute testInstance1 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + UserAttribute testInstance2 = new UserAttribute("browser_type", "custom_attribute", "true", "safari"); + String expectedConditionJsonString = "[\"or\", [\"and\", {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}, {\"name\":\"browser_type\", \"type\":\"custom_attribute\", \"match\":\"true\", \"value\":\"safari\"}]]"; + List<Condition> userConditions = new ArrayList<>(); + userConditions.add(testInstance1); + userConditions.add(testInstance2); + AndCondition andCondition = new AndCondition(userConditions); + List<Condition> andConditions = new ArrayList<>(); + andConditions.add(andCondition); + OrCondition orCondition = new OrCondition(andConditions); + assertEquals(orCondition.toJson(), expectedConditionJsonString); + } + + /** + * Verify that UserAttribute.evaluate returns true on exact-matching visitor attribute data. + */ + @Test + public void userAttributeEvaluateTrue() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null, "chrome"); + assertTrue(testInstance.hashCode() != 0); + assertNull(testInstance.getMatch()); + assertEquals(testInstance.getName(), "browser_type"); + assertEquals(testInstance.getType(), "custom_attribute"); + assertTrue(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate returns false on non-exact-matching visitor attribute data. + */ + @Test + public void userAttributeEvaluateFalse() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", null, "firefox"); + assertFalse(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate returns false on unknown visitor attributes. + */ + @Test + public void userAttributeUnknownAttribute() throws Exception { + UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_attribute", null, "unknown"); + assertFalse(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate returns null on invalid match type. + */ + @Test + public void invalidMatchCondition() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "unknown_dimension", null, "chrome"); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate returns null on invalid match type. + */ + @Test + public void invalidMatch() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "blah", "chrome"); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + logbackVerifier.expectMessage(Level.WARN, + "Audience condition \"{name='browser_type', type='custom_attribute', match='blah', value='chrome'}\" uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK"); + } + + /** + * Verify that UserAttribute.evaluate returns null on invalid attribute type. + */ + @Test + public void unexpectedAttributeType() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + logbackVerifier.expectMessage(Level.WARN, + "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a value of type \"java.lang.String\" was passed for user attribute \"browser_type\""); + } + + /** + * Verify that UserAttribute.evaluate returns null on invalid attribute type. + */ + @Test + public void unexpectedAttributeTypeNull() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); + assertNull(testInstance.evaluate(null, OTUtils.user(Collections.singletonMap("browser_type", null)))); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because a null value was passed for user attribute \"browser_type\""); + } + + /** + * Verify that UserAttribute.evaluate returns null (and log debug message) on missing attribute value. + */ + @Test + public void missingAttribute_returnsNullAndLogDebugMessage() throws Exception { + // check with all valid value types for each match + + Map<String, Object[]> items = new HashMap<>(); + items.put("exact", new Object[]{"string", 123, true}); + items.put("substring", new Object[]{"string"}); + items.put("gt", new Object[]{123, 5.3}); + items.put("ge", new Object[]{123, 5.3}); + items.put("lt", new Object[]{123, 5.3}); + items.put("le", new Object[]{123, 5.3}); + items.put("semver_eq", new Object[]{"1.2.3"}); + items.put("semver_ge", new Object[]{"1.2.3"}); + items.put("semver_gt", new Object[]{"1.2.3"}); + items.put("semver_le", new Object[]{"1.2.3"}); + items.put("semver_lt", new Object[]{"1.2.3"}); + + for (Map.Entry<String, Object[]> entry : items.entrySet()) { + for (Object value : entry.getValue()) { + UserAttribute testInstance = new UserAttribute("n", "custom_attribute", entry.getKey(), value); + assertNull(testInstance.evaluate(null, OTUtils.user(Collections.EMPTY_MAP))); + String valueStr = (value instanceof String) ? ("'" + value + "'") : value.toString(); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience condition \"{name='n', type='custom_attribute', match='" + entry.getKey() + "', value=" + valueStr + "}\" evaluated to UNKNOWN because no value was passed for user attribute \"n\""); + } + } + } + + /** + * Verify that UserAttribute.evaluate returns null on passing null attribute object. + */ + @SuppressFBWarnings("NP_NULL_PARAM_DEREF_NONVIRTUAL") + @Test + public void nullAttribute() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "gt", 20); + assertNull(testInstance.evaluate(null, OTUtils.user(null))); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience condition \"{name='browser_type', type='custom_attribute', match='gt', value=20}\" evaluated to UNKNOWN because no value was passed for user attribute \"browser_type\""); + } + + /** + * Verify that UserAttribute.evaluate returns null on unknown condition type. + */ + @Test + public void unknownConditionType() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "blah", "exists", "firefox"); + assertNull(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + logbackVerifier.expectMessage(Level.WARN, + "Audience condition \"{name='browser_type', type='blah', match='exists', value='firefox'}\" uses an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK."); + } + + /** + * Verify that UserAttribute.evaluate for EXIST match type returns true for known visitor + * attributes with non-null instances and empty string. + */ + @Test + public void existsMatchConditionEmptyStringEvaluatesTrue() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); + Map<String, Object> attributes = new HashMap<>(); + attributes.put("browser_type", ""); + assertTrue(testInstance.evaluate(null, OTUtils.user(attributes))); + attributes.put("browser_type", null); + assertFalse(testInstance.evaluate(null, OTUtils.user(attributes))); + } + + /** + * Verify that UserAttribute.evaluate for EXIST match type returns true for known visitor + * attributes with non-null instances + */ + @Test + public void existsMatchConditionEvaluatesTrue() throws Exception { + UserAttribute testInstance = new UserAttribute("browser_type", "custom_attribute", "exists", "firefox"); + assertTrue(testInstance.evaluate(null, OTUtils.user(testUserAttributes))); + + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exists", false); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exists", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exists", 4.55); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "exists", testUserAttributes); + assertTrue(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for EXIST match type returns false for unknown visitor + * attributes OR null visitor attributes. + */ + @Test + public void existsMatchConditionEvaluatesFalse() throws Exception { + UserAttribute testInstance = new UserAttribute("bad_var", "custom_attribute", "exists", "chrome"); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exists", "chrome"); + assertFalse(testInstance.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns true for known visitor + * attributes where the values and the value's type are the same + */ + @Test + public void exactMatchConditionEvaluatesTrue() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "exact", "chrome"); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exact", true); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", 3); + UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "exact", (float) 3); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 3.55); + + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertTrue(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceFloat.evaluate(null, OTUtils.user(Collections.singletonMap("num_size", (float) 3)))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns null if the UserAttribute's + * value type is not a valid number. + */ + @Test + public void exactMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2,53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "exact", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "exact", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "exact", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); + assertNull(testInstanceFloat.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns null if the UserAttribute's condition + * value type is invalid number. + */ + @Test + public void invalidExactMatchConditionEvaluatesNull() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "exact", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "exact", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns false for known visitor + * attributes where the value's type are the same, but the values are different + */ + @Test + public void exactMatchConditionEvaluatesFalse() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "exact", "firefox"); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exact", false); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", 5.55); + + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertFalse(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for EXACT match type returns null for known visitor + * attributes where the value's type are different OR for values with null and object type. + */ + @Test + public void exactMatchConditionEvaluatesNull() { + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "exact", testUserAttributes); + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "exact", true); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "exact", "true"); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "exact", "3"); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "exact", "3.55"); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "exact", "null_val"); + + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + Map<String, Object> attr = new HashMap<>(); + attr.put("browser_type", "true"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(attr))); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is greater than + * the condition's value. + */ + @Test + public void gtMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", 2); + UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "gt", (float) 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 2.55); + + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceFloat.evaluate(null, OTUtils.user(Collections.singletonMap("num_size", (float) 3)))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + + Map<String, Object> badAttributes = new HashMap<>(); + badAttributes.put("num_size", "bobs burgers"); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(badAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void gtMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2, 53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "gt", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "gt", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "gt", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); + assertNull(testInstanceFloat.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void gtMatchConditionEvaluatesNullWithInvalidAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "gt", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "gt", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns false for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not greater + * than the condition's value. + */ + @Test + public void gtMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "gt", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "gt", 5.55); + + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void gtMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "gt", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "gt", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "gt", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "gt", 3.5); + + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + + /** + * Verify that UserAttribute.evaluate for GE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is greater or equal than + * the condition's value. + */ + @Test + public void geMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 2); + UserAttribute testInstanceFloat = new UserAttribute("num_size", "custom_attribute", "ge", (float) 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 2.55); + + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceFloat.evaluate(null, OTUtils.user(Collections.singletonMap("num_size", (float) 2)))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + + Map<String, Object> badAttributes = new HashMap<>(); + badAttributes.put("num_size", "bobs burgers"); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(badAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void geMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2, 53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "ge", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "ge", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "ge", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); + assertNull(testInstanceFloat.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is invalid number. + */ + @Test + public void geMatchConditionEvaluatesNullWithInvalidAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "ge", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "ge", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns false for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not greater or equal + * than the condition's value. + */ + @Test + public void geMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "ge", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "ge", 5.55); + + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GE match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void geMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "ge", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "ge", 3.5); + + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + + /** + * Verify that UserAttribute.evaluate for GT match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is less than + * the condition's value. + */ + @Test + public void ltMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 5.55); + + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for GT match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not less + * than the condition's value. + */ + @Test + public void ltMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "lt", 2.55); + + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for LT match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void ltMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "lt", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "lt", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "lt", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "lt", 3.5); + + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for LT match type returns null if the UserAttribute's + * value type is not a valid number. + */ + @Test + public void ltMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2,53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "lt", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "lt", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "lt", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); + assertNull(testInstanceFloat.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); + } + + /** + * Verify that UserAttribute.evaluate for LT match type returns null if the condition + * value type is not a valid number. + */ + @Test + public void ltMatchConditionEvaluatesNullWithInvalidAttributes() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "lt", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "lt", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "lt", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + + /** + * Verify that UserAttribute.evaluate for LE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is less or equal than + * the condition's value. + */ + @Test + public void leMatchConditionEvaluatesTrue() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 5); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 5.55); + + assertTrue(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertTrue(testInstanceDouble.evaluate(null, OTUtils.user(Collections.singletonMap("num_counts", 5.55)))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns true for known visitor + * attributes where the value's type is a number, and the UserAttribute's value is not less or equal + * than the condition's value. + */ + @Test + public void leMatchConditionEvaluatesFalse() { + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", 2); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "le", 2.55); + + assertFalse(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertFalse(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the UserAttribute's + * value type is not a number. + */ + @Test + public void leMatchConditionEvaluatesNull() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "le", 3.5); + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "le", 3.5); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "le", 3.5); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "le", 3.5); + + assertNull(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the UserAttribute's + * value type is not a valid number. + */ + @Test + public void leMatchConditionEvaluatesNullWithInvalidUserAttr() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + Double largeDouble = Math.pow(2,53) + 2; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + + UserAttribute testInstanceInteger = new UserAttribute( + "num_size", + "custom_attribute", + "le", + 5); + UserAttribute testInstanceFloat = new UserAttribute( + "num_size", + "custom_attribute", + "le", + (float) 5); + UserAttribute testInstanceDouble = new UserAttribute( + "num_counts", + "custom_attribute", + "le", + 5.2); + + assertNull(testInstanceInteger.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", bigInteger)))); + assertNull(testInstanceFloat.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_size", invalidFloatValue)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infinitePositiveInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, + OTUtils.user(Collections.singletonMap("num_counts", infiniteNegativeInfiniteDouble)))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", infiniteNANDouble))))); + assertNull(testInstanceDouble.evaluate( + null, OTUtils.user(Collections.singletonMap("num_counts", + Collections.singletonMap("num_counts", largeDouble))))); + } + + /** + * Verify that UserAttribute.evaluate for LE match type returns null if the condition + * value type is not a valid number. + */ + @Test + public void leMatchConditionEvaluatesNullWithInvalidAttributes() { + BigInteger bigInteger = new BigInteger("33221312312312312"); + Double infinitePositiveInfiniteDouble = Double.POSITIVE_INFINITY; + Double infiniteNegativeInfiniteDouble = Double.NEGATIVE_INFINITY; + Double infiniteNANDouble = Double.NaN; + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "le", bigInteger); + UserAttribute testInstancePositiveInfinite = new UserAttribute("num_counts", "custom_attribute", "le", infinitePositiveInfiniteDouble); + UserAttribute testInstanceNegativeInfiniteDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNegativeInfiniteDouble); + UserAttribute testInstanceNANDouble = new UserAttribute("num_counts", "custom_attribute", "le", infiniteNANDouble); + + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstancePositiveInfinite.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNegativeInfiniteDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNANDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the + * UserAttribute's value is a substring of the condition's value. + */ + @Test + public void substringMatchConditionEvaluatesTrue() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chrome"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the + * UserAttribute's value is a substring of the condition's value. + */ + @Test + public void substringMatchConditionPartialMatchEvaluatesTrue() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chro"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns true if the + * UserAttribute's value is NOT a substring of the condition's value. + */ + @Test + public void substringMatchConditionEvaluatesFalse() { + UserAttribute testInstanceString = new UserAttribute("browser_type", "custom_attribute", "substring", "chr0me"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testUserAttributes))); + } + + /** + * Verify that UserAttribute.evaluate for SUBSTRING match type returns null if the + * UserAttribute's value type is not a string. + */ + @Test + public void substringMatchConditionEvaluatesNull() { + UserAttribute testInstanceBoolean = new UserAttribute("is_firefox", "custom_attribute", "substring", "chrome1"); + UserAttribute testInstanceInteger = new UserAttribute("num_size", "custom_attribute", "substring", "chrome1"); + UserAttribute testInstanceDouble = new UserAttribute("num_counts", "custom_attribute", "substring", "chrome1"); + UserAttribute testInstanceObject = new UserAttribute("meta_data", "custom_attribute", "substring", "chrome1"); + UserAttribute testInstanceNull = new UserAttribute("null_val", "custom_attribute", "substring", "chrome1"); + + assertNull(testInstanceBoolean.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceInteger.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceDouble.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceObject.evaluate(null, OTUtils.user(testTypedUserAttributes))); + assertNull(testInstanceNull.evaluate(null, OTUtils.user(testTypedUserAttributes))); + } + + //======== Semantic version evaluation tests ========// + + // Test SemanticVersionEqualsMatch returns null if given invalid value type + @Test + public void testSemanticVersionEqualsMatchInvalidInput() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", 2.0); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + @Test + public void semanticVersionInvalidMajorShouldBeNumberOnly() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "a.1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + @Test + public void semanticVersionInvalidMinorShouldBeNumberOnly() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "1.b.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + @Test + public void semanticVersionInvalidPatchShouldBeNumberOnly() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "1.2.c"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test SemanticVersionEqualsMatch returns null if given invalid UserCondition Variable type + @Test + public void testSemanticVersionEqualsMatchInvalidUserConditionVariable() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", 2.0); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test SemanticVersionGTMatch returns null if given invalid value type + @Test + public void testSemanticVersionGTMatchInvalidInput() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", false); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test SemanticVersionGEMatch returns null if given invalid value type + @Test + public void testSemanticVersionGEMatchInvalidInput() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test SemanticVersionLTMatch returns null if given invalid value type + @Test + public void testSemanticVersionLTMatchInvalidInput() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test SemanticVersionLEMatch returns null if given invalid value type + @Test + public void testSemanticVersionLEMatchInvalidInput() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", 2); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.0.0"); + assertNull(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test if not same when targetVersion is only major.minor.patch and version is major.minor + @Test + public void testIsSemanticNotSameConditionValueMajorMinorPatch() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "1.2.0"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test if same when target is only major but user condition checks only major.minor,patch + @Test + public void testIsSemanticSameSingleDigit() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "3.0.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test if greater when User value patch is greater even when its beta + @Test + public void testIsSemanticGreaterWhenUserConditionComparesMajorMinorAndPatchVersion() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "3.1.1-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.0"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test if greater when preRelease is greater alphabetically + @Test + public void testIsSemanticGreaterWhenMajorMinorPatchReleaseVersionCharacter() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "3.1.1-beta.y.1+1.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; + // Test if greater when preRelease version number is greater + @Test + public void testIsSemanticGreaterWhenMajorMinorPatchPreReleaseVersionNum() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "3.1.1-beta.x.2+1.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "3.1.1-beta.x.1+1.1"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + // Test if equals semantic version even when only same preRelease is passed in user attribute and no build meta + @Test + public void testIsSemanticEqualWhenMajorMinorPatchPreReleaseVersionNum() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "3.1.1-beta.x.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.1.1-beta.x.1"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; + // Test if not same + @Test + public void testIsSemanticNotSameReturnsFalse() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.2"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.1"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } -/** - * Tests for the evaluation of different audience condition types (And, Or, Not, and UserAttribute) - */ -@SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT", - justification = "mockito verify calls do have a side-effect") -public class AudienceConditionEvaluationTest { + // Test when target is full semantic version major.minor.patch + @Test + public void testIsSemanticSameFull() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "3.0.1"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "3.0.1"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } - Map<String, String> testUserAttributes; + // Test compare less when user condition checks only major.minor + @Test + public void testIsSemanticLess() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.2"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } - @Before - public void initialize() { - testUserAttributes = new HashMap<String, String>(); - testUserAttributes.put("browser_type", "chrome"); - testUserAttributes.put("device_type", "Android"); + // When user condition checks major.minor but target is major.minor.patch then its equals + @Test + public void testIsSemanticLessFalse() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.0"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } - /** - * Verify that UserAttribute.evaluate returns true on exact-matching visitor attribute data. - */ + // Test compare less when target is full major.minor.patch @Test - public void userAttributeEvaluateTrue() throws Exception { - UserAttribute testInstance = new UserAttribute("browser_type", "custom_dimension", "chrome"); - assertTrue(testInstance.evaluate(testUserAttributes)); + public void testIsSemanticFullLess() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_lt", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } - /** - * Verify that UserAttribute.evaluate returns false on non-exact-matching visitor attribute data. - */ + // Test compare greater when user condition checks only major.minor @Test - public void userAttributeEvaluateFalse() throws Exception { - UserAttribute testInstance = new UserAttribute("browser_type", "custom_dimension", "firefox"); - assertFalse(testInstance.evaluate(testUserAttributes)); + public void testIsSemanticMore() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.3.6"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.2"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare greater when both are major.minor.patch-beta but target is greater than user condition + @Test + public void testIsSemanticMoreWhenBeta() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.3.6-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.3.5-beta"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare greater when target is major.minor.patch + @Test + public void testIsSemanticFullMore() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.7"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.6"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare greater when target is major.minor.patch is smaller then it returns false + @Test + public void testSemanticVersionGTFullMoreReturnsFalse() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.10"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare equal when both are exactly same - major.minor.patch-beta + @Test + public void testIsSemanticFullEqual() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.9-beta"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_eq", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare equal when both major.minor.patch is same, but due to beta user condition is smaller + @Test + public void testIsSemanticLessWhenBeta() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare greater when target is major.minor.patch-beta and user condition only compares major.minor.patch + @Test + public void testIsSemanticGreaterBeta() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_gt", "2.1.9-beta"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare equal when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenEqualsReturnsTrue() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenLessReturnsTrue() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.132.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.233.91"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticLessEqualsWhenGreaterReturnsFalse() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.233.91"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_le", "2.132.009"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare equal when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenEqualsReturnsTrue() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.1.9"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.1.9"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenLessReturnsTrue() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.233.91"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.132.9"); + assertTrue(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); + } + + // Test compare less when target is major.minor.patch + @Test + public void testIsSemanticGreaterEqualsWhenLessReturnsFalse() { + Map testAttributes = new HashMap<String, String>(); + testAttributes.put("version", "2.132.009"); + UserAttribute testInstanceString = new UserAttribute("version", "custom_attribute", "semver_ge", "2.233.91"); + assertFalse(testInstanceString.evaluate(null, OTUtils.user(testAttributes))); } /** - * Verify that UserAttribute.evaluate returns false on unknown visitor attributes. + * Verify that NotCondition.evaluate returns null when its condition is null. */ @Test - public void userAttributeUnknownAttribute() throws Exception { - UserAttribute testInstance = new UserAttribute("unknown_dim", "custom_dimension", "unknown"); - assertFalse(testInstance.evaluate(testUserAttributes)); + public void notConditionEvaluateNull() { + NotCondition notCondition = new NotCondition(new NullCondition()); + assertNull(notCondition.evaluate(null, OTUtils.user(testUserAttributes))); } /** * Verify that NotCondition.evaluate returns true when its condition operand evaluates to false. */ @Test - public void notConditionEvaluateTrue() throws Exception { + public void notConditionEvaluateTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(testUserAttributes)).thenReturn(false); + when(userAttribute.evaluate(null, user)).thenReturn(false); NotCondition notCondition = new NotCondition(userAttribute); - assertTrue(notCondition.evaluate(testUserAttributes)); - verify(userAttribute, times(1)).evaluate(testUserAttributes); + assertTrue(notCondition.evaluate(null, user)); + verify(userAttribute, times(1)).evaluate(null, user); } /** * Verify that NotCondition.evaluate returns false when its condition operand evaluates to true. */ @Test - public void notConditionEvaluateFalse() throws Exception { + public void notConditionEvaluateFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute = mock(UserAttribute.class); - when(userAttribute.evaluate(testUserAttributes)).thenReturn(true); + when(userAttribute.evaluate(null, user)).thenReturn(true); NotCondition notCondition = new NotCondition(userAttribute); - assertFalse(notCondition.evaluate(testUserAttributes)); - verify(userAttribute, times(1)).evaluate(testUserAttributes); + assertFalse(notCondition.evaluate(null, user)); + verify(userAttribute, times(1)).evaluate(null, user); + } + + /** + * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. + */ + @Test + public void orConditionEvaluateTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); + UserAttribute userAttribute1 = mock(UserAttribute.class); + when(userAttribute1.evaluate(null, user)).thenReturn(true); + + UserAttribute userAttribute2 = mock(UserAttribute.class); + when(userAttribute2.evaluate(null, user)).thenReturn(false); + + List<Condition> conditions = new ArrayList<Condition>(); + conditions.add(userAttribute1); + conditions.add(userAttribute2); + + OrCondition orCondition = new OrCondition(conditions); + assertTrue(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); + // shouldn't be called due to short-circuiting in 'Or' evaluation + verify(userAttribute2, times(0)).evaluate(null, user); + } + + /** + * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. + */ + @Test + public void orConditionEvaluateTrueWithNullAndTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); + UserAttribute userAttribute1 = mock(UserAttribute.class); + when(userAttribute1.evaluate(null, user)).thenReturn(null); + + UserAttribute userAttribute2 = mock(UserAttribute.class); + when(userAttribute2.evaluate(null, user)).thenReturn(true); + + List<Condition> conditions = new ArrayList<Condition>(); + conditions.add(userAttribute1); + conditions.add(userAttribute2); + + OrCondition orCondition = new OrCondition(conditions); + assertTrue(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); + // shouldn't be called due to short-circuiting in 'Or' evaluation + verify(userAttribute2, times(1)).evaluate(null, user); + } + + /** + * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. + */ + @Test + public void orConditionEvaluateNullWithNullAndFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); + UserAttribute userAttribute1 = mock(UserAttribute.class); + when(userAttribute1.evaluate(null, user)).thenReturn(null); + + UserAttribute userAttribute2 = mock(UserAttribute.class); + when(userAttribute2.evaluate(null, user)).thenReturn(false); + + List<Condition> conditions = new ArrayList<Condition>(); + conditions.add(userAttribute1); + conditions.add(userAttribute2); + + OrCondition orCondition = new OrCondition(conditions); + assertNull(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); + // shouldn't be called due to short-circuiting in 'Or' evaluation + verify(userAttribute2, times(1)).evaluate(null, user); } /** * Verify that OrCondition.evaluate returns true when at least one of its operand conditions evaluate to true. */ @Test - public void orConditionEvaluateTrue() throws Exception { + public void orConditionEvaluateFalseWithFalseAndFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(testUserAttributes)).thenReturn(true); + when(userAttribute1.evaluate(null, user)).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(null, user)).thenReturn(false); List<Condition> conditions = new ArrayList<Condition>(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertTrue(orCondition.evaluate(testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(testUserAttributes); + assertFalse(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'Or' evaluation - verify(userAttribute2, times(0)).evaluate(testUserAttributes); + verify(userAttribute2, times(1)).evaluate(null, user); } /** * Verify that OrCondition.evaluate returns false when all of its operand conditions evaluate to false. */ @Test - public void orConditionEvaluateFalse() throws Exception { + public void orConditionEvaluateFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); UserAttribute userAttribute1 = mock(UserAttribute.class); - when(userAttribute1.evaluate(testUserAttributes)).thenReturn(false); + when(userAttribute1.evaluate(null, user)).thenReturn(false); UserAttribute userAttribute2 = mock(UserAttribute.class); - when(userAttribute2.evaluate(testUserAttributes)).thenReturn(false); + when(userAttribute2.evaluate(null, user)).thenReturn(false); List<Condition> conditions = new ArrayList<Condition>(); conditions.add(userAttribute1); conditions.add(userAttribute2); OrCondition orCondition = new OrCondition(conditions); - assertFalse(orCondition.evaluate(testUserAttributes)); - verify(userAttribute1, times(1)).evaluate(testUserAttributes); - verify(userAttribute2, times(1)).evaluate(testUserAttributes); + assertFalse(orCondition.evaluate(null, user)); + verify(userAttribute1, times(1)).evaluate(null, user); + verify(userAttribute2, times(1)).evaluate(null, user); + } + + /** + * Verify that AndCondition.evaluate returns true when all of its operand conditions evaluate to true. + */ + @Test + public void andConditionEvaluateTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); + OrCondition orCondition1 = mock(OrCondition.class); + when(orCondition1.evaluate(null, user)).thenReturn(true); + + OrCondition orCondition2 = mock(OrCondition.class); + when(orCondition2.evaluate(null, user)).thenReturn(true); + + List<Condition> conditions = new ArrayList<Condition>(); + conditions.add(orCondition1); + conditions.add(orCondition2); + + AndCondition andCondition = new AndCondition(conditions); + assertTrue(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); + verify(orCondition2, times(1)).evaluate(null, user); + } + + /** + * Verify that AndCondition.evaluate returns true when all of its operand conditions evaluate to true. + */ + @Test + public void andConditionEvaluateFalseWithNullAndFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); + OrCondition orCondition1 = mock(OrCondition.class); + when(orCondition1.evaluate(null, user)).thenReturn(null); + + OrCondition orCondition2 = mock(OrCondition.class); + when(orCondition2.evaluate(null, user)).thenReturn(false); + + List<Condition> conditions = new ArrayList<Condition>(); + conditions.add(orCondition1); + conditions.add(orCondition2); + + AndCondition andCondition = new AndCondition(conditions); + assertFalse(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); + verify(orCondition2, times(1)).evaluate(null, user); } /** * Verify that AndCondition.evaluate returns true when all of its operand conditions evaluate to true. */ @Test - public void andConditionEvaluateTrue() throws Exception { + public void andConditionEvaluateNullWithNullAndTrue() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(testUserAttributes)).thenReturn(true); + when(orCondition1.evaluate(null, user)).thenReturn(null); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(null, user)).thenReturn(true); List<Condition> conditions = new ArrayList<Condition>(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertTrue(andCondition.evaluate(testUserAttributes)); - verify(orCondition1, times(1)).evaluate(testUserAttributes); - verify(orCondition2, times(1)).evaluate(testUserAttributes); + assertNull(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); + verify(orCondition2, times(1)).evaluate(null, user); } /** * Verify that AndCondition.evaluate returns false when any one of its operand conditions evaluate to false. */ @Test - public void andConditionEvaluateFalse() throws Exception { + public void andConditionEvaluateFalse() { + OptimizelyUserContext user = OTUtils.user(testUserAttributes); OrCondition orCondition1 = mock(OrCondition.class); - when(orCondition1.evaluate(testUserAttributes)).thenReturn(false); + when(orCondition1.evaluate(null, user)).thenReturn(false); OrCondition orCondition2 = mock(OrCondition.class); - when(orCondition2.evaluate(testUserAttributes)).thenReturn(true); + when(orCondition2.evaluate(null, user)).thenReturn(true); + // and[false, true] List<Condition> conditions = new ArrayList<Condition>(); conditions.add(orCondition1); conditions.add(orCondition2); AndCondition andCondition = new AndCondition(conditions); - assertFalse(andCondition.evaluate(testUserAttributes)); - verify(orCondition1, times(1)).evaluate(testUserAttributes); + assertFalse(andCondition.evaluate(null, user)); + verify(orCondition1, times(1)).evaluate(null, user); // shouldn't be called due to short-circuiting in 'And' evaluation - verify(orCondition2, times(0)).evaluate(testUserAttributes); + verify(orCondition2, times(0)).evaluate(null, user); + + OrCondition orCondition3 = mock(OrCondition.class); + when(orCondition3.evaluate(null, user)).thenReturn(null); + + // and[null, false] + List<Condition> conditions2 = new ArrayList<Condition>(); + conditions2.add(orCondition3); + conditions2.add(orCondition1); + + AndCondition andCondition2 = new AndCondition(conditions2); + assertFalse(andCondition2.evaluate(null, user)); + + // and[true, false, null] + List<Condition> conditions3 = new ArrayList<Condition>(); + conditions3.add(orCondition2); + conditions3.add(orCondition3); + conditions3.add(orCondition1); + + AndCondition andCondition3 = new AndCondition(conditions3); + assertFalse(andCondition3.evaluate(null, user)); + } + + /** + * Verify that with odp segment evaluator single ODP audience evaluates true + */ + @Test + public void singleODPAudienceEvaluateTrueIfSegmentExist() throws Exception { + + OptimizelyUserContext mockedUser = OTUtils.user(); + + UserAttribute testInstanceSingleAudience = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + List<Condition> userConditions = new ArrayList<>(); + userConditions.add(testInstanceSingleAudience); + AndCondition andCondition = new AndCondition(userConditions); + + // Should evaluate true if qualified segment exist + Whitebox.setInternalState(mockedUser, "qualifiedSegments", Collections.singletonList("odp-segment-1")); + + assertTrue(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator single ODP audience evaluates false + */ + @Test + public void singleODPAudienceEvaluateFalseIfSegmentNotExist() throws Exception { + + OptimizelyUserContext mockedUser = OTUtils.user(); + + UserAttribute testInstanceSingleAudience = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + List<Condition> userConditions = new ArrayList<>(); + userConditions.add(testInstanceSingleAudience); + AndCondition andCondition = new AndCondition(userConditions); + + // Should evaluate false if qualified segment does not exist + Whitebox.setInternalState(mockedUser, "qualifiedSegments", Collections.singletonList("odp-segment-2")); + + assertFalse(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator single ODP audience evaluates false when segments not provided + */ + @Test + public void singleODPAudienceEvaluateFalseIfSegmentNotProvided() throws Exception { + OptimizelyUserContext mockedUser = OTUtils.user(); + + UserAttribute testInstanceSingleAudience = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + List<Condition> userConditions = new ArrayList<>(); + userConditions.add(testInstanceSingleAudience); + AndCondition andCondition = new AndCondition(userConditions); + + // Should evaluate false if qualified segment does not exist + Whitebox.setInternalState(mockedUser, "qualifiedSegments", Collections.emptyList()); + + assertFalse(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience true when segment provided exist + */ + @Test + public void singleODPAudienceEvaluateMultipleOdpConditions() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + Condition andCondition = createMultipleConditionAudienceAndOrODP(); + // Should evaluate correctly based on the given segments + List<String> qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(andCondition.evaluate(null, mockedUser)); + + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(andCondition.evaluate(null, mockedUser)); + + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience true when segment provided exist + */ + @Test + public void singleODPAudienceEvaluateMultipleOdpConditionsEvaluateFalse() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + Condition andCondition = createMultipleConditionAudienceAndOrODP(); + // Should evaluate correctly based on the given segments + List<String> qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(andCondition.evaluate(null, mockedUser)); + + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(andCondition.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience with multiple conditions true or false when segment conditions meet + */ + @Test + public void multipleAudienceEvaluateMultipleOdpConditionsEvaluate() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + // ["and", "1", "2"] + List<Condition> audience1And2 = new ArrayList<>(); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1")); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2")); + AndCondition audienceCondition1 = new AndCondition(audience1And2); + + // ["and", "3", "4"] + List<Condition> audience3And4 = new ArrayList<>(); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3")); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4")); + AndCondition audienceCondition2 = new AndCondition(audience3And4); + + // ["or", "5", "6"] + List<Condition> audience5And6 = new ArrayList<>(); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-5")); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-6")); + OrCondition audienceCondition3 = new OrCondition(audience5And6); + + + //Scenario 1- ['or', '1', '2', '3'] + List<Condition> conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(audienceCondition3); + + OrCondition implicitOr = new OrCondition(conditions); + // Should evaluate correctly based on the given segments + List<String> qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitOr.evaluate(null, mockedUser)); + + + //Scenario 2- ['and', '1', '2', '3'] + AndCondition implicitAnd = new AndCondition(conditions); + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + qualifiedSegments.add("odp-segment-6"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitAnd.evaluate(null, mockedUser)); + + + ////Scenario 3- ['and', '1', '2',['not', '3']] + conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(new NotCondition(audienceCondition3)); + implicitAnd = new AndCondition(conditions); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + qualifiedSegments.add("odp-segment-5"); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + } + + /** + * Verify that with odp segment evaluator evaluates multiple ODP audience with multiple type of evaluators + */ + @Test + public void multipleAudienceEvaluateMultipleOdpConditionsEvaluateWithMultipleTypeOfEvaluator() { + OptimizelyUserContext mockedUser = OTUtils.user(); + + // ["and", "1", "2"] + List<Condition> audience1And2 = new ArrayList<>(); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1")); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2")); + AndCondition audienceCondition1 = new AndCondition(audience1And2); + + // ["and", "3", "4"] + List<Condition> audience3And4 = new ArrayList<>(); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3")); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4")); + AndCondition audienceCondition2 = new AndCondition(audience3And4); + + // ["or", "chrome", "safari"] + List<Condition> chromeUserAudience = new ArrayList<>(); + chromeUserAudience.add(new UserAttribute("browser_type", "custom_attribute", "exact", "chrome")); + chromeUserAudience.add(new UserAttribute("browser_type", "custom_attribute", "exact", "safari")); + OrCondition audienceCondition3 = new OrCondition(chromeUserAudience); + + + //Scenario 1- ['or', '1', '2', '3'] + List<Condition> conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(audienceCondition3); + + OrCondition implicitOr = new OrCondition(conditions); + // Should evaluate correctly based on the given segments + List<String> qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitOr.evaluate(null, mockedUser)); + + + //Scenario 2- ['and', '1', '2', '3'] + AndCondition implicitAnd = new AndCondition(conditions); + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + + mockedUser = OTUtils.user(Collections.singletonMap("browser_type", "chrome")); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + mockedUser = OTUtils.user(Collections.singletonMap("browser_type", "chrome")); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertTrue(implicitAnd.evaluate(null, mockedUser)); + + // Should evaluate correctly based on the given segments + qualifiedSegments = new ArrayList<>(); + qualifiedSegments.add("odp-segment-1"); + qualifiedSegments.add("odp-segment-2"); + qualifiedSegments.add("odp-segment-3"); + qualifiedSegments.add("odp-segment-4"); + + mockedUser = OTUtils.user(Collections.singletonMap("browser_type", "not_chrome")); + Whitebox.setInternalState(mockedUser, "qualifiedSegments", qualifiedSegments); + assertFalse(implicitAnd.evaluate(null, mockedUser)); + } + + public Condition createMultipleConditionAudienceAndOrODP() { + UserAttribute testInstanceSingleAudience1 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1"); + UserAttribute testInstanceSingleAudience2 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2"); + UserAttribute testInstanceSingleAudience3 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3"); + UserAttribute testInstanceSingleAudience4 = new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4"); + + List<Condition> userConditionsOR = new ArrayList<>(); + userConditionsOR.add(testInstanceSingleAudience3); + userConditionsOR.add(testInstanceSingleAudience4); + OrCondition orCondition = new OrCondition(userConditionsOR); + List<Condition> userConditionsAnd = new ArrayList<>(); + userConditionsAnd.add(testInstanceSingleAudience1); + userConditionsAnd.add(testInstanceSingleAudience2); + userConditionsAnd.add(orCondition); + AndCondition andCondition = new AndCondition(userConditionsAnd); + + return andCondition; } /** - * Verify that {@link UserAttribute#evaluate(Map)} + * Verify that AndCondition.evaluate returns null when any one of its operand conditions evaluate to false. + */ + // @Test + // public void andConditionEvaluateNull() { + + // } + + /** + * Verify that {@link Condition#evaluate(com.optimizely.ab.config.ProjectConfig, com.optimizely.ab.OptimizelyUserContext)} * called when its attribute value is null * returns True when the user's attribute value is also null - * True when the attribute is not in the map - * False when empty string is used. - * @throws Exception + * True when the attribute is not in the map + * False when empty string is used. + * + * @ */ @Test - public void nullValueEvaluate() throws Exception { + public void nullValueEvaluate() { String attributeName = "attribute_name"; String attributeType = "attribute_type"; String attributeValue = null; UserAttribute nullValueAttribute = new UserAttribute( - attributeName, - attributeType, - attributeValue + attributeName, + attributeType, + "exact", + attributeValue ); - assertTrue(nullValueAttribute.evaluate(Collections.<String, String>emptyMap())); - assertTrue(nullValueAttribute.evaluate(Collections.singletonMap(attributeName, attributeValue))); - assertFalse(nullValueAttribute.evaluate((Collections.singletonMap(attributeName, "")))); + assertNull(nullValueAttribute.evaluate(null, OTUtils.user(Collections.<String, String>emptyMap()))); + assertNull(nullValueAttribute.evaluate(null, OTUtils.user(Collections.singletonMap(attributeName, attributeValue)))); + assertNull(nullValueAttribute.evaluate(null, OTUtils.user((Collections.singletonMap(attributeName, ""))))); + } + + @Test + public void getAllSegmentsFromAudience() { + Condition condition = createMultipleConditionAudienceAndOrODP(); + Audience audience = new Audience("1", "testAudience", condition); + assertEquals(new HashSet<>(Arrays.asList("odp-segment-1", "odp-segment-2", "odp-segment-3", "odp-segment-4")), audience.getSegments()); + + // ["and", "1", "2"] + List<Condition> audience1And2 = new ArrayList<>(); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-1")); + audience1And2.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-2")); + AndCondition audienceCondition1 = new AndCondition(audience1And2); + + // ["and", "3", "4"] + List<Condition> audience3And4 = new ArrayList<>(); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-3")); + audience3And4.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-4")); + AndCondition audienceCondition2 = new AndCondition(audience3And4); + + // ["or", "5", "6"] + List<Condition> audience5And6 = new ArrayList<>(); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-5")); + audience5And6.add(new UserAttribute("odp.audiences", "third_party_dimension", "qualified", "odp-segment-6")); + OrCondition audienceCondition3 = new OrCondition(audience5And6); + + + //['or', '1', '2', '3'] + List<Condition> conditions = new ArrayList<>(); + conditions.add(audienceCondition1); + conditions.add(audienceCondition2); + conditions.add(audienceCondition3); + + OrCondition implicitOr = new OrCondition(conditions); + audience = new Audience("1", "testAudience", implicitOr); + assertEquals(new HashSet<>(Arrays.asList("odp-segment-1", "odp-segment-2", "odp-segment-3", "odp-segment-4", "odp-segment-5", "odp-segment-6")), audience.getSegments()); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java new file mode 100644 index 000000000..5f2d1d62e --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/ExactMatchTest.java @@ -0,0 +1,84 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class ExactMatchTest { + + private ExactMatch match; + private static final List<Object> INVALIDS = Collections.unmodifiableList(Arrays.asList(new byte[0], new Object(), null)); + + @Before + public void setUp() { + match = new ExactMatch(); + } + + @Test + public void testInvalidConditionValues() { + for (Object invalid : INVALIDS) { + try { + match.eval(invalid, "valid"); + fail("should have raised exception"); + } catch (UnexpectedValueTypeException e) { + //pass + } + } + } + + @Test + public void testMismatchClasses() throws Exception { + assertNull(match.eval(false, "false")); + assertNull(match.eval("false", null)); + } + + @Test + public void testStringMatch() throws Exception { + assertEquals(Boolean.TRUE, match.eval("", "")); + assertEquals(Boolean.TRUE, match.eval("true", "true")); + assertEquals(Boolean.FALSE, match.eval("true", "false")); + } + + @Test + public void testBooleanMatch() throws Exception { + assertEquals(Boolean.TRUE, match.eval(true, true)); + assertEquals(Boolean.TRUE, match.eval(false, false)); + assertEquals(Boolean.FALSE, match.eval(true, false)); + } + + @Test + public void testNumberMatch() throws UnexpectedValueTypeException { + assertEquals(Boolean.TRUE, match.eval(1, 1)); + assertEquals(Boolean.TRUE, match.eval(1L, 1L)); + assertEquals(Boolean.TRUE, match.eval(1.0, 1.0)); + assertEquals(Boolean.TRUE, match.eval(1, 1.0)); + assertEquals(Boolean.TRUE, match.eval(1L, 1.0)); + + assertEquals(Boolean.FALSE, match.eval(1, 2)); + assertEquals(Boolean.FALSE, match.eval(1L, 2L)); + assertEquals(Boolean.FALSE, match.eval(1.0, 2.0)); + assertEquals(Boolean.FALSE, match.eval(1, 1.1)); + assertEquals(Boolean.FALSE, match.eval(1L, 1.1)); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java new file mode 100644 index 000000000..cb6f2059e --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/MatchRegistryTest.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Test; + +import static com.optimizely.ab.config.audience.match.MatchRegistry.*; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.*; + +public class MatchRegistryTest { + + @Test + public void testDefaultMatchers() throws UnknownMatchTypeException { + assertThat(MatchRegistry.getMatch(EXACT), instanceOf(ExactMatch.class)); + assertThat(MatchRegistry.getMatch(EXISTS), instanceOf(ExistsMatch.class)); + assertThat(MatchRegistry.getMatch(GREATER_THAN), instanceOf(GTMatch.class)); + assertThat(MatchRegistry.getMatch(LESS_THAN), instanceOf(LTMatch.class)); + assertThat(MatchRegistry.getMatch(GREATER_THAN_EQ), instanceOf(GEMatch.class)); + assertThat(MatchRegistry.getMatch(LESS_THAN_EQ), instanceOf(LEMatch.class)); + assertThat(MatchRegistry.getMatch(LEGACY), instanceOf(DefaultMatchForLegacyAttributes.class)); + assertThat(MatchRegistry.getMatch(SEMVER_EQ), instanceOf(SemanticVersionEqualsMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_GE), instanceOf(SemanticVersionGEMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_GT), instanceOf(SemanticVersionGTMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_LE), instanceOf(SemanticVersionLEMatch.class)); + assertThat(MatchRegistry.getMatch(SEMVER_LT), instanceOf(SemanticVersionLTMatch.class)); + assertThat(MatchRegistry.getMatch(SUBSTRING), instanceOf(SubstringMatch.class)); + } + + @Test(expected = UnknownMatchTypeException.class) + public void testUnknownMatcher() throws UnknownMatchTypeException { + MatchRegistry.getMatch("UNKNOWN"); + } + + @Test + public void testRegister() throws UnknownMatchTypeException { + class TestMatcher implements Match { + @Override + public Boolean eval(Object conditionValue, Object attributeValue) { + return null; + } + } + + MatchRegistry.register("test-matcher", new TestMatcher()); + assertThat(MatchRegistry.getMatch("test-matcher"), instanceOf(TestMatcher.class)); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java new file mode 100644 index 000000000..19d67dd33 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/NumberComparatorTest.java @@ -0,0 +1,75 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class NumberComparatorTest { + + private static final List<Object> INVALIDS = Collections.unmodifiableList(Arrays.asList(null, "test", "", true)); + + @Test + public void testLessThan() throws UnknownValueTypeException { + assertTrue(NumberComparator.compare(0,1) < 0); + assertTrue(NumberComparator.compare(0,1.0) < 0); + assertTrue(NumberComparator.compare(0,1L) < 0); + } + + @Test + public void testGreaterThan() throws UnknownValueTypeException { + assertTrue(NumberComparator.compare(1,0) > 0); + assertTrue(NumberComparator.compare(1.0,0) > 0); + assertTrue(NumberComparator.compare(1L,0) > 0); + } + + @Test + public void testEquals() throws UnknownValueTypeException { + assertEquals(0, NumberComparator.compare(1, 1)); + assertEquals(0, NumberComparator.compare(1, 1.0)); + assertEquals(0, NumberComparator.compare(1L, 1)); + } + + @Test + public void testInvalidRight() { + for (Object invalid: INVALIDS) { + try { + NumberComparator.compare(0, invalid); + fail("should have failed for invalid object"); + } catch (UnknownValueTypeException e) { + // pass + } + } + } + + @Test + public void testInvalidLeft() { + for (Object invalid: INVALIDS) { + try { + NumberComparator.compare(invalid, 0); + fail("should have failed for invalid object"); + } catch (UnknownValueTypeException e) { + // pass + } + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java new file mode 100644 index 000000000..29383a7d7 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SemanticVersionTest.java @@ -0,0 +1,179 @@ +/** + * + * Copyright 2020, 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.junit.Assert.*; + +public class SemanticVersionTest { + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void semanticVersionInvalidOnlyDash() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("-"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidOnlyDot() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("."); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidDoubleDot() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(".."); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMultipleBuild() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("3.1.2-2+2.3+1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPlus() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("+"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPlusTest() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("+test"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidOnlySpace() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(" "); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidSpaces() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("2 .3. 0"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidDotButNoMinorVersion() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("2."); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidDotButNoMajorVersion() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(".2.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidComma() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion(","); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMissingMajorMinorPatch() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("+build-prerelease"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMajorShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("a.2.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidMinorShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.b.1"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidPatchShouldBeNumberOnly() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.2.c"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void semanticVersionInvalidShouldBeOfSizeLessThan3() throws Exception { + thrown.expect(Exception.class); + SemanticVersion semanticVersion = new SemanticVersion("1.2.2.3"); + semanticVersion.splitSemanticVersion(); + } + + @Test + public void testEquals() throws Exception { + assertEquals(0, SemanticVersion.compare("3.7.1", "3.7.1")); + assertEquals(0, SemanticVersion.compare("3.7.1", "3.7")); + assertEquals(0, SemanticVersion.compare("2.1.3+build", "2.1.3")); + assertEquals(0, SemanticVersion.compare("3.7.1-beta.1+2.3", "3.7.1-beta.1+2.3")); + } + + @Test + public void testLessThan() throws Exception { + assertTrue(SemanticVersion.compare("3.7.0", "3.7.1") < 0); + assertTrue(SemanticVersion.compare("3.7", "3.7.1") < 0); + assertTrue(SemanticVersion.compare("2.1.3-beta+1", "2.1.3-beta+1.2.3") < 0); + assertTrue(SemanticVersion.compare("2.1.3-beta-1", "2.1.3-beta-1.2.3") < 0); + } + + @Test + public void testGreaterThan() throws Exception { + assertTrue(SemanticVersion.compare("3.7.2", "3.7.1") > 0); + assertTrue(SemanticVersion.compare("3.7.1", "3.7.1-beta") > 0); + assertTrue(SemanticVersion.compare("2.1.3-beta+1.2.3", "2.1.3-beta+1") > 0); + assertTrue(SemanticVersion.compare("3.7.1-beta", "3.7.1-alpha") > 0); + assertTrue(SemanticVersion.compare("3.7.1+build", "3.7.1-prerelease") > 0); + assertTrue(SemanticVersion.compare("3.7.1-prerelease-prerelease+rc", "3.7.1-prerelease+build") > 0); + assertTrue(SemanticVersion.compare("3.7.1-beta.2", "3.7.1-beta.1") > 0); + } + + @Test + public void testSilentForNullOrMissingAttributesValues() throws Exception { + // SemanticVersionMatcher will throw UnexpectedValueType exception for invalid condition or attribute values (this exception is handled to log WARNING messages). + // But, for missing (or null) attribute value, it should not throw the exception. + assertNull(new SemanticVersionEqualsMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionGEMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionGTMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionLEMatch().eval("1.2.3", null)); + assertNull(new SemanticVersionLTMatch().eval("1.2.3", null)); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java new file mode 100644 index 000000000..0d417eefe --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/audience/match/SubstringMatchTest.java @@ -0,0 +1,65 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.audience.match; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class SubstringMatchTest { + + private SubstringMatch match; + private static final List<Object> INVALIDS = Collections.unmodifiableList(Arrays.asList(new byte[0], new Object(), null)); + + @Before + public void setUp() { + match = new SubstringMatch(); + } + + @Test + public void testInvalidConditionValues() { + for (Object invalid : INVALIDS) { + try { + match.eval(invalid, "valid"); + fail("should have raised exception"); + } catch (UnexpectedValueTypeException e) { + //pass + } + } + } + + @Test + public void testInvalidAttributesValues() throws UnexpectedValueTypeException { + for (Object invalid : INVALIDS) { + assertNull(match.eval("valid", invalid)); + } + } + + @Test + public void testStringMatch() throws Exception { + assertEquals(Boolean.TRUE, match.eval("", "any")); + assertEquals(Boolean.TRUE, match.eval("same", "same")); + assertEquals(Boolean.TRUE, match.eval("a", "ab")); + assertEquals(Boolean.FALSE, match.eval("ab", "a")); + assertEquals(Boolean.FALSE, match.eval("a", "b")); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java index 5911932f8..c43f29599 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/DefaultConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,15 +16,90 @@ */ package com.optimizely.ab.config.parser; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.internal.PropertyUtils; +import org.hamcrest.CoreMatchers; +import org.junit.Assert; +import org.junit.Ignore; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.*; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static junit.framework.TestCase.fail; /** * Tests for {@link DefaultConfigParser}. */ +@RunWith(Parameterized.class) public class DefaultConfigParserTest { + @Parameterized.Parameters(name = "{index}") + public static Collection<Object[]> data() throws IOException { + return Arrays.asList(new Object[][]{ + { + validConfigJsonV2(), + validProjectConfigV2() + }, + { + validConfigJsonV3(), + validProjectConfigV3() + }, + { + validConfigJsonV4(), + validProjectConfigV4() + } + }); + } + + @Parameterized.Parameter(0) + public String validDatafile; + + @Parameterized.Parameter(1) + public ProjectConfig validProjectConfig; + + /** + * This method is to test DefaultConfigParser when different default_parser gets set. + * For example: when optimizely_default_parser environment variable will be set to "GSON_CONFIG_PARSER" than + * "DefaultConfigParser.getInstance()" returns "GsonConfigParser" and parse ProjectConfig using it. Also + * this test will assertThat "configParser" (Provided in env variable) is instance of "GsonConfigParser.class" + * + * @throws Exception + */ @Test - public void createThrowException() throws Exception { - // FIXME - mdodsworth: hmmm, this isn't going to be the easiest thing to test + public void testPropertyDefaultParser() throws Exception { + String defaultParser = PropertyUtils.get("default_parser"); + ConfigParser configParser = DefaultConfigParser.getInstance(); + ProjectConfig actual = configParser.parseProjectConfig(validDatafile); + ProjectConfig expected = validProjectConfig; + verifyProjectConfig(actual, expected); + Class expectedParser = GsonConfigParser.class; + + if(defaultParser != null) { + DefaultConfigParser.ConfigParserSupplier defaultParserSupplier = DefaultConfigParser.ConfigParserSupplier.valueOf(defaultParser); + switch (defaultParserSupplier) { + case GSON_CONFIG_PARSER: + expectedParser = GsonConfigParser.class; + break; + case JACKSON_CONFIG_PARSER: + expectedParser = JacksonConfigParser.class; + break; + case JSON_CONFIG_PARSER: + expectedParser = JsonConfigParser.class; + break; + case JSON_SIMPLE_CONFIG_PARSER: + expectedParser = JsonSimpleConfigParser.class; + break; + default: + fail("Not a valid config parser"); + } + } + + Assert.assertThat(configParser, CoreMatchers.instanceOf(expectedParser)); } } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java index 3c5cc947e..ea0d9cac8 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/GsonConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,19 +16,40 @@ */ package com.optimizely.ab.config.parser; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.internal.InvalidAudienceCondition; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.*; /** * Tests for {@link GsonConfigParser}. @@ -65,6 +86,188 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseNullFeatureEnabledProjectConfigV4() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + + assertNotNull(actual); + + assertNotNull(actual.getExperiments()); + + assertNotNull(actual.getFeatureFlags()); + + } + + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + + @Test + public void parseAudience() throws Exception { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("id", "123"); + jsonObject.addProperty("name", "blah"); + jsonObject.addProperty("conditions", + "[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"exact\", \"value\":100.0}]]]"); + + AudienceGsonDeserializer deserializer = new AudienceGsonDeserializer(); + Type audienceType = new TypeToken<List<Audience>>() { + }.getType(); + + Audience audience = deserializer.deserialize(jsonObject, audienceType, null); + + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseAudienceLeaf() throws Exception { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("id", "123"); + jsonObject.addProperty("name", "blah"); + jsonObject.addProperty("conditions", + "{\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"exact\", \"value\":100.0}"); + + AudienceGsonDeserializer deserializer = new AudienceGsonDeserializer(); + Type audienceType = new TypeToken<List<Audience>>() { + }.getType(); + + Audience audience = deserializer.deserialize(jsonObject, audienceType, null); + + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseTypedAudienceLeaf() throws Exception { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("id", "123"); + jsonObject.addProperty("name", "blah"); + + JsonObject userAttribute = new JsonObject(); + userAttribute.addProperty("name", "doubleKey"); + userAttribute.addProperty("type", "custom_attribute"); + userAttribute.addProperty("match", "lt"); + userAttribute.addProperty("value", 100.0); + + jsonObject.add("conditions", userAttribute); + + AudienceGsonDeserializer deserializer = new AudienceGsonDeserializer(); + Type audienceType = new TypeToken<List<TypedAudience>>() { + }.getType(); + + Audience audience = deserializer.deserialize(jsonObject, audienceType, null); + + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseInvalidAudience() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("id", "123"); + jsonObject.addProperty("name", "blah"); + jsonObject.addProperty("conditions", + "[\"and\", [\"or\", [\"or\", \"123\"]]]"); + + AudienceGsonDeserializer deserializer = new AudienceGsonDeserializer(); + Type audienceType = new TypeToken<List<Audience>>() { + }.getType(); + + Audience audience = deserializer.deserialize(jsonObject, audienceType, null); + + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseAudienceConditions() throws Exception { + JsonObject jsonObject = new JsonObject(); + JsonArray conditions = new JsonArray(); + conditions.add("and"); + conditions.add("1"); + conditions.add("2"); + conditions.add("3"); + + jsonObject.add("audienceConditions", conditions); + Condition condition = GsonHelpers.parseAudienceConditions(jsonObject); + + assertNotNull(condition); + } + + @Test + public void parseAudienceCondition() throws Exception { + JsonObject jsonObject = new JsonObject(); + + Gson gson = new Gson(); + + + JsonElement leaf = gson.toJsonTree("1"); + + jsonObject.add("audienceConditions", leaf); + Condition condition = GsonHelpers.parseAudienceConditions(jsonObject); + + assertNotNull(condition); + } + + @Test + public void parseInvalidAudienceConditions() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + + JsonObject jsonObject = new JsonObject(); + JsonArray conditions = new JsonArray(); + conditions.add("and"); + conditions.add("1"); + conditions.add("2"); + JsonObject userAttribute = new JsonObject(); + userAttribute.addProperty("match", "exact"); + userAttribute.addProperty("type", "custom_attribute"); + userAttribute.addProperty("value", "string"); + userAttribute.addProperty("name", "StringCondition"); + conditions.add(userAttribute); + + jsonObject.add("audienceConditions", conditions); + GsonHelpers.parseAudienceConditions(jsonObject); + + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ @@ -102,11 +305,120 @@ public void emptyJsonExceptionWrapping() throws Exception { * Verify that null JSON results in a {@link ConfigParseException} being thrown. */ @Test - @SuppressFBWarnings(value="NP_NONNULL_PARAM_VIOLATION", justification="Testing nullness contract violation") + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") public void nullJsonExceptionWrapping() throws Exception { thrown.expect(ConfigParseException.class); GsonConfigParser parser = new GsonConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void integrationsArrayAbsent() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + GsonConfigParser parser = new GsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void testToJson() { + Map<String, Object> map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + GsonConfigParser parser = new GsonConfigParser(); + String json = parser.toJson(map); + assertEquals(json, expectedString); + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map<String, Object> expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + GsonConfigParser parser = new GsonConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // invalid JSON string + + String invalidJson = "'k1':'v1','k2':3.5"; + try { + map = parser.fromJson(invalidJson, Map.class); + fail("Expected failure for parsing: " + map.toString()); + } catch (JsonParseException e) { + assertTrue(true); + } + + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java index 82e9c8d15..733ae49a5 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JacksonConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,19 +16,33 @@ */ package com.optimizely.ab.config.parser; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.internal.InvalidAudienceCondition; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; +import java.util.HashMap; +import java.util.Map; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.*; /** * Tests for {@link JacksonConfigParser}. @@ -56,6 +70,7 @@ public void parseProjectConfigV3() throws Exception { verifyProjectConfig(actual, expected); } + @SuppressFBWarnings("NP_NULL_PARAM_DEREF") @Test public void parseProjectConfigV4() throws Exception { JacksonConfigParser parser = new JacksonConfigParser(); @@ -65,6 +80,186 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseNullFeatureEnabledProjectConfigV4() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + + assertNotNull(actual); + + assertNotNull(actual.getExperiments()); + + assertNotNull(actual.getFeatureFlags()); + + } + + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + + @Test + public void parseAudience() throws Exception { + String audienceString = + "{" + + "\"id\": \"3468206645\"," + + "\"name\": \"DOUBLE\"," + + "\"conditions\": \"[\\\"and\\\", [\\\"or\\\", [\\\"or\\\", {\\\"name\\\": \\\"doubleKey\\\", \\\"type\\\": \\\"custom_attribute\\\", \\\"match\\\":\\\"lt\\\", \\\"value\\\":100.0}]]]\"" + + "},"; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Audience audience = objectMapper.readValue(audienceString, Audience.class); + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseAudienceLeaf() throws Exception { + String audienceString = + "{" + + "\"id\": \"3468206645\"," + + "\"name\": \"DOUBLE\"," + + "\"conditions\": \"{\\\"name\\\": \\\"doubleKey\\\", \\\"type\\\": \\\"custom_attribute\\\", \\\"match\\\":\\\"lt\\\", \\\"value\\\":100.0}\"" + + "},"; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Audience audience = objectMapper.readValue(audienceString, Audience.class); + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseTypedAudienceLeaf() throws Exception { + String audienceString = + "{" + + "\"id\": \"3468206645\"," + + "\"name\": \"DOUBLE\"," + + "\"conditions\": {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}" + + "},"; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(TypedAudience.class, new TypedAudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Audience audience = objectMapper.readValue(audienceString, TypedAudience.class); + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseInvalidAudience() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + String audienceString = + "{" + + "\"id\": \"123\"," + + "\"name\":\"blah\"," + + "\"conditions\":" + + "\"[\\\"and\\\", [\\\"or\\\", [\\\"or\\\", \\\"123\\\"]]]\"" + + "}"; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Audience audience = objectMapper.readValue(audienceString, Audience.class); + assertNotNull(audience); + assertNotNull(audience.getConditions()); + } + + @Test + public void parseAudienceCondition() throws Exception { + String conditionString = "\"123\""; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Condition condition = objectMapper.readValue(conditionString, Condition.class); + assertNotNull(condition); + } + + @Test + public void parseAudienceConditions() throws Exception { + String conditionString = + "[\"and\", \"12\", \"123\"]"; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Condition condition = objectMapper.readValue(conditionString, Condition.class); + assertNotNull(condition); + } + + @Test + public void parseInvalidAudienceConditions() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + + String jsonString = "[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}]]]"; + + ObjectMapper objectMapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addDeserializer(Audience.class, new AudienceJacksonDeserializer(objectMapper)); + module.addDeserializer(Condition.class, new ConditionJacksonDeserializer(objectMapper)); + objectMapper.registerModule(module); + + Condition condition = objectMapper.readValue(jsonString, Condition.class); + assertNotNull(condition); + + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ @@ -102,11 +297,125 @@ public void emptyJsonExceptionWrapping() throws Exception { * Verify that null JSON results in a {@link ConfigParseException} being thrown. */ @Test - @SuppressFBWarnings(value="NP_NONNULL_PARAM_VIOLATION", justification="Testing nullness contract violation") + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") public void nullJsonExceptionWrapping() throws Exception { thrown.expect(ConfigParseException.class); JacksonConfigParser parser = new JacksonConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void integrationsArrayAbsent() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + JacksonConfigParser parser = new JacksonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void testToJson() { + Map<String, Object> map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + JacksonConfigParser parser = new JacksonConfigParser(); + String json = null; + try { + json = parser.toJson(map); + assertEquals(json, expectedString); + } catch (JsonParseException e) { + fail("Parse to serialize to a JSON string: " + e.getMessage()); + } + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map<String, Object> expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + JacksonConfigParser parser = new JacksonConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // invalid JSON string + + String invalidJson = "'k1':'v1','k2':3.5"; + try { + map = parser.fromJson(invalidJson, Map.class); + fail("Expected failure for parsing: " + map.toString()); + } catch (JsonParseException e) { + assertTrue(true); + } + + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java index 5acc758f9..844d7448b 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,35 @@ */ package com.optimizely.ab.config.parser; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; - +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; +import com.optimizely.ab.internal.InvalidAudienceCondition; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.*; /** * Tests for {@link JsonConfigParser}. @@ -66,6 +81,136 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseNullFeatureEnabledProjectConfigV4() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + + assertNotNull(actual); + + assertNotNull(actual.getExperiments()); + + assertNotNull(actual.getFeatureFlags()); + + } + + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + + @Test + public void parseAudience() throws Exception { + JSONObject jsonObject = new JSONObject(); + + jsonObject.append("id", "123"); + jsonObject.append("name", "blah"); + jsonObject.append("conditions", + "[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}]]]"); + + Condition<UserAttribute> condition = ConditionUtils.parseConditions(UserAttribute.class, new JSONArray("[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}]]]")); + + assertNotNull(condition); + } + + @Test + public void parseAudienceLeaf() throws Exception { + JSONObject jsonObject = new JSONObject(); + + jsonObject.append("id", "123"); + jsonObject.append("name", "blah"); + jsonObject.append("conditions", + "{\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}"); + + Condition<UserAttribute> condition = ConditionUtils.parseConditions(UserAttribute.class, new JSONObject("{\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}")); + + assertNotNull(condition); + } + + @Test + public void parseInvalidAudience() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + JSONObject jsonObject = new JSONObject(); + jsonObject.append("id", "123"); + jsonObject.append("name", "blah"); + jsonObject.append("conditions", + "[\"and\", [\"or\", [\"or\", \"123\"]]]"); + + ConditionUtils.parseConditions(UserAttribute.class, new JSONArray("[\"and\", [\"or\", [\"or\", \"123\"]]]")); + } + + @Test + public void parseAudienceCondition() throws Exception { + String conditions = "1"; + + Condition condition = ConditionUtils.parseConditions(AudienceIdCondition.class, conditions); + assertNotNull(condition); + } + + @Test + public void parseAudienceConditions() throws Exception { + JSONArray conditions = new JSONArray(); + conditions.put("and"); + conditions.put("1"); + conditions.put("2"); + conditions.put("3"); + + Condition condition = ConditionUtils.parseConditions(AudienceIdCondition.class, conditions); + assertNotNull(condition); + } + + @Test + public void parseInvalidAudienceConditions() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + + JSONArray conditions = new JSONArray(); + conditions.put("and"); + conditions.put("1"); + conditions.put("2"); + JSONObject userAttribute = new JSONObject(); + userAttribute.append("match", "exact"); + userAttribute.append("type", "custom_attribute"); + userAttribute.append("value", "string"); + userAttribute.append("name", "StringCondition"); + conditions.put(userAttribute); + + ConditionUtils.parseConditions(AudienceIdCondition.class, conditions); + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ @@ -103,11 +248,117 @@ public void emptyJsonExceptionWrapping() throws Exception { * Verify that null JSON results in a {@link ConfigParseException} being thrown. */ @Test - @SuppressFBWarnings(value="NP_NONNULL_PARAM_VIOLATION", justification="Testing nullness contract violation") + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") public void nullJsonExceptionWrapping() throws Exception { thrown.expect(ConfigParseException.class); JsonConfigParser parser = new JsonConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void integrationsArrayAbsent() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + JsonConfigParser parser = new JsonConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + @Test + public void testToJson() { + Map<String, Object> map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + JsonConfigParser parser = new JsonConfigParser(); + String json = parser.toJson(map); + assertEquals(json, expectedString); + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map<String, Object> expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + JsonConfigParser parser = new JsonConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // not-supported parse type + + try { + List value = parser.fromJson(json, List.class); + fail("Unsupported parse target type: " + value.toString()); + } catch (JsonParseException e) { + assertTrue(true); + } + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java new file mode 100644 index 000000000..330abea75 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonHelpersTest.java @@ -0,0 +1,89 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config.parser; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * Tests for {@link JsonHelpers}. + */ +public class JsonHelpersTest { + private Map<String, Object> map; + private JSONArray jsonArray; + private JSONObject jsonObject; + + @Before + public void setUp() throws Exception { + List<Object> list = new ArrayList<Object>(); + list.add("vv1"); + list.add(true); + + map = new HashMap<String, Object>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", list); + + jsonArray = new JSONArray(); + jsonArray.put("vv1"); + jsonArray.put(true); + + jsonObject = new JSONObject(); + jsonObject.put("k1", "v1"); + jsonObject.put("k2", 3.5); + jsonObject.put("k3", jsonArray); + } + @Test + public void testConvertToJsonObject() { + JSONObject value = (JSONObject) JsonHelpers.convertToJsonObject(map); + + assertEquals(value.getString("k1"), "v1"); + assertEquals(value.getDouble("k2"), 3.5, 0.01); + JSONArray array = value.getJSONArray("k3"); + assertEquals(array.get(0), "vv1"); + assertEquals(array.get(1), true); + } + + @Test + public void testJsonObjectToMap() { + Map<String, Object> value = JsonHelpers.jsonObjectToMap(jsonObject); + + assertEquals(value.get("k1"), "v1"); + assertEquals((Double) value.get("k2"), 3.5, 0.01); + ArrayList array = (ArrayList) value.get("k3"); + assertEquals(array.get(0), "vv1"); + assertEquals(array.get(1), true); + } + + @Test + public void testJsonArrayToList() { + List<Object> value = JsonHelpers.jsonArrayToList(jsonArray); + + assertEquals(value.get(0), "vv1"); + assertEquals(value.get(1), true); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java index 02064ab03..1844fa967 100644 --- a/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java +++ b/core-api/src/test/java/com/optimizely/ab/config/parser/JsonSimpleConfigParserTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019-2020, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,35 @@ */ package com.optimizely.ab.config.parser; +import com.optimizely.ab.config.FeatureFlag; +import com.optimizely.ab.config.FeatureVariable; import com.optimizely.ab.config.ProjectConfig; - +import com.optimizely.ab.config.audience.AudienceIdCondition; +import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.UserAttribute; +import com.optimizely.ab.internal.ConditionUtils; +import com.optimizely.ab.internal.InvalidAudienceCondition; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validConfigJsonV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV3; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; -import static com.optimizely.ab.config.ProjectConfigTestUtils.verifyProjectConfig; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.nullFeatureEnabledConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validConfigJsonV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV3; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.verifyProjectConfig; +import static org.junit.Assert.*; /** * Tests for {@link JsonSimpleConfigParser}. @@ -66,6 +81,135 @@ public void parseProjectConfigV4() throws Exception { verifyProjectConfig(actual, expected); } + @Test + public void parseNullFeatureEnabledProjectConfigV4() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + + assertNotNull(actual); + + assertNotNull(actual.getExperiments()); + + assertNotNull(actual.getFeatureFlags()); + + } + + @Test + public void parseFeatureVariablesWithJsonPatched() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // "string" type + "json" subType + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_patched"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithJsonNative() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // native "json" type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("json_native"); + + assertEquals(variable.getType(), "json"); + } + + @Test + public void parseFeatureVariablesWithFutureType() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + + // unknown type + + FeatureFlag featureFlag = actual.getFeatureKeyMapping().get("multi_variate_future_feature"); + FeatureVariable variable = featureFlag.getVariableKeyToFeatureVariableMap().get("future_variable"); + + assertEquals(variable.getType(), "future_type"); + } + + @Test + public void parseAudience() throws Exception { + JSONObject jsonObject = new JSONObject(); + + jsonObject.append("id", "123"); + jsonObject.append("name", "blah"); + jsonObject.append("conditions", + "[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}]]]"); + + Condition<UserAttribute> condition = ConditionUtils.parseConditions(UserAttribute.class, new JSONArray("[\"and\", [\"or\", [\"or\", {\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}]]]")); + + assertNotNull(condition); + } + + @Test + public void parseAudienceLeaf() throws Exception { + JSONObject jsonObject = new JSONObject(); + + jsonObject.append("id", "123"); + jsonObject.append("name", "blah"); + jsonObject.append("conditions", + "{\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}"); + + Condition<UserAttribute> condition = ConditionUtils.parseConditions(UserAttribute.class, new JSONObject("{\"name\": \"doubleKey\", \"type\": \"custom_attribute\", \"match\":\"lt\", \"value\":100.0}")); + + assertNotNull(condition); + } + + @Test + public void parseInvalidAudience() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + JSONObject jsonObject = new JSONObject(); + jsonObject.append("id", "123"); + jsonObject.append("name", "blah"); + jsonObject.append("conditions", + "[\"and\", [\"or\", [\"or\", \"123\"]]]"); + + ConditionUtils.parseConditions(UserAttribute.class, new JSONArray("[\"and\", [\"or\", [\"or\", \"123\"]]]")); + } + + @Test + public void parseAudienceConditions() throws Exception { + JSONArray conditions = new JSONArray(); + conditions.put("and"); + conditions.put("1"); + conditions.put("2"); + conditions.put("3"); + + Condition condition = ConditionUtils.parseConditions(AudienceIdCondition.class, conditions); + assertNotNull(condition); + } + + @Test + public void parseAudienceCondition() throws Exception { + String conditions = "1"; + + Condition condition = ConditionUtils.parseConditions(AudienceIdCondition.class, conditions); + assertNotNull(condition); + } + + @Test + public void parseInvalidAudienceConditions() throws Exception { + thrown.expect(InvalidAudienceCondition.class); + + JSONArray conditions = new JSONArray(); + conditions.put("and"); + conditions.put("1"); + conditions.put("2"); + JSONObject userAttribute = new JSONObject(); + userAttribute.append("match", "exact"); + userAttribute.append("type", "custom_attribute"); + userAttribute.append("value", "string"); + userAttribute.append("name", "StringCondition"); + conditions.put(userAttribute); + + ConditionUtils.parseConditions(AudienceIdCondition.class, conditions); + } + /** * Verify that invalid JSON results in a {@link ConfigParseException} being thrown. */ @@ -87,6 +231,7 @@ public void validJsonRequiredFieldMissingExceptionWrapping() throws Exception { JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); parser.parseProjectConfig("{\"valid\": \"json\"}"); } + /** * Verify that empty string JSON results in a {@link ConfigParseException} being thrown. */ @@ -97,15 +242,123 @@ public void emptyJsonExceptionWrapping() throws Exception { JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); parser.parseProjectConfig(""); } + /** * Verify that null JSON results in a {@link ConfigParseException} being thrown. */ @Test - @SuppressFBWarnings(value="NP_NONNULL_PARAM_VIOLATION", justification="Testing nullness contract violation") + @SuppressFBWarnings(value = "NP_NONNULL_PARAM_VIOLATION", justification = "Testing nullness contract violation") public void nullJsonExceptionWrapping() throws Exception { thrown.expect(ConfigParseException.class); JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); parser.parseProjectConfig(null); } + + @Test + public void integrationsArrayAbsent() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(nullFeatureEnabledConfigJsonV4()); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasODP() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + ProjectConfig actual = parser.parseProjectConfig(validConfigJsonV4()); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherIntegration() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"not-odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), ""); + assertEquals(actual.getPublicKeyForODP(), ""); + } + + @Test + public void integrationsArrayHasMissingHost() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"publicKey\": \"test-key\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getHostForODP(), null); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void integrationsArrayHasOtherKeys() throws Exception { + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String integrationsObject = ", \"integrations\": [" + + "{ \"key\": \"odp\", " + + "\"host\": \"https://example.com\", " + + "\"publicKey\": \"test-key\", " + + "\"new-key\": \"new-value\" }" + + "]}"; + String datafile = nullFeatureEnabledConfigJsonV4(); + datafile = datafile.substring(0, datafile.lastIndexOf("}")) + integrationsObject; + ProjectConfig actual = parser.parseProjectConfig(datafile); + assertEquals(actual.getIntegrations().size(), 1); + assertEquals(actual.getHostForODP(), "https://example.com"); + assertEquals(actual.getPublicKeyForODP(), "test-key"); + } + + @Test + public void testToJson() { + Map<String, Object> map = new HashMap<>(); + map.put("k1", "v1"); + map.put("k2", 3.5); + map.put("k3", true); + + String expectedString = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + String json = parser.toJson(map); + assertEquals(json, expectedString); + } + + @Test + public void testFromJson() { + String json = "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true}"; + + Map<String, Object> expectedMap = new HashMap<>(); + expectedMap.put("k1", "v1"); + expectedMap.put("k2", 3.5); + expectedMap.put("k3", true); + + JsonSimpleConfigParser parser = new JsonSimpleConfigParser(); + + Map map = null; + try { + map = parser.fromJson(json, Map.class); + assertEquals(map, expectedMap); + } catch (JsonParseException e) { + fail("Parse to map failed: " + e.getMessage()); + } + + // not-supported parse type + + try { + List value = parser.fromJson(json, List.class); + fail("Unsupported parse target type: " + value.toString()); + } catch (JsonParseException e) { + assertEquals(e.getMessage(), "Parsing fails with a unsupported type"); + } + } + } diff --git a/core-api/src/test/java/com/optimizely/ab/error/RaiseExceptionErrorHandlerTest.java b/core-api/src/test/java/com/optimizely/ab/error/RaiseExceptionErrorHandlerTest.java new file mode 100644 index 000000000..3b5fcf585 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/error/RaiseExceptionErrorHandlerTest.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2019 Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.error; + +import com.optimizely.ab.OptimizelyRuntimeException; +import org.junit.Before; +import org.junit.Test; + +public class RaiseExceptionErrorHandlerTest { + + private RaiseExceptionErrorHandler errorHandler; + + @Before + public void setUp() throws Exception { + errorHandler = new RaiseExceptionErrorHandler(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void handleError() { + errorHandler.handleError(new OptimizelyRuntimeException()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/event/BatchEventProcessorTest.java b/core-api/src/test/java/com/optimizely/ab/event/BatchEventProcessorTest.java new file mode 100644 index 000000000..5f42e9a3f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/BatchEventProcessorTest.java @@ -0,0 +1,319 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.optimizely.ab.EventHandlerRule; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.event.internal.*; +import com.optimizely.ab.notification.NotificationCenter; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.Collections; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class BatchEventProcessorTest { + + private static final String EVENT_ID = "eventId"; + private static final String EVENT_NAME = "eventName"; + private static final String USER_ID = "userId"; + + private static final int MAX_BATCH_SIZE = 10; + private static final long MAX_DURATION_MS = 1000; + private static final long TIMEOUT_MS = 5000; + + @Mock + private ProjectConfig projectConfig; + + @Rule + public EventHandlerRule eventHandlerRule = new EventHandlerRule(); + + private BlockingQueue<Object> eventQueue; + private BatchEventProcessor eventProcessor; + private NotificationCenter notificationCenter; + + @Before + public void setUp() throws Exception { + when(projectConfig.getRevision()).thenReturn("1"); + when(projectConfig.getProjectId()).thenReturn("X"); + + eventQueue = new ArrayBlockingQueue<>(100); + notificationCenter = new NotificationCenter(); + } + + @After + public void tearDown() throws Exception { + if (eventProcessor != null) { + eventProcessor.close(); + } + } + + @Test + public void testDrainOnClose() throws Exception { + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + setEventProcessor(eventHandlerRule); + eventProcessor.process(userEvent); + eventProcessor.close(); + + assertEquals(0, eventQueue.size()); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + } + + @Test + public void testFlushMaxBatchSize() throws Exception { + CountDownLatch countDownLatch = new CountDownLatch(1); + setEventProcessor(logEvent -> { + assertEquals(MAX_BATCH_SIZE, logEvent.getEventBatch().getVisitors().size()); + eventHandlerRule.dispatchEvent(logEvent); + countDownLatch.countDown(); + }); + + for (int i = 0; i < MAX_BATCH_SIZE; i++) { + String eventName = EVENT_NAME + i; + UserEvent userEvent = buildConversionEvent(eventName); + eventProcessor.process(userEvent); + eventHandlerRule.expectConversion(eventName, USER_ID); + } + + if (!countDownLatch.await(MAX_DURATION_MS * 3, TimeUnit.MILLISECONDS)) { + fail("Exceeded timeout waiting for events to flush."); + } + + assertEquals(0, eventQueue.size()); + eventHandlerRule.expectCalls(1); + } + + @Test + public void testFlushOnMaxTimeout() throws Exception { + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + + CountDownLatch countDownLatch = new CountDownLatch(1); + setEventProcessor(logEvent -> { + eventHandlerRule.dispatchEvent(logEvent); + countDownLatch.countDown(); + }); + + eventProcessor.process(userEvent); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventProcessor.close(); + + if (!countDownLatch.await( TIMEOUT_MS * 3, TimeUnit.MILLISECONDS)) { + fail("Exceeded timeout waiting for events to flush."); + } + + assertEquals(0, eventQueue.size()); + eventHandlerRule.expectCalls(1); + } + + @Test + public void testFlush() throws Exception { + setEventProcessor(logEvent -> eventHandlerRule.dispatchEvent(logEvent)); + + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + eventProcessor.process(userEvent); + eventProcessor.flush(); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventProcessor.process(userEvent); + eventProcessor.flush(); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventHandlerRule.expectCalls(2); + } + + @Test + public void testFlushOnMismatchRevision() throws Exception { + setEventProcessor(logEvent -> eventHandlerRule.dispatchEvent(logEvent)); + + ProjectConfig projectConfig1 = mock(ProjectConfig.class); + when(projectConfig1.getRevision()).thenReturn("1"); + when(projectConfig1.getProjectId()).thenReturn("X"); + UserEvent userEvent1 = buildConversionEvent(EVENT_NAME, projectConfig1); + eventProcessor.process(userEvent1); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + ProjectConfig projectConfig2 = mock(ProjectConfig.class); + when(projectConfig2.getRevision()).thenReturn("2"); + when(projectConfig1.getProjectId()).thenReturn("X"); + UserEvent userEvent2 = buildConversionEvent(EVENT_NAME, projectConfig2); + eventProcessor.process(userEvent2); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventProcessor.close(); + eventHandlerRule.expectCalls(2); + } + + @Test + public void testFlushOnMismatchProjectId() throws Exception { + setEventProcessor(logEvent -> eventHandlerRule.dispatchEvent(logEvent)); + + ProjectConfig projectConfig1 = mock(ProjectConfig.class); + when(projectConfig1.getRevision()).thenReturn("1"); + when(projectConfig1.getProjectId()).thenReturn("X"); + UserEvent userEvent1 = buildConversionEvent(EVENT_NAME, projectConfig1); + eventProcessor.process(userEvent1); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + ProjectConfig projectConfig2 = mock(ProjectConfig.class); + when(projectConfig1.getRevision()).thenReturn("1"); + when(projectConfig2.getProjectId()).thenReturn("Y"); + UserEvent userEvent2 = buildConversionEvent(EVENT_NAME, projectConfig2); + eventProcessor.process(userEvent2); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventProcessor.close(); + eventHandlerRule.expectCalls(2); + } + + @Test + public void testStopAndStart() throws Exception { + setEventProcessor(logEvent -> eventHandlerRule.dispatchEvent(logEvent)); + + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + eventProcessor.process(userEvent); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventProcessor.close(); + + eventProcessor.process(userEvent); + eventHandlerRule.expectConversion(EVENT_NAME, USER_ID); + + eventProcessor.start(); + + eventProcessor.close(); + eventHandlerRule.expectCalls(2); + } + + @Test + public void testNotificationCenter() throws Exception { + AtomicInteger counter = new AtomicInteger(); + notificationCenter.addNotificationHandler(LogEvent.class, x -> counter.incrementAndGet()); + setEventProcessor(logEvent -> {}); + + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + eventProcessor.process(userEvent); + eventProcessor.close(); + + assertEquals(1, counter.intValue()); + } + + @Test + public void testCloseTimeout() throws Exception { + CountDownLatch countDownLatch = new CountDownLatch(1); + setEventProcessor(logEvent -> { + if (!countDownLatch.await(TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)) { + fail("Exceeded timeout waiting for close."); + } + }); + + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + eventProcessor.process(userEvent); + eventProcessor.close(); + + countDownLatch.countDown(); + } + + @Test + public void testCloseEventHandler() throws Exception { + EventHandler mockEventHandler = mock( + EventHandler.class, + withSettings().extraInterfaces(AutoCloseable.class) + ); + + setEventProcessor(mockEventHandler); + eventProcessor.close(); + verify((AutoCloseable) mockEventHandler).close(); + } + + @Test + public void testInvalidBatchSizeUsesDefault() { + eventProcessor = BatchEventProcessor.builder() + .withEventQueue(eventQueue) + .withBatchSize(-1) + .withFlushInterval(MAX_DURATION_MS) + .withEventHandler(new NoopEventHandler()) + .withNotificationCenter(notificationCenter) + .withTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build(); + + assertEquals(eventProcessor.batchSize, BatchEventProcessor.DEFAULT_BATCH_SIZE); + } + + @Test + public void testInvalidFlushIntervalUsesDefault() { + eventProcessor = BatchEventProcessor.builder() + .withEventQueue(eventQueue) + .withBatchSize(MAX_BATCH_SIZE) + .withFlushInterval(-1L) + .withEventHandler(new NoopEventHandler()) + .withNotificationCenter(notificationCenter) + .withTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build(); + + assertEquals(eventProcessor.flushInterval, BatchEventProcessor.DEFAULT_BATCH_INTERVAL); + } + + @Test + public void testInvalidTimeoutUsesDefault() { + eventProcessor = BatchEventProcessor.builder() + .withEventQueue(eventQueue) + .withBatchSize(MAX_BATCH_SIZE) + .withFlushInterval(MAX_DURATION_MS) + .withEventHandler(new NoopEventHandler()) + .withNotificationCenter(notificationCenter) + .withTimeout(-1L, TimeUnit.MILLISECONDS) + .build(); + + assertEquals(eventProcessor.timeoutMillis, BatchEventProcessor.DEFAULT_TIMEOUT_INTERVAL); + } + + @Test(expected = IllegalArgumentException.class) + public void testDefaultEventHandler() { + eventProcessor = BatchEventProcessor.builder().build(); + } + + private void setEventProcessor(EventHandler eventHandler) { + eventProcessor = BatchEventProcessor.builder() + .withEventQueue(eventQueue) + .withBatchSize(MAX_BATCH_SIZE) + .withFlushInterval(MAX_DURATION_MS) + .withEventHandler(eventHandler) + .withNotificationCenter(notificationCenter) + .withTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build(); + } + + private ConversionEvent buildConversionEvent(String eventName) { + return buildConversionEvent(eventName, projectConfig); + } + + private static ConversionEvent buildConversionEvent(String eventName, ProjectConfig projectConfig) { + return UserEventFactory.createConversionEvent(projectConfig, USER_ID, EVENT_ID, eventName, + Collections.emptyMap(), Collections.emptyMap()); + } +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/event/ForwardingEventProcessorTest.java b/core-api/src/test/java/com/optimizely/ab/event/ForwardingEventProcessorTest.java new file mode 100644 index 000000000..591b73129 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/ForwardingEventProcessorTest.java @@ -0,0 +1,79 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.event.internal.*; +import com.optimizely.ab.notification.NotificationCenter; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.*; + +@RunWith(MockitoJUnitRunner.class) +public class ForwardingEventProcessorTest { + + private static final String EVENT_ID = "eventId"; + private static final String EVENT_NAME = "eventName"; + private static final String USER_ID = "userId"; + + private ForwardingEventProcessor eventProcessor; + private AtomicBoolean atomicBoolean = new AtomicBoolean(); + private NotificationCenter notificationCenter = new NotificationCenter(); + + @Mock + private ProjectConfig projectConfig; + + @Before + public void setUp() throws Exception { + atomicBoolean.set(false); + eventProcessor = new ForwardingEventProcessor(logEvent -> { + assertNotNull(logEvent.getEventBatch()); + assertEquals(logEvent.getRequestMethod(), LogEvent.RequestMethod.POST); + assertEquals(logEvent.getEndpointUrl(), EventFactory.EVENT_ENDPOINT); + atomicBoolean.set(true); + }, notificationCenter); + } + + @Test + public void testEventHandler() { + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + eventProcessor.process(userEvent); + assertTrue(atomicBoolean.get()); + } + + @Test + public void testNotifications() { + AtomicBoolean notifcationTriggered = new AtomicBoolean(); + notificationCenter.addNotificationHandler(LogEvent.class, x -> notifcationTriggered.set(true)); + UserEvent userEvent = buildConversionEvent(EVENT_NAME); + eventProcessor.process(userEvent); + assertTrue(atomicBoolean.get()); + assertTrue(notifcationTriggered.get()); + } + + private ConversionEvent buildConversionEvent(String eventName) { + return UserEventFactory.createConversionEvent(projectConfig, USER_ID, EVENT_ID, eventName, + Collections.emptyMap(), Collections.emptyMap()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/event/LogEventTest.java b/core-api/src/test/java/com/optimizely/ab/event/LogEventTest.java new file mode 100644 index 000000000..f801c13a8 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/LogEventTest.java @@ -0,0 +1,78 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.optimizely.ab.event.internal.payload.EventBatch; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; + +public class LogEventTest { + + private static final LogEvent.RequestMethod REQUEST_METHOD = LogEvent.RequestMethod.POST; + private static final String ENDPOINT_URL = "endpoint"; + private static final Map<String, String> REQUEST_PARAMS = Collections.singletonMap("KEY", "VALUE"); + private static final EventBatch EVENT_BATCH = new EventBatch(); + + private LogEvent logEvent; + + @Before + public void setUp() throws Exception { + logEvent = new LogEvent(REQUEST_METHOD, ENDPOINT_URL, REQUEST_PARAMS, EVENT_BATCH); + } + + @Test + public void testGetRequestMethod() { + assertEquals(REQUEST_METHOD, logEvent.getRequestMethod()); + } + + @Test + public void testGetEndpointUrl() { + assertEquals(ENDPOINT_URL, logEvent.getEndpointUrl()); + } + + @Test + public void testGetRequestParams() { + assertEquals(REQUEST_PARAMS, logEvent.getRequestParams()); + } + + @Test + public void testGetBody() { + assertEquals("{}", logEvent.getBody()); + } + + @Test + public void testGetEventBatch() { + assertEquals(EVENT_BATCH, logEvent.getEventBatch()); + } + + @Test + public void testToString() { + assertEquals("LogEvent{requestMethod=POST, endpointUrl='endpoint', requestParams={KEY=VALUE}, body='{}'}", logEvent.toString()); + } + + @Test + public void testEquals() { + LogEvent otherLogEvent = new LogEvent(REQUEST_METHOD, ENDPOINT_URL, REQUEST_PARAMS, EVENT_BATCH); + assertTrue(logEvent.equals(logEvent)); + assertTrue(logEvent.equals(otherLogEvent)); + } +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/event/NoopEventHandlerTest.java b/core-api/src/test/java/com/optimizely/ab/event/NoopEventHandlerTest.java index fe72704d5..8bfde4b7a 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/NoopEventHandlerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/NoopEventHandlerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017 Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/BaseEventTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/BaseEventTest.java new file mode 100644 index 000000000..2513ce9fc --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/BaseEventTest.java @@ -0,0 +1,43 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class BaseEventTest { + + private BaseEvent baseEvent; + + @Before + public void setUp() { + this.baseEvent = new BaseEvent(); + } + + @Test + public void getUUID() { + assertNotNull(baseEvent.getUUID()); + assertFalse(baseEvent.getUUID().isEmpty()); + } + + @Test + public void getTimestamp() { + assertTrue(baseEvent.getTimestamp() > 0); + } +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java new file mode 100644 index 000000000..55b04296a --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/ClientEngineInfoTest.java @@ -0,0 +1,46 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import com.optimizely.ab.event.internal.payload.EventBatch; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ClientEngineInfoTest { + + @After + public void tearDown() throws Exception { + ClientEngineInfo.setClientEngineName(ClientEngineInfo.DEFAULT_NAME); + } + + @Test + public void testSetAndGetClientEngine() { + // default "java-sdk" name + assertEquals("java-sdk", ClientEngineInfo.getClientEngineName()); + + ClientEngineInfo.setClientEngineName(null); + assertEquals("java-sdk", ClientEngineInfo.getClientEngineName()); + + ClientEngineInfo.setClientEngineName(""); + assertEquals("java-sdk", ClientEngineInfo.getClientEngineName()); + + ClientEngineInfo.setClientEngineName("test-name"); + assertEquals("test-name", ClientEngineInfo.getClientEngineName()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/ConversionEventTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/ConversionEventTest.java new file mode 100644 index 000000000..8bc72cc2e --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/ConversionEventTest.java @@ -0,0 +1,80 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +public class ConversionEventTest { + + private static final UserContext USER_CONTEXT = mock(UserContext.class); + private static final String EVENT_ID = "layerId"; + private static final String EVENT_KEY = "experimentKey"; + private static final Number REVENUE = 100; + private static final Number VALUE = 9.99; + private static final Map<String, ?> TAGS = Collections.singletonMap("KEY", "VALUE"); + + private ConversionEvent conversionEvent; + + @Before + public void setUp() throws Exception { + conversionEvent = new ConversionEvent.Builder() + .withUserContext(USER_CONTEXT) + .withEventId(EVENT_ID) + .withEventKey(EVENT_KEY) + .withRevenue(REVENUE) + .withValue(VALUE) + .withTags(TAGS) + .build(); + } + + @Test + public void getUserContext() { + assertSame(USER_CONTEXT, conversionEvent.getUserContext()); + } + + @Test + public void getEventId() { + assertSame(EVENT_ID, conversionEvent.getEventId()); + } + + @Test + public void getEventKey() { + assertSame(EVENT_KEY, conversionEvent.getEventKey()); + } + + @Test + public void getRevenue() { + assertSame(REVENUE, conversionEvent.getRevenue()); + } + + @Test + public void getValue() { + assertSame(VALUE, conversionEvent.getValue()); + } + + @Test + public void getTags() { + assertSame(TAGS, conversionEvent.getTags()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java index be170c839..e347074a8 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/EventFactoryTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2018, Optimizely and contributors + * Copyright 2016-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,50 +20,38 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.optimizely.ab.bucketing.Bucketer; -import com.optimizely.ab.bucketing.DecisionService; -import com.optimizely.ab.bucketing.UserProfileService; -import com.optimizely.ab.config.Attribute; -import com.optimizely.ab.config.EventType; -import com.optimizely.ab.config.Experiment; -import com.optimizely.ab.config.ProjectConfig; -import com.optimizely.ab.config.Variation; -import com.optimizely.ab.error.ErrorHandler; -import com.optimizely.ab.error.NoOpErrorHandler; +import com.optimizely.ab.config.*; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.event.internal.payload.Decision; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; import com.optimizely.ab.event.internal.payload.EventBatch; import com.optimizely.ab.internal.ControlAttribute; import com.optimizely.ab.internal.ReservedEventKey; +import com.optimizely.ab.optimizelydecision.DecisionResponse; +import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; -import javax.annotation.Nullable; import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.*; + +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.ValidProjectConfigV4.*; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNotSame; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.closeTo; -import static org.hamcrest.Matchers.containsInAnyOrder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @RunWith(Parameterized.class) @@ -71,22 +59,21 @@ public class EventFactoryTest { @Parameterized.Parameters public static Collection<Object[]> data() throws IOException { - return Arrays.asList(new Object[][] { - { - 2, - validProjectConfigV2() - }, - { - 4, - validProjectConfigV4() - } + return Arrays.asList(new Object[][]{ + { + 2, + validProjectConfigV2() + }, + { + 4, + validProjectConfigV4() + } }); } private Gson gson = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create(); - private EventFactory factory = new EventFactory(); + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); private static String userId = "userId"; private int datafileVersion; @@ -98,6 +85,11 @@ public EventFactoryTest(int datafileVersion, this.validProjectConfig = validProjectConfig; } + @After + public void tearDown() { + ClientEngineInfo.setClientEngineName(ClientEngineInfo.DEFAULT_NAME); + } + /** * Verify {@link com.optimizely.ab.event.internal.payload.EventBatch} event creation */ @@ -108,42 +100,44 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { Variation bucketedVariation = activatedExperiment.getVariations().get(0); Attribute attribute = validProjectConfig.getAttributes().get(0); String userId = "userId"; + String ruleType = "experiment"; Map<String, String> attributeMap = new HashMap<String, String>(); attributeMap.put(attribute.getKey(), "value"); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); - + DecisionMetadata metadata = new DecisionMetadata(activatedExperiment.getKey(), activatedExperiment.getKey(), ruleType, "variationKey", true); Decision expectedDecision = new Decision.Builder() - .setCampaignId(activatedExperiment.getLayerId()) - .setExperimentId(activatedExperiment.getId()) - .setVariationId(bucketedVariation.getId()) - .setIsCampaignHoldback(false) - .build(); + .setCampaignId(activatedExperiment.getLayerId()) + .setExperimentId(activatedExperiment.getId()) + .setVariationId(bucketedVariation.getId()) + .setMetadata(metadata) + .setIsCampaignHoldback(false) + .build(); com.optimizely.ab.event.internal.payload.Attribute feature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(attribute.getId()) - .setKey(attribute.getKey()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue("value") - .build(); + .setEntityId(attribute.getId()) + .setKey(attribute.getKey()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue("value") + .build(); com.optimizely.ab.event.internal.payload.Attribute userAgentFeature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) - .setKey(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue("Chrome") - .build(); + .setEntityId(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) + .setKey(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue("Chrome") + .build(); com.optimizely.ab.event.internal.payload.Attribute botFilteringFeature = getBotFilteringAttribute(); List<com.optimizely.ab.event.internal.payload.Attribute> expectedUserFeatures; - if(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) expectedUserFeatures = Arrays.asList(userAgentFeature, feature, botFilteringFeature); else expectedUserFeatures = Arrays.asList(userAgentFeature, feature); - LogEvent impressionEvent = factory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, - userId, attributeMap); + LogEvent impressionEvent = createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, + userId, attributeMap); // verify that request endpoint is correct assertThat(impressionEvent.getEndpointUrl(), is(EventFactory.EVENT_ENDPOINT)); @@ -152,17 +146,18 @@ public void createImpressionEventPassingUserAgentAttribute() throws Exception { // verify payload information assertThat(eventBatch.getVisitors().get(0).getVisitorId(), is(userId)); - assertThat((double) eventBatch.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double)System.currentTimeMillis(), 1000.0)); + assertThat((double) eventBatch.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double) System.currentTimeMillis(), 1000.0)); assertFalse(eventBatch.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getIsCampaignHoldback()); assertThat(eventBatch.getAnonymizeIp(), is(validProjectConfig.getAnonymizeIP())); + assertTrue(eventBatch.getEnrichDecisions()); assertThat(eventBatch.getProjectId(), is(validProjectConfig.getProjectId())); assertThat(eventBatch.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0), is(expectedDecision)); assertThat(eventBatch.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getCampaignId(), - is(activatedExperiment.getLayerId())); + is(activatedExperiment.getLayerId())); assertThat(eventBatch.getAccountId(), is(validProjectConfig.getAccountId())); assertThat(eventBatch.getVisitors().get(0).getAttributes(), is(expectedUserFeatures)); assertThat(eventBatch.getClientName(), is(EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.getClientVersion())); assertNull(eventBatch.getVisitors().get(0).getSessionId()); } @@ -178,31 +173,38 @@ public void createImpressionEvent() throws Exception { String userId = "userId"; Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "value"); + DecisionMetadata decisionMetadata = new DecisionMetadata.Builder() + .setFlagKey(activatedExperiment.getKey()) + .setRuleType("experiment") + .setVariationKey(bucketedVariation.getKey()) + .build(); + Decision expectedDecision = new Decision.Builder() - .setCampaignId(activatedExperiment.getLayerId()) - .setExperimentId(activatedExperiment.getId()) - .setVariationId(bucketedVariation.getId()) - .setIsCampaignHoldback(false) - .build(); + .setCampaignId(activatedExperiment.getLayerId()) + .setExperimentId(activatedExperiment.getId()) + .setVariationId(bucketedVariation.getId()) + .setMetadata(decisionMetadata) + .setIsCampaignHoldback(false) + .build(); com.optimizely.ab.event.internal.payload.Attribute feature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(attribute.getId()) - .setKey(attribute.getKey()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue("value") - .build(); + .setEntityId(attribute.getId()) + .setKey(attribute.getKey()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue("value") + .build(); com.optimizely.ab.event.internal.payload.Attribute botFilteringFeature = getBotFilteringAttribute(); List<com.optimizely.ab.event.internal.payload.Attribute> expectedUserFeatures; - if(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) expectedUserFeatures = Arrays.asList(feature, botFilteringFeature); else expectedUserFeatures = Arrays.asList(feature); - LogEvent impressionEvent = factory.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, - userId, attributeMap); + LogEvent impressionEvent = createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, + userId, attributeMap); // verify that request endpoint is correct assertThat(impressionEvent.getEndpointUrl(), is(EventFactory.EVENT_ENDPOINT)); @@ -211,17 +213,18 @@ public void createImpressionEvent() throws Exception { // verify payload information assertThat(eventBatch.getVisitors().get(0).getVisitorId(), is(userId)); - assertThat((double) eventBatch.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double)System.currentTimeMillis(), 1000.0)); + assertThat((double) eventBatch.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double) System.currentTimeMillis(), 1000.0)); assertFalse(eventBatch.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getIsCampaignHoldback()); assertThat(eventBatch.getAnonymizeIp(), is(validProjectConfig.getAnonymizeIP())); + assertTrue(eventBatch.getEnrichDecisions()); assertThat(eventBatch.getProjectId(), is(validProjectConfig.getProjectId())); assertThat(eventBatch.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0), is(expectedDecision)); assertThat(eventBatch.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getCampaignId(), - is(activatedExperiment.getLayerId())); + is(activatedExperiment.getLayerId())); assertThat(eventBatch.getAccountId(), is(validProjectConfig.getAccountId())); assertThat(eventBatch.getVisitors().get(0).getAttributes(), is(expectedUserFeatures)); assertThat(eventBatch.getClientName(), is(EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertThat(eventBatch.getClientVersion(), is(BuildVersionInfo.getClientVersion())); assertNull(eventBatch.getVisitors().get(0).getSessionId()); } @@ -237,8 +240,8 @@ public void createImpressionEventIgnoresUnknownAttributes() throws Exception { Variation bucketedVariation = activatedExperiment.getVariations().get(0); LogEvent impressionEvent = - factory.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", - Collections.singletonMap("unknownAttribute", "blahValue")); + createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", + Collections.singletonMap("unknownAttribute", "blahValue")); EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); @@ -250,12 +253,308 @@ public void createImpressionEventIgnoresUnknownAttributes() throws Exception { } /** - * Verify that supplying {@link EventFactory} with a custom client engine and client version results in impression + * Verify that passing through an list value attribute causes that attribute to be ignored, rather than + * causing an exception to be thrown and passing only the valid attributes. + */ + @Test + public void createConversionEventIgnoresInvalidAndAcceptsValidAttributes() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + EventType eventType = validProjectConfig.getEventTypes().get(0); + + Attribute attribute1 = validProjectConfig.getAttributes().get(0); + Attribute attribute2 = validProjectConfig.getAttributes().get(1); + Attribute doubleAttribute = validProjectConfig.getAttributes().get(5); + Attribute integerAttribute = validProjectConfig.getAttributes().get(4); + Attribute boolAttribute = validProjectConfig.getAttributes().get(3); + Attribute emptyAttribute = validProjectConfig.getAttributes().get(6); + + BigInteger bigInteger = new BigInteger("12323"); + BigDecimal bigDecimal = new BigDecimal("123"); + double validDoubleAttribute = 13.1; + int validIntegerAttribute = 12; + boolean validBoolAttribute = true; + + Map<String, Object> eventTagMap = new HashMap<>(); + eventTagMap.put("boolean_param", false); + eventTagMap.put("string_param", "123"); + + HashMap<String, Object> attributes = new HashMap<>(); + attributes.put(attribute1.getKey(), bigInteger); + attributes.put(attribute2.getKey(), bigDecimal); + attributes.put(doubleAttribute.getKey(), validDoubleAttribute); + attributes.put(integerAttribute.getKey(), validIntegerAttribute); + attributes.put(boolAttribute.getKey(), validBoolAttribute); + attributes.put(emptyAttribute.getKey(), validBoolAttribute); + + LogEvent conversionEvent = createConversionEvent( + validProjectConfig, + userId, + eventType.getId(), + eventType.getKey(), + attributes, + eventTagMap); + + EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); + + //Check valid attributes are getting passed. + assertEquals(conversion.getVisitors().get(0).getAttributes().get(0).getKey(), boolAttribute.getKey()); + assertEquals(conversion.getVisitors().get(0).getAttributes().get(0).getValue(), validBoolAttribute); + + assertEquals(conversion.getVisitors().get(0).getAttributes().get(1).getKey(), doubleAttribute.getKey()); + assertEquals(conversion.getVisitors().get(0).getAttributes().get(1).getValue(), validDoubleAttribute); + + assertEquals(conversion.getVisitors().get(0).getAttributes().get(2).getKey(), integerAttribute.getKey()); + assertEquals((int) ((double) conversion.getVisitors().get(0).getAttributes().get(2).getValue()), validIntegerAttribute); + + // verify that no Feature is created for attribute.getKey() -> invalidAttribute + for (com.optimizely.ab.event.internal.payload.Attribute feature : conversion.getVisitors().get(0).getAttributes()) { + assertNotSame(feature.getKey(), attribute1.getKey()); + assertNotSame(feature.getValue(), bigInteger); + assertNotSame(feature.getKey(), attribute2.getKey()); + assertNotSame(feature.getValue(), bigDecimal); + assertNotSame(feature.getKey(), emptyAttribute.getKey()); + assertNotSame(feature.getValue(), doubleAttribute); + } + } + + /** + * Verify that passing through an list of invalid value attribute causes that attribute to be ignored, rather than + * causing an exception to be thrown and passing only the valid attributes. + */ + @Test + public void createConversionEventIgnoresInvalidAcceptValidValOfValidAttr() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + EventType eventType = validProjectConfig.getEventTypes().get(0); + + Attribute validFloatAttribute = validProjectConfig.getAttributes().get(0); + Attribute invalidFloatAttribute = validProjectConfig.getAttributes().get(1); + Attribute doubleAttribute = validProjectConfig.getAttributes().get(5); + Attribute integerAttribute = validProjectConfig.getAttributes().get(4); + Attribute boolAttribute = validProjectConfig.getAttributes().get(3); + Attribute emptyAttribute = validProjectConfig.getAttributes().get(6); + + float validFloatValue = 2.1f; + float invalidFloatValue = (float) (Math.pow(2, 53) + 2000000000); + double invalidDoubleAttribute = Math.pow(2, 53) + 2; + long validLongAttribute = 12; + boolean validBoolAttribute = true; + + Map<String, Object> eventTagMap = new HashMap<>(); + eventTagMap.put("boolean_param", false); + eventTagMap.put("string_param", "123"); + + HashMap<String, Object> attributes = new HashMap<>(); + attributes.put(validFloatAttribute.getKey(), validFloatValue); + attributes.put(invalidFloatAttribute.getKey(), invalidFloatValue); + attributes.put(doubleAttribute.getKey(), invalidDoubleAttribute); + attributes.put(integerAttribute.getKey(), validLongAttribute); + attributes.put(boolAttribute.getKey(), validBoolAttribute); + attributes.put(emptyAttribute.getKey(), validBoolAttribute); + + LogEvent conversionEvent = createConversionEvent( + validProjectConfig, + userId, + eventType.getId(), + eventType.getKey(), + attributes, + eventTagMap); + + EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); + + //Check valid attributes are getting passed. + assertEquals(conversion.getVisitors().get(0).getAttributes().get(0).getKey(), boolAttribute.getKey()); + assertEquals(conversion.getVisitors().get(0).getAttributes().get(0).getValue(), validBoolAttribute); + assertEquals(conversion.getVisitors().get(0).getAttributes().get(1).getKey(), validFloatAttribute.getKey()); + //In the condition below we are checking Value of float with double value because impression gets visitors from JSON so that converts it into double + assertEquals(conversion.getVisitors().get(0).getAttributes().get(1).getValue(), 2.1); + assertEquals(conversion.getVisitors().get(0).getAttributes().get(2).getKey(), integerAttribute.getKey()); + assertEquals((long) ((double) conversion.getVisitors().get(0).getAttributes().get(2).getValue()), validLongAttribute); + + // verify that no Feature is created for attribute.getKey() -> invalidAttribute + for (com.optimizely.ab.event.internal.payload.Attribute feature : conversion.getVisitors().get(0).getAttributes()) { + assertNotSame(feature.getKey(), invalidFloatAttribute.getKey()); + assertNotSame(feature.getValue(), invalidFloatValue); + assertNotSame(feature.getKey(), doubleAttribute.getKey()); + assertNotSame(feature.getValue(), invalidDoubleAttribute); + assertNotSame(feature.getKey(), emptyAttribute.getKey()); + assertNotSame(feature.getValue(), doubleAttribute); + } + } + + /** + * Verify that passing through an list of -ve invalid attribute value causes that attribute to be ignored, rather than + * causing an exception to be thrown and passing only the valid attributes. + */ + @Test + public void createConversionEventIgnoresNegativeInvalidAndAcceptsValidValueOfValidTypeAttributes() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + + EventType eventType = validProjectConfig.getEventTypes().get(0); + + Attribute validFloatAttribute = validProjectConfig.getAttributes().get(0); + Attribute invalidFloatAttribute = validProjectConfig.getAttributes().get(1); + Attribute doubleAttribute = validProjectConfig.getAttributes().get(5); + Attribute integerAttribute = validProjectConfig.getAttributes().get(4); + Attribute emptyAttribute = validProjectConfig.getAttributes().get(6); + + float validFloatValue = -2.1f; + float invalidFloatValue = -((float) (Math.pow(2, 53) + 2000000000)); + double invalidDoubleAttribute = -(Math.pow(2, 53) + 2); + long validLongAttribute = -12; + + Map<String, Object> eventTagMap = new HashMap<>(); + eventTagMap.put("boolean_param", false); + eventTagMap.put("string_param", "123"); + + HashMap<String, Object> attributes = new HashMap<>(); + attributes.put(validFloatAttribute.getKey(), validFloatValue); + attributes.put(invalidFloatAttribute.getKey(), invalidFloatValue); + attributes.put(doubleAttribute.getKey(), invalidDoubleAttribute); + attributes.put(integerAttribute.getKey(), validLongAttribute); + + LogEvent conversionEvent = createConversionEvent( + validProjectConfig, + userId, + eventType.getId(), + eventType.getKey(), + attributes, + eventTagMap); + + EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); + + //Check valid attributes are getting passed. + assertEquals(conversion.getVisitors().get(0).getAttributes().get(0).getKey(), validFloatAttribute.getKey()); + //In below condition I am checking Value of float with double value because impression gets visitors from json so that converts it into double + assertEquals(conversion.getVisitors().get(0).getAttributes().get(0).getValue(), -2.1); + assertEquals(conversion.getVisitors().get(0).getAttributes().get(1).getKey(), integerAttribute.getKey()); + assertEquals((long) ((double) conversion.getVisitors().get(0).getAttributes().get(1).getValue()), validLongAttribute); + + // verify that no Feature is created for attribute.getKey() -> invalidAttribute + for (com.optimizely.ab.event.internal.payload.Attribute feature : conversion.getVisitors().get(0).getAttributes()) { + assertNotSame(feature.getKey(), invalidFloatAttribute.getKey()); + assertNotSame(feature.getValue(), invalidFloatValue); + assertNotSame(feature.getKey(), doubleAttribute.getKey()); + assertNotSame(feature.getValue(), invalidDoubleAttribute); + assertNotSame(feature.getKey(), emptyAttribute.getKey()); + assertNotSame(feature.getValue(), doubleAttribute); + } + } + + /** + * Verify that passing through an list value attribute causes that attribute to be ignored, rather than + * causing an exception to be thrown. + */ + @Test + public void createImpressionEventIgnoresInvalidAttributes() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + // use the "valid" project config and its associated experiment, variation, and attributes + ProjectConfig projectConfig = validProjectConfig; + Experiment activatedExperiment = projectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + Attribute attribute1 = validProjectConfig.getAttributes().get(0); + Attribute attribute2 = validProjectConfig.getAttributes().get(1); + BigInteger bigInteger = new BigInteger("12323"); + BigDecimal bigDecimal = new BigDecimal("123"); + + HashMap<String, Object> attributes = new HashMap<>(); + attributes.put(attribute1.getKey(), bigInteger); + attributes.put(attribute2.getKey(), bigDecimal); + + LogEvent impressionEvent = + createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", + attributes); + + EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); + + // verify that no Feature is created for attribute.getKey() -> invalidAttribute + for (com.optimizely.ab.event.internal.payload.Attribute feature : impression.getVisitors().get(0).getAttributes()) { + assertNotSame(feature.getKey(), attribute1.getKey()); + assertNotSame(feature.getValue(), bigInteger); + assertNotSame(feature.getKey(), attribute2.getKey()); + assertNotSame(feature.getValue(), bigDecimal); + } + } + + /** + * Verify that Integer, Decimal, Bool and String variables are allowed to pass. + */ + @Test + public void createImpressionEventWithIntegerDecimalBoolAndStringAttributes() { + assumeTrue(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())); + // use the "valid" project config and its associated experiment, variation, and attributes + ProjectConfig projectConfig = validProjectConfig; + Experiment activatedExperiment = projectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + Attribute doubleAttribute = validProjectConfig.getAttributes().get(5); + Attribute integerAttribute = validProjectConfig.getAttributes().get(4); + Attribute boolAttribute = validProjectConfig.getAttributes().get(3); + Attribute stringAttribute = validProjectConfig.getAttributes().get(0); + double validDoubleAttribute = 13.1; + int validIntegerAttribute = 12; + boolean validBoolAttribute = true; + String validStringAttribute = "grayfindor"; + + HashMap<String, Object> attributes = new HashMap<>(); + attributes.put(doubleAttribute.getKey(), validDoubleAttribute); + attributes.put(integerAttribute.getKey(), validIntegerAttribute); + attributes.put(boolAttribute.getKey(), validBoolAttribute); + attributes.put(stringAttribute.getKey(), validStringAttribute); + + LogEvent impressionEvent = + createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", + attributes); + + EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); + + + assertEquals(impression.getVisitors().get(0).getAttributes().get(0).getKey(), boolAttribute.getKey()); + assertEquals(impression.getVisitors().get(0).getAttributes().get(0).getValue(), validBoolAttribute); + + assertEquals(impression.getVisitors().get(0).getAttributes().get(1).getKey(), doubleAttribute.getKey()); + assertEquals(impression.getVisitors().get(0).getAttributes().get(1).getValue(), validDoubleAttribute); + + assertEquals(impression.getVisitors().get(0).getAttributes().get(2).getKey(), integerAttribute.getKey()); + assertEquals((int) ((double) impression.getVisitors().get(0).getAttributes().get(2).getValue()), validIntegerAttribute); + + assertEquals(impression.getVisitors().get(0).getAttributes().get(3).getKey(), stringAttribute.getKey()); + assertEquals(impression.getVisitors().get(0).getAttributes().get(3).getValue(), validStringAttribute); + + } + + /** + * Verify that passing through an null value attribute causes that attribute to be ignored, rather than + * causing an exception to be thrown. + */ + @Test + public void createImpressionEventIgnoresNullAttributes() { + // use the "valid" project config and its associated experiment, variation, and attributes + ProjectConfig projectConfig = validProjectConfig; + Experiment activatedExperiment = projectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + Attribute attribute = validProjectConfig.getAttributes().get(0); + + LogEvent impressionEvent = + createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, "userId", + Collections.singletonMap(attribute.getKey(), null)); + + EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); + + // verify that no Feature is created for attribute.getKey() -> null + for (com.optimizely.ab.event.internal.payload.Attribute feature : impression.getVisitors().get(0).getAttributes()) { + assertNotSame(feature.getKey(), attribute.getKey()); + assertNotSame(feature.getValue(), null); + } + } + + /** + * Verify that supplying {@link ClientEngineInfo} with a custom client engine and client version results in impression * events being sent with the overriden values. */ @Test public void createImpressionEventAndroidClientEngineClientVersion() throws Exception { - EventFactory factory = new EventFactory(EventBatch.ClientEngine.ANDROID_SDK, "0.0.0"); + ClientEngineInfo.setClientEngineName("android-sdk"); ProjectConfig projectConfig = validProjectConfigV2(); Experiment activatedExperiment = projectConfig.getExperiments().get(0); Variation bucketedVariation = activatedExperiment.getVariations().get(0); @@ -263,22 +562,22 @@ public void createImpressionEventAndroidClientEngineClientVersion() throws Excep String userId = "userId"; Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "value"); - LogEvent impressionEvent = factory.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, - userId, attributeMap); + LogEvent impressionEvent = createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, + userId, attributeMap); EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); - assertThat(impression.getClientName(), is(EventBatch.ClientEngine.ANDROID_SDK.getClientEngineValue())); - assertThat(impression.getClientVersion(), is("0.0.0")); + assertThat(impression.getClientName(), is("android-sdk")); +// assertThat(impression.getClientVersion(), is("0.0.0")); } /** - * Verify that supplying {@link EventFactory} with a custom Android TV client engine and client version + * Verify that supplying {@link ClientEngineInfo} with a custom Android TV client engine and client version * results in impression events being sent with the overriden values. */ @Test public void createImpressionEventAndroidTVClientEngineClientVersion() throws Exception { String clientVersion = "0.0.0"; - EventFactory factory = new EventFactory(EventBatch.ClientEngine.ANDROID_TV_SDK, clientVersion); + ClientEngineInfo.setClientEngineName("android-tv-sdk"); ProjectConfig projectConfig = validProjectConfigV2(); Experiment activatedExperiment = projectConfig.getExperiments().get(0); Variation bucketedVariation = activatedExperiment.getVariations().get(0); @@ -286,12 +585,12 @@ public void createImpressionEventAndroidTVClientEngineClientVersion() throws Exc String userId = "userId"; Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "value"); - LogEvent impressionEvent = factory.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, - userId, attributeMap); + LogEvent impressionEvent = createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, + userId, attributeMap); EventBatch impression = gson.fromJson(impressionEvent.getBody(), EventBatch.class); - assertThat(impression.getClientName(), is(EventBatch.ClientEngine.ANDROID_TV_SDK.getClientEngineValue())); - assertThat(impression.getClientVersion(), is(clientVersion)); + assertThat(impression.getClientName(), is("android-tv-sdk")); +// assertThat(impression.getClientVersion(), is(clientVersion)); } /** @@ -304,58 +603,18 @@ public void createConversionEvent() throws Exception { EventType eventType = validProjectConfig.getEventTypes().get(0); String userId = "userId"; - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - - List<Experiment> allExperiments = validProjectConfig.getExperiments(); - List<Experiment> experimentsForEventKey = validProjectConfig.getExperimentsForEventKey(eventType.getKey()); - - // Bucket to the first variation for all experiments. However, only a subset of the experiments will actually - // call the bucket function. - for (Experiment experiment : allExperiments) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); - } - DecisionService decisionService = new DecisionService( - mockBucketAlgorithm, - mock(ErrorHandler.class), - validProjectConfig, - mock(UserProfileService.class) - ); - Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), AUDIENCE_GRYFFINDOR_VALUE); Map<String, Object> eventTagMap = new HashMap<String, Object>(); eventTagMap.put("boolean_param", false); eventTagMap.put("string_param", "123"); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( + LogEvent conversionEvent = createConversionEvent( validProjectConfig, - decisionService, - eventType.getKey(), - userId, - attributeMap); - LogEvent conversionEvent = factory.createConversionEvent( - validProjectConfig, - experimentVariationMap, userId, eventType.getId(), eventType.getKey(), attributeMap, eventTagMap); - List<Decision> expectedDecisions = new ArrayList<Decision>(); - - for (Experiment experiment : experimentsForEventKey) { - if (experiment.isRunning()) { - Decision layerState = new Decision.Builder() - .setCampaignId(experiment.getLayerId()) - .setExperimentId(experiment.getId()) - .setVariationId(experiment.getVariations().get(0).getId()) - .setIsCampaignHoldback(false) - .build(); - - expectedDecisions.add(layerState); - } - } - // verify that the request endpoint is correct assertThat(conversionEvent.getEndpointUrl(), is(EventFactory.EVENT_ENDPOINT)); @@ -363,35 +622,34 @@ public void createConversionEvent() throws Exception { // verify payload information assertThat(conversion.getVisitors().get(0).getVisitorId(), is(userId)); - assertThat((double)conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), - closeTo((double)System.currentTimeMillis(), 120.0)); + assertThat((double) conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), + closeTo((double) System.currentTimeMillis(), 120.0)); assertThat(conversion.getProjectId(), is(validProjectConfig.getProjectId())); assertThat(conversion.getAccountId(), is(validProjectConfig.getAccountId())); com.optimizely.ab.event.internal.payload.Attribute feature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(attribute.getId()).setKey(attribute.getKey()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue(AUDIENCE_GRYFFINDOR_VALUE) - .build(); + .setEntityId(attribute.getId()).setKey(attribute.getKey()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue(AUDIENCE_GRYFFINDOR_VALUE) + .build(); List<com.optimizely.ab.event.internal.payload.Attribute> expectedUserFeatures = new ArrayList<com.optimizely.ab.event.internal.payload.Attribute>(); expectedUserFeatures.add(feature); - if(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { expectedUserFeatures.add(getBotFilteringAttribute()); } assertEquals(conversion.getVisitors().get(0).getAttributes(), expectedUserFeatures); - assertThat(conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions(), containsInAnyOrder(expectedDecisions.toArray())); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getEntityId(), eventType.getId()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getKey(), eventType.getKey()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getRevenue(), null); assertTrue(conversion.getVisitors().get(0).getAttributes().containsAll(expectedUserFeatures)); assertTrue(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTags().equals(eventTagMap)); - assertFalse(conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getIsCampaignHoldback()); assertEquals(conversion.getAnonymizeIp(), validProjectConfig.getAnonymizeIP()); + assertTrue(conversion.getEnrichDecisions()); assertEquals(conversion.getClientName(), EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue()); - assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.getClientVersion()); } /** @@ -405,60 +663,20 @@ public void createConversionEventPassingUserAgentAttribute() throws Exception { EventType eventType = validProjectConfig.getEventTypes().get(0); String userId = "userId"; - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - - List<Experiment> allExperiments = validProjectConfig.getExperiments(); - List<Experiment> experimentsForEventKey = validProjectConfig.getExperimentsForEventKey(eventType.getKey()); - - // Bucket to the first variation for all experiments. However, only a subset of the experiments will actually - // call the bucket function. - for (Experiment experiment : allExperiments) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); - } - DecisionService decisionService = new DecisionService( - mockBucketAlgorithm, - mock(ErrorHandler.class), - validProjectConfig, - mock(UserProfileService.class) - ); - Map<String, String> attributeMap = new HashMap<String, String>(); attributeMap.put(attribute.getKey(), AUDIENCE_GRYFFINDOR_VALUE); attributeMap.put(ControlAttribute.USER_AGENT_ATTRIBUTE.toString(), "Chrome"); Map<String, Object> eventTagMap = new HashMap<String, Object>(); eventTagMap.put("boolean_param", false); eventTagMap.put("string_param", "123"); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - decisionService, - eventType.getKey(), - userId, - attributeMap); - LogEvent conversionEvent = factory.createConversionEvent( + LogEvent conversionEvent = createConversionEvent( validProjectConfig, - experimentVariationMap, userId, eventType.getId(), eventType.getKey(), attributeMap, eventTagMap); - List<Decision> expectedDecisions = new ArrayList<Decision>(); - - for (Experiment experiment : experimentsForEventKey) { - if (experiment.isRunning()) { - Decision layerState = new Decision.Builder() - .setCampaignId(experiment.getLayerId()) - .setExperimentId(experiment.getId()) - .setVariationId(experiment.getVariations().get(0).getId()) - .setIsCampaignHoldback(false) - .build(); - - expectedDecisions.add(layerState); - } - } - // verify that the request endpoint is correct assertThat(conversionEvent.getEndpointUrl(), is(EventFactory.EVENT_ENDPOINT)); @@ -470,37 +688,36 @@ public void createConversionEventPassingUserAgentAttribute() throws Exception { assertThat(conversion.getAccountId(), is(validProjectConfig.getAccountId())); com.optimizely.ab.event.internal.payload.Attribute feature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(attribute.getId()).setKey(attribute.getKey()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue(AUDIENCE_GRYFFINDOR_VALUE) - .build(); + .setEntityId(attribute.getId()).setKey(attribute.getKey()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue(AUDIENCE_GRYFFINDOR_VALUE) + .build(); com.optimizely.ab.event.internal.payload.Attribute userAgentFeature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) - .setKey(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue("Chrome") - .build(); + .setEntityId(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) + .setKey(ControlAttribute.USER_AGENT_ATTRIBUTE.toString()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue("Chrome") + .build(); List<com.optimizely.ab.event.internal.payload.Attribute> expectedUserFeatures = new ArrayList<com.optimizely.ab.event.internal.payload.Attribute>(); expectedUserFeatures.add(userAgentFeature); expectedUserFeatures.add(feature); - if(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { expectedUserFeatures.add(getBotFilteringAttribute()); } assertEquals(conversion.getVisitors().get(0).getAttributes(), expectedUserFeatures); - assertThat(conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions(), containsInAnyOrder(expectedDecisions.toArray())); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getEntityId(), eventType.getId()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getKey(), eventType.getKey()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getRevenue(), null); assertTrue(conversion.getVisitors().get(0).getAttributes().containsAll(expectedUserFeatures)); assertTrue(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTags().equals(eventTagMap)); - assertFalse(conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getIsCampaignHoldback()); assertEquals(conversion.getAnonymizeIp(), validProjectConfig.getAnonymizeIP()); + assertTrue(conversion.getEnrichDecisions()); assertEquals(conversion.getClientName(), EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue()); - assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.getClientVersion()); } /** @@ -520,30 +737,22 @@ public void createConversionParamsWithEventMetrics() throws Exception { // Bucket to the first variation for all experiments. for (Experiment experiment : validProjectConfig.getExperiments()) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + when(mockBucketAlgorithm.bucket(experiment, userId, validProjectConfig)) + .thenReturn(DecisionResponse.responseNoReasons(experiment.getVariations().get(0))); } - DecisionService decisionService = new DecisionService( - mockBucketAlgorithm, - mock(ErrorHandler.class), - validProjectConfig, - mock(UserProfileService.class) - ); Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "value"); Map<String, Object> eventTagMap = new HashMap<String, Object>(); eventTagMap.put(ReservedEventKey.REVENUE.toString(), revenue); eventTagMap.put(ReservedEventKey.VALUE.toString(), value); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - decisionService, - eventType.getKey(), - userId, - attributeMap); - LogEvent conversionEvent = factory.createConversionEvent(validProjectConfig, experimentVariationMap, userId, - eventType.getId(), eventType.getKey(), attributeMap, - eventTagMap); + LogEvent conversionEvent = createConversionEvent( + validProjectConfig, + userId, + eventType.getId(), + eventType.getKey(), + attributeMap, + eventTagMap); EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); // we're not going to verify everything, only the event metrics @@ -552,8 +761,7 @@ public void createConversionParamsWithEventMetrics() throws Exception { } /** - * Verify that precedence is given to forced variation bucketing over audience evaluation when constructing a - * conversion event. + * Verify that conversion event is always created. */ @Test public void createConversionEventForcedVariationBucketingPrecedesAudienceEval() { @@ -562,147 +770,86 @@ public void createConversionEventForcedVariationBucketingPrecedesAudienceEval() if (datafileVersion == 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); whitelistedUserId = MULTIVARIATE_EXPERIMENT_FORCED_VARIATION_USER_ID_GRED; - } - else { + } else { eventType = validProjectConfig.getEventTypes().get(0); whitelistedUserId = "testUser1"; } - DecisionService decisionService = new DecisionService( - new Bucketer(validProjectConfig), - new NoOpErrorHandler(), + LogEvent conversionEvent = createConversionEvent( validProjectConfig, - mock(UserProfileService.class) - ); - - // attributes are empty so user won't be in the audience for experiment using the event, but bucketing - // will still take place - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - decisionService, - eventType.getKey(), - whitelistedUserId, - Collections.<String, String>emptyMap()); - LogEvent conversionEvent = factory.createConversionEvent( - validProjectConfig, - experimentVariationMap, whitelistedUserId, eventType.getId(), eventType.getKey(), - Collections.<String, String>emptyMap(), - Collections.<String, Object>emptyMap()); + Collections.emptyMap(), + Collections.emptyMap()); + assertNotNull(conversionEvent); - - EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); - if (datafileVersion == 4) { - // 2 experiments use the event - // basic experiment has no audience - // user is whitelisted in to one audience - assertEquals(2, conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions().size()); - } - else { - assertEquals(1, conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions().size()); - } } /** - * Verify that precedence is given to experiment status over forced variation bucketing when constructing a - * conversion event. + * Verify conversion event is always created. */ @Test public void createConversionEventExperimentStatusPrecedesForcedVariation() { EventType eventType; if (datafileVersion == 4) { eventType = validProjectConfig.getEventNameMapping().get(EVENT_PAUSED_EXPERIMENT_KEY); - } - else { + } else { eventType = validProjectConfig.getEventTypes().get(3); } String whitelistedUserId = PAUSED_EXPERIMENT_FORCED_VARIATION_USER_ID_CONTROL; - Bucketer bucketer = spy(new Bucketer(validProjectConfig)); - DecisionService decisionService = new DecisionService( - bucketer, - mock(ErrorHandler.class), - validProjectConfig, - mock(UserProfileService.class) - ); + LogEvent conversionEvent = createConversionEvent( + validProjectConfig, + whitelistedUserId, + eventType.getId(), + eventType.getKey(), + Collections.emptyMap(), + Collections.emptyMap()); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - decisionService, - eventType.getKey(), - whitelistedUserId, - Collections.<String, String>emptyMap()); - LogEvent conversionEvent = factory.createConversionEvent( - validProjectConfig, - experimentVariationMap, - whitelistedUserId, - eventType.getId(), - eventType.getKey(), - Collections.<String, String>emptyMap(), - Collections.<String, Object>emptyMap()); - - for (Experiment experiment : validProjectConfig.getExperiments()) { - verify(bucketer, never()).bucket(experiment, whitelistedUserId); - } - - assertNull(conversionEvent); + assertNotNull(conversionEvent); } /** - * Verify that supplying {@link EventFactory} with a custom client engine and client version results in conversion + * Verify that supplying {@link ClientEngineInfo} with a custom client engine and client version results in conversion * events being sent with the overriden values. */ @Test public void createConversionEventAndroidClientEngineClientVersion() throws Exception { - EventFactory factory = new EventFactory(EventBatch.ClientEngine.ANDROID_SDK, "0.0.0"); + ClientEngineInfo.setClientEngineName("android-sdk"); Attribute attribute = validProjectConfig.getAttributes().get(0); EventType eventType = validProjectConfig.getEventTypes().get(0); Bucketer mockBucketAlgorithm = mock(Bucketer.class); for (Experiment experiment : validProjectConfig.getExperiments()) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + when(mockBucketAlgorithm.bucket(experiment, userId, validProjectConfig)) + .thenReturn(DecisionResponse.responseNoReasons(experiment.getVariations().get(0))); } - DecisionService decisionService = new DecisionService( - mockBucketAlgorithm, - mock(ErrorHandler.class), - validProjectConfig, - mock(UserProfileService.class) - ); Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "value"); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( - validProjectConfig, - decisionService, - eventType.getKey(), - userId, - attributeMap); - LogEvent conversionEvent = factory.createConversionEvent( - validProjectConfig, - experimentVariationMap, - userId, - eventType.getId(), - eventType.getKey(), - attributeMap, - Collections.<String, Object>emptyMap()); + LogEvent conversionEvent = createConversionEvent( + validProjectConfig, + userId, + eventType.getId(), + eventType.getKey(), + attributeMap, + Collections.emptyMap()); EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); - assertThat(conversion.getClientName(), is(EventBatch.ClientEngine.ANDROID_SDK.getClientEngineValue())); - assertThat(conversion.getClientVersion(), is("0.0.0")); + assertThat(conversion.getClientName(), is("android-sdk")); +// assertThat(conversion.getClientVersion(), is("0.0.0")); } /** - * Verify that supplying {@link EventFactory} with a Android TV client engine and client version results in + * Verify that supplying {@link ClientEngineInfo} with a Android TV client engine and client version results in * conversion events being sent with the overriden values. */ @Test public void createConversionEventAndroidTVClientEngineClientVersion() throws Exception { String clientVersion = "0.0.0"; - EventFactory factory = new EventFactory(EventBatch.ClientEngine.ANDROID_TV_SDK, clientVersion); + ClientEngineInfo.setClientEngineName("android-tv-sdk"); ProjectConfig projectConfig = validProjectConfigV2(); Attribute attribute = projectConfig.getAttributes().get(0); EventType eventType = projectConfig.getEventTypes().get(0); @@ -710,44 +857,35 @@ public void createConversionEventAndroidTVClientEngineClientVersion() throws Exc Bucketer mockBucketAlgorithm = mock(Bucketer.class); for (Experiment experiment : projectConfig.getExperiments()) { - when(mockBucketAlgorithm.bucket(experiment, userId)) - .thenReturn(experiment.getVariations().get(0)); + when(mockBucketAlgorithm.bucket(experiment, userId, validProjectConfig)) + .thenReturn(DecisionResponse.responseNoReasons(experiment.getVariations().get(0))); } Map<String, String> attributeMap = Collections.singletonMap(attribute.getKey(), "value"); - List<Experiment> experimentList = projectConfig.getExperimentsForEventKey(eventType.getKey()); - Map<Experiment, Variation> experimentVariationMap = new HashMap<Experiment, Variation>(experimentList.size()); - for (Experiment experiment : experimentList) { - experimentVariationMap.put(experiment, experiment.getVariations().get(0)); - } - LogEvent conversionEvent = factory.createConversionEvent( - projectConfig, - experimentVariationMap, - userId, - eventType.getId(), - eventType.getKey(), - attributeMap, - Collections.<String, Object>emptyMap()); + LogEvent conversionEvent = createConversionEvent( + projectConfig, + userId, + eventType.getId(), + eventType.getKey(), + attributeMap, + Collections.emptyMap()); + EventBatch conversion = gson.fromJson(conversionEvent.getBody(), EventBatch.class); - assertThat(conversion.getClientName(), is(EventBatch.ClientEngine.ANDROID_TV_SDK.getClientEngineValue())); - assertThat(conversion.getClientVersion(), is(clientVersion)); + assertThat(conversion.getClientName(), is("android-tv-sdk")); +// assertThat(conversion.getClientVersion(), is(clientVersion)); } /** - * Verify that supplying an empty Experiment Variation map to - * {@link EventFactory#createConversionEvent(ProjectConfig, Map, String, String, String, Map, Map)} - * returns a null {@link LogEvent}. + * Verify that supplying an empty Experiment Variation map returns an Event {@link LogEvent}. */ @Test - public void createConversionEventReturnsNullWhenExperimentVariationMapIsEmpty() { + public void createConversionEventReturnsNotNullWhenExperimentVariationMapIsEmpty() { EventType eventType = validProjectConfig.getEventTypes().get(0); - EventFactory factory = new EventFactory(); - LogEvent conversionEvent = factory.createConversionEvent( + LogEvent conversionEvent = createConversionEvent( validProjectConfig, - Collections.<Experiment, Variation>emptyMap(), userId, eventType.getId(), eventType.getKey(), @@ -755,7 +893,7 @@ public void createConversionEventReturnsNullWhenExperimentVariationMapIsEmpty() Collections.<String, String>emptyMap() ); - assertNull(conversionEvent); + assertNotNull(conversionEvent); } /** @@ -775,35 +913,35 @@ public void createImpressionEventWithBucketingId() throws Exception { attributeMap.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), "variation"); Decision expectedDecision = new Decision.Builder() - .setCampaignId(activatedExperiment.getLayerId()) - .setExperimentId(activatedExperiment.getId()) - .setVariationId(bucketedVariation.getId()) - .setIsCampaignHoldback(false) - .build(); + .setCampaignId(activatedExperiment.getLayerId()) + .setExperimentId(activatedExperiment.getId()) + .setVariationId(bucketedVariation.getId()) + .setIsCampaignHoldback(false) + .build(); com.optimizely.ab.event.internal.payload.Attribute feature = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(attribute.getId()).setKey(attribute.getKey()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue("value") - .build(); + .setEntityId(attribute.getId()).setKey(attribute.getKey()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue("value") + .build(); com.optimizely.ab.event.internal.payload.Attribute feature1 = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) - .setKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue("variation") - .build(); + .setEntityId(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) + .setKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue("variation") + .build(); List<com.optimizely.ab.event.internal.payload.Attribute> expectedUserFeatures = new ArrayList<com.optimizely.ab.event.internal.payload.Attribute>(); expectedUserFeatures.add(feature); expectedUserFeatures.add(feature1); - if(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { expectedUserFeatures.add(getBotFilteringAttribute()); } - LogEvent impressionEvent = factory.createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, - userId, attributeMap); + LogEvent impressionEvent = createImpressionEvent(projectConfig, activatedExperiment, bucketedVariation, + userId, attributeMap); // verify that request endpoint is correct assertThat(impressionEvent.getEndpointUrl(), is(EventFactory.EVENT_ENDPOINT)); @@ -812,9 +950,10 @@ public void createImpressionEventWithBucketingId() throws Exception { // verify payload information assertThat(impression.getVisitors().get(0).getVisitorId(), is(userId)); - assertThat((double)impression.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double)System.currentTimeMillis(), 1000.0)); + assertThat((double) impression.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double) System.currentTimeMillis(), 1000.0)); assertFalse(impression.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getIsCampaignHoldback()); assertThat(impression.getAnonymizeIp(), is(projectConfig.getAnonymizeIP())); + assertTrue(impression.getEnrichDecisions()); assertThat(impression.getProjectId(), is(projectConfig.getProjectId())); assertThat(impression.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0), is(expectedDecision)); assertThat(impression.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getCampaignId(), is(activatedExperiment.getLayerId())); @@ -822,7 +961,7 @@ public void createImpressionEventWithBucketingId() throws Exception { assertThat(impression.getVisitors().get(0).getAttributes(), is(expectedUserFeatures)); assertThat(impression.getClientName(), is(EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue())); - assertThat(impression.getClientVersion(), is(BuildVersionInfo.VERSION)); + assertThat(impression.getClientVersion(), is(BuildVersionInfo.getClientVersion())); assertNull(impression.getVisitors().get(0).getSessionId()); } @@ -837,24 +976,6 @@ public void createConversionEventWithBucketingId() throws Exception { String userId = "userId"; String bucketingId = "bucketingId"; - Bucketer mockBucketAlgorithm = mock(Bucketer.class); - - List<Experiment> allExperiments = validProjectConfig.getExperiments(); - List<Experiment> experimentsForEventKey = validProjectConfig.getExperimentsForEventKey(eventType.getKey()); - - // Bucket to the first variation for all experiments. However, only a subset of the experiments will actually - // call the bucket function. - for (Experiment experiment : allExperiments) { - when(mockBucketAlgorithm.bucket(experiment, bucketingId)) - .thenReturn(experiment.getVariations().get(0)); - } - DecisionService decisionService = new DecisionService( - mockBucketAlgorithm, - mock(ErrorHandler.class), - validProjectConfig, - mock(UserProfileService.class) - ); - Map<String, String> attributeMap = new java.util.HashMap<String, String>(); attributeMap.put(attribute.getKey(), AUDIENCE_GRYFFINDOR_VALUE); attributeMap.put(ControlAttribute.BUCKETING_ATTRIBUTE.toString(), bucketingId); @@ -863,37 +984,14 @@ public void createConversionEventWithBucketingId() throws Exception { eventTagMap.put("boolean_param", false); eventTagMap.put("string_param", "123"); - Map<Experiment, Variation> experimentVariationMap = createExperimentVariationMap( + LogEvent conversionEvent = createConversionEvent( validProjectConfig, - decisionService, - eventType.getKey(), - userId, - attributeMap); - - LogEvent conversionEvent = factory.createConversionEvent( - validProjectConfig, - experimentVariationMap, userId, eventType.getId(), eventType.getKey(), attributeMap, eventTagMap); - List<Decision> expectedDecisions = new ArrayList<Decision>(); - - for (Experiment experiment : experimentsForEventKey) { - if (experiment.isRunning()) { - Decision decision = new Decision.Builder() - .setCampaignId(experiment.getLayerId()) - .setExperimentId(experiment.getId()) - .setVariationId(experiment.getVariations().get(0).getId()) - .setIsCampaignHoldback(false) - .build(); - - expectedDecisions.add(decision); - } - } - // verify that the request endpoint is correct assertThat(conversionEvent.getEndpointUrl(), is(EventFactory.EVENT_ENDPOINT)); @@ -901,74 +999,95 @@ public void createConversionEventWithBucketingId() throws Exception { // verify payload information assertThat(conversion.getVisitors().get(0).getVisitorId(), is(userId)); - assertThat((double)conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double)System.currentTimeMillis(), 1000.0)); + assertThat((double) conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTimestamp(), closeTo((double) System.currentTimeMillis(), 1000.0)); assertThat(conversion.getProjectId(), is(validProjectConfig.getProjectId())); assertThat(conversion.getAccountId(), is(validProjectConfig.getAccountId())); com.optimizely.ab.event.internal.payload.Attribute attribute1 = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(attribute.getId()).setKey(attribute.getKey()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue(AUDIENCE_GRYFFINDOR_VALUE) - .build(); + .setEntityId(attribute.getId()).setKey(attribute.getKey()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue(AUDIENCE_GRYFFINDOR_VALUE) + .build(); com.optimizely.ab.event.internal.payload.Attribute attribute2 = new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) - .setKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue(bucketingId) - .build(); + .setEntityId(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) + .setKey(ControlAttribute.BUCKETING_ATTRIBUTE.toString()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue(bucketingId) + .build(); List<com.optimizely.ab.event.internal.payload.Attribute> expectedUserFeatures = new ArrayList<com.optimizely.ab.event.internal.payload.Attribute>(); expectedUserFeatures.add(attribute1); expectedUserFeatures.add(attribute2); - if(datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { + if (datafileVersion >= Integer.parseInt(ProjectConfig.Version.V4.toString())) { expectedUserFeatures.add(getBotFilteringAttribute()); } assertEquals(conversion.getVisitors().get(0).getAttributes(), expectedUserFeatures); - assertThat(conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions(), containsInAnyOrder(expectedDecisions.toArray())); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getEntityId(), eventType.getId()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getType(), eventType.getKey()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getKey(), eventType.getKey()); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getRevenue(), null); assertEquals(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getQuantity(), null); assertTrue(conversion.getVisitors().get(0).getSnapshots().get(0).getEvents().get(0).getTags().equals(eventTagMap)); - assertFalse(conversion.getVisitors().get(0).getSnapshots().get(0).getDecisions().get(0).getIsCampaignHoldback()); assertEquals(conversion.getAnonymizeIp(), validProjectConfig.getAnonymizeIP()); + assertTrue(conversion.getEnrichDecisions()); assertEquals(conversion.getClientName(), EventBatch.ClientEngine.JAVA_SDK.getClientEngineValue()); - assertEquals(conversion.getClientVersion(), BuildVersionInfo.VERSION); + assertEquals(conversion.getClientVersion(), BuildVersionInfo.getClientVersion()); } - //========== helper methods =========// - public static Map<Experiment, Variation> createExperimentVariationMap(ProjectConfig projectConfig, - DecisionService decisionService, - String eventName, - String userId, - @Nullable Map<String, String> attributes) { - - List<Experiment> eventExperiments = projectConfig.getExperimentsForEventKey(eventName); - Map<Experiment, Variation> experimentVariationMap = new HashMap<Experiment, Variation>(eventExperiments.size()); - for (Experiment experiment : eventExperiments) { - if (experiment.isRunning()) { - Variation variation = decisionService.getVariation(experiment, userId, attributes); - if (variation != null) { - experimentVariationMap.put(experiment, variation); - } - } - } + private com.optimizely.ab.event.internal.payload.Attribute getBotFilteringAttribute() { + return new com.optimizely.ab.event.internal.payload.Attribute.Builder() + .setEntityId(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) + .setKey(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) + .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) + .setValue(validProjectConfig.getBotFiltering()) + .build(); + } - return experimentVariationMap; + /** + * Helper method for generating an impression based LogEvent. + */ + public static LogEvent createImpressionEvent(ProjectConfig projectConfig, + Experiment activatedExperiment, + Variation variation, + String userId, + Map<String, ?> attributes) { + + UserEvent userEvent = UserEventFactory.createImpressionEvent( + projectConfig, + activatedExperiment, + variation, + userId, + attributes, + activatedExperiment.getKey(), + "experiment", + true); + + return EventFactory.createLogEvent(userEvent); + } - private com.optimizely.ab.event.internal.payload.Attribute getBotFilteringAttribute() { - return new com.optimizely.ab.event.internal.payload.Attribute.Builder() - .setEntityId(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) - .setKey(ControlAttribute.BOT_FILTERING_ATTRIBUTE.toString()) - .setType(com.optimizely.ab.event.internal.payload.Attribute.CUSTOM_ATTRIBUTE_TYPE) - .setValue(validProjectConfig.getBotFiltering()) - .build(); + /** + * Helper method for generating a conversion based LogEvent. + */ + private static LogEvent createConversionEvent(ProjectConfig projectConfig, + String userId, + String eventId, + String eventName, + Map<String, ?> attributes, + Map<String, ?> eventTags) { + + UserEvent userEvent = UserEventFactory.createConversionEvent( + projectConfig, + userId, + eventId, + eventName, + attributes, + eventTags); + + return EventFactory.createLogEvent(userEvent); } } - diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/ImpressionEventTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/ImpressionEventTest.java new file mode 100644 index 000000000..66b7fac37 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/ImpressionEventTest.java @@ -0,0 +1,77 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +public class ImpressionEventTest { + + private static final UserContext USER_CONTEXT = mock(UserContext.class); + private static final String LAYER_ID = "layerId"; + private static final String EXPERIMENT_ID = "experimentId"; + private static final String EXPERIMENT_KEY = "experimentKey"; + private static final String VARIATION_ID = "variationId"; + private static final String VARIATION_KEY = "variationKey"; + + private ImpressionEvent impressionEvent; + + @Before + public void setUp() throws Exception { + impressionEvent = new ImpressionEvent.Builder() + .withUserContext(USER_CONTEXT) + .withLayerId(LAYER_ID) + .withExperimentId(EXPERIMENT_ID) + .withExperimentKey(EXPERIMENT_KEY) + .withVariationId(VARIATION_ID) + .withVariationKey(VARIATION_KEY) + .build(); + } + + @Test + public void getUserContext() { + assertSame(USER_CONTEXT, impressionEvent.getUserContext()); + } + + @Test + public void getLayerId() { + assertSame(LAYER_ID, impressionEvent.getLayerId()); + } + + @Test + public void getExperimentId() { + assertSame(EXPERIMENT_ID, impressionEvent.getExperimentId()); + } + + @Test + public void getExperimentKey() { + assertSame(EXPERIMENT_KEY, impressionEvent.getExperimentKey()); + } + + @Test + public void getVariationId() { + assertSame(VARIATION_ID, impressionEvent.getVariationId()); + } + + @Test + public void getVariationKey() { + assertSame(VARIATION_KEY, impressionEvent.getVariationKey()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java new file mode 100644 index 000000000..a7739bb73 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/UserEventFactoryTest.java @@ -0,0 +1,143 @@ +/** + * + * Copyright 2019-2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event.internal; + +import com.google.common.collect.ImmutableMap; +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.internal.payload.DecisionMetadata; +import com.optimizely.ab.internal.ReservedEventKey; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; + + +@RunWith(MockitoJUnitRunner.class) +public class UserEventFactoryTest { + + private static final String USER_ID = "USER_ID"; + private static final Map<String, ?> ATTRIBUTES = Collections.singletonMap("KEY", "VALUE"); + + private static final String EVENT_ID = "layerId"; + private static final String EVENT_KEY = "experimentKey"; + private static final Number REVENUE = 100L; + private static final Number VALUE = 9.99; + private static final Map<String, ?> TAGS = ImmutableMap.of( + "KEY", "VALUE", + ReservedEventKey.REVENUE.toString(), REVENUE, + ReservedEventKey.VALUE.toString(), VALUE + ); + + private static final String LAYER_ID = "layerId"; + private static final String EXPERIMENT_ID = "experimentId"; + private static final String EXPERIMENT_KEY = "experimentKey"; + private static final String VARIATION_ID = "variationId"; + private static final String VARIATION_KEY = "variationKey"; + + @Mock + private ProjectConfig projectConfig; + + private Experiment experiment; + private Variation variation; + private DecisionMetadata decisionMetadata; + + @Before + public void setUp() { + experiment = new Experiment(EXPERIMENT_ID, EXPERIMENT_KEY, LAYER_ID); + variation = new Variation(VARIATION_ID, VARIATION_KEY); + decisionMetadata = new DecisionMetadata("", EXPERIMENT_KEY, "experiment", VARIATION_KEY, true); + } + + @Test + public void createImpressionEventNull() { + + ImpressionEvent actual = UserEventFactory.createImpressionEvent( + projectConfig, + experiment, + null, + USER_ID, + ATTRIBUTES, + EXPERIMENT_KEY, + "rollout", + false + ); + assertNull(actual); + } + + @Test + public void createImpressionEvent() { + ImpressionEvent actual = UserEventFactory.createImpressionEvent( + projectConfig, + experiment, + variation, + USER_ID, + ATTRIBUTES, + "", + "experiment", + true + ); + + assertTrue(actual.getTimestamp() > 0); + assertNotNull(actual.getUUID()); + + assertSame(projectConfig, actual.getUserContext().getProjectConfig()); + + assertEquals(USER_ID, actual.getUserContext().getUserId()); + assertEquals(ATTRIBUTES, actual.getUserContext().getAttributes()); + + assertEquals(LAYER_ID, actual.getLayerId()); + assertEquals(EXPERIMENT_ID, actual.getExperimentId()); + assertEquals(EXPERIMENT_KEY, actual.getExperimentKey()); + assertEquals(VARIATION_ID, actual.getVariationId()); + assertEquals(VARIATION_KEY, actual.getVariationKey()); + assertEquals(decisionMetadata, actual.getMetadata()); + } + + @Test + public void createConversionEvent() { + ConversionEvent actual = UserEventFactory.createConversionEvent( + projectConfig, + USER_ID, + EVENT_ID, + EVENT_KEY, + ATTRIBUTES, + TAGS + ); + + assertTrue(actual.getTimestamp() > 0); + assertNotNull(actual.getUUID()); + + assertSame(projectConfig, actual.getUserContext().getProjectConfig()); + + assertEquals(USER_ID, actual.getUserContext().getUserId()); + assertEquals(ATTRIBUTES, actual.getUserContext().getAttributes()); + + assertEquals(EVENT_ID, actual.getEventId()); + assertEquals(EVENT_KEY, actual.getEventKey()); + assertEquals(REVENUE, actual.getRevenue()); + assertEquals(VALUE, actual.getValue()); + assertEquals(TAGS, actual.getTags()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java index 4581225a8..05573a7d8 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/GsonSerializerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,8 +42,8 @@ public class GsonSerializerTest { private GsonSerializer serializer = new GsonSerializer(); private Gson gson = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .create(); + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); @Test public void serializeImpression() throws IOException { diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java index 76776aacd..fb068e3ab 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JacksonSerializerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,8 +41,8 @@ public class JacksonSerializerTest { private JacksonSerializer serializer = new JacksonSerializer(); private ObjectMapper mapper = - new ObjectMapper().setPropertyNamingStrategy( - PropertyNamingStrategy.SNAKE_CASE); + new ObjectMapper().setPropertyNamingStrategy( + PropertyNamingStrategy.SNAKE_CASE); @Test @@ -85,4 +85,3 @@ public void serializeConversionWithSessionId() throws IOException { assertThat(actual, is(expected)); } } - diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java index 2c1fcdfa3..e0a15ba3c 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/JsonSimpleSerializerTest.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,8 +46,8 @@ public class JsonSimpleSerializerTest { public void serializeImpression() throws IOException, ParseException { EventBatch impression = generateImpression(); // can't compare JSON strings since orders could vary so compare JSONObjects instead - JSONObject actual = (JSONObject)parser.parse(serializer.serialize(impression)); - JSONObject expected = (JSONObject)parser.parse(generateImpressionJson()); + JSONObject actual = (JSONObject) parser.parse(serializer.serialize(impression)); + JSONObject expected = (JSONObject) parser.parse(generateImpressionJson()); assertThat(actual, is(expected)); } @@ -56,8 +56,8 @@ public void serializeImpression() throws IOException, ParseException { public void serializeImpressionWithSessionId() throws IOException, ParseException { EventBatch impression = generateImpressionWithSessionId(); // can't compare JSON strings since orders could vary so compare JSONObjects instead - JSONObject actual = (JSONObject)parser.parse(serializer.serialize(impression)); - JSONObject expected = (JSONObject)parser.parse(generateImpressionWithSessionIdJson()); + JSONObject actual = (JSONObject) parser.parse(serializer.serialize(impression)); + JSONObject expected = (JSONObject) parser.parse(generateImpressionWithSessionIdJson()); assertThat(actual, is(expected)); } @@ -66,8 +66,8 @@ public void serializeImpressionWithSessionId() throws IOException, ParseExceptio public void serializeConversion() throws IOException, ParseException { EventBatch conversion = generateConversion(); // can't compare JSON strings since orders could vary so compare JSONObjects instead - JSONObject actual = (JSONObject)parser.parse(serializer.serialize(conversion)); - JSONObject expected = (JSONObject)parser.parse(generateConversionJson()); + JSONObject actual = (JSONObject) parser.parse(serializer.serialize(conversion)); + JSONObject expected = (JSONObject) parser.parse(generateConversionJson()); assertThat(actual, is(expected)); } @@ -76,8 +76,8 @@ public void serializeConversion() throws IOException, ParseException { public void serializeConversionWithSessionId() throws IOException, ParseException { EventBatch conversion = generateConversionWithSessionId(); // can't compare JSON strings since orders could vary so compare JSONObjects instead - JSONObject actual = (JSONObject)parser.parse(serializer.serialize(conversion)); - JSONObject expected = (JSONObject)parser.parse(generateConversionWithSessionIdJson()); + JSONObject actual = (JSONObject) parser.parse(serializer.serialize(conversion)); + JSONObject expected = (JSONObject) parser.parse(generateConversionWithSessionIdJson()); assertThat(actual, is(expected)); } diff --git a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java index 8edf39baa..eba153aa8 100644 --- a/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java +++ b/core-api/src/test/java/com/optimizely/ab/event/internal/serializer/SerializerTestUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,56 +42,56 @@ public class SerializerTestUtils { private static final String sessionId = "sessionid"; private static final String revision = "1"; private static final Decision decision = new Decision.Builder() - .setCampaignId(layerId) - .setExperimentId(experimentId) - .setVariationId(variationId) - .setIsCampaignHoldback(isLayerHoldback) - .build(); + .setCampaignId(layerId) + .setExperimentId(experimentId) + .setVariationId(variationId) + .setIsCampaignHoldback(isLayerHoldback) + .build(); private static final String featureId = "6"; private static final String featureName = "testfeature"; private static final String featureType = "custom"; private static final String featureValue = "testfeaturevalue"; private static final List<Attribute> userFeatures = Collections.singletonList(new Attribute.Builder() - .setEntityId(featureId) - .setKey(featureName) - .setType(featureType) - .setValue(featureValue) - .build()); + .setEntityId(featureId) + .setKey(featureName) + .setType(featureType) + .setValue(featureValue) + .build()); private static final String eventEntityId = "7"; private static final String eventName = "testevent"; private static final List<Event> events = Collections.singletonList(new Event.Builder() - .setTimestamp(timestamp) - .setUuid("uuid") - .setEntityId(eventEntityId) - .setKey(eventName) - .setRevenue(5000L) - .setType(eventName) - .build() + .setTimestamp(timestamp) + .setUuid("uuid") + .setEntityId(eventEntityId) + .setKey(eventName) + .setRevenue(5000L) + .setType(eventName) + .build() ); static EventBatch generateImpression() { Snapshot snapshot = new Snapshot.Builder() - .setDecisions(Collections.singletonList(decision)) - .setEvents(events) - .build(); + .setDecisions(Collections.singletonList(decision)) + .setEvents(events) + .build(); Visitor visitor = new Visitor.Builder() - .setVisitorId(visitorId) - .setAttributes(userFeatures) - .setSnapshots(Collections.singletonList(snapshot)) - .build(); + .setVisitorId(visitorId) + .setAttributes(userFeatures) + .setSnapshots(Collections.singletonList(snapshot)) + .build(); return new EventBatch.Builder() - .setClientVersion("0.1.1") - .setAccountId(accountId) - .setVisitors(Collections.singletonList(visitor)) - .setAnonymizeIp(true) - .setProjectId(projectId) - .setRevision(revision) - .build(); + .setClientVersion("0.1.1") + .setAccountId(accountId) + .setVisitors(Collections.singletonList(visitor)) + .setAnonymizeIp(true) + .setProjectId(projectId) + .setRevision(revision) + .build(); } static EventBatch generateImpressionWithSessionId() { @@ -102,12 +102,24 @@ static EventBatch generateImpressionWithSessionId() { } static EventBatch generateConversion() { - EventBatch conversion = generateImpression(); - conversion.setClientVersion("0.1.1"); - conversion.setAnonymizeIp(true); - conversion.setRevision(revision); + Snapshot snapshot = new Snapshot.Builder() + .setEvents(events) + .build(); - return conversion; + Visitor visitor = new Visitor.Builder() + .setVisitorId(visitorId) + .setAttributes(userFeatures) + .setSnapshots(Collections.singletonList(snapshot)) + .build(); + + return new EventBatch.Builder() + .setClientVersion("0.1.1") + .setAccountId(accountId) + .setVisitors(Collections.singletonList(visitor)) + .setAnonymizeIp(true) + .setProjectId(projectId) + .setRevision(revision) + .build(); } static EventBatch generateConversionWithSessionId() { @@ -124,7 +136,7 @@ static String generateImpressionJson() throws IOException { static String generateImpressionWithSessionIdJson() throws IOException { String impressionJson = Resources.toString(Resources.getResource("serializer/impression-session-id.json"), - Charsets.UTF_8); + Charsets.UTF_8); return impressionJson.replaceAll("\\s+", ""); } @@ -135,7 +147,7 @@ static String generateConversionJson() throws IOException { static String generateConversionWithSessionIdJson() throws IOException { String conversionJson = Resources.toString(Resources.getResource("serializer/conversion-session-id.json"), - Charsets.UTF_8); + Charsets.UTF_8); return conversionJson.replaceAll("\\s+", ""); } } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java new file mode 100644 index 000000000..79aa96ff3 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/DefaultLRUCacheTest.java @@ -0,0 +1,172 @@ +/** + * + * Copyright 2022, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.*; + +public class DefaultLRUCacheTest { + + @Test + public void createSaveAndLookupOneItem() { + Cache<String> cache = new DefaultLRUCache<>(); + assertNull(cache.lookup("key1")); + cache.save("key1", "value1"); + assertEquals("value1", cache.lookup("key1")); + } + + @Test + public void saveAndLookupMultipleItems() { + DefaultLRUCache<List<String>> cache = new DefaultLRUCache<>(); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + String[] itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user1 to bottom of the list and push up others. + assertEquals("user2", itemKeys[0]); + assertEquals("user3", itemKeys[1]); + assertEquals("user1", itemKeys[2]); + + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user2 to bottom of the list and push up others. + assertEquals("user3", itemKeys[0]); + assertEquals("user1", itemKeys[1]); + assertEquals("user2", itemKeys[2]); + + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // Lookup should move user3 to bottom of the list and push up others. + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + } + + @Test + public void saveShouldReorderList() { + DefaultLRUCache<List<String>> cache = new DefaultLRUCache<>(); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + String[] itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user1 to bottom of the list and push up others. + assertEquals("user2", itemKeys[0]); + assertEquals("user3", itemKeys[1]); + assertEquals("user1", itemKeys[2]); + + cache.save("user2", Arrays.asList("segment3", "segment4")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user2 to bottom of the list and push up others. + assertEquals("user3", itemKeys[0]); + assertEquals("user1", itemKeys[1]); + assertEquals("user2", itemKeys[2]); + + cache.save("user3", Arrays.asList("segment5", "segment6")); + + itemKeys = cache.linkedHashMap.keySet().toArray(new String[0]); + // save should move user3 to bottom of the list and push up others. + assertEquals("user1", itemKeys[0]); + assertEquals("user2", itemKeys[1]); + assertEquals("user3", itemKeys[2]); + } + + @Test + public void whenCacheIsDisabled() { + DefaultLRUCache<List<String>> cache = new DefaultLRUCache<>(0,Cache.DEFAULT_TIMEOUT_SECONDS); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertNull(cache.lookup("user1")); + assertNull(cache.lookup("user2")); + assertNull(cache.lookup("user3")); + } + + @Test + public void whenItemsExpire() throws InterruptedException { + DefaultLRUCache<List<String>> cache = new DefaultLRUCache<>(Cache.DEFAULT_MAX_SIZE, 1); + cache.save("user1", Arrays.asList("segment1", "segment2")); + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + assertEquals(1, cache.linkedHashMap.size()); + Thread.sleep(1000); + assertNull(cache.lookup("user1")); + assertEquals(0, cache.linkedHashMap.size()); + } + + @Test + public void whenCacheReachesMaxSize() { + DefaultLRUCache<List<String>> cache = new DefaultLRUCache<>(2, Cache.DEFAULT_TIMEOUT_SECONDS); + + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertEquals(2, cache.linkedHashMap.size()); + + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + assertNull(cache.lookup("user1")); + } + + @Test + public void whenCacheIsReset() { + DefaultLRUCache<List<String>> cache = new DefaultLRUCache<>(); + cache.save("user1", Arrays.asList("segment1", "segment2")); + cache.save("user2", Arrays.asList("segment3", "segment4")); + cache.save("user3", Arrays.asList("segment5", "segment6")); + + assertEquals(Arrays.asList("segment1", "segment2"), cache.lookup("user1")); + assertEquals(Arrays.asList("segment3", "segment4"), cache.lookup("user2")); + assertEquals(Arrays.asList("segment5", "segment6"), cache.lookup("user3")); + + assertEquals(3, cache.linkedHashMap.size()); + + cache.reset(); + + assertNull(cache.lookup("user1")); + assertNull(cache.lookup("user2")); + assertNull(cache.lookup("user3")); + + assertEquals(0, cache.linkedHashMap.size()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java index bfdcf08df..d7965ccac 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/ExperimentUtilsTest.java @@ -1,12 +1,12 @@ /** * - * Copyright 2017, Optimizely and contributors + * Copyright 2017, 2019-2020, 2022, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,6 +16,7 @@ */ package com.optimizely.ab.internal; +import ch.qos.logback.classic.Level; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Experiment.ExperimentStatus; import com.optimizely.ab.config.ProjectConfig; @@ -23,21 +24,27 @@ import com.optimizely.ab.config.Variation; import com.optimizely.ab.config.audience.Audience; import com.optimizely.ab.config.audience.Condition; +import com.optimizely.ab.config.audience.TypedAudience; +import com.optimizely.ab.testutils.OTUtils; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.junit.BeforeClass; +import org.junit.Rule; import org.junit.Test; import java.io.IOException; import java.util.Collections; import java.util.Map; -import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV2; -import static com.optimizely.ab.config.ProjectConfigTestUtils.validProjectConfigV4; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.noAudienceProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV2; +import static com.optimizely.ab.config.DatafileProjectConfigTestUtils.validProjectConfigV4; import static com.optimizely.ab.config.ValidProjectConfigV4.ATTRIBUTE_NATIONALITY_KEY; import static com.optimizely.ab.config.ValidProjectConfigV4.AUDIENCE_WITH_MISSING_VALUE_VALUE; import static com.optimizely.ab.config.ValidProjectConfigV4.EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY; import static com.optimizely.ab.internal.ExperimentUtils.isExperimentActive; -import static com.optimizely.ab.internal.ExperimentUtils.isUserInExperiment; +import static com.optimizely.ab.internal.ExperimentUtils.doesUserMeetAudienceConditions; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.EXPERIMENT; +import static com.optimizely.ab.internal.LoggingConstants.LoggingEntityType.RULE; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -46,6 +53,9 @@ */ public class ExperimentUtilsTest { + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + private static ProjectConfig projectConfig; private static ProjectConfig noAudienceProjectConfig; private static ProjectConfig v4ProjectConfig; @@ -114,48 +124,116 @@ public void isExperimentActiveReturnsFalseWhenTheExperimentIsNotStarted() { /** * If the {@link Experiment} does not have any {@link Audience}s, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return true; + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return true; */ @Test - public void isUserInExperimentReturnsTrueIfExperimentHasNoAudiences() { + public void doesUserMeetAudienceConditionsReturnsTrueIfExperimentHasNoAudiences() { Experiment experiment = noAudienceProjectConfig.getExperiments().get(0); + assertTrue(doesUserMeetAudienceConditions(noAudienceProjectConfig, experiment, OTUtils.user(Collections.<String, String>emptyMap()), RULE, "Everyone Else").getResult()); + } - assertTrue(isUserInExperiment(noAudienceProjectConfig, experiment, Collections.<String, String>emptyMap())); + /** + * If the {@link Experiment} contains at least one {@link Audience}, but attributes is empty, + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. + */ + @Test + public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserHasNoAttributes() { + Experiment experiment = projectConfig.getExperiments().get(0); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(Collections.<String, String>emptyMap()), EXPERIMENT, experiment.getKey()).getResult(); + assertTrue(result); + logbackVerifier.expectMessage(Level.DEBUG, + "Evaluating audiences for experiment \"etag1\": [100]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"100\" evaluated to true."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"etag1\" collectively evaluated to true."); } /** * If the {@link Experiment} contains at least one {@link Audience}, but attributes is empty, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return false. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. */ + @SuppressFBWarnings("NP_NULL_PARAM_DEREF_NONVIRTUAL") @Test - public void isUserInExperimentReturnsFalseIfExperimentHasAudiencesButUserHasNoAttributes() { + public void doesUserMeetAudienceConditionsEvaluatesEvenIfExperimentHasAudiencesButUserSendNullAttributes() throws Exception { Experiment experiment = projectConfig.getExperiments().get(0); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(null), EXPERIMENT, experiment.getKey()).getResult(); + + assertTrue(result); + logbackVerifier.expectMessage(Level.DEBUG, + "Evaluating audiences for experiment \"etag1\": [100]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"100\" evaluated to true."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"etag1\" collectively evaluated to true."); + } + + /** + * If the {@link Experiment} contains {@link TypedAudience}, and attributes is valid and true, + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return true. + */ + @Test + public void doesUserMeetAudienceConditionsEvaluatesExperimentHasTypedAudiences() { + Experiment experiment = v4ProjectConfig.getExperiments().get(1); + Map<String, Boolean> attribute = Collections.singletonMap("booleanKey", true); + Boolean result = doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(attribute), EXPERIMENT, experiment.getKey()).getResult(); - assertFalse(isUserInExperiment(projectConfig, experiment, Collections.<String, String>emptyMap())); + assertTrue(result); + logbackVerifier.expectMessage(Level.DEBUG, + "Evaluating audiences for experiment \"typed_audience_experiment\": [or, 3468206643, 3468206644, 3468206646, 3468206645]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"3468206643\" with conditions: [and, [or, [or, {name='booleanKey', type='custom_attribute', match='exact', value=true}]]]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"3468206643\" evaluated to true."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"typed_audience_experiment\" collectively evaluated to true."); } /** * If the attributes satisfies at least one {@link Condition} in an {@link Audience} of the {@link Experiment}, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return true. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return true. */ @Test - public void isUserInExperimentReturnsTrueIfUserSatisfiesAnAudience() { + public void doesUserMeetAudienceConditionsReturnsTrueIfUserSatisfiesAnAudience() { Experiment experiment = projectConfig.getExperiments().get(0); Map<String, String> attributes = Collections.singletonMap("browser_type", "chrome"); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(attributes), EXPERIMENT, experiment.getKey()).getResult(); - assertTrue(isUserInExperiment(projectConfig, experiment, attributes)); + assertTrue(result); + logbackVerifier.expectMessage(Level.DEBUG, + "Evaluating audiences for experiment \"etag1\": [100]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"100\" evaluated to true."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"etag1\" collectively evaluated to true."); } /** * If the attributes satisfies no {@link Condition} of any {@link Audience} of the {@link Experiment}, - * then {@link ExperimentUtils#isUserInExperiment(ProjectConfig, Experiment, Map)} should return false. + * then {@link ExperimentUtils#doesUserMeetAudienceConditions(ProjectConfig, Experiment, Map, String, String)} should return false. */ @Test - public void isUserInExperimentReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { + public void doesUserMeetAudienceConditionsReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { Experiment experiment = projectConfig.getExperiments().get(0); Map<String, String> attributes = Collections.singletonMap("browser_type", "firefox"); + Boolean result = doesUserMeetAudienceConditions(projectConfig, experiment, OTUtils.user(attributes), EXPERIMENT, experiment.getKey()).getResult(); + + assertFalse(result); + logbackVerifier.expectMessage(Level.DEBUG, + "Evaluating audiences for experiment \"etag1\": [100]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"100\" with conditions: [and, [or, [not, [or, {name='browser_type', type='custom_attribute', match='null', value='firefox'}]]]]."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"100\" evaluated to false."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"etag1\" collectively evaluated to false."); - assertFalse(isUserInExperiment(projectConfig, experiment, attributes)); } /** @@ -163,19 +241,55 @@ public void isUserInExperimentReturnsTrueIfUserDoesNotSatisfyAnyAudiences() { * they must explicitly pass in null in order for us to evaluate this. Otherwise we will say they do not match. */ @Test - public void isUserInExperimentHandlesNullValue() { + public void doesUserMeetAudienceConditionsHandlesNullValue() { Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); Map<String, String> satisfiesFirstCondition = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, - AUDIENCE_WITH_MISSING_VALUE_VALUE); - Map<String, String> attributesWithNull = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, null); + AUDIENCE_WITH_MISSING_VALUE_VALUE); Map<String, String> nonMatchingMap = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, "American"); - assertTrue(isUserInExperiment(v4ProjectConfig, experiment, satisfiesFirstCondition)); - assertTrue(isUserInExperiment(v4ProjectConfig, experiment, attributesWithNull)); - assertFalse(isUserInExperiment(v4ProjectConfig, experiment, nonMatchingMap)); + assertTrue(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(satisfiesFirstCondition), EXPERIMENT, experiment.getKey()).getResult()); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(nonMatchingMap), EXPERIMENT, experiment.getKey()).getResult()); + } + + /** + * Audience will evaluate null when condition value is null and attribute value passed is also null + */ + @Test + public void doesUserMeetAudienceConditionsHandlesNullValueAttributesWithNull() { + Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); + Map<String, String> attributesWithNull = Collections.singletonMap(ATTRIBUTE_NATIONALITY_KEY, null); + + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(attributesWithNull), EXPERIMENT, experiment.getKey()).getResult()); + + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); + logbackVerifier.expectMessage(Level.WARN, + "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"2196265320\" evaluated to null."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"experiment_with_malformed_audience\" collectively evaluated to null."); + } + + /** + * Audience will evaluate null when condition value is null + */ + @Test + public void doesUserMeetAudienceConditionsHandlesNullConditionValue() { + Experiment experiment = v4ProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_WITH_MALFORMED_AUDIENCE_KEY); + Map<String, String> attributesEmpty = Collections.emptyMap(); // It should explicitly be set to null otherwise we will return false on empty maps - assertFalse(isUserInExperiment(v4ProjectConfig, experiment, Collections.<String, String>emptyMap())); + assertFalse(doesUserMeetAudienceConditions(v4ProjectConfig, experiment, OTUtils.user(attributesEmpty), EXPERIMENT, experiment.getKey()).getResult()); + + logbackVerifier.expectMessage(Level.DEBUG, + "Starting to evaluate audience \"2196265320\" with conditions: [and, [or, [or, {name='nationality', type='custom_attribute', match='null', value='English'}, {name='nationality', type='custom_attribute', match='null', value=null}]]]."); + logbackVerifier.expectMessage(Level.WARN, + "Audience condition \"{name='nationality', type='custom_attribute', match='null', value=null}\" has an unsupported condition value. You may need to upgrade to a newer release of the Optimizely SDK."); + logbackVerifier.expectMessage(Level.DEBUG, + "Audience \"2196265320\" evaluated to null."); + logbackVerifier.expectMessage(Level.INFO, + "Audiences for experiment \"experiment_with_malformed_audience\" collectively evaluated to null."); } /** @@ -186,13 +300,14 @@ public void isUserInExperimentHandlesNullValue() { */ private Experiment makeMockExperimentWithStatus(ExperimentStatus status) { return new Experiment("12345", - "mockExperimentKey", - status.toString(), - "layerId", - Collections.<String>emptyList(), - Collections.<Variation>emptyList(), - Collections.<String, String>emptyMap(), - Collections.<TrafficAllocation>emptyList() - ); + "mockExperimentKey", + status.toString(), + "layerId", + Collections.<String>emptyList(), + null, + Collections.<Variation>emptyList(), + Collections.<String, String>emptyMap(), + Collections.<TrafficAllocation>emptyList() + ); } } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java b/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java new file mode 100644 index 000000000..a65e9b6f5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java @@ -0,0 +1,46 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class JsonParserProviderTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonParserProviderWhenNoDefaultIsSet() { + assertEquals(JsonParserProvider.GSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } + + @Test + public void getCorrectParserProviderWhenValidDefaultIsProvided() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonParserProvider.JSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } + + @Test + public void getGsonParserWhenProvidedDefaultParserDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(JsonParserProvider.GSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java b/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java index 3ce4f39a7..b967d1790 100644 --- a/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java +++ b/core-api/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java @@ -1,36 +1,48 @@ +/** + * Copyright 2019, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.optimizely.ab.internal; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.IThrowableProxy; -import ch.qos.logback.core.Appender; +import ch.qos.logback.core.AppenderBase; import org.junit.rules.TestRule; import org.junit.runner.Description; import org.junit.runners.model.Statement; -import org.mockito.ArgumentMatcher; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.verification.VerificationMode; import org.slf4j.LoggerFactory; import java.util.LinkedList; import java.util.List; +import java.util.ListIterator; -import static org.mockito.Matchers.argThat; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.when; -import static org.mockito.MockitoAnnotations.initMocks; +import static org.junit.Assert.fail; /** + * TODO As a usability improvement we should require expected messages be added after the message are expected to be + * logged. This will allow us to map the failure immediately back to the test line number as opposed to the async + * validation now that happens at the end of each individual test. + * * From http://techblog.kenshoo.com/2013/08/junit-rule-for-verifying-logback-logging.html */ public class LogbackVerifier implements TestRule { private List<ExpectedLogEvent> expectedEvents = new LinkedList<ExpectedLogEvent>(); - @Mock - private Appender<ILoggingEvent> appender; + private CaptureAppender appender; @Override public Statement apply(final Statement base, Description description) { @@ -57,34 +69,46 @@ public void expectMessage(Level level, String msg) { } public void expectMessage(Level level, String msg, Class<? extends Throwable> throwableClass) { - expectMessage(level, msg, null, times(1)); + expectMessage(level, msg, null, 1); } - public void expectMessage(Level level, String msg, VerificationMode times) { + public void expectMessage(Level level, String msg, int times) { expectMessage(level, msg, null, times); } public void expectMessage(Level level, String msg, Class<? extends Throwable> throwableClass, - VerificationMode times) { - expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass, times)); + int times) { + for (int i = 0; i < times; i++) { + expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass)); + } } private void before() { - initMocks(this); - when(appender.getName()).thenReturn("MOCK"); + appender = new CaptureAppender(); + appender.setName("MOCK"); + appender.start(); ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).addAppender(appender); } private void verify() throws Throwable { + ListIterator<ILoggingEvent> actualIterator = appender.getEvents().listIterator(); + for (final ExpectedLogEvent expectedEvent : expectedEvents) { - Mockito.verify(appender, expectedEvent.times).doAppend(argThat(new ArgumentMatcher<ILoggingEvent>() { - @Override - public boolean matches(final Object argument) { - return expectedEvent.matches((ILoggingEvent) argument); + boolean found = false; + while (actualIterator.hasNext()) { + ILoggingEvent actual = actualIterator.next(); + + if (expectedEvent.matches(actual)) { + found = true; + break; } - })); + } + + if (!found) { + fail(expectedEvent.toString()); + } } } @@ -92,20 +116,31 @@ private void after() { ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).detachAppender(appender); } + private static class CaptureAppender extends AppenderBase<ILoggingEvent> { + + List<ILoggingEvent> actualLoggingEvent = new LinkedList<>(); + + @Override + protected void append(ILoggingEvent eventObject) { + actualLoggingEvent.add(eventObject); + } + + public List<ILoggingEvent> getEvents() { + return actualLoggingEvent; + } + } + private final static class ExpectedLogEvent { private final String message; private final Level level; private final Class<? extends Throwable> throwableClass; - private final VerificationMode times; private ExpectedLogEvent(Level level, String message, - Class<? extends Throwable> throwableClass, - VerificationMode times) { + Class<? extends Throwable> throwableClass) { this.message = message; this.level = level; this.throwableClass = throwableClass; - this.times = times; } private boolean matches(ILoggingEvent actual) { @@ -119,5 +154,15 @@ private boolean matchThrowables(ILoggingEvent actual) { IThrowableProxy eventProxy = actual.getThrowableProxy(); return throwableClass == null || eventProxy != null && throwableClass.getName().equals(eventProxy.getClassName()); } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ExpectedLogEvent{"); + sb.append("level=").append(level); + sb.append(", message='").append(message).append('\''); + sb.append(", throwableClass=").append(throwableClass); + sb.append('}'); + return sb.toString(); + } } } diff --git a/core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java b/core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java new file mode 100644 index 000000000..4f130a848 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/NotificationRegistryTest.java @@ -0,0 +1,84 @@ +/** + * + * Copyright 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.notification.NotificationCenter; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.Assert; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + + +public class NotificationRegistryTest { + + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void getNullNotificationCenterWhenSDKeyIsNull() { + String sdkKey = null; + NotificationCenter notificationCenter = NotificationRegistry.getInternalNotificationCenter(sdkKey); + assertNull(notificationCenter); + } + + @Test + public void getSameNotificationCenterWhenSDKKeyIsSameButNotNull() { + String sdkKey = "testSDkKey"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey); + assertEquals(notificationCenter1, notificationCenter2); + } + + @Test + public void getSameNotificationCenterWhenSDKKeyIsEmpty() { + String sdkKey1 = ""; + String sdkKey2 = ""; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey2); + assertEquals(notificationCenter1, notificationCenter2); + } + + @Test + public void getDifferentNotificationCenterWhenSDKKeyIsNotSame() { + String sdkKey1 = "testSDkKey1"; + String sdkKey2 = "testSDkKey2"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey2); + Assert.assertNotEquals(notificationCenter1, notificationCenter2); + } + + @Test + public void clearRegistryNotificationCenterClearsOldNotificationCenter() { + String sdkKey1 = "testSDkKey1"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationRegistry.clearNotificationCenterRegistry(sdkKey1); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + + Assert.assertNotEquals(notificationCenter1, notificationCenter2); + } + + @SuppressFBWarnings("NP_NONNULL_PARAM_VIOLATION") + @Test + public void clearRegistryNotificationCenterWillNotCauseExceptionIfPassedNullSDkKey() { + String sdkKey1 = "testSDkKey1"; + NotificationCenter notificationCenter1 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + NotificationRegistry.clearNotificationCenterRegistry(null); + NotificationCenter notificationCenter2 = NotificationRegistry.getInternalNotificationCenter(sdkKey1); + + Assert.assertEquals(notificationCenter1, notificationCenter2); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/PropertyUtilTest.java b/core-api/src/test/java/com/optimizely/ab/internal/PropertyUtilTest.java new file mode 100644 index 000000000..6c16a52d7 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/PropertyUtilTest.java @@ -0,0 +1,51 @@ +/** + * + * Copyright 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class PropertyUtilTest { + + private static final String SHARED_KEY = "test.prop"; + private static final String EXPECTED = "bar"; + + @After + public void tearDown() { + System.setProperty(SHARED_KEY, "INVALID"); + } + + @Test + public void testSystemPropBeforeOptimizelyProp() { + String expected = "foo"; + System.setProperty("optimizely." + SHARED_KEY, expected); + assertEquals(expected, PropertyUtils.get(SHARED_KEY)); + } + + @Test + public void getFromOptimizelyProp() { + assertEquals(EXPECTED, PropertyUtils.get("file.only")); + } + + @Test + public void getFromSystemProp() { + System.setProperty("optimizely.sys.only", EXPECTED); + assertEquals(EXPECTED, PropertyUtils.get("sys.only")); + } +} \ No newline at end of file diff --git a/core-api/src/test/java/com/optimizely/ab/internal/SafetyUtilsTest.java b/core-api/src/test/java/com/optimizely/ab/internal/SafetyUtilsTest.java new file mode 100644 index 000000000..462a4218b --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/SafetyUtilsTest.java @@ -0,0 +1,58 @@ +/** + * + * Copyright 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.Test; + +import java.io.Closeable; +import java.io.IOException; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class SafetyUtilsTest { + + @Test + public void tryCloseAutoCloseable() throws Exception { + AutoCloseable autocloseable = mock(AutoCloseable.class); + SafetyUtils.tryClose(autocloseable); + + verify(autocloseable).close(); + } + + @Test + public void tryCloseCloseable() throws Exception { + Closeable closeable = mock(Closeable.class); + SafetyUtils.tryClose(closeable); + + verify(closeable).close(); + } + + @Test + public void tryCloseNullDoesNotThrow() throws Exception { + SafetyUtils.tryClose(null); + } + + @Test + public void tryCloseExceptionDoesNotThrow() throws Exception { + AutoCloseable autocloseable = mock(AutoCloseable.class); + doThrow(new RuntimeException()).when(autocloseable).close(); + SafetyUtils.tryClose(autocloseable); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/ActivateNotificationListenerTest.java b/core-api/src/test/java/com/optimizely/ab/notification/ActivateNotificationListenerTest.java new file mode 100644 index 000000000..f7fcda09b --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/ActivateNotificationListenerTest.java @@ -0,0 +1,75 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.LogEvent; +import org.junit.Before; +import org.junit.Test; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +public class ActivateNotificationListenerTest { + + private static final Experiment EXPERIMENT = mock(Experiment.class); + private static final Variation VARIATION = mock(Variation.class); + private static final String USER_ID = "userID"; + private static final Map<String, String> USER_ATTRIBUTES = Collections.singletonMap("user", "attr"); + private static final LogEvent LOG_EVENT = new LogEvent( + LogEvent.RequestMethod.POST, + "endpoint", + Collections.emptyMap(), + null + ); + + private ActivateNotification activateNotification; + private ActivateNotificationListener activateNotificationListener; + + @Before + public void setUp() throws Exception { + activateNotification = new ActivateNotification(EXPERIMENT, USER_ID, USER_ATTRIBUTES, VARIATION, LOG_EVENT); + activateNotificationListener = new ActivateNotificationHandler(); + } + + @Test + public void testNotifyWithArgArray() { + activateNotificationListener.notify(EXPERIMENT, USER_ID, USER_ATTRIBUTES, VARIATION, LOG_EVENT); + } + + @Test + public void testNotifyWithActivateNotificationArg() { + activateNotificationListener.handle(activateNotification); + } + + private static class ActivateNotificationHandler extends ActivateNotificationListener { + + @Override + public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, ?> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { + assertEquals(EXPERIMENT, experiment); + assertEquals(USER_ID, userId); + assertEquals(USER_ATTRIBUTES, attributes); + assertEquals(VARIATION, variation); + assertEquals(LOG_EVENT, event); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/ActivateNotificationTest.java b/core-api/src/test/java/com/optimizely/ab/notification/ActivateNotificationTest.java new file mode 100644 index 000000000..567cc1434 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/ActivateNotificationTest.java @@ -0,0 +1,78 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.LogEvent; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +public class ActivateNotificationTest { + + private static final Experiment EXPERIMENT = mock(Experiment.class); + private static final Variation VARIATION = mock(Variation.class); + private static final String USER_ID = "userID"; + private static final Map<String, String> USER_ATTRIBUTES = Collections.singletonMap("user", "attr"); + private static final LogEvent LOG_EVENT = new LogEvent( + LogEvent.RequestMethod.POST, + "endpoint", + Collections.emptyMap(), + null + ); + + private ActivateNotification activateNotification; + + @Before + public void setUp() throws Exception { + activateNotification = new ActivateNotification(EXPERIMENT, USER_ID, USER_ATTRIBUTES, VARIATION, LOG_EVENT); + } + + @Test + public void testGetExperiment() { + assertEquals(EXPERIMENT, activateNotification.getExperiment()); + } + + @Test + public void testGetUserId() { + assertEquals(USER_ID, activateNotification.getUserId()); + } + + @Test + public void testGetAttributes() { + assertEquals(USER_ATTRIBUTES, activateNotification.getAttributes()); + } + + @Test + public void testGetVariation() { + assertEquals(VARIATION, activateNotification.getVariation()); + } + + @Test + public void testGetEvent() { + assertEquals(LOG_EVENT, activateNotification.getEvent()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/DecisionNotificationTest.java b/core-api/src/test/java/com/optimizely/ab/notification/DecisionNotificationTest.java new file mode 100644 index 000000000..8ba0e7f7b --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/DecisionNotificationTest.java @@ -0,0 +1,209 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.notification; + +import com.optimizely.ab.OptimizelyRuntimeException; +import com.optimizely.ab.bucketing.FeatureDecision; +import com.optimizely.ab.config.FeatureVariable; +import com.optimizely.ab.config.Variation; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class DecisionNotificationTest { + + private static final Boolean FEATURE_ENABLED = Boolean.FALSE; + private static final String EXPERIMENT_KEY = "experimentKey"; + private static final String FEATURE_KEY = "featureKey"; + private static final String FEATURE_VARIABLE_KEY = "featureVariableKey"; + private static final String FEATURE_TEST = "featureTest"; + private static final String FEATURE_TEST_VARIATION = "featureTestVariation"; + private static final String USER_ID = "userID"; + private static final Map<String, String> USER_ATTRIBUTES = Collections.singletonMap("user", "attr"); + private static final Variation VARIATION = mock(Variation.class); + + private FeatureTestSourceInfo featureTestSourceInfo; + private RolloutSourceInfo rolloutSourceInfo; + private DecisionNotification experimentDecisionNotification; + private DecisionNotification featureDecisionNotification; + private DecisionNotification featureVariableDecisionNotification; + + @Before + public void setUp() { + experimentDecisionNotification = DecisionNotification.newExperimentDecisionNotificationBuilder() + .withUserId(USER_ID) + .withAttributes(USER_ATTRIBUTES) + .withExperimentKey(EXPERIMENT_KEY) + .withVariation(VARIATION) + .withType(NotificationCenter.DecisionNotificationType.AB_TEST.toString()) + .build(); + featureTestSourceInfo = new FeatureTestSourceInfo(FEATURE_TEST, FEATURE_TEST_VARIATION); + rolloutSourceInfo = new RolloutSourceInfo(); + featureDecisionNotification = DecisionNotification.newFeatureDecisionNotificationBuilder() + .withUserId(USER_ID) + .withFeatureKey(FEATURE_KEY) + .withFeatureEnabled(FEATURE_ENABLED) + .withSource(FeatureDecision.DecisionSource.FEATURE_TEST) + .withAttributes(USER_ATTRIBUTES) + .withSourceInfo(featureTestSourceInfo) + .build(); + featureVariableDecisionNotification = DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withUserId(USER_ID) + .withFeatureKey(FEATURE_KEY) + .withFeatureEnabled(Boolean.TRUE) + .withVariableKey(FEATURE_VARIABLE_KEY) + .withVariableType(FeatureVariable.STRING_TYPE) + .withAttributes(USER_ATTRIBUTES) + .build(); + } + + @Test + public void testGetType() { + assertEquals(NotificationCenter.DecisionNotificationType.AB_TEST.toString(), experimentDecisionNotification.getType()); + assertEquals(NotificationCenter.DecisionNotificationType.FEATURE.toString(), featureDecisionNotification.getType()); + assertEquals(NotificationCenter.DecisionNotificationType.FEATURE_VARIABLE.toString(), featureVariableDecisionNotification.getType()); + } + + @Test + public void testGetUserId() { + assertEquals(USER_ID, experimentDecisionNotification.getUserId()); + assertEquals(USER_ID, featureDecisionNotification.getUserId()); + assertEquals(USER_ID, featureVariableDecisionNotification.getUserId()); + } + + @Test + public void testGetAttributes() { + assertEquals(USER_ATTRIBUTES, experimentDecisionNotification.getAttributes()); + assertEquals(USER_ATTRIBUTES, featureDecisionNotification.getAttributes()); + assertEquals(USER_ATTRIBUTES, featureVariableDecisionNotification.getAttributes()); + } + + @Test + public void testGetDecisionInfo() { + // Assert for Experiment's DecisionInfo + HashMap<String, String> expectedExperimentDecisionInfo = new HashMap<>(); + expectedExperimentDecisionInfo.put(DecisionNotification.ExperimentDecisionNotificationBuilder.EXPERIMENT_KEY, EXPERIMENT_KEY); + expectedExperimentDecisionInfo.put(DecisionNotification.ExperimentDecisionNotificationBuilder.VARIATION_KEY, VARIATION.getKey()); + assertEquals(expectedExperimentDecisionInfo, experimentDecisionNotification.getDecisionInfo()); + + // Assert for Feature's DecisionInfo + Map<String, ?> actualFeatureDecisionInfo = featureDecisionNotification.getDecisionInfo(); + assertFalse((Boolean) actualFeatureDecisionInfo.get(DecisionNotification.FeatureDecisionNotificationBuilder.FEATURE_ENABLED)); + assertEquals(FEATURE_KEY, actualFeatureDecisionInfo.get(DecisionNotification.FeatureDecisionNotificationBuilder.FEATURE_KEY)); + assertEquals(FeatureDecision.DecisionSource.FEATURE_TEST.toString(), actualFeatureDecisionInfo.get(DecisionNotification.FeatureDecisionNotificationBuilder.SOURCE)); + assertEquals(featureTestSourceInfo.get(), actualFeatureDecisionInfo.get(DecisionNotification.FeatureDecisionNotificationBuilder.SOURCE_INFO)); + + // Assert for Feature Variable's DecisionInfo + Map<String, ?> actualFeatureVariableDecisionInfo = featureVariableDecisionNotification.getDecisionInfo(); + assertTrue((Boolean) actualFeatureVariableDecisionInfo.get(DecisionNotification.FeatureVariableDecisionNotificationBuilder.FEATURE_ENABLED)); + assertEquals(FEATURE_KEY, actualFeatureVariableDecisionInfo.get(DecisionNotification.FeatureVariableDecisionNotificationBuilder.FEATURE_KEY)); + assertEquals(FEATURE_VARIABLE_KEY, actualFeatureVariableDecisionInfo.get(DecisionNotification.FeatureVariableDecisionNotificationBuilder.VARIABLE_KEY)); + assertEquals(FeatureVariable.STRING_TYPE, actualFeatureVariableDecisionInfo.get(DecisionNotification.FeatureVariableDecisionNotificationBuilder.VARIABLE_TYPE)); + assertEquals(FeatureDecision.DecisionSource.ROLLOUT.toString(), actualFeatureVariableDecisionInfo.get(DecisionNotification.FeatureVariableDecisionNotificationBuilder.SOURCE)); + assertEquals(rolloutSourceInfo.get(), actualFeatureVariableDecisionInfo.get(DecisionNotification.FeatureVariableDecisionNotificationBuilder.SOURCE_INFO)); + } + + @Test + public void testToString() { + assertEquals("DecisionNotification{type='ab-test', userId='userID', attributes={user=attr}, decisionInfo={experimentKey=experimentKey, variationKey=null}}", experimentDecisionNotification.toString()); + assertEquals("DecisionNotification{type='feature', userId='userID', attributes={user=attr}, decisionInfo={featureEnabled=false, sourceInfo={experimentKey=featureTest, variationKey=featureTestVariation}, source=feature-test, featureKey=featureKey}}", featureDecisionNotification.toString()); + assertEquals("DecisionNotification{type='feature-variable', userId='userID', attributes={user=attr}, decisionInfo={variableType=string, featureEnabled=true, sourceInfo={}, variableValue=null, variableKey=featureVariableKey, source=rollout, featureKey=featureKey}}", featureVariableDecisionNotification.toString()); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullTypeFailsExperimentNotificationBuild() { + DecisionNotification.newExperimentDecisionNotificationBuilder() + .withExperimentKey(EXPERIMENT_KEY) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullExperimentKeyFailsExperimentNotificationBuild() { + DecisionNotification.newExperimentDecisionNotificationBuilder() + .withType(NotificationCenter.DecisionNotificationType.AB_TEST.toString()) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullSourceFailsFeatureNotificationBuild() { + DecisionNotification.newFeatureDecisionNotificationBuilder() + .withFeatureKey(FEATURE_KEY) + .withFeatureEnabled(FEATURE_ENABLED) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullFeatureKeyFailsFeatureNotificationBuild() { + DecisionNotification.newFeatureDecisionNotificationBuilder() + .withFeatureEnabled(FEATURE_ENABLED) + .withSource(FeatureDecision.DecisionSource.ROLLOUT) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullFeatureEnabledFailsFeatureNotificationBuild() { + DecisionNotification.newFeatureDecisionNotificationBuilder() + .withFeatureKey(FEATURE_KEY) + .withSource(FeatureDecision.DecisionSource.ROLLOUT) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullFeatureKeyFailsFeatureVariableNotificationBuild() { + DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withFeatureEnabled(Boolean.TRUE) + .withVariableKey(FEATURE_VARIABLE_KEY) + .withVariableType(FeatureVariable.STRING_TYPE) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullFeatureEnabledFailsFeatureVariableNotificationBuild() { + DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withFeatureKey(FEATURE_KEY) + .withVariableKey(FEATURE_VARIABLE_KEY) + .withVariableType(FeatureVariable.STRING_TYPE) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullVariableKeyFailsFeatureVariableNotificationBuild() { + DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withFeatureKey(FEATURE_KEY) + .withFeatureEnabled(Boolean.TRUE) + .withVariableType(FeatureVariable.STRING_TYPE) + .build(); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void nullVariableTypeFailsFeatureVariableNotificationBuild() { + DecisionNotification.newFeatureVariableDecisionNotificationBuilder() + .withFeatureKey(FEATURE_KEY) + .withFeatureEnabled(Boolean.TRUE) + .withVariableKey(FEATURE_VARIABLE_KEY) + .build(); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/FeatureTestSourceInfoTest.java b/core-api/src/test/java/com/optimizely/ab/notification/FeatureTestSourceInfoTest.java new file mode 100644 index 000000000..52cf7847f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/FeatureTestSourceInfoTest.java @@ -0,0 +1,47 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.notification; + +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; + +import static org.junit.Assert.assertEquals; + +public class FeatureTestSourceInfoTest { + + private static final String EXPERIMENT_KEY = "featureTestKey"; + private static final String VARIATION_KEY = "featureTestVariationKey"; + + private FeatureTestSourceInfo featureSourceInfo; + + @Before + public void setUp() { + featureSourceInfo = new FeatureTestSourceInfo(EXPERIMENT_KEY, VARIATION_KEY); + } + + @Test + public void testGet() { + HashMap<String, String> expectedSourceInfo = new HashMap<>(); + expectedSourceInfo.put("experimentKey", EXPERIMENT_KEY); + expectedSourceInfo.put("variationKey", VARIATION_KEY); + + assertEquals(expectedSourceInfo, featureSourceInfo.get()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java b/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java index 08958be64..c9e911029 100644 --- a/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java +++ b/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java @@ -1,16 +1,37 @@ +/** + * + * Copyright 2018-2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.optimizely.ab.notification; import ch.qos.logback.classic.Level; +import com.optimizely.ab.OptimizelyRuntimeException; import com.optimizely.ab.config.Experiment; import com.optimizely.ab.config.Variation; import com.optimizely.ab.event.LogEvent; import com.optimizely.ab.internal.LogbackVerifier; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import static junit.framework.TestCase.assertNotSame; import static junit.framework.TestCase.assertTrue; @@ -27,102 +48,203 @@ public class NotificationCenterTest { public LogbackVerifier logbackVerifier = new LogbackVerifier(); @Before - public void initialize() { + public void setUp() { notificationCenter = new NotificationCenter(); activateNotification = mock(ActivateNotificationListener.class); trackNotification = mock(TrackNotificationListener.class); } + @After + public void tearDown() { + notificationCenter.clearAllNotificationListeners(); + } + @Test public void testAddWrongTrackNotificationListener() { int notificationId = notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, trackNotification); - logbackVerifier.expectMessage(Level.WARN,"Notification listener was the wrong type. It was not added to the notification center."); - assertEquals(notificationId, -1); + logbackVerifier.expectMessage(Level.WARN, "Notification listener was the wrong type. It was not added to the notification center."); + assertEquals(-1, notificationId); assertFalse(notificationCenter.removeNotificationListener(notificationId)); - } @Test public void testAddWrongActivateNotificationListener() { int notificationId = notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Track, activateNotification); - logbackVerifier.expectMessage(Level.WARN,"Notification listener was the wrong type. It was not added to the notification center."); - assertEquals(notificationId, -1); + logbackVerifier.expectMessage(Level.WARN, "Notification listener was the wrong type. It was not added to the notification center."); + assertEquals(-1, notificationId); assertFalse(notificationCenter.removeNotificationListener(notificationId)); } + @Test + public void testAddDecisionNotificationTwice() { + NotificationHandler<DecisionNotification> handler = decisionNotification -> { }; + NotificationManager<DecisionNotification> manager = + notificationCenter.getNotificationManager(DecisionNotification.class); + + int notificationId = manager.addHandler(handler); + int notificationId2 = manager.addHandler(handler); + logbackVerifier.expectMessage(Level.WARN, "Notification listener was already added"); + assertEquals(-1, notificationId2); + assertTrue(notificationCenter.removeNotificationListener(notificationId)); + } + @Test public void testAddActivateNotificationTwice() { ActivateNotificationListener listener = new ActivateNotificationListener() { @Override - public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { + public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, ?> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { } }; int notificationId = notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, listener); int notificationId2 = notificationCenter.addNotificationListener(NotificationCenter.NotificationType.Activate, listener); - logbackVerifier.expectMessage(Level.WARN,"Notificication listener was already added"); - assertEquals(notificationId2, -1); + logbackVerifier.expectMessage(Level.WARN, "Notification listener was already added"); + assertEquals(-1, notificationId2); assertTrue(notificationCenter.removeNotificationListener(notificationId)); - notificationCenter.clearAllNotificationListeners(); } @Test public void testAddActivateNotification() { - int notificationId = notificationCenter.addActivateNotificationListener(new ActivateNotificationListenerInterface() { + int notificationId = notificationCenter.addActivateNotificationListener(new ActivateNotificationListener() { @Override - public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { + public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, ?> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { } }); - assertNotSame(notificationId, -1); + assertNotSame(-1, notificationId); assertTrue(notificationCenter.removeNotificationListener(notificationId)); - notificationCenter.clearAllNotificationListeners(); + } + + @Test + public void testAddDecisionNotification() { + NotificationManager<DecisionNotification> manager = notificationCenter.getNotificationManager(DecisionNotification.class); + int notificationId = manager.addHandler(decisionNotification -> { }); + assertNotSame(-1, notificationId); + assertTrue(manager.remove(notificationId)); } @Test public void testAddTrackNotification() { - int notificationId = notificationCenter.addTrackNotificationListener(new TrackNotificationListenerInterface() { + int notificationId = notificationCenter.addTrackNotificationListener(new TrackNotificationListener() { @Override - public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { + public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, ?> attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { } }); - assertNotSame(notificationId, -1); + assertNotSame(-1, notificationId); assertTrue(notificationCenter.removeNotificationListener(notificationId)); - notificationCenter.clearAllNotificationListeners(); } @Test public void testNotificationTypeClasses() { assertEquals(NotificationCenter.NotificationType.Activate.getNotificationTypeClass(), - ActivateNotificationListener.class); + ActivateNotificationListener.class); assertEquals(NotificationCenter.NotificationType.Track.getNotificationTypeClass(), TrackNotificationListener.class); } @Test public void testAddTrackNotificationInterface() { - int notificationId = notificationCenter.addTrackNotificationListener(new TrackNotificationListenerInterface() { - @Override - public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { + final AtomicBoolean triggered = new AtomicBoolean(); + int notificationId = notificationCenter.addTrackNotificationListener((eventKey, userId, attributes, eventTags, event) -> triggered.set(true)); + notificationCenter.send(new TrackNotification()); - } - }); - assertNotSame(notificationId, -1); + assertNotSame(-1, notificationId); + assertTrue(triggered.get()); assertTrue(notificationCenter.removeNotificationListener(notificationId)); - notificationCenter.clearAllNotificationListeners(); + } + + @Test + public void testAddDecisionNotificationInterface() { + NotificationManager<DecisionNotification> manager = notificationCenter.getNotificationManager(DecisionNotification.class); + int notificationId = manager.addHandler(decisionNotification -> { }); + assertNotSame(-1, notificationId); + assertTrue(manager.remove(notificationId)); } @Test public void testAddActivateNotificationInterface() { - int notificationId = notificationCenter.addActivateNotificationListener(new ActivateNotificationListenerInterface() { - @Override - public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map<String, String> attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { + final AtomicBoolean triggered = new AtomicBoolean(); + int notificationId = notificationCenter.addActivateNotificationListener((experiment, userId, attributes, variation, event) -> triggered.set(true)); + notificationCenter.send(new ActivateNotification()); - } - }); - assertNotSame(notificationId, -1); + assertNotSame(-1, notificationId); + assertTrue(triggered.get()); assertTrue(notificationCenter.removeNotificationListener(notificationId)); - notificationCenter.clearAllNotificationListeners(); } + @Test + public void testAddValidNotificationHandler() { + assertEquals(1, notificationCenter.addNotificationHandler(ActivateNotification.class, x -> {})); + assertEquals(2, notificationCenter.addNotificationHandler(DecisionNotification.class, x -> {})); + assertEquals(3, notificationCenter.addNotificationHandler(TrackNotification.class, x -> {})); + assertEquals(4, notificationCenter.addNotificationHandler(UpdateConfigNotification.class, x -> {})); + assertEquals(5, notificationCenter.addNotificationHandler(LogEvent.class, x -> {})); + } + + @Test + public void testAddInvalidNotificationHandler() { + int actual = notificationCenter.addNotificationHandler(Integer.class, i -> {}); + assertEquals(-1, actual); + } + + @Test + @Deprecated + public void testClearNotificationByActivateType() { + NotificationManager<ActivateNotification> manager = notificationCenter.getNotificationManager(ActivateNotification.class); + int id = manager.addHandler(message -> {}); + + notificationCenter.clearNotificationListeners(NotificationCenter.NotificationType.Activate); + assertFalse(manager.remove(id)); + } + + @Test + @Deprecated + public void testClearNotificationByTrackType() { + NotificationManager<TrackNotification> manager = notificationCenter.getNotificationManager(TrackNotification.class); + int id = manager.addHandler(message -> {}); + + notificationCenter.clearNotificationListeners(NotificationCenter.NotificationType.Track); + assertFalse(manager.remove(id)); + } + + @Test + @Deprecated + public void testAddActivateListenerInterface() { + int id = notificationCenter.addActivateNotificationListener((experiment, userId, attributes, variation, event) -> { }); + + NotificationManager<ActivateNotification> manager = notificationCenter.getNotificationManager(ActivateNotification.class); + assertTrue(manager.remove(id)); + } + + @Test + @Deprecated + public void testAddTrackListenerInterface() { + int id = notificationCenter.addTrackNotificationListener((experiment, userId, attributes, variation, event) -> { }); + + NotificationManager<TrackNotification> manager = notificationCenter.getNotificationManager(TrackNotification.class); + assertTrue(manager.remove(id)); + } + + @Test(expected = OptimizelyRuntimeException.class) + public void testSendWithoutHandler() { + notificationCenter.send(new TestNotification("")); + } + + @Test + public void testSendWithHandler() { + testSendWithNotification(new TrackNotification()); + testSendWithNotification(new DecisionNotification()); + testSendWithNotification(new ActivateNotification()); + testSendWithNotification(new LogEvent(LogEvent.RequestMethod.GET, "localhost", Collections.emptyMap(), null)); + } + + private void testSendWithNotification(Object notification) { + TestNotificationHandler handler = new TestNotificationHandler<>(); + notificationCenter.getNotificationManager(notification.getClass()).addHandler(handler); + notificationCenter.send(notification); + + List messages = handler.getMessages(); + assertEquals(1, messages.size()); + assertEquals(notification, messages.get(0)); + } } diff --git a/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java b/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java new file mode 100644 index 000000000..58767ac7a --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/NotificationManagerTest.java @@ -0,0 +1,106 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +public class NotificationManagerTest { + + private NotificationManager<TestNotification> notificationManager; + private AtomicInteger counter; + + @Before + public void setUp() { + counter = new AtomicInteger(); + notificationManager = new NotificationManager<>(counter); + } + + @Test + public void testAddListener() { + assertEquals(1, notificationManager.addHandler(new TestNotificationHandler<>())); + assertEquals(2, notificationManager.addHandler(new TestNotificationHandler<>())); + assertEquals(3, notificationManager.addHandler(new TestNotificationHandler<>())); + } + + @Test + public void testSend() { + TestNotificationHandler<TestNotification> handler = new TestNotificationHandler<>(); + assertEquals(1, notificationManager.addHandler(handler)); + + notificationManager.send(new TestNotification("message1")); + notificationManager.send(new TestNotification("message2")); + notificationManager.send(new TestNotification("message3")); + + List<TestNotification> messages = handler.getMessages(); + assertEquals(3, messages.size()); + assertEquals("message1", messages.get(0).getMessage()); + assertEquals("message2", messages.get(1).getMessage()); + assertEquals("message3", messages.get(2).getMessage()); + } + + @Test + public void testSendWithError() { + TestNotificationHandler<TestNotification> handler = new TestNotificationHandler<>(); + assertEquals(1, notificationManager.addHandler(message -> {throw new RuntimeException("handle me");})); + assertEquals(2, notificationManager.addHandler(handler)); + + notificationManager.send(new TestNotification("message1")); + + List<TestNotification> messages = handler.getMessages(); + assertEquals(1, messages.size()); + assertEquals("message1", messages.get(0).getMessage()); + } + + @Test + public void testThreadSafety() throws InterruptedException { + int numThreads = 10; + int numRepeats = 2; + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + CountDownLatch latch = new CountDownLatch(numThreads); + AtomicBoolean failedAlready = new AtomicBoolean(false); + + for(int i = 0; i < numThreads; i++) { + executor.execute(() -> { + try { + for (int j = 0; j < numRepeats; j++) { + if(!failedAlready.get()) { + notificationManager.addHandler(new TestNotificationHandler<>()); + notificationManager.send(new TestNotification("message1")); + } + } + } catch (Exception e) { + failedAlready.set(true); + } finally { + latch.countDown(); + } + }); + } + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertEquals(numThreads * numRepeats, notificationManager.size()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/RolloutSourceInfoTest.java b/core-api/src/test/java/com/optimizely/ab/notification/RolloutSourceInfoTest.java new file mode 100644 index 000000000..fc5d33652 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/RolloutSourceInfoTest.java @@ -0,0 +1,42 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.notification; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class RolloutSourceInfoTest { + + private RolloutSourceInfo rolloutSourceInfo; + + @Before + public void setUp() { + rolloutSourceInfo = new RolloutSourceInfo(); + } + + @Test + public void testGet() { + Map<String, String> expectedInfo = Collections.EMPTY_MAP; + assertEquals(expectedInfo, rolloutSourceInfo.get()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/TestNotification.java b/core-api/src/test/java/com/optimizely/ab/notification/TestNotification.java new file mode 100644 index 000000000..131d82896 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/TestNotification.java @@ -0,0 +1,32 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +/** + * TestNotification used for unit testing NotificationCenter and NotificationManager + */ +class TestNotification { + private final String message; + + TestNotification(String message) { + this.message = message; + } + + String getMessage() { + return message; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/TestNotificationHandler.java b/core-api/src/test/java/com/optimizely/ab/notification/TestNotificationHandler.java new file mode 100644 index 000000000..8941edff5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/TestNotificationHandler.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import java.util.ArrayList; +import java.util.List; + +/** + * TestNotificationHandler used for unit testing NotificationCenter and NotificationManager + */ +class TestNotificationHandler<T> implements NotificationHandler<T> { + private final List<T> messages = new ArrayList<>(); + + @Override + public void handle(T message) { + messages.add(message); + } + + List<T> getMessages() { + return messages; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/TrackNotificationListenerTest.java b/core-api/src/test/java/com/optimizely/ab/notification/TrackNotificationListenerTest.java new file mode 100644 index 000000000..5ad9c21cb --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/TrackNotificationListenerTest.java @@ -0,0 +1,72 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import com.optimizely.ab.event.LogEvent; +import org.junit.Before; +import org.junit.Test; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; + +public class TrackNotificationListenerTest { + + private static final String EVENT_KEY = "eventKey"; + private static final String USER_ID = "userID"; + private static final Map<String, String> USER_ATTRIBUTES = Collections.singletonMap("user", "attr"); + private static final Map<String, String> EVENT_TAGS = Collections.singletonMap("event", "tag"); + private static final LogEvent LOG_EVENT = new LogEvent( + LogEvent.RequestMethod.POST, + "endpoint", + Collections.emptyMap(), + null + ); + + private TrackNotification trackNotification; + private TrackNotificationHandler trackNotificationHandler; + + @Before + public void setUp() throws Exception { + trackNotification = new TrackNotification(EVENT_KEY, USER_ID, USER_ATTRIBUTES, EVENT_TAGS, LOG_EVENT); + trackNotificationHandler = new TrackNotificationHandler(); + } + + @Test + public void testNotifyWithArgArray() { + trackNotificationHandler.notify(EVENT_KEY, USER_ID, USER_ATTRIBUTES, EVENT_TAGS, LOG_EVENT); + } + + @Test + public void testNotifyWithTrackNotificationArg() { + trackNotificationHandler.handle(trackNotification); + } + + private static class TrackNotificationHandler extends TrackNotificationListener { + + @Override + public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map<String, ?> attributes, @Nonnull Map<String, ?> eventTags, @Nonnull LogEvent event) { + assertEquals(EVENT_KEY, eventKey); + assertEquals(USER_ID, userId); + assertEquals(USER_ATTRIBUTES, attributes); + assertEquals(EVENT_TAGS, eventTags); + assertEquals(LOG_EVENT, event); + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/notification/TrackNotificationTest.java b/core-api/src/test/java/com/optimizely/ab/notification/TrackNotificationTest.java new file mode 100644 index 000000000..dbe8abddc --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/TrackNotificationTest.java @@ -0,0 +1,77 @@ +/** + * + * Copyright 2019, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import com.optimizely.ab.event.LogEvent; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.*; + +public class TrackNotificationTest { + + private static final String EVENT_KEY = "eventKey"; + private static final String USER_ID = "userID"; + private static final Map<String, String> USER_ATTRIBUTES = Collections.singletonMap("user", "attr"); + private static final Map<String, String> EVENT_TAGS = Collections.singletonMap("event", "tag"); + private static final LogEvent LOG_EVENT = new LogEvent( + LogEvent.RequestMethod.POST, + "endpoint", + Collections.emptyMap(), + null + ); + + private TrackNotification trackNotification; + + @Before + public void setUp() throws Exception { + trackNotification = new TrackNotification(EVENT_KEY, USER_ID, USER_ATTRIBUTES, EVENT_TAGS, LOG_EVENT); + } + + @Test + public void testGetEventKey() { + assertEquals(EVENT_KEY, trackNotification.getEventKey()); + } + + @Test + public void testGetUserId() { + assertEquals(USER_ID, trackNotification.getUserId()); + } + + @Test + public void testGetAttributes() { + assertEquals(USER_ATTRIBUTES, trackNotification.getAttributes()); + } + + @Test + public void testGetEventTags() { + assertEquals(EVENT_TAGS, trackNotification.getEventTags()); + } + + @Test + public void testGetEvent() { + assertEquals(LOG_EVENT, trackNotification.getEvent()); + } + + @Test + public void testToString() { + assertEquals("TrackNotification{eventKey='eventKey', userId='userID', attributes={user=attr}, eventTags={event=tag}, event=LogEvent{requestMethod=POST, endpointUrl='endpoint', requestParams={}, body=''}}", trackNotification.toString()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java new file mode 100644 index 000000000..0ade4652f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPEventManagerTest.java @@ -0,0 +1,590 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.event.internal.BuildVersionInfo; +import com.optimizely.ab.event.internal.ClientEngineInfo; +import com.optimizely.ab.event.internal.payload.EventBatch; +import com.optimizely.ab.internal.LogbackVerifier; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.*; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +@RunWith(MockitoJUnitRunner.class) +public class ODPEventManagerTest { + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Mock + ODPApiManager mockApiManager; + + @Captor + ArgumentCaptor<String> payloadCaptor; + + @Test + public void logAndDiscardEventWhenEventManagerIsNotRunning() { + ODPConfig odpConfig = new ODPConfig("key", "host", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.singletonMap("any-key", "any-value"), Collections.emptyMap()); + eventManager.sendEvent(event); + logbackVerifier.expectMessage(Level.WARN, "Failed to Process ODP Event. ODPEventManager is not running"); + } + + @Test + public void logAndDiscardEventWhenODPConfigNotReady() { + ODPConfig odpConfig = new ODPConfig(null, null, null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); + eventManager.start(); + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.singletonMap("any-key", "any-value"), Collections.emptyMap()); + eventManager.sendEvent(event); + logbackVerifier.expectMessage(Level.DEBUG, "Unable to Process ODP Event. ODPConfig is not ready."); + } + + @Test + public void dispatchEventsInCorrectNumberOfBatches() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + } + + @Test + public void logAndDiscardEventWhenIdentifiersEmpty() throws InterruptedException { + int flushInterval = 0; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + + ODPEvent event = new ODPEvent("test-type", "test-action", Collections.emptyMap(), Collections.emptyMap()); + eventManager.sendEvent(event); + Thread.sleep(500); + Mockito.verify(mockApiManager, never()).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (event identifiers must have at least one key-value pair)"); + } + + @Test + public void dispatchEventsWithCorrectPayload() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + int flushInterval = 0; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 6; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + Mockito.verify(mockApiManager, times(6)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + List<String> payloads = payloadCaptor.getAllValues(); + + for (int i = 0; i < payloads.size(); i++) { + JSONArray events = new JSONArray(payloads.get(i)); + assertEquals(1, events.length()); + for (int j = 0; j < events.length(); j++) { + int id = (1 * i) + j; + JSONObject event = events.getJSONObject(j); + assertEquals("test-type-" + id , event.getString("type")); + assertEquals("test-action-" + id , event.getString("action")); + assertEquals("value1-" + id, event.getJSONObject("identifiers").getString("identifier1")); + assertEquals("value2-" + id, event.getJSONObject("identifiers").getString("identifier2")); + assertEquals("data-value1-" + id, event.getJSONObject("data").getString("data1")); + assertEquals(id, event.getJSONObject("data").getInt("data2")); + assertEquals("sdk", event.getJSONObject("data").getString("data_source_type")); + } + } + } + + @Test + public void dispatchEventsWithCorrectFlushInterval() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + + // Last batch is incomplete so it needs almost a second to flush. + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + } + + @Test + public void retryFailedEvents() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(500); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + + // Should be called thrice for each batch + Mockito.verify(mockApiManager, times(6)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + + // Last batch is incomplete so it needs almost a second to flush. + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(9)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + } + + @Test + public void shouldFlushAllScheduledEventsBeforeStopping() throws InterruptedException { + int flushInterval = 20000; + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 8; i++) { + eventManager.sendEvent(getEvent(i)); + } + eventManager.stop(); + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + logbackVerifier.expectMessage(Level.DEBUG, "Exiting ODP Event Dispatcher Thread."); + } + + @Test + public void prepareCorrectPayloadForIdentifyUser() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + int flushInterval = 0; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 2; i++) { + eventManager.identifyUser("the-vuid-" + i, "the-fs-user-id-" + i); + } + + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + + String payload = payloadCaptor.getValue(); + JSONArray events = new JSONArray(payload); + assertEquals(1, events.length()); + for (int i = 0; i < events.length(); i++) { + JSONObject event = events.getJSONObject(i); + assertEquals("fullstack", event.getString("type")); + assertEquals("identified", event.getString("action")); + assertEquals("the-vuid-" + (i + 1), event.getJSONObject("identifiers").getString("vuid")); + assertEquals("the-fs-user-id-" + (i + 1), event.getJSONObject("identifiers").getString("fs_user_id")); + assertEquals("sdk", event.getJSONObject("data").getString("data_source_type")); + } + } + + @Test + public void preparePayloadForIdentifyUserWithVariationsOfFsUserId() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + int flushInterval = 1; + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager, null, flushInterval); + eventManager.updateSettings(odpConfig); + eventManager.start(); + ODPEvent event1 = new ODPEvent("fullstack", + "identified", + new HashMap<String, String>() {{ + put("fs-user-id", "123"); + }}, null); + + ODPEvent event2 = new ODPEvent("fullstack", + "identified", + new HashMap<String, String>() {{ + put("FS-user-ID", "123"); + }}, null); + + ODPEvent event3 = new ODPEvent("fullstack", + "identified", + new HashMap<String, String>() {{ + put("FS_USER_ID", "123"); + put("fs.user.id", "456"); + }}, null); + + ODPEvent event4 = new ODPEvent("fullstack", + "identified", + new HashMap<String, String>() {{ + put("fs_user_id", "123"); + put("fsuserid", "456"); + }}, null); + List<Map<String, String>> expectedIdentifiers = new ArrayList<Map<String, String>>() {{ + add(new HashMap<String, String>() {{ + put("fs_user_id", "123"); + }}); + add(new HashMap<String, String>() {{ + put("fs_user_id", "123"); + }}); + add(new HashMap<String, String>() {{ + put("fs_user_id", "123"); + put("fs.user.id", "456"); + }}); + add(new HashMap<String, String>() {{ + put("fs_user_id", "123"); + put("fsuserid", "456"); + }}); + }}; + eventManager.sendEvent(event1); + eventManager.sendEvent(event2); + eventManager.sendEvent(event3); + eventManager.sendEvent(event4); + + Thread.sleep(1500); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), payloadCaptor.capture()); + + String payload = payloadCaptor.getValue(); + JSONArray events = new JSONArray(payload); + assertEquals(4, events.length()); + for (int i = 0; i < events.length(); i++) { + JSONObject event = events.getJSONObject(i); + assertEquals(event.getJSONObject("identifiers").toMap(), expectedIdentifiers.get(i)); + } + } + + @Test + public void identifyUserWithVuidAndUserId() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor<ODPEvent> captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser("vuid_123", "test-user"); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map<String, String> identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 2); + assertEquals(identifiers.get("vuid"), "vuid_123"); + assertEquals(identifiers.get("fs_user_id"), "test-user"); + } + + @Test + public void identifyUserWithVuidOnly() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor<ODPEvent> captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser("vuid_123", null); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map<String, String> identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 1); + assertEquals(identifiers.get("vuid"), "vuid_123"); + } + + @Test + public void identifyUserWithUserIdOnly() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor<ODPEvent> captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser(null, "test-user"); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map<String, String> identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 1); + assertEquals(identifiers.get("fs_user_id"), "test-user"); + } + + @Test + public void identifyUserWithVuidAsUserId() throws InterruptedException { + ODPEventManager eventManager = spy(new ODPEventManager(mockApiManager)); + ArgumentCaptor<ODPEvent> captor = ArgumentCaptor.forClass(ODPEvent.class); + + eventManager.identifyUser(null, "vuid_123"); + verify(eventManager, times(1)).sendEvent(captor.capture()); + + ODPEvent event = captor.getValue(); + Map<String, String> identifiers = event.getIdentifiers(); + assertEquals(identifiers.size(), 1); + // SDK will convert userId to vuid when userId has a valid vuid format. + assertEquals(identifiers.get("vuid"), "vuid_123"); + } + + @Test + public void applyUpdatedODPConfigWhenAvailable() throws InterruptedException { + Mockito.reset(mockApiManager); + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(202); + ODPConfig odpConfig = new ODPConfig("key", "http://www.odp-host.com", null); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.updateSettings(odpConfig); + eventManager.start(); + for (int i = 0; i < 25; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(500); + Mockito.verify(mockApiManager, times(2)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + eventManager.updateSettings(new ODPConfig("new-key", "http://www.new-odp-host.com")); + + // Should immediately Flush current batch with old ODP config when settings are changed + Thread.sleep(100); + Mockito.verify(mockApiManager, times(3)).sendEvents(eq("key"), eq("http://www.odp-host.com/v3/events"), any()); + + // New events should use new config + for (int i = 0; i < 10; i++) { + eventManager.sendEvent(getEvent(i)); + } + Thread.sleep(100); + Mockito.verify(mockApiManager, times(1)).sendEvents(eq("new-key"), eq("http://www.new-odp-host.com/v3/events"), any()); + } + + @Test + public void validateEventData() { + ODPEvent event = new ODPEvent("type", "action", null, null); + Map<String, Object> data = new HashMap<>(); + + data.put("String", "string Value"); + data.put("Integer", 100); + data.put("Float", 33.89); + data.put("Boolean", true); + data.put("null", null); + event.setData(data); + assertTrue(event.isDataValid()); + + data.put("RandomObject", new Object()); + assertFalse(event.isDataValid()); + } + + @Test + public void validateEventCommonData() { + Map<String, Object> sourceData = new HashMap<>(); + sourceData.put("k1", "v1"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + Map<String, Object> merged = eventManager.augmentCommonData(sourceData); + + assertEquals(merged.get("k1"), "v1"); + assertTrue(merged.get("idempotence_id").toString().length() > 16); + assertEquals(merged.get("data_source_type"), "sdk"); + assertEquals(merged.get("data_source"), "java-sdk"); + assertTrue(merged.get("data_source_version").toString().length() > 0); + assertEquals(merged.size(), 5); + + // when clientInfo is overridden (android-sdk): + + ClientEngineInfo.setClientEngine(EventBatch.ClientEngine.ANDROID_SDK); + BuildVersionInfo.setClientVersion("1.2.3"); + merged = eventManager.augmentCommonData(sourceData); + + assertEquals(merged.get("k1"), "v1"); + assertTrue(merged.get("idempotence_id").toString().length() > 16); + assertEquals(merged.get("data_source_type"), "sdk"); + assertEquals(merged.get("data_source"), "android-sdk"); + assertEquals(merged.get("data_source_version"), "1.2.3"); + assertEquals(merged.size(), 5); + + // restore the default values for other tests + ClientEngineInfo.setClientEngine(ClientEngineInfo.DEFAULT); + BuildVersionInfo.setClientVersion(BuildVersionInfo.VERSION); + } + + @Test + public void validateAugmentCommonData() { + Map<String, Object> sourceData = new HashMap<>(); + sourceData.put("k1", "source-1"); + sourceData.put("k2", "source-2"); + Map<String, Object> userCommonData = new HashMap<>(); + userCommonData.put("k3", "common-1"); + userCommonData.put("k4", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map<String, Object> merged = eventManager.augmentCommonData(sourceData); + + // event-sourceData + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + // userCommonData + assertEquals(merged.get("k3"), "common-1"); + assertEquals(merged.get("k4"), "common-2"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertEquals(merged.get("data_source_type"), "sdk"); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 8); + } + + @Test + public void validateAugmentCommonData_keyConflicts1() { + Map<String, Object> sourceData = new HashMap<>(); + sourceData.put("k1", "source-1"); + sourceData.put("k2", "source-2"); + Map<String, Object> userCommonData = new HashMap<>(); + userCommonData.put("k1", "common-1"); + userCommonData.put("k2", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map<String, Object> merged = eventManager.augmentCommonData(sourceData); + + // event-sourceData overrides userCommonData + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertEquals(merged.get("data_source_type"), "sdk"); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 6); + } + + @Test + public void validateAugmentCommonData_keyConflicts2() { + Map<String, Object> sourceData = new HashMap<>(); + sourceData.put("data_source_type", "source-1"); + Map<String, Object> userCommonData = new HashMap<>(); + userCommonData.put("data_source_type", "common-1"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map<String, Object> merged = eventManager.augmentCommonData(sourceData); + + // event-sourceData overrides userCommonData and sdk-generated common data + assertEquals(merged.get("data_source_type"), "source-1"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 4); + } + + @Test + public void validateAugmentCommonData_keyConflicts3() { + Map<String, Object> sourceData = new HashMap<>(); + sourceData.put("k1", "source-1"); + Map<String, Object> userCommonData = new HashMap<>(); + userCommonData.put("data_source_type", "common-1"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonData(userCommonData); + + Map<String, Object> merged = eventManager.augmentCommonData(sourceData); + + // userCommonData overrides sdk-generated common data + assertEquals(merged.get("data_source_type"), "common-1"); + assertEquals(merged.get("k1"), "source-1"); + // sdk-generated common data + assertNotNull(merged.get("idempotence_id")); + assertNotNull(merged.get("data_source")); + assertNotNull(merged.get("data_source_version")); + + assertEquals(merged.size(), 5); + } + + @Test + public void validateAugmentCommonIdentifiers() { + Map<String, String> sourceIdentifiers = new HashMap<>(); + sourceIdentifiers.put("k1", "source-1"); + sourceIdentifiers.put("k2", "source-2"); + Map<String, String> userCommonIdentifiers = new HashMap<>(); + userCommonIdentifiers.put("k3", "common-1"); + userCommonIdentifiers.put("k4", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonIdentifiers(userCommonIdentifiers); + + Map<String, String> merged = eventManager.augmentCommonIdentifiers(sourceIdentifiers); + + // event-sourceIdentifiers + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + // userCommonIdentifiers + assertEquals(merged.get("k3"), "common-1"); + assertEquals(merged.get("k4"), "common-2"); + + assertEquals(merged.size(), 4); + } + + @Test + public void validateAugmentCommonIdentifiers_keyConflicts() { + Map<String, String> sourceIdentifiers = new HashMap<>(); + sourceIdentifiers.put("k1", "source-1"); + sourceIdentifiers.put("k2", "source-2"); + Map<String, String> userCommonIdentifiers = new HashMap<>(); + userCommonIdentifiers.put("k1", "common-1"); + userCommonIdentifiers.put("k2", "common-2"); + + Mockito.reset(mockApiManager); + ODPEventManager eventManager = new ODPEventManager(mockApiManager); + eventManager.setUserCommonIdentifiers(userCommonIdentifiers); + + Map<String, String> merged = eventManager.augmentCommonIdentifiers(sourceIdentifiers); + + // event-sourceIdentifiers overrides userCommonIdentifiers + assertEquals(merged.get("k1"), "source-1"); + assertEquals(merged.get("k2"), "source-2"); + + assertEquals(merged.size(), 2); + } + + private ODPEvent getEvent(int id) { + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("identifier1", "value1-" + id); + identifiers.put("identifier2", "value2-" + id); + + Map<String, Object> data = new HashMap<>(); + data.put("data1", "data-value1-" + id); + data.put("data2", id); + + return new ODPEvent("test-type-" + id , "test-action-" + id, identifiers, data); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java new file mode 100644 index 000000000..0dcc9104a --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerBuilderTest.java @@ -0,0 +1,95 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.internal.Cache; +import org.junit.Test; + +import java.util.*; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +public class ODPManagerBuilderTest { + + @Test + public void withApiManager() { + ODPApiManager mockApiManager = mock(ODPApiManager.class); + ODPManager odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("Segment-1", "Segment-2"))); + odpManager.getSegmentManager().getQualifiedSegments("test-user"); + verify(mockApiManager).fetchQualifiedSegments(any(), any(), any(), any(), any()); + } + + @Test + public void withSegmentManager() { + ODPSegmentManager mockSegmentManager = mock(ODPSegmentManager.class); + ODPEventManager mockEventManager = mock(ODPEventManager.class); + ODPManager odpManager = ODPManager.builder() + .withSegmentManager(mockSegmentManager) + .withEventManager(mockEventManager) + .build(); + assertSame(mockSegmentManager, odpManager.getSegmentManager()); + } + + @Test + public void withEventManager() { + ODPSegmentManager mockSegmentManager = mock(ODPSegmentManager.class); + ODPEventManager mockEventManager = mock(ODPEventManager.class); + ODPManager odpManager = ODPManager.builder() + .withSegmentManager(mockSegmentManager) + .withEventManager(mockEventManager) + .build(); + assertSame(mockEventManager, odpManager.getEventManager()); + } + + @Test + public void withSegmentCache() { + Cache<List<String>> mockCache = mock(Cache.class); + ODPApiManager mockApiManager = mock(ODPApiManager.class); + ODPManager odpManager = ODPManager.builder() + .withApiManager(mockApiManager) + .withSegmentCache(mockCache) + .build(); + + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("Segment-1", "Segment-2"))); + odpManager.getSegmentManager().getQualifiedSegments("test-user"); + verify(mockCache).lookup("fs_user_id-$-test-user"); + } + + @Test + public void withUserCommonDataAndCommonIdentifiers() { + Map<String, Object> data = new HashMap<>(); + data.put("k1", "v1"); + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("k2", "v2"); + + ODPEventManager mockEventManager = mock(ODPEventManager.class); + ODPSegmentManager mockSegmentManager = mock(ODPSegmentManager.class); + ODPManager.builder() + .withUserCommonData(data) + .withUserCommonIdentifiers(identifiers) + .withEventManager(mockEventManager) + .withSegmentManager(mockSegmentManager) + .build(); + + verify(mockEventManager).setUserCommonData(eq(data)); + verify(mockEventManager).setUserCommonIdentifiers(eq(identifiers)); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java new file mode 100644 index 000000000..1e1f59f29 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPManagerTest.java @@ -0,0 +1,132 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +public class ODPManagerTest { + private static final List<String> API_RESPONSE = Arrays.asList(new String[]{"segment1", "segment2"}); + + @Mock + ODPApiManager mockApiManager; + + @Mock + ODPEventManager mockEventManager; + + @Mock + ODPSegmentManager mockSegmentManager; + + @Before + public void setup() { + mockApiManager = mock(ODPApiManager.class); + mockEventManager = mock(ODPEventManager.class); + mockSegmentManager = mock(ODPSegmentManager.class); + } + + @Test + public void shouldStartEventManagerWhenODPManagerIsInitialized() { + ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); + + verify(mockEventManager, times(1)).start(); + } + + @Test + public void shouldStopEventManagerWhenCloseIsCalled() { + ODPManager odpManager = ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); + odpManager.updateSettings("test-key", "test-host", Collections.emptySet()); + + // Stop is not called in the default flow. + verify(mockEventManager, times(0)).stop(); + + odpManager.close(); + // stop should be called when odpManager is closed. + verify(mockEventManager, times(1)).stop(); + } + + @Test + public void shouldUseNewSettingsInEventManagerWhenODPConfigIsUpdated() throws InterruptedException { + Mockito.when(mockApiManager.sendEvents(any(), any(), any())).thenReturn(200); + ODPManager odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("segment1", "segment2"))); + + odpManager.getEventManager().identifyUser("vuid", "fsuid"); + Thread.sleep(2000); + verify(mockApiManager, times(1)) + .sendEvents(eq("test-key"), eq("test-host/v3/events"), any()); + + odpManager.updateSettings("test-host-updated", "test-key-updated", new HashSet<>(Arrays.asList("segment1"))); + odpManager.getEventManager().identifyUser("vuid", "fsuid"); + Thread.sleep(1200); + verify(mockApiManager, times(1)) + .sendEvents(eq("test-key-updated"), eq("test-host-updated/v3/events"), any()); + } + + @Test + public void shouldUseNewSettingsInSegmentManagerWhenODPConfigIsUpdated() { + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + ODPManager odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + odpManager.updateSettings("test-host", "test-key", new HashSet<>(Arrays.asList("segment1", "segment2"))); + + odpManager.getSegmentManager().getQualifiedSegments("test-id"); + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(eq("test-key"), eq("test-host/v3/graphql"), any(), any(), any()); + + odpManager.updateSettings("test-host-updated", "test-key-updated", new HashSet<>(Arrays.asList("segment1"))); + odpManager.getSegmentManager().getQualifiedSegments("test-id"); + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(eq("test-key-updated"), eq("test-host-updated/v3/graphql"), any(), any(), any()); + } + + @Test + public void shouldGetEventManager() { + ODPManager odpManager = ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); + assertNotNull(odpManager.getEventManager()); + + odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + assertNotNull(odpManager.getEventManager()); + } + + @Test + public void shouldGetSegmentManager() { + ODPManager odpManager = ODPManager.builder().withSegmentManager(mockSegmentManager).withEventManager(mockEventManager).build(); + assertNotNull(odpManager.getSegmentManager()); + + odpManager = ODPManager.builder().withApiManager(mockApiManager).build(); + assertNotNull(odpManager.getSegmentManager()); + } + + @Test + public void isVuid() { + assertTrue(ODPManager.isVuid("vuid_123")); + assertFalse(ODPManager.isVuid("vuid123")); + assertFalse(ODPManager.isVuid("any_123")); + assertFalse(ODPManager.isVuid("")); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java new file mode 100644 index 000000000..3d71f0d2c --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java @@ -0,0 +1,417 @@ +/** + * + * Copyright 2022-2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.Cache; +import com.optimizely.ab.internal.LogbackVerifier; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +import java.util.*; +import java.util.concurrent.CountDownLatch; + +public class ODPSegmentManagerTest { + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Mock + Cache<List<String>> mockCache; + + @Mock + ODPApiManager mockApiManager; + + private static final List<String> API_RESPONSE = Arrays.asList(new String[]{"segment1", "segment2"}); + + @Before + public void setup() { + mockCache = mock(Cache.class); + mockApiManager = mock(ODPApiManager.class); + } + + @Test + public void cacheHit() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("fs_user_id-$-testId"); + + // Cache hit! No api call was made to the server. + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Hit. Returning segments from Cache."); + + assertEquals(Arrays.asList("segment1-cached", "segment2-cached"), segments); + } + + @Test + public void cacheMiss() { + Mockito.when(mockCache.lookup(any())).thenReturn(null); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager.getQualifiedSegments(ODPUserKey.VUID, "testId"); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("vuid-$-testId"); + + // Cache miss! Make api call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(1)).save("vuid-$-testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Miss. Making a call to ODP Server."); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void ignoreCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.IGNORE_CACHE)); + + // Cache Ignored! lookup should not be called + verify(mockCache, times(0)).lookup(any()); + + // Cache Ignored! Make API Call but do NOT save because of cacheIgnore + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void resetCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + // Call reset + verify(mockCache, times(1)).reset(); + + // Cache Reset! lookup should not be called because cache would be empty. + verify(mockCache, times(0)).lookup(any()); + + // Cache reset but not Ignored! Make API Call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(1)).save("fs_user_id-$-testId", Arrays.asList("segment1", "segment2")); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void resetAndIgnoreCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager + .getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Arrays.asList(ODPSegmentOption.RESET_CACHE, ODPSegmentOption.IGNORE_CACHE)); + + // Call reset + verify(mockCache, times(1)).reset(); + + verify(mockCache, times(0)).lookup(any()); + + // Cache is also Ignored! Make API Call but do not save + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(0)).save(any(), any()); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void odpConfigNotReady() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig(null, null, new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)"); + + assertNull(segments); + } + + @Test + public void noSegmentsInProject() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", null); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + List<String> segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "No Segments are used in the project, Not Fetching segments. Returning empty list"); + + assertEquals(Collections.emptyList(), segments); + } + + // Tests for Async version + + @Test + public void cacheHitAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", (segments) -> { + assertEquals(Arrays.asList("segment1-cached", "segment2-cached"), segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("fs_user_id-$-testId"); + + // Cache hit! No api call was made to the server. + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Hit. Returning segments from Cache."); + } + + @Test + public void cacheMissAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(null); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.VUID, "testId", (segments) -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("vuid-$-testId"); + + // Cache miss! Make api call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(1)).save("vuid-$-testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Miss. Making a call to ODP Server."); + } + + @Test + public void ignoreCacheAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }, Collections.singletonList(ODPSegmentOption.IGNORE_CACHE)); + + countDownLatch.await(); + + // Cache Ignored! lookup should not be called + verify(mockCache, times(0)).lookup(any()); + + // Cache Ignored! Make API Call but do NOT save because of cacheIgnore + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + } + + @Test + public void resetCacheAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }, Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + countDownLatch.await(); + + // Call reset + verify(mockCache, times(1)).reset(); + + // Cache Reset! lookup should not be called because cache would be empty. + verify(mockCache, times(0)).lookup(any()); + + // Cache reset but not Ignored! Make API Call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(1)).save("fs_user_id-$-testId", Arrays.asList("segment1", "segment2")); + } + + @Test + public void resetAndIgnoreCacheAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anySet())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Arrays.asList("segment1", "segment2"), segments); + countDownLatch.countDown(); + }, Arrays.asList(ODPSegmentOption.RESET_CACHE, ODPSegmentOption.IGNORE_CACHE)); + + countDownLatch.await(); + + // Call reset + verify(mockCache, times(1)).reset(); + + verify(mockCache, times(0)).lookup(any()); + + // Cache is also Ignored! Make API Call but do not save + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", new HashSet<>(Arrays.asList("segment1", "segment2"))); + verify(mockCache, times(0)).save(any(), any()); + } + + @Test + public void odpConfigNotReadyAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig(null, null, new HashSet<>(Arrays.asList("segment1", "segment2"))); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertNull(segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)"); + } + + @Test + public void noSegmentsInProjectAsync() throws InterruptedException { + CountDownLatch countDownLatch = new CountDownLatch(1); + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", null); + ODPSegmentManager segmentManager = new ODPSegmentManager(mockApiManager, mockCache); + segmentManager.updateSettings(odpConfig); + segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", segments -> { + assertEquals(Collections.emptyList(), segments); + countDownLatch.countDown(); + }); + + countDownLatch.await(); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "No Segments are used in the project, Not Fetching segments. Returning empty list"); + } + + @Test + public void getQualifiedSegmentsWithUserId() { + ODPSegmentManager segmentManager = spy(new ODPSegmentManager(mockApiManager, mockCache)); + segmentManager.getQualifiedSegments("test-user"); + verify(segmentManager).getQualifiedSegments(ODPUserKey.FS_USER_ID, "test-user", Collections.emptyList()); + } + + @Test + public void getQualifiedSegmentsWithVuid() { + ODPSegmentManager segmentManager = spy(new ODPSegmentManager(mockApiManager, mockCache)); + segmentManager.getQualifiedSegments("vuid_123"); + // SDK will convert userId to vuid when userId has a valid vuid format. + verify(segmentManager).getQualifiedSegments(ODPUserKey.VUID, "vuid_123", Collections.emptyList()); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java new file mode 100644 index 000000000..a4f51a3a7 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java @@ -0,0 +1,50 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.odp.parser.impl.GsonParser; +import com.optimizely.ab.odp.parser.impl.JsonParser; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ResponseJsonParserFactoryTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonParserWhenNoDefaultIsSet() { + assertEquals(GsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } + + @Test + public void getCorrectParserWhenValidDefaultIsProvided() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } + + @Test + public void getGsonParserWhenGivenDefaultParserDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(GsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java new file mode 100644 index 000000000..454ab1718 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java @@ -0,0 +1,117 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; +import static junit.framework.TestCase.assertEquals; + +import com.optimizely.ab.odp.parser.impl.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; +import java.util.List; + +@RunWith(Parameterized.class) +public class ResponseJsonParserTest { + private final ResponseJsonParser jsonParser; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + public ResponseJsonParserTest(ResponseJsonParser jsonParser) { + super(); + this.jsonParser = jsonParser; + } + + @Parameterized.Parameters + public static List<ResponseJsonParser> input() { + return Arrays.asList(new GsonParser(), new JsonParser(), new JsonSimpleParser(), new JacksonParser()); + } + + @Test + public void returnSegmentsListWhenResponseIsCorrect() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList("has_email", "has_email_opted_in"), parsedSegments); + } + + @Test + public void excludeSegmentsWhenStateNotQualified() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"not_qualified\"}}]}}}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList("has_email"), parsedSegments); + } + + @Test + public void returnEmptyListWhenResponseHasEmptyArray() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[]}}}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList(), parsedSegments); + } + + @Test + public void returnNullWhenJsonFormatIsValidButUnexpectedData() { + String responseToParse = "{\"data\"\"consumer\":{\"randomKey\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Error parsing qualified segments from response"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullWhenJsonIsMalformed() { + String responseToParse = "{\"data\"\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Error parsing qualified segments from response"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturned() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"code\":\"INVALID_IDENTIFIER_EXCEPTION\", \"classification\":\"DataFetchingException\"}}],\"data\":{\"customer\":null}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.WARN, "Audience segments fetch failed (invalid identifier)"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogNoErrorWhenErrorResponseIsReturnedButCodeKeyIsNotPresent() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}],\"data\":{\"customer\":null}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (DataFetchingException)"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturnedButCodeValueIsNotInvalidIdentifierException() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"code\":\"OTHER_EXCEPTIONS\", \"classification\":\"DataFetchingException\"}}],\"data\":{\"customer\":null}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (DataFetchingException)"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturnedButCodeValueIsNotInvalidIdentifierExceptionNullClassification() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"code\":\"OTHER_EXCEPTIONS\"}}],\"data\":{\"customer\":null}}"; + List<String> parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (decode error)"); + assertEquals(null, parsedSegments); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java new file mode 100644 index 000000000..5c47a1f4f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerFactoryTest.java @@ -0,0 +1,64 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.odp.serializer.impl.GsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JacksonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSerializer; +import com.optimizely.ab.odp.serializer.impl.JsonSimpleSerializer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class ODPJsonSerializerFactoryTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonSerializerWhenNoDefaultIsSet() { + assertEquals(GsonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getCorrectSerializerWhenValidDefaultIsProvidedIsJson() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getCorrectSerializerWhenValidDefaultIsProvidedIsJsonSimple() { + PropertyUtils.set("default_parser", "JSON_SIMPLE_CONFIG_PARSER"); + assertEquals(JsonSimpleSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getCorrectSerializerWhenValidDefaultIsProvidedIsJackson() { + PropertyUtils.set("default_parser", "JACKSON_CONFIG_PARSER"); + assertEquals(JacksonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } + + @Test + public void getGsonSerializerWhenGivenDefaultSerializerDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(GsonSerializer.class, ODPJsonSerializerFactory.getSerializer().getClass()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java new file mode 100644 index 000000000..7a9538a8f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/serializer/ODPJsonSerializerTest.java @@ -0,0 +1,85 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.serializer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.ODPEvent; +import com.optimizely.ab.odp.serializer.impl.*; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.*; + +import static junit.framework.TestCase.assertEquals; + +@RunWith(Parameterized.class) +public class ODPJsonSerializerTest { + private final ODPJsonSerializer jsonSerializer; + + public ODPJsonSerializerTest(ODPJsonSerializer jsonSerializer) { + super(); + this.jsonSerializer = jsonSerializer; + } + + @Parameterized.Parameters + public static List<ODPJsonSerializer> input() { + return Arrays.asList(new GsonSerializer(), new JsonSerializer(), new JsonSimpleSerializer(), new JacksonSerializer()); + } + + @Test + public void serializeMultipleEvents() throws JsonProcessingException { + List<ODPEvent> events = Arrays.asList( + createTestEvent("1"), + createTestEvent("2"), + createTestEvent("3") + ); + + ObjectMapper mapper = new ObjectMapper(); + + String expectedResult = "[{\"type\":\"type-1\",\"action\":\"action-1\",\"identifiers\":{\"vuid-1-3\":\"fs-1-3\",\"vuid-1-1\":\"fs-1-1\",\"vuid-1-2\":\"fs-1-2\"},\"data\":{\"source\":\"java-sdk\",\"data-1\":\"data-value-1\",\"data-num\":1,\"data-bool-true\":true,\"data-bool-false\":false,\"data-float\":2.33,\"data-null\":null}},{\"type\":\"type-2\",\"action\":\"action-2\",\"identifiers\":{\"vuid-2-3\":\"fs-2-3\",\"vuid-2-2\":\"fs-2-2\",\"vuid-2-1\":\"fs-2-1\"},\"data\":{\"source\":\"java-sdk\",\"data-1\":\"data-value-2\",\"data-num\":2,\"data-bool-true\":true,\"data-bool-false\":false,\"data-float\":2.33,\"data-null\":null}},{\"type\":\"type-3\",\"action\":\"action-3\",\"identifiers\":{\"vuid-3-3\":\"fs-3-3\",\"vuid-3-2\":\"fs-3-2\",\"vuid-3-1\":\"fs-3-1\"},\"data\":{\"source\":\"java-sdk\",\"data-1\":\"data-value-3\",\"data-num\":3,\"data-bool-true\":true,\"data-bool-false\":false,\"data-float\":2.33,\"data-null\":null}}]"; + String serializedString = jsonSerializer.serializeEvents(events); + assertEquals(mapper.readTree(expectedResult), mapper.readTree(serializedString)); + } + + @Test + public void serializeEmptyList() throws JsonProcessingException { + List<ODPEvent> events = Collections.emptyList(); + String expectedResult = "[]"; + String serializedString = jsonSerializer.serializeEvents(events); + assertEquals(expectedResult, serializedString); + } + + private static ODPEvent createTestEvent(String index) { + Map<String, String> identifiers = new HashMap<>(); + identifiers.put("vuid-" + index + "-1", "fs-" + index + "-1"); + identifiers.put("vuid-" + index + "-2", "fs-" + index + "-2"); + identifiers.put("vuid-" + index + "-3", "fs-" + index + "-3"); + + Map<String, Object> data = new HashMap<>(); + data.put("source", "java-sdk"); + data.put("data-1", "data-value-" + index); + data.put("data-num", Integer.parseInt(index)); + data.put("data-float", 2.33); + data.put("data-bool-true", true); + data.put("data-bool-false", false); + data.put("data-null", null); + + + return new ODPEvent("type-" + index, "action-" + index, identifiers, data); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java new file mode 100644 index 000000000..904d5e2d7 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyAttributeTest.java @@ -0,0 +1,39 @@ +/**************************************************************************** + * Copyright 2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class OptimizelyAttributeTest { + + @Test + public void testOptimizelyAttribute() { + OptimizelyAttribute optimizelyAttribute1 = new OptimizelyAttribute( + "5", + "test_attribute" + ); + OptimizelyAttribute optimizelyAttribute2 = new OptimizelyAttribute( + "5", + "test_attribute" + ); + assertEquals("5", optimizelyAttribute1.getId()); + assertEquals("test_attribute", optimizelyAttribute1.getKey()); + assertEquals(optimizelyAttribute1, optimizelyAttribute2); + assertEquals(optimizelyAttribute1.hashCode(), optimizelyAttribute2.hashCode()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java new file mode 100644 index 000000000..418cb2494 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigServiceTest.java @@ -0,0 +1,684 @@ +/**************************************************************************** + * Copyright 2020-2021, 2023, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.config.*; +import com.optimizely.ab.config.audience.Audience; +import com.optimizely.ab.internal.LogbackVerifier; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.*; +import static java.util.Arrays.asList; +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class OptimizelyConfigServiceTest { + + private ProjectConfig projectConfig; + private OptimizelyConfigService optimizelyConfigService; + private OptimizelyConfig expectedConfig; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Before + public void initialize() { + projectConfig = generateOptimizelyConfig(); + optimizelyConfigService = new OptimizelyConfigService(projectConfig); + expectedConfig = getExpectedConfig(); + } + + @Test + public void testGetExperimentsMap() { + Map<String, OptimizelyExperiment> optimizelyExperimentMap = optimizelyConfigService.getExperimentsMap(); + assertEquals(optimizelyExperimentMap.size(), 2); + assertEquals(expectedConfig.getExperimentsMap(), optimizelyExperimentMap); + } + + @Test + public void testGetExperimentsMapWithDuplicateKeys() { + List<Experiment> experiments = Arrays.asList( + new Experiment( + "first", + "duplicate_key", + null, null, Collections.<String>emptyList(), null, + Collections.<Variation>emptyList(), Collections.<String, String>emptyMap(), Collections.<TrafficAllocation>emptyList() + ), + new Experiment( + "second", + "duplicate_key", + null, null, Collections.<String>emptyList(), null, + Collections.<Variation>emptyList(), Collections.<String, String>emptyMap(), Collections.<TrafficAllocation>emptyList() + ) + ); + + ProjectConfig projectConfig = mock(ProjectConfig.class); + OptimizelyConfigService optimizelyConfigService = new OptimizelyConfigService(projectConfig); + when(projectConfig.getExperiments()).thenReturn(experiments); + + Map<String, OptimizelyExperiment> optimizelyExperimentMap = optimizelyConfigService.getExperimentsMap(); + assertEquals("Duplicate keys should be overwritten", optimizelyExperimentMap.size(), 1); + assertEquals("Duplicate keys should be overwritten", optimizelyExperimentMap.get("duplicate_key").getId(), "second"); + logbackVerifier.expectMessage(Level.WARN, "Duplicate experiment keys found in datafile: duplicate_key"); + } + + @Test + public void testRevision() { + String revision = optimizelyConfigService.getConfig().getRevision(); + assertEquals(expectedConfig.getRevision(), revision); + } + + @Test + public void testSdkKey() { + String sdkKey = optimizelyConfigService.getConfig().getSdkKey(); + assertEquals(expectedConfig.getSdkKey(), sdkKey); + } + + @Test + public void testEnvironmentKey() { + String environmentKey = optimizelyConfigService.getConfig().getEnvironmentKey(); + assertEquals(expectedConfig.getEnvironmentKey(), environmentKey); + } + + @Test + public void testGetFeaturesMap() { + Map<String, OptimizelyExperiment> optimizelyExperimentMap = optimizelyConfigService.getExperimentsMap(); + Map<String, OptimizelyFeature> optimizelyFeatureMap = optimizelyConfigService.getFeaturesMap(optimizelyExperimentMap); + assertEquals(2, optimizelyFeatureMap.size()); + assertEquals(expectedConfig.getFeaturesMap(), optimizelyFeatureMap); + } + + @Test + public void testGetFeatureVariablesMap() { + FeatureFlag featureFlag = projectConfig.getFeatureFlags().get(1); + Map<String, OptimizelyVariable> optimizelyVariableMap = + optimizelyConfigService.getFeatureVariablesMap(featureFlag.getVariables()); + Map<String, OptimizelyVariable> expectedVariablesMap = + expectedConfig.getFeaturesMap().get("multi_variate_feature").getVariablesMap(); + assertEquals(expectedVariablesMap.size(), optimizelyVariableMap.size()); + assertEquals(expectedVariablesMap, optimizelyVariableMap); + } + + @Test + public void testGetExperimentsMapForFeature() { + List<String> experimentIds = projectConfig.getFeatureFlags().get(1).getExperimentIds(); + Map<String, OptimizelyExperiment> optimizelyFeatureExperimentMap = + optimizelyConfigService.getExperimentsMapForFeature(experimentIds); + assertEquals(expectedConfig.getFeaturesMap().get("multi_variate_feature").getExperimentsMap().size(), optimizelyFeatureExperimentMap.size()); + } + + @Test + public void testGetFeatureVariableUsageInstanceMap() { + List<FeatureVariableUsageInstance> featureVariableUsageInstances = + projectConfig.getExperiments().get(1).getVariations().get(1).getFeatureVariableUsageInstances(); + Map<String, OptimizelyVariable> optimizelyVariableMap = + optimizelyConfigService.getFeatureVariableUsageInstanceMap(featureVariableUsageInstances); + Map<String, OptimizelyVariable> expectedOptimizelyVariableMap = new HashMap<String, OptimizelyVariable>() {{ + put( + "675244127", + new OptimizelyVariable( + "675244127", + null, + null, + "F" + ) + ); + put( + "4052219963", + new OptimizelyVariable( + "4052219963", + null, + null, + "eorge" + ) + ); + }}; + assertEquals(expectedOptimizelyVariableMap.size(), optimizelyVariableMap.size()); + assertEquals(expectedOptimizelyVariableMap, optimizelyVariableMap); + } + + @Test + public void testGetVariationsMap() { + Map<String, OptimizelyVariation> optimizelyVariationMap = + optimizelyConfigService.getVariationsMap(projectConfig.getExperiments().get(1).getVariations(), "3262035800", null); + assertEquals(expectedConfig.getExperimentsMap().get("multivariate_experiment").getVariationsMap().size(), optimizelyVariationMap.size()); + assertEquals(expectedConfig.getExperimentsMap().get("multivariate_experiment").getVariationsMap(), optimizelyVariationMap); + } + + @Test + public void testGetExperimentFeatureKey() { + String featureKey = optimizelyConfigService.getExperimentFeatureKey("3262035800"); + assertEquals("multi_variate_feature", featureKey); + } + + @Test + public void testGenerateFeatureKeyToVariablesMap() { + Map<String, List<FeatureVariable>> featureKeyToVariableMap = optimizelyConfigService.generateFeatureKeyToVariablesMap(); + FeatureVariable featureVariable = featureKeyToVariableMap.get("multi_variate_feature").get(0); + OptimizelyVariable expectedOptimizelyVariable = expectedConfig.getFeaturesMap().get("multi_variate_feature").getVariablesMap().get("first_letter"); + assertEquals(expectedOptimizelyVariable.getId(), featureVariable.getId()); + assertEquals(expectedOptimizelyVariable.getValue(), featureVariable.getDefaultValue()); + assertEquals(expectedOptimizelyVariable.getKey(), featureVariable.getKey()); + assertEquals(expectedOptimizelyVariable.getType(), featureVariable.getType()); + } + + @Test + public void testGetMergedVariablesMap() { + Variation variation = projectConfig.getExperiments().get(1).getVariations().get(1); + Map<String, OptimizelyVariable> optimizelyVariableMap = optimizelyConfigService.getMergedVariablesMap(variation, "3262035800", null); + Map<String, OptimizelyVariable> expectedOptimizelyVariableMap = + expectedConfig.getExperimentsMap().get("multivariate_experiment").getVariationsMap().get("Feorge").getVariablesMap(); + assertEquals(expectedOptimizelyVariableMap.size(), optimizelyVariableMap.size()); + assertEquals(expectedOptimizelyVariableMap, optimizelyVariableMap); + } + + @Test + public void testGetAudiencesMap() { + Map<String, String> actualAudiencesMap = optimizelyConfigService.getAudiencesMap( + asList( + new OptimizelyAudience( + "123456", + "test_audience_1", + "[\"and\", [\"or\", \"1\", \"2\"], \"3\"]" + ) + ) + ); + + Map<String, String> expectedAudiencesMap = optimizelyConfigService.getAudiencesMap(expectedConfig.getAudiences()); + + assertEquals(expectedAudiencesMap, actualAudiencesMap); + } + + private ProjectConfig generateOptimizelyConfig() { + return new DatafileProjectConfig( + "2360254204", + true, + true, + true, + "3918735994", + "1480511547", + "ValidProjectConfigV4", + "production", + "4", + asList( + new Attribute( + "553339214", + "house" + ), + new Attribute( + "58339410", + "nationality" + ) + ), + Collections.<Audience>emptyList(), + Collections.<Audience>emptyList(), + asList( + new EventType( + "3785620495", + "basic_event", + asList("1323241596", "2738374745", "3042640549", "3262035800", "3072915611") + ), + new EventType( + "3195631717", + "event_with_paused_experiment", + asList("2667098701") + ) + ), + asList( + new Experiment( + "1323241596", + "basic_experiment", + "Running", + "1630555626", + Collections.<String>emptyList(), + null, + asList( + new Variation( + "1423767502", + "A", + Collections.<FeatureVariableUsageInstance>emptyList() + ), + new Variation( + "3433458314", + "B", + Collections.<FeatureVariableUsageInstance>emptyList() + ) + ), + Collections.singletonMap("Harry Potter", "A"), + asList( + new TrafficAllocation( + "1423767502", + 5000 + ), + new TrafficAllocation( + "3433458314", + 10000 + ) + ) + ), + new Experiment( + "3262035800", + "multivariate_experiment", + "Running", + "3262035800", + asList("3468206642"), + null, + asList( + new Variation( + "1880281238", + "Fred", + true, + asList( + new FeatureVariableUsageInstance( + "675244127", + "F" + ), + new FeatureVariableUsageInstance( + "4052219963", + "red" + ) + ) + ), + new Variation( + "3631049532", + "Feorge", + true, + asList( + new FeatureVariableUsageInstance( + "675244127", + "F" + ), + new FeatureVariableUsageInstance( + "4052219963", + "eorge" + ) + ) + ) + ), + Collections.singletonMap("Fred", "Fred"), + asList( + new TrafficAllocation( + "1880281238", + 2500 + ), + new TrafficAllocation( + "3631049532", + 5000 + ), + new TrafficAllocation( + "4204375027", + 7500 + ), + new TrafficAllocation( + "2099211198", + 10000 + ) + ) + ) + ), + asList( + new FeatureFlag( + "4195505407", + "boolean_feature", + "", + Collections.<String>emptyList(), + Collections.<FeatureVariable>emptyList() + ), + new FeatureFlag( + "3263342226", + "multi_variate_feature", + "813411034", + asList("3262035800"), + asList( + new FeatureVariable( + "675244127", + "first_letter", + "H", + FeatureVariable.VariableStatus.ACTIVE, + FeatureVariable.STRING_TYPE, + null + ), + new FeatureVariable( + "4052219963", + "rest_of_name", + "arry", + FeatureVariable.VariableStatus.ACTIVE, + FeatureVariable.STRING_TYPE, + null + ) + ) + ) + ), + Collections.<Group>emptyList(), + Collections.<Rollout>emptyList(), + Collections.<Integration>emptyList() + ); + } + + OptimizelyConfig getExpectedConfig() { + Map<String, OptimizelyExperiment> optimizelyExperimentMap = new HashMap<>(); + optimizelyExperimentMap.put( + "multivariate_experiment", + new OptimizelyExperiment( + "3262035800", + "multivariate_experiment", + new HashMap<String, OptimizelyVariation>() {{ + put( + "Feorge", + new OptimizelyVariation( + "3631049532", + "Feorge", + true, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "eorge" + ) + ); + }} + ) + ); + put( + "Fred", + new OptimizelyVariation( + "1880281238", + "Fred", + true, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "red" + ) + ); + }} + ) + ); + }}, + "" + ) + ); + optimizelyExperimentMap.put( + "basic_experiment", + new OptimizelyExperiment( + "1323241596", + "basic_experiment", + new HashMap<String, OptimizelyVariation>() {{ + put( + "A", + new OptimizelyVariation( + "1423767502", + "A", + null, + Collections.emptyMap() + ) + ); + put( + "B", + new OptimizelyVariation( + "3433458314", + "B", + null, + Collections.emptyMap() + ) + ); + }}, + "" + ) + ); + + Map<String, OptimizelyFeature> optimizelyFeatureMap = new HashMap<>(); + optimizelyFeatureMap.put( + "multi_variate_feature", + new OptimizelyFeature( + "3263342226", + "multi_variate_feature", + new HashMap<String, OptimizelyExperiment>() {{ + put( + "multivariate_experiment", + new OptimizelyExperiment( + "3262035800", + "multivariate_experiment", + new HashMap<String, OptimizelyVariation>() {{ + put( + "Feorge", + new OptimizelyVariation( + "3631049532", + "Feorge", + true, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "eorge" + ) + ); + }} + ) + ); + put( + "Fred", + new OptimizelyVariation( + "1880281238", + "Fred", + true, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "red" + ) + ); + }} + ) + ); + }}, + "" + ) + ); + }}, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "H" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "arry" + ) + ); + }}, + asList( + new OptimizelyExperiment( + "3262035800", + "multivariate_experiment", + new HashMap<String, OptimizelyVariation>() {{ + put( + "Feorge", + new OptimizelyVariation( + "3631049532", + "Feorge", + true, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "eorge" + ) + ); + }} + ) + ); + put( + "Fred", + new OptimizelyVariation( + "1880281238", + "Fred", + true, + new HashMap<String, OptimizelyVariable>() {{ + put( + "first_letter", + new OptimizelyVariable( + "675244127", + "first_letter", + "string", + "F" + ) + ); + put( + "rest_of_name", + new OptimizelyVariable( + "4052219963", + "rest_of_name", + "string", + "red" + ) + ); + }} + ) + ); + }}, + "" + ) + ), + Collections.<OptimizelyExperiment>emptyList() + ) + ); + optimizelyFeatureMap.put( + "boolean_feature", + new OptimizelyFeature( + "4195505407", + "boolean_feature", + Collections.emptyMap(), + Collections.emptyMap(), + Collections.<OptimizelyExperiment>emptyList(), + Collections.<OptimizelyExperiment>emptyList() + ) + ); + + return new OptimizelyConfig( + optimizelyExperimentMap, + optimizelyFeatureMap, + "1480511547", + "ValidProjectConfigV4", + "production", + asList( + new OptimizelyAttribute( + "553339214", + "house" + ), + new OptimizelyAttribute( + "58339410", + "nationality" + ) + ), + asList( + new OptimizelyEvent( + "3785620495", + "basic_event", + asList("1323241596", "2738374745", "3042640549", "3262035800", "3072915611") + ), + new OptimizelyEvent( + "3195631717", + "event_with_paused_experiment", + asList("2667098701") + ) + ), + asList( + new OptimizelyAudience( + "123456", + "test_audience_1", + "[\"and\", [\"or\", \"1\", \"2\"], \"3\"]" + ) + ), + null + ); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java new file mode 100644 index 000000000..58acadd3f --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyConfigTest.java @@ -0,0 +1,85 @@ +/**************************************************************************** + * Copyright 2020-2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import static com.optimizely.ab.optimizelyconfig.OptimizelyExperimentTest.generateVariationMap; +import static com.optimizely.ab.optimizelyconfig.OptimizelyVariationTest.generateVariablesMap; +import static org.junit.Assert.assertEquals; + +public class OptimizelyConfigTest { + + @Test + public void testOptimizelyConfig() { + OptimizelyConfig optimizelyConfig = new OptimizelyConfig( + generateExperimentMap(), + generateFeatureMap(), + "101", + "testingSdkKey", + "development", + null, + null, + null, + null + ); + assertEquals("101", optimizelyConfig.getRevision()); + assertEquals("testingSdkKey", optimizelyConfig.getSdkKey()); + assertEquals("development", optimizelyConfig.getEnvironmentKey()); + // verify the experiments map + Map<String, OptimizelyExperiment> optimizelyExperimentMap = generateExperimentMap(); + assertEquals(optimizelyExperimentMap.size(), optimizelyConfig.getExperimentsMap().size()); + assertEquals(optimizelyExperimentMap, optimizelyConfig.getExperimentsMap()); + + // verify the features map + Map<String, OptimizelyFeature> optimizelyFeatureMap = generateFeatureMap(); + assertEquals(optimizelyFeatureMap.size(), optimizelyConfig.getFeaturesMap().size()); + assertEquals(optimizelyFeatureMap, optimizelyConfig.getFeaturesMap()); + } + + private Map<String, OptimizelyExperiment> generateExperimentMap() { + Map<String, OptimizelyExperiment> optimizelyExperimentMap = new HashMap<>(); + optimizelyExperimentMap.put("test_exp_1", new OptimizelyExperiment( + "33", + "test_exp_1", + generateVariationMap(), + "" + )); + optimizelyExperimentMap.put("test_exp_2", new OptimizelyExperiment( + "34", + "test_exp_2", + generateVariationMap(), + "" + )); + return optimizelyExperimentMap; + } + + private Map<String, OptimizelyFeature> generateFeatureMap() { + Map<String, OptimizelyFeature> optimizelyFeatureMap = new HashMap<>(); + optimizelyFeatureMap.put("test_feature_1", new OptimizelyFeature( + "42", + "test_feature_1", + generateExperimentMap(), + generateVariablesMap(), + Collections.emptyList(), + Collections.emptyList() + )); + return optimizelyFeatureMap; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java new file mode 100644 index 000000000..5bd5d9a4c --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyEventTest.java @@ -0,0 +1,40 @@ +/**************************************************************************** + * Copyright 2020-2021, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static java.util.Arrays.asList; + +public class OptimizelyEventTest { + @Test + public void testOptimizelyEvent() { + OptimizelyEvent optimizelyEvent1 = new OptimizelyEvent( + "5", + "test_event", + asList("123","234","345") + ); + OptimizelyEvent optimizelyEvent2 = new OptimizelyEvent( + "5", + "test_event", + asList("123","234","345") + ); + assertEquals("5", optimizelyEvent1.getId()); + assertEquals("test_event", optimizelyEvent1.getKey()); + assertEquals(optimizelyEvent1, optimizelyEvent2); + assertEquals(optimizelyEvent1.hashCode(), optimizelyEvent2.hashCode()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java new file mode 100644 index 000000000..954a90f29 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyExperimentTest.java @@ -0,0 +1,60 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import static com.optimizely.ab.optimizelyconfig.OptimizelyVariationTest.generateVariablesMap; +import static org.junit.Assert.assertEquals; + +public class OptimizelyExperimentTest { + + @Test + public void testOptimizelyExperiment() { + OptimizelyExperiment optimizelyExperiment = new OptimizelyExperiment( + "31", + "test_exp", + generateVariationMap(), + "" + ); + assertEquals("31", optimizelyExperiment.getId()); + assertEquals("test_exp", optimizelyExperiment.getKey()); + Map<String, OptimizelyVariation> optimizelyVariationMap = generateVariationMap(); + assertEquals(optimizelyVariationMap.size(), optimizelyExperiment.getVariationsMap().size()); + // verifying the variations + assertEquals(optimizelyVariationMap, optimizelyExperiment.getVariationsMap()); + } + + static Map<String, OptimizelyVariation> generateVariationMap() { + // now creating map of variations + Map<String, OptimizelyVariation> optimizelyVariationMap = new HashMap<>(); + optimizelyVariationMap.put("test_var_key_1", new OptimizelyVariation( + "13", + "test_var_key_1", + true, + generateVariablesMap() + )); + optimizelyVariationMap.put("test_var_key_2", new OptimizelyVariation( + "14", + "test_var_key_2", + false, + generateVariablesMap() + )); + return optimizelyVariationMap; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java new file mode 100644 index 000000000..a6789311b --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyFeatureTest.java @@ -0,0 +1,67 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import static com.optimizely.ab.optimizelyconfig.OptimizelyVariationTest.generateVariablesMap; +import static com.optimizely.ab.optimizelyconfig.OptimizelyExperimentTest.generateVariationMap; +import static org.junit.Assert.assertEquals; + +public class OptimizelyFeatureTest { + + @Test + public void testOptimizelyFeature() { + OptimizelyFeature optimizelyFeature = new OptimizelyFeature( + "41", + "test_feature", + generateExperimentMap(), + generateVariablesMap(), + Collections.emptyList(), + Collections.emptyList() + ); + assertEquals("41", optimizelyFeature.getId()); + assertEquals("test_feature", optimizelyFeature.getKey()); + // verifying experiments map + Map<String, OptimizelyExperiment> optimizelyExperimentMap = generateExperimentMap(); + assertEquals(optimizelyExperimentMap.size(), optimizelyFeature.getExperimentsMap().size()); + assertEquals(optimizelyExperimentMap, optimizelyFeature.getExperimentsMap()); + // verifying variables map + Map<String, OptimizelyVariable> optimizelyVariableMap = generateVariablesMap(); + assertEquals(optimizelyVariableMap.size(), optimizelyFeature.getVariablesMap().size()); + assertEquals(optimizelyVariableMap, optimizelyFeature.getVariablesMap()); + } + + static Map<String, OptimizelyExperiment> generateExperimentMap() { + Map<String, OptimizelyExperiment> optimizelyExperimentMap = new HashMap<>(); + optimizelyExperimentMap.put("test_exp_1", new OptimizelyExperiment ( + "32", + "test_exp_1", + generateVariationMap(), + "" + )); + optimizelyExperimentMap.put("test_exp_2", new OptimizelyExperiment ( + "33", + "test_exp_2", + generateVariationMap(), + "" + )); + return optimizelyExperimentMap; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariableTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariableTest.java new file mode 100644 index 000000000..895a9fdb6 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariableTest.java @@ -0,0 +1,37 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class OptimizelyVariableTest { + + @Test + public void testOptimizelyVariable() { + OptimizelyVariable optimizelyVariable = new OptimizelyVariable( + "7", + "test_variable_key", + "integer", + "10" + ); + assertEquals("7", optimizelyVariable.getId()); + assertEquals("test_variable_key", optimizelyVariable.getKey()); + assertEquals("integer", optimizelyVariable.getType()); + assertEquals("10", optimizelyVariable.getValue()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariationTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariationTest.java new file mode 100644 index 000000000..7ae9cfbd5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyconfig/OptimizelyVariationTest.java @@ -0,0 +1,59 @@ +/**************************************************************************** + * Copyright 2020, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.ab.optimizelyconfig; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import static org.junit.Assert.assertEquals; + +public class OptimizelyVariationTest { + + @Test + public void testOptimizelyVariation() { + OptimizelyVariation optimizelyVariation = new OptimizelyVariation( + "12", + "test_var_key", + false, + generateVariablesMap() + ); + assertEquals("12", optimizelyVariation.getId()); + assertEquals("test_var_key", optimizelyVariation.getKey()); + assertEquals(false, optimizelyVariation.getFeatureEnabled()); + + Map<String, OptimizelyVariable> expectedoptimizelyVariableMap = generateVariablesMap(); + assertEquals(expectedoptimizelyVariableMap.size(), optimizelyVariation.getVariablesMap().size()); + assertEquals(expectedoptimizelyVariableMap, optimizelyVariation.getVariablesMap()); + } + + static Map<String, OptimizelyVariable> generateVariablesMap() { + Map<String, OptimizelyVariable> optimizelyVariableMap = new HashMap<>(); + optimizelyVariableMap.put("test_variable_key_1", new OptimizelyVariable( + "7", + "test_variable_key_1", + "integer", + "10" + )); + optimizelyVariableMap.put("test_variable_key_2", new OptimizelyVariable( + "8", + "test_variable_key_2", + "boolean", + "true" + )); + return optimizelyVariableMap; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java new file mode 100644 index 000000000..dd2c476af --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelydecision/OptimizelyDecisionTest.java @@ -0,0 +1,79 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelydecision; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static junit.framework.TestCase.assertEquals; +import static org.junit.Assert.assertTrue; + +public class OptimizelyDecisionTest { + + @Test + public void testOptimizelyDecision() { + String variationKey = "var1"; + boolean enabled = true; + OptimizelyJSON variables = new OptimizelyJSON("{\"k1\":\"v1\"}"); + String ruleKey = null; + String flagKey = "flag1"; + OptimizelyUserContext userContext = new OptimizelyUserContext(Optimizely.builder().build(), "tester"); + List<String> reasons = new ArrayList<>(); + + OptimizelyDecision decision = new OptimizelyDecision( + variationKey, + enabled, + variables, + ruleKey, + flagKey, + userContext, + reasons + ); + + assertEquals(decision.getVariationKey(), variationKey); + assertEquals(decision.getEnabled(), enabled); + assertEquals(decision.getVariables(), variables); + assertEquals(decision.getRuleKey(), ruleKey); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), userContext); + assertEquals(decision.getReasons(), reasons); + } + + @Test + public void testNewErrorDecision() { + String flagKey = "flag1"; + OptimizelyUserContext userContext = new OptimizelyUserContext(Optimizely.builder().build(), "tester"); + String error = "SDK has an error"; + + OptimizelyDecision decision = OptimizelyDecision.newErrorDecision(flagKey, userContext, error); + + assertEquals(decision.getVariationKey(), null); + assertEquals(decision.getEnabled(), false); + assertTrue(decision.getVariables().isEmpty()); + assertEquals(decision.getRuleKey(), null); + assertEquals(decision.getFlagKey(), flagKey); + assertEquals(decision.getUserContext(), userContext); + assertEquals(decision.getReasons().size(), 1); + assertEquals(decision.getReasons().get(0), error); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java new file mode 100644 index 000000000..501c5d17d --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONTest.java @@ -0,0 +1,299 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.*; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.util.*; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +/** + * Common tests for all JSON parsers + */ +@RunWith(Parameterized.class) +public class OptimizelyJSONTest { + + @Parameterized.Parameters(name = "{index}: {0}") + public static Collection<ConfigParser> data() throws IOException { + return Arrays.asList( + new GsonConfigParser(), + new JacksonConfigParser(), + new JsonConfigParser(), + new JsonSimpleConfigParser() + ); + } + + @Parameterized.Parameter(0) + public ConfigParser parser; + + private String orgJson; + private Map<String,Object> orgMap; + private boolean canSupportGetValue; + + @Before + public void setUp() throws Exception { + Class parserClass = parser.getClass(); + canSupportGetValue = parserClass.equals(GsonConfigParser.class) || + parserClass.equals(JacksonConfigParser.class); + + orgJson = + "{ " + + " \"k1\": \"v1\", " + + " \"k2\": true, " + + " \"k3\": { " + + " \"kk1\": 1.2, " + + " \"kk2\": { " + + " \"kkk1\": true, " + + " \"kkk2\": 3.5, " + + " \"kkk3\": \"vvv3\", " + + " \"kkk4\": [5.7, true, \"vvv4\"] " + + " } " + + " } " + + "} "; + + Map<String,Object> m3 = new HashMap<String,Object>(); + m3.put("kkk1", true); + m3.put("kkk2", 3.5); + m3.put("kkk3", "vvv3"); + m3.put("kkk4", new ArrayList(Arrays.asList(5.7, true, "vvv4"))); + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 1.2); + m2.put("kk2", m3); + + Map<String,Object> m1 = new HashMap<String, Object>(); + m1.put("k1", "v1"); + m1.put("k2", true); + m1.put("k3", m2); + + orgMap = m1; + } + + private String compact(String str) { + return str.replaceAll("\\s", ""); + } + + + // Common tests for all parsers (GSON, Jackson, Json, JsonSimple) + @Test + public void testOptimizelyJSON() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + Map<String,Object> map = oj1.toMap(); + + OptimizelyJSON oj2 = new OptimizelyJSON(map, parser); + String data = oj2.toString(); + + assertEquals(compact(data), compact(orgJson)); + } + + @Test + public void testToStringFromString() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + assertEquals(compact(oj1.toString()), compact(orgJson)); + } + + @Test + public void testToStringFromMap() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgMap, parser); + assertEquals(compact(oj1.toString()), compact(orgJson)); + } + + @Test + public void testToMapFromString() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + assertEquals(oj1.toMap(), orgMap); + } + + @Test + public void testToMapFromMap() { + OptimizelyJSON oj1 = new OptimizelyJSON(orgMap, parser); + assertEquals(oj1.toMap(), orgMap); + } + + // GetValue tests + + @Test + public void testGetValueNullKeyPath() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD1 md1 = oj1.getValue(null, TestTypes.MD1.class); + assertNotNull(md1); + assertEquals(md1.k1, "v1"); + assertEquals(md1.k2, true); + assertEquals(md1.k3.kk1, 1.2, 0.01); + assertEquals(md1.k3.kk2.kkk1, true); + assertEquals((Double)md1.k3.kk2.kkk4[0], 5.7, 0.01); + assertEquals(md1.k3.kk2.kkk4[2], "vvv4"); + } + + @Test + public void testGetValueEmptyKeyPath() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD1 md1 = oj1.getValue("", TestTypes.MD1.class); + assertEquals(md1.k1, "v1"); + assertEquals(md1.k2, true); + assertEquals(md1.k3.kk1, 1.2, 0.01); + assertEquals(md1.k3.kk2.kkk1, true); + assertEquals((Double) md1.k3.kk2.kkk4[0], 5.7, 0.01); + assertEquals(md1.k3.kk2.kkk4[2], "vvv4"); + } + + @Test + public void testGetValueWithKeyPathToMapWithLevel1() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD2 md2 = oj1.getValue("k3", TestTypes.MD2.class); + assertNotNull(md2); + assertEquals(md2.kk1, 1.2, 0.01); + assertEquals(md2.kk2.kkk1, true); + } + + @Test + public void testGetValueWithKeyPathToMapWithLevel2() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD3 md3 = oj1.getValue("k3.kk2", TestTypes.MD3.class); + assertNotNull(md3); + assertEquals(md3.kkk1, true); + } + + @Test + public void testGetValueWithKeyPathToBoolean() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + Boolean value = oj1.getValue("k3.kk2.kkk1", Boolean.class); + assertNotNull(value); + assertEquals(value, true); + } + + @Test + public void testGetValueWithKeyPathToDouble() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + Double value = oj1.getValue("k3.kk2.kkk2", Double.class); + assertNotNull(value); + assertEquals(value.doubleValue(), 3.5, 0.01); + } + + @Test + public void testGetValueWithKeyPathToString() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k3.kk2.kkk3", String.class); + assertNotNull(value); + assertEquals(value, "vvv3"); + } + + @Test + public void testGetValueNotDestroying() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + TestTypes.MD3 md3 = oj1.getValue("k3.kk2", TestTypes.MD3.class); + assertNotNull(md3); + assertEquals(md3.kkk1, true); + assertEquals(md3.kkk2, 3.5, 0.01); + assertEquals(md3.kkk3, "vvv3"); + assertEquals((Double) md3.kkk4[0], 5.7, 0.01); + assertEquals(md3.kkk4[2], "vvv4"); + + // verify previous getValue does not destroy the data + + TestTypes.MD3 newMd3 = oj1.getValue("k3.kk2", TestTypes.MD3.class); + assertNotNull(newMd3); + assertEquals(newMd3.kkk1, true); + assertEquals(newMd3.kkk2, 3.5, 0.01); + assertEquals(newMd3.kkk3, "vvv3"); + assertEquals((Double) newMd3.kkk4[0], 5.7, 0.01); + assertEquals(newMd3.kkk4[2], "vvv4"); + } + + @Test + public void testGetValueWithInvalidKeyPath() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k3..kkk3", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithInvalidKeyPath2() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k1.", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithInvalidKeyPath3() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("x9", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithInvalidKeyPath4() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + String value = oj1.getValue("k3.x9", String.class); + assertNull(value); + } + + @Test + public void testGetValueWithWrongType() throws JsonParseException { + assumeTrue("GetValue API is supported for Gson and Jackson parsers only", canSupportGetValue); + + OptimizelyJSON oj1 = new OptimizelyJSON(orgJson, parser); + + Integer value = oj1.getValue("k3.kk2.kkk3", Integer.class); + assertNull(value); + } + +} + diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java new file mode 100644 index 000000000..ebbed4bf5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithGsonParserTest.java @@ -0,0 +1,103 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.GsonConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Tests for GSON parser only + */ +public class OptimizelyJSONWithGsonParserTest { + protected ConfigParser getParser() { + return new GsonConfigParser(); + } + + @Test + public void testGetValueWithNotMatchingType() throws JsonParseException { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + // GSON returns non-null object but variable is null (while Jackson returns null object) + + TestTypes.NotMatchingType md = oj1.getValue(null, TestTypes.NotMatchingType.class); + assertNull(md.x99); + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // GSON parser toMap() adds ".0" to all integers + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3.0); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1.0); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // GSON parser toString() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + + @Test + public void testIntegerProcessing3() throws JsonParseException { + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + TestTypes.MDN1 obj = oj1.getValue(null, TestTypes.MDN1.class); + + assertEquals(obj.k1, 1); + assertEquals(obj.k2, 2.5, 0.01); + assertEquals(obj.k3.kk1, 3); + assertEquals(obj.k3.kk2, 4.0, 0.01); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java new file mode 100644 index 000000000..c8f800918 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJacksonParserTest.java @@ -0,0 +1,100 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.JacksonConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Tests for Jackson parser only + */ +public class OptimizelyJSONWithJacksonParserTest { + protected ConfigParser getParser() { + return new JacksonConfigParser(); + } + + @Test + public void testGetValueWithNotMatchingType() throws JsonParseException { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + // Jackson returns null object when variables not matching (while GSON returns an object with null variables + + TestTypes.NotMatchingType md = oj1.getValue(null, TestTypes.NotMatchingType.class); + assertNull(md); + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // Jackson parser toMap() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // Jackson parser toString() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + + @Test + public void testIntegerProcessing3() throws JsonParseException { + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + TestTypes.MDN1 obj = oj1.getValue(null, TestTypes.MDN1.class); + + assertEquals(obj.k1, 1); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java new file mode 100644 index 000000000..05e308a39 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonParserTest.java @@ -0,0 +1,91 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.JsonConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * Tests for org.json parser only + */ +public class OptimizelyJSONWithJsonParserTest { + protected ConfigParser getParser() { + return new JsonConfigParser(); + } + + @Test + public void testGetValueThrowsException() { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + try { + String str = oj1.getValue(null, String.class); + fail("GetValue is not supported for or.json paraser: " + str); + } catch (JsonParseException e) { + assertEquals(e.getMessage(), "A proper JSON parser is not available. Use Gson or Jackson parser for this operation."); + } + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // org.json parser toMap() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // org.json parser toString() drops ".0" from double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java new file mode 100644 index 000000000..d66ca63a1 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/OptimizelyJSONWithJsonSimpleParserTest.java @@ -0,0 +1,93 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +import com.optimizely.ab.config.parser.ConfigParser; +import com.optimizely.ab.config.parser.JsonParseException; +import com.optimizely.ab.config.parser.JsonSimpleConfigParser; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * Tests for org.json.simple parser only + */ +public class OptimizelyJSONWithJsonSimpleParserTest { + protected ConfigParser getParser() { + return new JsonSimpleConfigParser(); + } + + @Test + public void testGetValueThrowsException() { + OptimizelyJSON oj1 = new OptimizelyJSON("{\"k1\": 3.5}", getParser()); + + try { + String str = oj1.getValue(null, String.class); + fail("GetValue is not supported for or.json paraser: " + str); + } catch (JsonParseException e) { + assertEquals(e.getMessage(), "A proper JSON parser is not available. Use Gson or Jackson parser for this operation."); + } + } + + // Tests for integer/double processing + + @Test + public void testIntegerProcessing() throws JsonParseException { + + // org.json.simple parser toMap() keeps ".0" in double + // org.json.simple parser toMap() return Long type for integer value + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", Long.valueOf(3)); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", Long.valueOf(1)); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(json, getParser()); + assertEquals(oj1.toMap(), m1); + } + + @Test + public void testIntegerProcessing2() throws JsonParseException { + + // org.json.simple parser toString() keeps ".0" in double + + String json = "{\"k1\":1,\"k2\":2.5,\"k3\":{\"kk1\":3,\"kk2\":4.0}}"; + + Map<String,Object> m2 = new HashMap<String,Object>(); + m2.put("kk1", 3); + m2.put("kk2", 4.0); + + Map<String,Object> m1 = new HashMap<String,Object>(); + m1.put("k1", 1); + m1.put("k2", 2.5); + m1.put("k3", m2); + + OptimizelyJSON oj1 = new OptimizelyJSON(m1, getParser()); + assertEquals(oj1.toString(), json); + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java new file mode 100644 index 000000000..4fa8260fd --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/optimizelyjson/TestTypes.java @@ -0,0 +1,61 @@ +/** + * + * Copyright 2020, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.optimizelyjson; + +/** + * Test types for parsing JSON strings to Java objects (OptimizelyJSON) + */ +public class TestTypes { + + public static class MD1 { + public String k1; + public boolean k2; + public MD2 k3; + } + + public static class MD2 { + public double kk1; + public MD3 kk2; + } + + public static class MD3 { + public boolean kkk1; + public double kkk2; + public String kkk3; + public Object[] kkk4; + } + + // Invalid parse type + + public static class NotMatchingType { + public String x99; + } + + // Test types for integer parsing tests + + public static class MDN1 { + public int k1; + public double k2; + public MDN2 k3; + } + + public static class MDN2 { + public int kk1; + public double kk2; + } + +} diff --git a/core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java b/core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java new file mode 100644 index 000000000..36c184369 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/testutils/OTUtils.java @@ -0,0 +1,36 @@ +/** + * + * Copyright 2022, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.testutils; + +import com.optimizely.ab.*; +import java.util.Collections; +import java.util.Map; + +public class OTUtils { + public static OptimizelyUserContext user(String userId, Map<String, ?> attributes) { + Optimizely optimizely = new Optimizely.Builder().build(); + return new OptimizelyUserContext(optimizely, userId, attributes); + } + + public static OptimizelyUserContext user(Map<String,?> attributes) { + return user("any-user", attributes); + } + + public static OptimizelyUserContext user() { + return user("any-user", Collections.emptyMap()); + } +} \ No newline at end of file diff --git a/core-api/src/test/resources/config/decide-project-config.json b/core-api/src/test/resources/config/decide-project-config.json new file mode 100644 index 000000000..eb7b0f802 --- /dev/null +++ b/core-api/src/test/resources/config/decide-project-config.json @@ -0,0 +1,346 @@ +{ + "version": "4", + "sendFlagDecisions": true, + "rollouts": [ + { + "experiments": [ + { + "audienceIds": ["13389130056"], + "forcedVariations": {}, + "id": "3332020515", + "key": "3332020515", + "layerId": "3319450668", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 10000, + "entityId": "3324490633" + } + ], + "variations": [ + { + "featureEnabled": true, + "id": "3324490633", + "key": "3324490633", + "variables": [] + } + ] + }, + { + "audienceIds": ["12208130097"], + "forcedVariations": {}, + "id": "3332020494", + "key": "3332020494", + "layerId": "3319450668", + "status": "Running", + "trafficAllocation": [ + { + "endOfRange": 0, + "entityId": "3324490562" + } + ], + "variations": [ + { + "featureEnabled": true, + "id": "3324490562", + "key": "3324490562", + "variables": [] + } + ] + }, + { + "status": "Running", + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "18257766532", + "key": "18257766532", + "featureEnabled": true + } + ], + "id": "18322080788", + "key": "18322080788", + "layerId": "18263344648", + "trafficAllocation": [ + { + "entityId": "18257766532", + "endOfRange": 10000 + } + ], + "forcedVariations": { } + } + ], + "id": "3319450668" + } + ], + "anonymizeIP": true, + "botFiltering": true, + "projectId": "10431130345", + "variables": [], + "featureFlags": [ + { + "experimentIds": ["10390977673"], + "id": "4482920077", + "key": "feature_1", + "rolloutId": "3319450668", + "variables": [ + { + "defaultValue": "42", + "id": "2687470095", + "key": "i_42", + "type": "integer" + }, + { + "defaultValue": "4.2", + "id": "2689280165", + "key": "d_4_2", + "type": "double" + }, + { + "defaultValue": "true", + "id": "2689660112", + "key": "b_true", + "type": "boolean" + }, + { + "defaultValue": "foo", + "id": "2696150066", + "key": "s_foo", + "type": "string" + }, + { + "defaultValue": "{\"value\":1}", + "id": "2696150067", + "key": "j_1", + "type": "string", + "subType": "json" + }, + { + "defaultValue": "invalid", + "id": "2696150068", + "key": "i_1", + "type": "invalid", + "subType": "" + } + ] + }, + { + "experimentIds": ["10420810910"], + "id": "4482920078", + "key": "feature_2", + "rolloutId": "", + "variables": [ + { + "defaultValue": "42", + "id": "2687470095", + "key": "i_42", + "type": "integer" + } + ] + }, + { + "experimentIds": [], + "id": "44829230000", + "key": "feature_3", + "rolloutId": "", + "variables": [] + } + ], + "experiments": [ + { + "status": "Running", + "key": "exp_with_audience", + "layerId": "10420273888", + "trafficAllocation": [ + { + "entityId": "10389729780", + "endOfRange": 10000 + } + ], + "audienceIds": ["13389141123"], + "variations": [ + { + "variables": [], + "featureEnabled": true, + "id": "10389729780", + "key": "a" + }, + { + "variables": [], + "id": "10416523121", + "key": "b" + } + ], + "forcedVariations": {}, + "id": "10390977673" + }, + { + "status": "Running", + "key": "exp_no_audience", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418551353", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": true, + "id": "10418551353", + "key": "variation_with_traffic" + }, + { + "variables": [], + "featureEnabled": false, + "id": "10418510624", + "key": "variation_no_traffic" + } + ], + "forcedVariations": {}, + "id": "10420810910" + } + ], + "audiences": [ + { + "id": "13389141123", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"gender\", \"type\": \"custom_attribute\", \"value\": \"f\"}]]]", + "name": "gender" + }, + { + "id": "13389130056", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"country\", \"type\": \"custom_attribute\", \"value\": \"US\"}]]]", + "name": "US" + }, + { + "id": "12208130097", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"exact\", \"name\": \"browser\", \"type\": \"custom_attribute\", \"value\": \"safari\"}]]]", + "name": "safari" + }, + { + "id": "age_18", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "age_18" + }, + { + "id": "invalid_format", + "conditions": "[]", + "name": "invalid_format" + }, + { + "id": "invalid_condition", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": \"US\"}]]]", + "name": "invalid_condition" + }, + { + "id": "invalid_type", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"invalid\", \"value\": 18}]]]", + "name": "invalid_type" + }, + { + "id": "invalid_match", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"invalid\", \"name\": \"age\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "invalid_match" + }, + { + "id": "nil_value", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"name\": \"age\", \"type\": \"custom_attribute\"}]]]", + "name": "nil_value" + }, + { + "id": "invalid_name", + "conditions": "[\"and\", [\"or\", [\"or\", {\"match\": \"gt\", \"type\": \"custom_attribute\", \"value\": 18}]]]", + "name": "invalid_name" + } + ], + "groups": [ + { + "policy": "random", + "trafficAllocation": [ + { + "entityId": "10390965532", + "endOfRange": 10000 + } + ], + "experiments": [ + { + "status": "Running", + "key": "group_exp_1", + "layerId": "10420222423", + "trafficAllocation": [ + { + "entityId": "10389752311", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": false, + "id": "10389752311", + "key": "a" + } + ], + "forcedVariations": {}, + "id": "10390965532" + }, + { + "status": "Running", + "key": "group_exp_2", + "layerId": "10417730432", + "trafficAllocation": [ + { + "entityId": "10418524243", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "variations": [ + { + "variables": [], + "featureEnabled": false, + "id": "10418524243", + "key": "a" + } + ], + "forcedVariations": {}, + "id": "10420843432" + } + ], + "id": "13142870430" + } + ], + "attributes": [ + { + "id": "10401066117", + "key": "gender" + }, + { + "id": "10401066170", + "key": "testvar" + } + ], + "accountId": "10367498574", + "events": [ + { + "experimentIds": [ + "10420810910" + ], + "id": "10404198134", + "key": "event1" + }, + { + "experimentIds": [ + "10420810910", + "10390977673" + ], + "id": "10404198135", + "key": "event_multiple_running_exp_attached" + } + ], + "revision": "241" +} diff --git a/core-api/src/test/resources/config/invalid-project-config-v5.json b/core-api/src/test/resources/config/invalid-project-config-v5.json new file mode 100644 index 000000000..d9c3d1936 --- /dev/null +++ b/core-api/src/test/resources/config/invalid-project-config-v5.json @@ -0,0 +1,166 @@ +{ + "accountId": "789", + "projectId": "1234", + "version": "5", + "revision": "42", + "experiments": [ + { + "id": "223", + "key": "etag1", + "status": "Running", + "layerId": "1", + "percentageIncluded": 9000, + "audienceIds": [], + "variations": [{ + "id": "276", + "key": "vtag1", + "variables": [] + }, { + "id": "277", + "key": "vtag2", + "variables": [] + }], + "forcedVariations": { + "testUser1": "vtag1", + "testUser2": "vtag2" + }, + "trafficAllocation": [{ + "entityId": "276", + "endOfRange": 3500 + }, { + "entityId": "277", + "endOfRange": 9000 + }] + }, + { + "id": "118", + "key": "etag2", + "status": "Not started", + "layerId": "2", + "audienceIds": [], + "variations": [{ + "id": "278", + "key": "vtag3", + "variables": [] + }, { + "id": "279", + "key": "vtag4", + "variables": [] + }], + "forcedVariations": {}, + "trafficAllocation": [{ + "entityId": "278", + "endOfRange": 4500 + }, { + "entityId": "279", + "endOfRange": 9000 + }] + }, + { + "id": "119", + "key": "etag3", + "status": "Launched", + "layerId": "3", + "audienceIds": [], + "variations": [{ + "id": "280", + "key": "vtag5" + }, { + "id": "281", + "key": "vtag6" + }], + "forcedVariations": {}, + "trafficAllocation": [{ + "entityId": "280", + "endOfRange": 5000 + }, { + "entityId": "281", + "endOfRange": 10000 + }] + }, + { + "id": "120", + "key": "no_variable_feature_test", + "status": "Running", + "layerId": "3", + "audienceIds": [], + "variations": [ + { + "id": "282", + "key": "no_variable_feature_test_variation_1" + }, + { + "id": "283", + "key": "no_variable_feature_test_variation_2" + } + ], + "forcedVariations": {}, + "trafficAllocation": [ + { + "entityId": "282", + "endOfRange": 5000 + }, + { + "entityId": "283", + "endOfRange": 10000 + } + ] + } + ], + "featureFlags": [ + { + "id": "4195505407", + "key": "no_variable_feature", + "rolloutId": "", + "experimentIds": [120], + "variables": [] + } + ], + "groups": [], + "audiences": [], + "attributes": [ + { + "id": "134", + "key": "browser_type" + } + ], + "events": [ + { + "id": "971", + "key": "clicked_cart", + "experimentIds": [ + "223" + ] + }, + { + "id": "098", + "key": "Total Revenue", + "experimentIds": [ + "223" + ] + }, + { + "id": "099", + "key": "clicked_purchase", + "experimentIds": [ + "118", + "223" + ] + }, + { + "id": "100", + "key": "launched_exp_event", + "experimentIds": [ + "119" + ] + }, + { + "id": "101", + "key": "event_with_launched_and_running_experiments", + "experimentIds": [ + "119", + "223" + ] + } + ] +} \ No newline at end of file diff --git a/core-api/src/test/resources/config/no-audience-project-config-v3.json b/core-api/src/test/resources/config/no-audience-project-config-v3.json index 713b84017..c203fa5c9 100644 --- a/core-api/src/test/resources/config/no-audience-project-config-v3.json +++ b/core-api/src/test/resources/config/no-audience-project-config-v3.json @@ -125,6 +125,5 @@ "223" ] } - ], - "variables": [] -} \ No newline at end of file + ] +} diff --git a/core-api/src/test/resources/config/null-featureEnabled-config-v4.json b/core-api/src/test/resources/config/null-featureEnabled-config-v4.json new file mode 100644 index 000000000..27f664c06 --- /dev/null +++ b/core-api/src/test/resources/config/null-featureEnabled-config-v4.json @@ -0,0 +1,151 @@ +{ + "version": "4", + "rollouts": [ + { + "experiments": [ + { + "status": "Not started", + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "13140960316", + "key": "13140960316", + "featureEnabled": false + } + ], + "id": "13129630268", + "key": "13129630268", + "layerId": "13144860300", + "trafficAllocation": [ + { + "entityId": "13140960316", + "endOfRange": 0 + } + ], + "forcedVariations": {} + } + ], + "id": "13144860300" + } + ], + "typedAudiences": [], + "anonymizeIP": true, + "projectId": "13135560574", + "featureFlags": [ + { + "experimentIds": [ + "13144660444" + ], + "rolloutId": "13144860300", + "variables": [], + "id": "13146780594", + "key": "eet_feature" + } + ], + "experiments": [ + { + "status": "Running", + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "13146910503", + "key": "variation_1" + }, + { + "variables": [], + "id": "13131760333", + "key": "variation_2" + } + ], + "id": "13142800105", + "key": "background_experiment", + "layerId": "13127680535", + "trafficAllocation": [ + { + "entityId": "13131760333", + "endOfRange": 2500 + }, + { + "entityId": "13131760333", + "endOfRange": 5000 + }, + { + "entityId": "13146910503", + "endOfRange": 7500 + }, + { + "entityId": "13146910503", + "endOfRange": 10000 + } + ], + "forcedVariations": {} + }, + { + "status": "Running", + "audienceIds": [], + "variations": [ + { + "variables": [], + "id": "13139030567", + "key": "variation_1", + "featureEnabled": null + }, + { + "variables": [], + "id": "13119930608", + "key": "variation_2", + "featureEnabled": true + } + ], + "id": "13144660444", + "key": "eet_feature_test", + "layerId": "13150530269", + "trafficAllocation": [ + { + "entityId": "13119930608", + "endOfRange": 5000 + }, + { + "entityId": "13139030567", + "endOfRange": 10000 + } + ], + "forcedVariations": {} + } + ], + "audiences": [ + { + "conditions": "[\"or\", {\"match\": \"exact\", \"name\": \"$opt_dummy_attribute\", \"type\": \"custom_attribute\", \"value\": \"$opt_dummy_value\"}]", + "id": "$opt_dummy_audience", + "name": "Auto-Generated Dummy Audience" + } + ], + "groups": [], + "attributes": [ + { + "id": "13129420244", + "key": "browser_type" + } + ], + "botFiltering": false, + "accountId": "8362480420", + "events": [ + { + "experimentIds": [ + "13144660444" + ], + "id": "13120080243", + "key": "eet_conversion" + }, + { + "experimentIds": [ + "13142800105" + ], + "id": "13150190466", + "key": "sample_conversion" + } + ], + "revision": "11" +} \ No newline at end of file diff --git a/core-api/src/test/resources/config/valid-project-config-v2.json b/core-api/src/test/resources/config/valid-project-config-v2.json index 3645a1088..449de0326 100644 --- a/core-api/src/test/resources/config/valid-project-config-v2.json +++ b/core-api/src/test/resources/config/valid-project-config-v2.json @@ -186,7 +186,7 @@ { "id": "100", "name": "not_firefox_users", - "conditions": "[\"and\", [\"or\", [\"not\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_dimension\", \"value\":\"firefox\"}]]]]" + "conditions": "[\"and\", [\"or\", [\"not\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_attribute\", \"value\":\"firefox\"}]]]]" } ], "attributes": [ diff --git a/core-api/src/test/resources/config/valid-project-config-v3.json b/core-api/src/test/resources/config/valid-project-config-v3.json index 14ec9e0c9..2ed605d50 100644 --- a/core-api/src/test/resources/config/valid-project-config-v3.json +++ b/core-api/src/test/resources/config/valid-project-config-v3.json @@ -201,7 +201,7 @@ { "id": "100", "name": "not_firefox_users", - "conditions": "[\"and\", [\"or\", [\"not\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_dimension\", \"value\":\"firefox\"}]]]]" + "conditions": "[\"and\", [\"or\", [\"not\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_attribute\", \"value\":\"firefox\"}]]]]" } ], "attributes": [ @@ -240,63 +240,5 @@ "118" ] } - ], - "variables": [ - { - "id": "1", - "key": "boolean_variable", - "type": "boolean", - "defaultValue": "False", - "status": "active" - }, - { - "id": "2", - "key": "integer_variable", - "type": "integer", - "defaultValue": "5", - "status": "active" - }, - { - "id": "3", - "key": "string_variable", - "type": "string", - "defaultValue": "string_live_variable", - "status": "active" - }, - { - "id": "4", - "key": "double_variable", - "type": "double", - "defaultValue": "13.37", - "status": "active" - }, - { - "id": "5", - "key": "archived_variable", - "type": "boolean", - "defaultValue": "True", - "status": "archived" - }, - { - "id": "6", - "key": "etag1_variable", - "type": "boolean", - "defaultValue": "False", - "status": "active" - }, - { - "id": "7", - "key": "group_etag1_variable", - "type": "boolean", - "defaultValue": "False", - "status": "active" - }, - { - "id": "8", - "key": "unused_string_variable", - "type": "string", - "defaultValue": "unused_variable", - "status": "active" - } ] -} \ No newline at end of file +} diff --git a/core-api/src/test/resources/config/valid-project-config-v4.json b/core-api/src/test/resources/config/valid-project-config-v4.json index db961012e..cc0de0908 100644 --- a/core-api/src/test/resources/config/valid-project-config-v4.json +++ b/core-api/src/test/resources/config/valid-project-config-v4.json @@ -2,29 +2,74 @@ "accountId": "2360254204", "anonymizeIP": true, "botFiltering": true, + "sendFlagDecisions": true, "projectId": "3918735994", "revision": "1480511547", + "sdkKey": "ValidProjectConfigV4", + "environmentKey": "production", "version": "4", "audiences": [ { "id": "3468206642", "name": "Gryffindors", - "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_dimension\", \"value\":\"Gryffindor\"}]]]" + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\":\"Gryffindor\"}]]]" }, { "id": "3988293898", "name": "Slytherins", - "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_dimension\", \"value\":\"Slytherin\"}]]]" + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\":\"Slytherin\"}]]]" }, { "id": "4194404272", "name": "english_citizens", - "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_dimension\", \"value\":\"English\"}]]]" + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\":\"English\"}]]]" }, { "id": "2196265320", "name": "audience_with_missing_value", - "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_dimension\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_dimension\"}]]]" + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_attribute\"}]]]" + } + ], + "typedAudiences": [ + { + "id": "3468206643", + "name": "BOOL", + "conditions": ["and", ["or", ["or", {"name": "booleanKey", "type": "custom_attribute", "match":"exact", "value":true}]]] + }, + { + "id": "3468206646", + "name": "INTEXACT", + "conditions": ["and", ["or", ["or", {"name": "integerKey", "type": "custom_attribute", "match":"exact", "value":1.0}]]] + }, + { + "id": "3468206644", + "name": "INT", + "conditions": ["and", ["or", ["or", {"name": "integerKey", "type": "custom_attribute", "match":"gt", "value":1.0}]]] + }, + { + "id": "3468206645", + "name": "DOUBLE", + "conditions": ["and", ["or", ["or", {"name": "doubleKey", "type": "custom_attribute", "match":"lt", "value":100.0}]]] + }, + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "match":"exact", "value":"Gryffindor"}]]] + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "match":"substring", "value":"Slytherin"}]]] + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": ["and", ["or", ["or", {"name": "nationality", "type": "custom_attribute", "match":"exact", "value":"English"}]]] + }, + { + "id": "2196265320", + "name": "audience_with_missing_value", + "conditions": ["and", ["or", ["or", {"name": "nationality", "type": "custom_attribute", "value": "English"}, {"name": "nationality", "type": "custom_attribute"}]]] } ], "attributes": [ @@ -39,6 +84,22 @@ { "id": "583394100", "key": "$opt_test" + }, + { + "id": "323434545", + "key": "booleanKey" + }, + { + "id": "616727838", + "key": "integerKey" + }, + { + "id": "808797686", + "key": "doubleKey" + }, + { + "id": "808797686", + "key": "" } ], "events": [ @@ -102,6 +163,99 @@ "Tom Riddle": "B" } }, + { + "id": "1323241597", + "key": "typed_audience_experiment", + "layerId": "1630555627", + "status": "Running", + "variations": [ + { + "id": "1423767503", + "key": "A", + "variables": [] + }, + { + "id": "3433458315", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767503", + "endOfRange": 5000 + }, + { + "entityId": "3433458315", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206646", "3468206645"], + "audienceConditions" : ["or", "3468206643", "3468206644", "3468206646", "3468206645" ], + "forcedVariations": {} + }, + { + "id": "1323241598", + "key": "typed_audience_experiment_with_and", + "layerId": "1630555628", + "status": "Running", + "variations": [ + { + "id": "1423767504", + "key": "A", + "variables": [] + }, + { + "id": "3433458316", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767504", + "endOfRange": 5000 + }, + { + "entityId": "3433458316", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206645"], + "audienceConditions" : ["and", "3468206643", "3468206644", "3468206645"], + "forcedVariations": {} + }, + { + "id": "1323241599", + "key": "typed_audience_experiment_leaf_condition", + "layerId": "1630555629", + "status": "Running", + "variations": [ + { + "id": "1423767505", + "key": "A", + "variables": [] + }, + { + "id": "3433458317", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767505", + "endOfRange": 5000 + }, + { + "entityId": "3433458317", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions" : "3468206643", + "forcedVariations": {} + }, { "id": "3262035800", "key": "multivariate_experiment", @@ -120,6 +274,10 @@ { "id": "4052219963", "value": "red" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s1\",\"k2\":103.5,\"k3\":false,\"k4\":{\"kk1\":\"ss1\",\"kk2\":true}}" } ] }, @@ -135,6 +293,10 @@ { "id": "4052219963", "value": "eorge" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s2\",\"k2\":203.5,\"k3\":true,\"k4\":{\"kk1\":\"ss2\",\"kk2\":true}}" } ] }, @@ -150,6 +312,10 @@ { "id": "4052219963", "value": "red" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s3\",\"k2\":303.5,\"k3\":true,\"k4\":{\"kk1\":\"ss3\",\"kk2\":false}}" } ] }, @@ -165,6 +331,10 @@ { "id": "4052219963", "value": "eorge" + }, + { + "id": "4111661000", + "value": "{\"k1\":\"s4\",\"k2\":403.5,\"k3\":false,\"k4\":{\"kk1\":\"ss4\",\"kk2\":true}}" } ] } @@ -481,8 +651,7 @@ "key": "double_variable", "type": "double", "defaultValue": "14.99" - } - ] + } ] }, { "id": "3281420120", @@ -543,6 +712,33 @@ "key": "rest_of_name", "type": "string", "defaultValue": "arry" + }, + { + "id": "4111661000", + "key": "json_patched", + "type": "string", + "subType": "json", + "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" + } + ] + }, + { + "id": "3263342227", + "key": "multi_variate_future_feature", + "rolloutId": "813411034", + "experimentIds": ["3262035800"], + "variables": [ + { + "id": "4111661001", + "key": "json_native", + "type": "json", + "defaultValue": "{\"k1\":\"v1\",\"k2\":3.5,\"k3\":true,\"k4\":{\"kk1\":\"vv1\",\"kk2\":false}}" + }, + { + "id": "4111661002", + "key": "future_variable", + "type": "future_type", + "defaultValue": "future_value" } ] }, @@ -750,5 +946,11 @@ ] } ], - "variables": [] + "integrations": [ + { + "key": "odp", + "host": "https://example.com", + "publicKey": "test-key" + } + ] } diff --git a/core-api/src/test/resources/optimizely.properties b/core-api/src/test/resources/optimizely.properties new file mode 100644 index 000000000..0afb8e23d --- /dev/null +++ b/core-api/src/test/resources/optimizely.properties @@ -0,0 +1,2 @@ +file.only = bar +test.prop = baz diff --git a/core-api/src/test/resources/serializer/conversion-session-id.json b/core-api/src/test/resources/serializer/conversion-session-id.json index 2c1671da1..63140ed91 100644 --- a/core-api/src/test/resources/serializer/conversion-session-id.json +++ b/core-api/src/test/resources/serializer/conversion-session-id.json @@ -4,14 +4,6 @@ { "snapshots": [ { - "decisions": [ - { - "variation_id": "4", - "campaign_id": "2", - "experiment_id": "5", - "is_campaign_holdback": false - } - ], "events": [ { "revenue": 5000, @@ -39,6 +31,7 @@ "client_name": "java-sdk", "client_version": "0.1.1", "anonymize_ip": true, + "enrich_decisions": true, "project_id": "1", "revision": "1" } \ No newline at end of file diff --git a/core-api/src/test/resources/serializer/conversion.json b/core-api/src/test/resources/serializer/conversion.json index 2203295dd..d974728d3 100644 --- a/core-api/src/test/resources/serializer/conversion.json +++ b/core-api/src/test/resources/serializer/conversion.json @@ -4,14 +4,6 @@ { "snapshots": [ { - "decisions": [ - { - "variation_id": "4", - "campaign_id": "2", - "experiment_id": "5", - "is_campaign_holdback": false - } - ], "events": [ { "revenue": 5000, @@ -38,6 +30,7 @@ "client_name": "java-sdk", "client_version": "0.1.1", "anonymize_ip": true, + "enrich_decisions": true, "project_id": "1", "revision": "1" } \ No newline at end of file diff --git a/core-api/src/test/resources/serializer/impression-session-id.json b/core-api/src/test/resources/serializer/impression-session-id.json index 2c1671da1..0b58b5a91 100644 --- a/core-api/src/test/resources/serializer/impression-session-id.json +++ b/core-api/src/test/resources/serializer/impression-session-id.json @@ -39,6 +39,7 @@ "client_name": "java-sdk", "client_version": "0.1.1", "anonymize_ip": true, + "enrich_decisions": true, "project_id": "1", "revision": "1" } \ No newline at end of file diff --git a/core-api/src/test/resources/serializer/impression.json b/core-api/src/test/resources/serializer/impression.json index 2203295dd..69a6df155 100644 --- a/core-api/src/test/resources/serializer/impression.json +++ b/core-api/src/test/resources/serializer/impression.json @@ -38,6 +38,7 @@ "client_name": "java-sdk", "client_version": "0.1.1", "anonymize_ip": true, + "enrich_decisions": true, "project_id": "1", "revision": "1" } \ No newline at end of file diff --git a/core-httpclient-impl/README.md b/core-httpclient-impl/README.md new file mode 100644 index 000000000..762acb31a --- /dev/null +++ b/core-httpclient-impl/README.md @@ -0,0 +1,246 @@ +# Java SDK Async HTTP Client + +This package provides default implementations of an Optimizely `EventHandler` and `ProjectConfigManager`. +The package also includes a factory class, `OptimizelyFactory`, which you can use to instantiate the Optimizely SDK +with the default configuration of `AsyncEventHandler` and `HttpProjectConfigManager`. + +## Installation + +### Gradle +```groovy +compile 'com.optimizely.ab:core-httpclient-impl:{VERSION}' +``` + +### Maven +```xml +<dependency> + <groupId>com.optimizely.ab</groupId> + <artifactId>core-httpclient-impl</artifactId> + <version>{VERSION}</version> +</dependency> + +``` + + +## Basic usage +```java +package com.optimizely; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyFactory; + +public class App { + + public static void main(String[] args) { + String sdkKey = args[0]; + Optimizely optimizely = OptimizelyFactory.newDefaultInstance(sdkKey); + } +} +``` + +## Advanced usage +```java +package com.optimizely; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.config.HttpProjectConfigManager; +import com.optimizely.ab.event.AsyncEventHandler; +import com.optimizely.ab.event.EventHandler; +import java.util.concurrent.TimeUnit; + +public class App { + + public static void main(String[] args) { + String sdkKey = args[0]; + EventHandler eventHandler = AsyncEventHandler.builder() + .withQueueCapacity(20000) + .withNumWorkers(5) + .build(); + + ProjectConfigManager projectConfigManager = HttpProjectConfigManager.builder() + .withSdkKey(sdkKey) + .withPollingInterval(1L, TimeUnit.MINUTES) + .build(); + + Optimizely optimizely = Optimizely.builder() + .withEventHandler(eventHandler) + .withConfigManager(projectConfigManager) + .build(); + } +} +``` + +## AsyncEventHandler + +[`AsyncEventHandler`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java) +provides an implementation of [`EventHandler`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/EventHandler.java) +backed by a `ThreadPoolExecutor`. Events triggered from the Optimizely SDK are queued immediately as discrete tasks to +the executor and processed in the order they were submitted. + +Each worker is responsible for making outbound HTTP requests to the Optimizely log endpoint for metrics tracking. +Configure the default queue size and number of workers via global properties. Use `AsyncEventHandler.Builder` to +override the default queue size and number of workers. + +### Use `AsyncEventHandler` + +To use `AsyncEventHandler`, you must build an instance with `AsyncEventHandler.Builder` and pass the instance to the `Optimizely.Builder`: + +```java +EventHandler eventHandler = AsyncEventHandler.builder() + .withQueueCapacity(20000) + .withNumWorkers(5) + .build(); +``` + +#### Queue capacity + +You can set the queue capacity to initialize the backing queue for the executor service. If the queue fills up, events +will be dropped and an exception will be logged. Setting a higher queue value will prevent event loss but will use more +memory if the workers cannot keep up with the production rate. + +#### Number of workers + +The number of workers determines the number of threads the thread pool uses. + +### Builder Methods +The following builder methods can be used to custom configure the `AsyncEventHandler`. + +|Method Name|Default Value|Description| +|---|---|-----------------------------------------------| +|`withQueueCapacity(int)`|10000|Queue size for pending logEvents| +|`withNumWorkers(int)`|2|Number of worker threads| +|`withMaxTotalConnections(int)`|200|Maximum number of connections| +|`withMaxPerRoute(int)`|20|Maximum number of connections per route| +|`withValidateAfterInactivity(int)`|1000|Time to maintain idle connections (in milliseconds)| + +### Advanced configuration +The following properties can be set to override the default configuration. + +|Property Name|Default Value|Description| +|---|---|-----------------------------------------------| +|**async.event.handler.queue.capacity**|10000|Queue size for pending logEvents| +|**async.event.handler.num.workers**|2|Number of worker threads| +|**async.event.handler.max.connections**|200|Maximum number of connections| +|**async.event.handler.event.max.per.route**|20|Maximum number of connections per route| +|**async.event.handler.validate.after**|1000|Time to maintain idle connections (in milliseconds)| + +## HttpProjectConfigManager + +[`HttpProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java) +is an implementation of the abstract [`PollingProjectConfigManager`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/config/PollingProjectConfigManager.java). +The `poll` method is extended and makes an HTTP GET request to the configured URL to asynchronously download the +project datafile and initialize an instance of the ProjectConfig. + +By default, `HttpProjectConfigManager` will block until the first successful datafile retrieval, up to a configurable timeout. +Set the frequency of the polling method and the blocking timeout with `HttpProjectConfigManager.Builder`, +pulling the default values from global properties. + +### Use `HttpProjectConfigManager` + +```java +ProjectConfigManager projectConfigManager = HttpProjectConfigManager.builder() + .withSdkKey(sdkKey) + .withPollingInterval(1, TimeUnit.MINUTES) + .build(); +``` + +#### SDK key + +The SDK key is used to compose the outbound HTTP request to the default datafile location on the Optimizely CDN. + +#### Polling interval + +The polling interval is used to specify a fixed delay between consecutive HTTP requests for the datafile. + +#### Initial datafile + +You can provide an initial datafile via the builder to bootstrap the `ProjectConfigManager` so that it can be used +immediately without blocking execution. The initial datafile also serves as a fallback datafile if HTTP connection +cannot be established. This is useful in mobile environments, where internet connectivity is not guaranteed. +The initial datafile will be discarded after the first successful datafile poll. + +### Builder Methods +The following builder methods can be used to custom configure the `HttpProjectConfigManager`. + +|Builder Method|Default Value|Description| +|---|---|---| +|`withDatafile(String)`|null|Initial datafile, typically sourced from a local cached source.| +|`withUrl(String)`|null|URL override location used to specify custom HTTP source for the Optimizely datafile.| +|`withFormat(String)`|https://cdn.optimizely.com/datafiles/%s.json|Parameterized datafile URL by SDK key.| +|`withPollingInterval(Long, TimeUnit)`|5 minutes|Fixed delay between fetches for the datafile.| +|`withBlockingTimeout(Long, TimeUnit)`|10 seconds|Maximum time to wait for initial bootstrapping.| +|`withSdkKey(String)`|null|Optimizely project SDK key. Required unless source URL is overridden.| +|`withDatafileAccessToken(String)`|null|Token for authenticated datafile access.| + +### Advanced configuration +The following properties can be set to override the default configuration. + +|Property Name|Default Value|Description| +|---|---|---| +|**http.project.config.manager.polling.duration**|5|Fixed delay between fetches for the datafile| +|**http.project.config.manager.polling.unit**|MINUTES|Time unit corresponding to polling interval| +|**http.project.config.manager.blocking.duration**|10|Maximum time to wait for initial bootstrapping| +|**http.project.config.manager.blocking.unit**|SECONDS|Time unit corresponding to blocking duration| +|**http.project.config.manager.sdk.key**|null|Optimizely project SDK key| +|**http.project.config.manager.datafile.auth.token**|null|Token for authenticated datafile access| + +## Update Config Notifications +A notification signal will be triggered whenever a _new_ datafile is fetched. To subscribe to these notifications you can +use the `Optimizely.addUpdateConfigNotificationHandler`: + +```java +NotificationHandler<UpdateConfigNotification> handler = message -> + System.out.println("Received new datafile configuration"); + +optimizely.addUpdateConfigNotificationHandler(handler); +``` +or add the handler directly to the `NotificationCenter`: +```java +notificationCenter.addNotificationHandler(UpdateConfigNotification.class, handler); +``` + +## optimizely.properties + +When an `optimizely.properties` file is available within the runtime classpath it can be used to provide +default values of a given Optimizely resource. Refer to the resource implementation for available configuration parameters. + +### Example `optimizely.properties` file + +```properties +http.project.config.manager.polling.duration = 1 +http.project.config.manager.polling.unit = MINUTES + +async.event.handler.queue.capacity = 20000 +async.event.handler.num.workers = 5 +``` + + +## OptimizelyFactory + +In this package, [`OptimizelyFactory`](https://github.com/optimizely/java-sdk/blob/master/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java) +provides basic utility to instantiate the Optimizely SDK with a minimal number of configuration options. +Configuration properties are sourced from Java system properties, environment variables, or an +`optimizely.properties` file, in that order. + +`OptimizelyFactory` does not capture all configuration and initialization options. For more use cases, +build the resources via their respective builder classes. + +### Use `OptimizelyFactory` + +You must provide the SDK key at runtime, either directly via the factory method: +```Java +Optimizely optimizely = OptimizelyFactory.newDefaultInstance(<<SDK_KEY>>); +``` + +If you provide the SDK via a global property, use the empty signature: +```Java +Optimizely optimizely = OptimizelyFactory.newDefaultInstance(); +``` + +### Event batching +`OptimizelyFactory` uses the [`BatchEventProcessor`](https://github.com/optimizely/java-sdk/blob/master/core-api/src/main/java/com/optimizely/ab/event/BatchEventProcessor.java) +to enable request batching to the Optimizely logging endpoint. By default, a maximum of 10 events are included in each batch +for a maximum interval of 30 seconds. These parameters are configurable via systems properties or through the +`OptimizelyFactory#setMaxEventBatchSize` and `OptimizelyFactory#setMaxEventBatchInterval` methods. + diff --git a/core-httpclient-impl/build.gradle b/core-httpclient-impl/build.gradle index 7e452d36e..ab5644555 100644 --- a/core-httpclient-impl/build.gradle +++ b/core-httpclient-impl/build.gradle @@ -1,7 +1,12 @@ dependencies { - compile project(':core-api') - - compileOnly group: 'com.google.code.gson', name: 'gson', version: gsonVersion + implementation project(':core-api') + implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion + implementation group: 'org.slf4j', name: 'slf4j-api', version: slf4jVersion + implementation group: 'com.google.code.findbugs', name: 'annotations', version: findbugsAnnotationVersion + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: findbugsJsrVersion + testImplementation 'org.mock-server:mockserver-netty:5.1.1' +} - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion +task exhaustiveTest { + dependsOn('test') } diff --git a/core-httpclient-impl/gradle.properties b/core-httpclient-impl/gradle.properties deleted file mode 100644 index db89b1cbc..000000000 --- a/core-httpclient-impl/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -httpClientVersion = 4.5.2 diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java index bb7ca3e76..bc697e642 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/HttpClientUtils.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, 2022-2023, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,15 +23,33 @@ */ public final class HttpClientUtils { - private static final int CONNECTION_TIMEOUT_MS = 10000; - private static final int CONNECTION_REQUEST_TIMEOUT_MS = 5000; - private static final int SOCKET_TIMEOUT_MS = 10000; + public static final int CONNECTION_TIMEOUT_MS = 10000; + public static final int CONNECTION_REQUEST_TIMEOUT_MS = 5000; + public static final int SOCKET_TIMEOUT_MS = 10000; + public static final int DEFAULT_VALIDATE_AFTER_INACTIVITY = 1000; + public static final int DEFAULT_MAX_CONNECTIONS = 200; + public static final int DEFAULT_MAX_PER_ROUTE = 20; + private static RequestConfig requestConfigWithTimeout; - private HttpClientUtils() { } + private HttpClientUtils() { + } public static final RequestConfig DEFAULT_REQUEST_CONFIG = RequestConfig.custom() .setConnectTimeout(CONNECTION_TIMEOUT_MS) .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS) .setSocketTimeout(SOCKET_TIMEOUT_MS) .build(); + + public static RequestConfig getDefaultRequestConfigWithTimeout(int timeoutMillis) { + requestConfigWithTimeout = RequestConfig.custom() + .setConnectTimeout(timeoutMillis) + .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT_MS) + .setSocketTimeout(timeoutMillis) + .build(); + return requestConfigWithTimeout; + } + + public static OptimizelyHttpClient getDefaultHttpClient() { + return OptimizelyHttpClient.builder().build(); + } } diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java index 8e1443c8a..594ce0e20 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/NamedThreadFactory.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2017, 2019, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,16 +28,26 @@ public class NamedThreadFactory implements ThreadFactory { private final String nameFormat; private final boolean daemon; - private final ThreadFactory backingThreadFactory = Executors.defaultThreadFactory(); + private final ThreadFactory backingThreadFactory; private final AtomicLong threadCount = new AtomicLong(0); /** * @param nameFormat the thread name format which should include a string placeholder for the thread number - * @param daemon whether the threads created should be {@link Thread#daemon}s or not + * @param daemon whether the threads created should be {@link Thread#daemon}s or not */ public NamedThreadFactory(String nameFormat, boolean daemon) { + this(nameFormat, daemon, null); + } + + /** + * @param nameFormat the thread name format which should include a string placeholder for the thread number + * @param daemon whether the threads created should be {@link Thread#daemon}s or not + * @param backingThreadFactory the backing {@link ThreadFactory} to use for creating threads + */ + public NamedThreadFactory(String nameFormat, boolean daemon, ThreadFactory backingThreadFactory) { this.nameFormat = nameFormat; this.daemon = daemon; + this.backingThreadFactory = backingThreadFactory != null ? backingThreadFactory : Executors.defaultThreadFactory(); } @Override diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java new file mode 100644 index 000000000..f26851375 --- /dev/null +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyFactory.java @@ -0,0 +1,379 @@ +/** + * + * Copyright 2019-2021, 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.config.HttpProjectConfigManager; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.event.AsyncEventHandler; +import com.optimizely.ab.event.BatchEventProcessor; +import com.optimizely.ab.event.EventHandler; +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.odp.DefaultODPApiManager; +import com.optimizely.ab.odp.ODPApiManager; +import com.optimizely.ab.odp.ODPManager; +import org.apache.http.impl.client.CloseableHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +/** + * OptimizelyFactory is a utility class to instantiate an {@link Optimizely} client with a minimal + * number of configuration options. Basic default parameters can be configured via system properties + * or through the use of an optimizely.properties file. System properties takes precedence over + * the properties file and are managed via the {@link PropertyUtils} class. + * + * OptimizelyFactory also provides setter methods to override the system properties at runtime. + * <ul> + * <li>{@link OptimizelyFactory#setMaxEventBatchSize}</li> + * <li>{@link OptimizelyFactory#setMaxEventBatchInterval}</li> + * <li>{@link OptimizelyFactory#setEventQueueParams}</li> + * <li>{@link OptimizelyFactory#setBlockingTimeout}</li> + * <li>{@link OptimizelyFactory#setPollingInterval}</li> + * <li>{@link OptimizelyFactory#setSdkKey}</li> + * <li>{@link OptimizelyFactory#setDatafileAccessToken}</li> + * </ul> + * + */ +public final class OptimizelyFactory { + private static final Logger logger = LoggerFactory.getLogger(OptimizelyFactory.class); + + /** + * Convenience method for setting the maximum number of events contained within a batch. + * {@link AsyncEventHandler} + * + * @param batchSize The max number of events for batching + */ + public static void setMaxEventBatchSize(int batchSize) { + if (batchSize <= 0) { + logger.warn("Batch size cannot be <= 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(BatchEventProcessor.CONFIG_BATCH_SIZE, Integer.toString(batchSize)); + } + + /** + * Convenience method for setting the maximum time interval in milliseconds between event dispatches. + * {@link AsyncEventHandler} + * + * @param batchInterval The max time interval for event batching + */ + public static void setMaxEventBatchInterval(long batchInterval) { + if (batchInterval <= 0) { + logger.warn("Batch interval cannot be <= 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(BatchEventProcessor.CONFIG_BATCH_INTERVAL, Long.toString(batchInterval)); + } + + /** + * Convenience method for setting the required queueing parameters for event dispatching. + * {@link AsyncEventHandler} + * + * @param queueCapacity A depth of the event queue + * @param numberWorkers The number of workers + */ + public static void setEventQueueParams(int queueCapacity, int numberWorkers) { + if (queueCapacity <= 0) { + logger.warn("Queue capacity cannot be <= 0. Reverting to default configuration."); + return; + } + + if (numberWorkers <= 0) { + logger.warn("Number of workers cannot be <= 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(AsyncEventHandler.CONFIG_QUEUE_CAPACITY, Integer.toString(queueCapacity)); + PropertyUtils.set(AsyncEventHandler.CONFIG_NUM_WORKERS, Integer.toString(numberWorkers)); + } + + /** + * Convenience method for setting the blocking timeout. + * {@link HttpProjectConfigManager.Builder#withBlockingTimeout(Long, TimeUnit)} + * + * @param blockingDuration The blocking time duration + * @param blockingTimeout The blocking time unit + */ + public static void setBlockingTimeout(long blockingDuration, TimeUnit blockingTimeout) { + if (blockingTimeout == null) { + logger.warn("TimeUnit cannot be null. Reverting to default configuration."); + return; + } + + if (blockingDuration <= 0) { + logger.warn("Timeout cannot be <= 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_BLOCKING_DURATION, Long.toString(blockingDuration)); + PropertyUtils.set(HttpProjectConfigManager.CONFIG_BLOCKING_UNIT, blockingTimeout.toString()); + } + + /** + * Convenience method for setting the evict idle connections. + * {@link HttpProjectConfigManager.Builder#withEvictIdleConnections(long, TimeUnit)} + * + * @param maxIdleTime The connection idle time duration (0 to disable eviction) + * @param maxIdleTimeUnit The connection idle time unit + */ + public static void setEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUnit) { + if (maxIdleTimeUnit == null) { + logger.warn("TimeUnit cannot be null. Reverting to default configuration."); + return; + } + + if (maxIdleTime < 0) { + logger.warn("Timeout cannot be < 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_EVICT_DURATION, Long.toString(maxIdleTime)); + PropertyUtils.set(HttpProjectConfigManager.CONFIG_EVICT_UNIT, maxIdleTimeUnit.toString()); + } + + /** + * Convenience method for setting the polling interval on System properties. + * {@link HttpProjectConfigManager.Builder#withPollingInterval(Long, TimeUnit)} + * + * @param pollingDuration The polling interval + * @param pollingTimeout The polling time unit + */ + public static void setPollingInterval(long pollingDuration, TimeUnit pollingTimeout) { + if (pollingTimeout == null) { + logger.warn("TimeUnit cannot be null. Reverting to default configuration."); + return; + } + + if (pollingDuration <= 0) { + logger.warn("Interval cannot be <= 0. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_POLLING_DURATION, Long.toString(pollingDuration)); + PropertyUtils.set(HttpProjectConfigManager.CONFIG_POLLING_UNIT, pollingTimeout.toString()); + } + + /** + * Convenience method for setting the sdk key on System properties. + * {@link HttpProjectConfigManager.Builder#withSdkKey(String)} + * + * @param sdkKey The sdk key + */ + public static void setSdkKey(String sdkKey) { + if (sdkKey == null) { + logger.warn("SDK key cannot be null. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_SDK_KEY, sdkKey); + } + + /** + * Convenience method for setting the Datafile Access Token on System properties. + * {@link HttpProjectConfigManager.Builder#withDatafileAccessToken(String)} + * + * @param datafileAccessToken The datafile access token + */ + public static void setDatafileAccessToken(String datafileAccessToken) { + if (datafileAccessToken == null) { + logger.warn("Datafile Access Token cannot be null. Reverting to default configuration."); + return; + } + + PropertyUtils.set(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN, datafileAccessToken); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance() { + String sdkKey = PropertyUtils.get(HttpProjectConfigManager.CONFIG_SDK_KEY); + return newDefaultInstance(sdkKey); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * EventHandler - {@link AsyncEventHandler} + * ProjectConfigManager - {@link HttpProjectConfigManager} + * + * @param sdkKey SDK key used to build the ProjectConfigManager. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance(String sdkKey) { + if (sdkKey == null) { + logger.error("Must provide an sdkKey, returning non-op Optimizely client"); + return newDefaultInstance(new ProjectConfigManager() { + @Override + public ProjectConfig getConfig() { + return null; + } + + @Override + public ProjectConfig getCachedConfig() { + return null; + } + + @Override + public String getSDKKey() { + return null; + } + }); + } + + return newDefaultInstance(sdkKey, null); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * EventHandler - {@link AsyncEventHandler} + * ProjectConfigManager - {@link HttpProjectConfigManager} + * + * @param sdkKey SDK key used to build the ProjectConfigManager. + * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance(String sdkKey, String fallback) { + String datafileAccessToken = PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN); + return newDefaultInstance(sdkKey, fallback, datafileAccessToken); + } + + /** + * Returns a new Optimizely instance with authenticated datafile support. + * + * @param sdkKey SDK key used to build the ProjectConfigManager. + * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. + * @param datafileAccessToken Token for authenticated datafile access. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance(String sdkKey, String fallback, String datafileAccessToken) { + return newDefaultInstance(sdkKey, fallback, datafileAccessToken, null); + } + + /** + * Returns a new Optimizely instance with authenticated datafile support. + * + * @param sdkKey SDK key used to build the ProjectConfigManager. + * @param fallback Fallback datafile string used by the ProjectConfigManager to be immediately available. + * @param datafileAccessToken Token for authenticated datafile access. + * @param customHttpClient Customizable CloseableHttpClient to build OptimizelyHttpClient. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance( + String sdkKey, + String fallback, + String datafileAccessToken, + CloseableHttpClient customHttpClient + ) { + OptimizelyHttpClient optimizelyHttpClient = customHttpClient == null ? null : new OptimizelyHttpClient(customHttpClient); + + NotificationCenter notificationCenter = new NotificationCenter(); + + HttpProjectConfigManager.Builder builder = HttpProjectConfigManager.builder() + .withDatafile(fallback) + .withNotificationCenter(notificationCenter) + .withOptimizelyHttpClient(optimizelyHttpClient) + .withSdkKey(sdkKey); + + if (datafileAccessToken != null) { + builder.withDatafileAccessToken(datafileAccessToken); + } + + ProjectConfigManager configManager = builder.build(); + + EventHandler eventHandler = AsyncEventHandler.builder() + .withOptimizelyHttpClient(optimizelyHttpClient) + .build(); + + ODPApiManager odpApiManager = new DefaultODPApiManager(optimizelyHttpClient); + + return newDefaultInstance(configManager, notificationCenter, eventHandler, odpApiManager); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * EventHandler - {@link AsyncEventHandler} + * + * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance(ProjectConfigManager configManager) { + return newDefaultInstance(configManager, null); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * EventHandler - {@link AsyncEventHandler} + * + * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param notificationCenter The {@link NotificationCenter} supplied to Optimizely instance. + * @return A new Optimizely instance + */ + public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter) { + EventHandler eventHandler = AsyncEventHandler.builder().build(); + return newDefaultInstance(configManager, notificationCenter, eventHandler); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * + * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param notificationCenter The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param eventHandler The {@link EventHandler} supplied to Optimizely instance. + * @return A new Optimizely instance + * */ + public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler) { + return newDefaultInstance(configManager, notificationCenter, eventHandler, null); + } + + /** + * Returns a new Optimizely instance based on preset configuration. + * + * @param configManager The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param notificationCenter The {@link ProjectConfigManager} supplied to Optimizely instance. + * @param eventHandler The {@link EventHandler} supplied to Optimizely instance. + * @param odpApiManager The {@link ODPApiManager} supplied to Optimizely instance. + * @return A new Optimizely instance + * */ + public static Optimizely newDefaultInstance(ProjectConfigManager configManager, NotificationCenter notificationCenter, EventHandler eventHandler, ODPApiManager odpApiManager) { + if (notificationCenter == null) { + notificationCenter = new NotificationCenter(); + } + + BatchEventProcessor eventProcessor = BatchEventProcessor.builder() + .withEventHandler(eventHandler) + .withNotificationCenter(notificationCenter) + .build(); + + ODPManager odpManager = ODPManager.builder() + .withApiManager(odpApiManager != null ? odpApiManager : new DefaultODPApiManager()) + .build(); + + return Optimizely.builder() + .withEventProcessor(eventProcessor) + .withConfigManager(configManager) + .withNotificationCenter(notificationCenter) + .withODPManager(odpManager) + .build(); + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java new file mode 100644 index 000000000..5b515aea6 --- /dev/null +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/OptimizelyHttpClient.java @@ -0,0 +1,157 @@ +/** + * + * Copyright 2019, 2022 Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.HttpClientUtils; + +import org.apache.http.client.HttpClient; +import org.apache.http.client.HttpRequestRetryHandler; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +/** + * Basic HttpClient wrapper to be utilized for fetching the datafile + * and for posting events through the EventHandler + * + * TODO abstract out interface and move into core? + */ +public class OptimizelyHttpClient implements Closeable { + + private static final Logger logger = LoggerFactory.getLogger(OptimizelyHttpClient.class); + private final CloseableHttpClient httpClient; + + OptimizelyHttpClient(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + @VisibleForTesting + HttpClient getHttpClient() { + return httpClient; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public void close() throws IOException { + this.httpClient.close(); + } + + public <T> T execute(final HttpUriRequest request, final ResponseHandler<? extends T> responseHandler) throws IOException { + return httpClient.execute(request, responseHandler); + } + + public CloseableHttpResponse execute(final HttpUriRequest request) throws IOException { + return httpClient.execute(request); + } + + public static class Builder { + // The following static values are public so that they can be tweaked if necessary. + // These are the recommended settings for http protocol. https://hc.apache.org/httpcomponents-client-ga/tutorial/html/connmgmt.html + // The maximum number of connections allowed across all routes. + int maxTotalConnections = HttpClientUtils.DEFAULT_MAX_CONNECTIONS; + // The maximum number of connections allowed for a route + int maxPerRoute = HttpClientUtils.DEFAULT_MAX_PER_ROUTE; + // Defines period of inactivity in milliseconds after which persistent connections must be re-validated prior to being leased to the consumer. + // If this is too long, it's expected to see more requests dropped on staled connections (dropped by the server or networks). + // We can configure retries (POST for AsyncEventDispatcher) to cover the staled connections. + int validateAfterInactivity = HttpClientUtils.DEFAULT_VALIDATE_AFTER_INACTIVITY; + // force-close the connection after this idle time (with 0, eviction is disabled by default) + long evictConnectionIdleTimePeriod = 0; + HttpRequestRetryHandler customRetryHandler = null; + TimeUnit evictConnectionIdleTimeUnit = TimeUnit.MILLISECONDS; + private int timeoutMillis = HttpClientUtils.CONNECTION_TIMEOUT_MS; + + + private Builder() { + + } + + public Builder withMaxTotalConnections(int maxTotalConnections) { + this.maxTotalConnections = maxTotalConnections; + return this; + } + + public Builder withMaxPerRoute(int maxPerRoute) { + this.maxPerRoute = maxPerRoute; + return this; + } + + public Builder withValidateAfterInactivity(int validateAfterInactivity) { + this.validateAfterInactivity = validateAfterInactivity; + return this; + } + + public Builder withEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUnit) { + this.evictConnectionIdleTimePeriod = maxIdleTime; + this.evictConnectionIdleTimeUnit = maxIdleTimeUnit; + return this; + } + + // customize retryHandler (DefaultHttpRequestRetryHandler will be used by default) + public Builder withRetryHandler(HttpRequestRetryHandler retryHandler) { + this.customRetryHandler = retryHandler; + return this; + } + + public Builder setTimeoutMillis(int timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } + + public OptimizelyHttpClient build() { + PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(); + poolingHttpClientConnectionManager.setMaxTotal(maxTotalConnections); + poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxPerRoute); + poolingHttpClientConnectionManager.setValidateAfterInactivity(validateAfterInactivity); + + HttpClientBuilder builder = HttpClients.custom() + .setDefaultRequestConfig(HttpClientUtils.getDefaultRequestConfigWithTimeout(timeoutMillis)) + .setConnectionManager(poolingHttpClientConnectionManager) + .disableCookieManagement() + .useSystemProperties(); + if (customRetryHandler != null) { + builder.setRetryHandler(customRetryHandler); + } + + logger.debug("Creating HttpClient with timeout: " + timeoutMillis); + + if (evictConnectionIdleTimePeriod > 0) { + builder.evictIdleConnections(evictConnectionIdleTimePeriod, evictConnectionIdleTimeUnit); + } + + CloseableHttpClient closableHttpClient = builder.build(); + + return new OptimizelyHttpClient(closableHttpClient); + } + } + +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java new file mode 100644 index 000000000..2e99d3ae9 --- /dev/null +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -0,0 +1,407 @@ +/** + * + * Copyright 2019, 2021, 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.config.parser.ConfigParseException; +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.notification.NotificationCenter; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.locks.ReentrantLock; +import javax.annotation.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.apache.http.*; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * HttpProjectConfigManager is an implementation of a {@link PollingProjectConfigManager} + * backed by a datafile. Currently this is loosely tied to Apache HttpClient + * implementation which is the client of choice in this package. + */ +public class HttpProjectConfigManager extends PollingProjectConfigManager { + + public static final String CONFIG_POLLING_DURATION = "http.project.config.manager.polling.duration"; + public static final String CONFIG_POLLING_UNIT = "http.project.config.manager.polling.unit"; + public static final String CONFIG_BLOCKING_DURATION = "http.project.config.manager.blocking.duration"; + public static final String CONFIG_BLOCKING_UNIT = "http.project.config.manager.blocking.unit"; + public static final String CONFIG_EVICT_DURATION = "http.project.config.manager.evict.duration"; + public static final String CONFIG_EVICT_UNIT = "http.project.config.manager.evict.unit"; + public static final String CONFIG_SDK_KEY = "http.project.config.manager.sdk.key"; + public static final String CONFIG_DATAFILE_AUTH_TOKEN = "http.project.config.manager.datafile.auth.token"; + + public static final long DEFAULT_POLLING_DURATION = 5; + public static final TimeUnit DEFAULT_POLLING_UNIT = TimeUnit.MINUTES; + public static final long DEFAULT_BLOCKING_DURATION = 10; + public static final TimeUnit DEFAULT_BLOCKING_UNIT = TimeUnit.SECONDS; + public static final long DEFAULT_EVICT_DURATION = 1; + public static final TimeUnit DEFAULT_EVICT_UNIT = TimeUnit.MINUTES; + + private static final Logger logger = LoggerFactory.getLogger(HttpProjectConfigManager.class); + + @VisibleForTesting + public final OptimizelyHttpClient httpClient; + private final URI uri; + private final String datafileAccessToken; + private String datafileLastModified; + private final ReentrantLock lock = new ReentrantLock(); + + private HttpProjectConfigManager(long period, + TimeUnit timeUnit, + OptimizelyHttpClient httpClient, + String url, + String datafileAccessToken, + long blockingTimeoutPeriod, + TimeUnit blockingTimeoutUnit, + NotificationCenter notificationCenter, + @Nullable ThreadFactory threadFactory) { + super(period, timeUnit, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter, threadFactory); + this.httpClient = httpClient; + this.uri = URI.create(url); + this.datafileAccessToken = datafileAccessToken; + } + + public URI getUri() { + return uri; + } + + public String getLastModified() { + return datafileLastModified; + } + + public String getDatafileFromResponse(HttpResponse response) throws NullPointerException, IOException { + StatusLine statusLine = response.getStatusLine(); + + if (statusLine == null) { + throw new ClientProtocolException("unexpected response from event endpoint, status is null"); + } + + int status = statusLine.getStatusCode(); + + // Datafile has not updated + if (status == HttpStatus.SC_NOT_MODIFIED) { + logger.debug("Not updating ProjectConfig as datafile has not updated since " + datafileLastModified); + return null; + } + + if (status >= 200 && status < 300) { + // read the response, so we can close the connection + HttpEntity entity = response.getEntity(); + Header lastModifiedHeader = response.getFirstHeader(HttpHeaders.LAST_MODIFIED); + if (lastModifiedHeader != null) { + datafileLastModified = lastModifiedHeader.getValue(); + } + return EntityUtils.toString(entity, "UTF-8"); + } else { + throw new ClientProtocolException("unexpected response when trying to fetch datafile, status: " + status); + } + } + + static ProjectConfig parseProjectConfig(String datafile) throws ConfigParseException { + return new DatafileProjectConfig.Builder().withDatafile(datafile).build(); + } + + @Override + protected ProjectConfig poll() { + HttpGet httpGet = createHttpRequest(); + CloseableHttpResponse response = null; + logger.debug("Fetching datafile from: {}", httpGet.getURI()); + try { + response = httpClient.execute(httpGet); + String datafile = getDatafileFromResponse(response); + if (datafile == null) { + return null; + } + return parseProjectConfig(datafile); + } catch (ConfigParseException | IOException e) { + logger.error("Error fetching datafile", e); + } + finally { + if (response != null) { + try { + response.close(); + } catch (IOException e) { + logger.warn(e.getLocalizedMessage()); + } + } + } + + return null; + } + + @Override + public void close() { + lock.lock(); + try { + super.close(); + try { + httpClient.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } finally { + lock.unlock(); + } + } + + @VisibleForTesting + HttpGet createHttpRequest() { + HttpGet httpGet = new HttpGet(uri); + + if (datafileAccessToken != null) { + httpGet.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + datafileAccessToken); + } + + if (datafileLastModified != null) { + httpGet.setHeader(HttpHeaders.IF_MODIFIED_SINCE, datafileLastModified); + } + + return httpGet; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String datafile; + private String url; + private String datafileAccessToken = null; + private String format = "https://cdn.optimizely.com/datafiles/%s.json"; + private String authFormat = "https://config.optimizely.com/datafiles/auth/%s.json"; + private OptimizelyHttpClient httpClient; + private NotificationCenter notificationCenter; + + String sdkKey = PropertyUtils.get(CONFIG_SDK_KEY); + long period = PropertyUtils.getLong(CONFIG_POLLING_DURATION, DEFAULT_POLLING_DURATION); + TimeUnit timeUnit = PropertyUtils.getEnum(CONFIG_POLLING_UNIT, TimeUnit.class, DEFAULT_POLLING_UNIT); + + long blockingTimeoutPeriod = PropertyUtils.getLong(CONFIG_BLOCKING_DURATION, DEFAULT_BLOCKING_DURATION); + TimeUnit blockingTimeoutUnit = PropertyUtils.getEnum(CONFIG_BLOCKING_UNIT, TimeUnit.class, DEFAULT_BLOCKING_UNIT); + + // force-close the persistent connection after this idle time + long evictConnectionIdleTimePeriod = PropertyUtils.getLong(CONFIG_EVICT_DURATION, DEFAULT_EVICT_DURATION); + TimeUnit evictConnectionIdleTimeUnit = PropertyUtils.getEnum(CONFIG_EVICT_UNIT, TimeUnit.class, DEFAULT_EVICT_UNIT); + ThreadFactory threadFactory = null; + + public Builder withDatafile(String datafile) { + this.datafile = datafile; + return this; + } + + public Builder withSdkKey(String sdkKey) { + this.sdkKey = sdkKey; + return this; + } + + public Builder withDatafileAccessToken(String token) { + this.datafileAccessToken = token; + return this; + } + + public Builder withUrl(String url) { + this.url = url; + return this; + } + + public Builder withFormat(String format) { + this.format = format; + return this; + } + + public Builder withOptimizelyHttpClient(OptimizelyHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + /** + * Makes HttpClient proactively evict idle connections from theƓ + * connection pool using a background thread. + * + * @see org.apache.http.impl.client.HttpClientBuilder#evictIdleConnections(long, TimeUnit) + * + * @param maxIdleTime maximum time persistent connections can stay idle while kept alive + * in the connection pool. Connections whose inactivity period exceeds this value will + * get closed and evicted from the pool. Set to 0 to disable eviction. + * @param maxIdleTimeUnit time unit for the above parameter. + * + * @return A HttpProjectConfigManager builder + */ + public Builder withEvictIdleConnections(long maxIdleTime, TimeUnit maxIdleTimeUnit) { + this.evictConnectionIdleTimePeriod = maxIdleTime; + this.evictConnectionIdleTimeUnit = maxIdleTimeUnit; + return this; + } + + /** + * Configure time to block before Completing the future. This timeout is used on the first call + * to {@link PollingProjectConfigManager#getConfig()}. If the timeout is exceeded then the + * PollingProjectConfigManager will begin returning null immediately until the call to Poll + * succeeds. + * + * @param period A timeout period + * @param timeUnit A timeout unit + * @return A HttpProjectConfigManager builder + */ + public Builder withBlockingTimeout(Long period, TimeUnit timeUnit) { + if (timeUnit == null) { + logger.warn("TimeUnit cannot be null. Keeping default period: {} and time unit: {}", this.blockingTimeoutPeriod, this.blockingTimeoutUnit); + return this; + } + + if (period == null) { + logger.warn("Timeout cannot be null. Keeping default period: {} and time unit: {}", this.blockingTimeoutPeriod, this.blockingTimeoutUnit); + return this; + } + + if (period <= 0) { + logger.warn("Timeout cannot be <= 0. Keeping default period: {} and time unit: {}", this.blockingTimeoutPeriod, this.blockingTimeoutUnit); + return this; + } + + this.blockingTimeoutPeriod = period; + this.blockingTimeoutUnit = timeUnit; + + return this; + } + + public Builder withPollingInterval(Long period, TimeUnit timeUnit) { + if (timeUnit == null) { + logger.warn("TimeUnit cannot be null. Keeping default period: {} and time unit: {}", this.period, this.timeUnit); + return this; + } + + if (period == null) { + logger.warn("Interval cannot be null. Keeping default period: {} and time unit: {}", this.period, this.timeUnit); + return this; + } + + if (period <= 0) { + logger.warn("Interval cannot be <= 0. Keeping default period: {} and time unit: {}", this.period, this.timeUnit); + return this; + } + + this.period = period; + this.timeUnit = timeUnit; + + return this; + } + + @SuppressFBWarnings("EI_EXPOSE_REP2") + public Builder withNotificationCenter(NotificationCenter notificationCenter) { + this.notificationCenter = notificationCenter; + return this; + } + + public Builder withThreadFactory(ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + + /** + * HttpProjectConfigManager.Builder that builds and starts a HttpProjectConfigManager. + * This is the default builder which will block until a config is available. + * + * @return {@link HttpProjectConfigManager} + */ + public HttpProjectConfigManager build() { + return build(false); + } + + /** + * HttpProjectConfigManager.Builder that builds and starts a HttpProjectConfigManager. + * + * @param defer When true, we will not wait for the configuration to be available + * before returning the HttpProjectConfigManager instance. + * @return {@link HttpProjectConfigManager} + */ + public HttpProjectConfigManager build(boolean defer) { + if (period <= 0) { + logger.warn("Invalid polling interval {}, {}. Defaulting to {}, {}", + period, timeUnit, DEFAULT_POLLING_DURATION, DEFAULT_POLLING_UNIT); + period = DEFAULT_POLLING_DURATION; + timeUnit = DEFAULT_POLLING_UNIT; + } + + if (blockingTimeoutPeriod <= 0) { + logger.warn("Invalid polling interval {}, {}. Defaulting to {}, {}", + blockingTimeoutPeriod, blockingTimeoutUnit, DEFAULT_BLOCKING_DURATION, DEFAULT_BLOCKING_UNIT); + blockingTimeoutPeriod = DEFAULT_BLOCKING_DURATION; + blockingTimeoutUnit = DEFAULT_BLOCKING_UNIT; + } + + if (httpClient == null) { + httpClient = OptimizelyHttpClient.builder() + .withEvictIdleConnections(evictConnectionIdleTimePeriod, evictConnectionIdleTimeUnit) + .build(); + } + if (sdkKey == null) { + throw new NullPointerException("sdkKey cannot be null"); + } + if (url == null) { + + if (datafileAccessToken == null) { + url = String.format(format, sdkKey); + } else { + url = String.format(authFormat, sdkKey); + } + } + + if (notificationCenter == null) { + notificationCenter = new NotificationCenter(); + } + + HttpProjectConfigManager httpProjectManager = new HttpProjectConfigManager( + period, + timeUnit, + httpClient, + url, + datafileAccessToken, + blockingTimeoutPeriod, + blockingTimeoutUnit, + notificationCenter, + threadFactory); + httpProjectManager.setSdkKey(sdkKey); + if (datafile != null) { + try { + ProjectConfig projectConfig = HttpProjectConfigManager.parseProjectConfig(datafile); + httpProjectManager.setConfig(projectConfig); + } catch (ConfigParseException e) { + logger.warn("Error parsing fallback datafile.", e); + } + } + + httpProjectManager.start(); + + // Optionally block until config is available. + if (!defer) { + httpProjectManager.getConfig(); + } + + return httpProjectManager; + } + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java index de4180b11..2a9c10ec9 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/event/AsyncEventHandler.java @@ -1,6 +1,6 @@ /** * - * Copyright 2016-2017, Optimizely and contributors + * Copyright 2016-2019,2021, Optimizely and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,12 @@ import com.optimizely.ab.HttpClientUtils; import com.optimizely.ab.NamedThreadFactory; +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.internal.PropertyUtils; +import java.util.concurrent.ThreadFactory; +import javax.annotation.Nullable; import org.apache.http.HttpResponse; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.ResponseHandler; @@ -27,21 +32,19 @@ import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.Closeable; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import javax.annotation.CheckForNull; @@ -49,113 +52,214 @@ * {@link EventHandler} implementation that queues events and has a separate pool of threads responsible * for the dispatch. */ -public class AsyncEventHandler implements EventHandler, Closeable { +public class AsyncEventHandler implements EventHandler, AutoCloseable { + + public static final String CONFIG_QUEUE_CAPACITY = "async.event.handler.queue.capacity"; + public static final String CONFIG_NUM_WORKERS = "async.event.handler.num.workers"; + public static final String CONFIG_MAX_CONNECTIONS = "async.event.handler.max.connections"; + public static final String CONFIG_MAX_PER_ROUTE = "async.event.handler.event.max.per.route"; + public static final String CONFIG_VALIDATE_AFTER_INACTIVITY = "async.event.handler.validate.after"; + + public static final int DEFAULT_QUEUE_CAPACITY = 10000; + public static final int DEFAULT_NUM_WORKERS = 2; - // The following static values are public so that they can be tweaked if necessary. - // These are the recommended settings for http protocol. https://hc.apache.org/httpcomponents-client-ga/tutorial/html/connmgmt.html - // The maximum number of connections allowed across all routes. - private int maxTotalConnections = 200; - // The maximum number of connections allowed for a route - private int maxPerRoute = 20; - // Defines period of inactivity in milliseconds after which persistent connections must be re-validated prior to being leased to the consumer. - private int validateAfterInactivity = 5000; private static final Logger logger = LoggerFactory.getLogger(AsyncEventHandler.class); private static final ProjectConfigResponseHandler EVENT_RESPONSE_HANDLER = new ProjectConfigResponseHandler(); - private final CloseableHttpClient httpClient; + @VisibleForTesting + public final OptimizelyHttpClient httpClient; private final ExecutorService workerExecutor; - private final BlockingQueue<LogEvent> logEventQueue; - public AsyncEventHandler(int queueCapacity, int numWorkers) { + private final long closeTimeout; + private final TimeUnit closeTimeoutUnit; + + /** + * @deprecated Use the builder {@link Builder} + * + * @param queueCapacity A depth of the event queue + * @param numWorkers The number of workers + */ + @Deprecated + public AsyncEventHandler(int queueCapacity, + int numWorkers) { this(queueCapacity, numWorkers, 200, 20, 5000); } - public AsyncEventHandler(int queueCapacity, int numWorkers, int maxConnections, int connectionsPerRoute, int validateAfter) { - if (queueCapacity <= 0) { - throw new IllegalArgumentException("queue capacity must be > 0"); - } + /** + * @deprecated Use the builder {@link Builder} + * + * @param queueCapacity A depth of the event queue + * @param numWorkers The number of workers + * @param maxConnections The max number of concurrent connections + * @param connectionsPerRoute The max number of concurrent connections per route + * @param validateAfter An inactivity period in milliseconds after which persistent connections must be re-validated prior to being leased to the consumer. + */ + @Deprecated + public AsyncEventHandler(int queueCapacity, + int numWorkers, + int maxConnections, + int connectionsPerRoute, + int validateAfter) { + this(queueCapacity, numWorkers, maxConnections, connectionsPerRoute, validateAfter, Long.MAX_VALUE, TimeUnit.MILLISECONDS); + } - this.maxTotalConnections = maxConnections; - this.maxPerRoute = connectionsPerRoute; - this.validateAfterInactivity = validateAfter; + public AsyncEventHandler(int queueCapacity, + int numWorkers, + int maxConnections, + int connectionsPerRoute, + int validateAfter, + long closeTimeout, + TimeUnit closeTimeoutUnit) { + this(queueCapacity, + numWorkers, + maxConnections, + connectionsPerRoute, + validateAfter, + closeTimeout, + closeTimeoutUnit, + null, + null); + } - this.logEventQueue = new ArrayBlockingQueue<LogEvent>(queueCapacity); - this.httpClient = HttpClients.custom() - .setDefaultRequestConfig(HttpClientUtils.DEFAULT_REQUEST_CONFIG) - .setConnectionManager(poolingHttpClientConnectionManager()) - .disableCookieManagement() - .build(); + public AsyncEventHandler(int queueCapacity, + int numWorkers, + int maxConnections, + int connectionsPerRoute, + int validateAfter, + long closeTimeout, + TimeUnit closeTimeoutUnit, + @Nullable OptimizelyHttpClient httpClient, + @Nullable ThreadFactory threadFactory) { + if (httpClient != null) { + this.httpClient = httpClient; + } else { + maxConnections = validateInput("maxConnections", maxConnections, HttpClientUtils.DEFAULT_MAX_CONNECTIONS); + connectionsPerRoute = validateInput("connectionsPerRoute", connectionsPerRoute, HttpClientUtils.DEFAULT_MAX_PER_ROUTE); + validateAfter = validateInput("validateAfter", validateAfter, HttpClientUtils.DEFAULT_VALIDATE_AFTER_INACTIVITY); + this.httpClient = OptimizelyHttpClient.builder() + .withMaxTotalConnections(maxConnections) + .withMaxPerRoute(connectionsPerRoute) + .withValidateAfterInactivity(validateAfter) + // infrequent event discards observed. staled connections force-closed after a long idle time. + .withEvictIdleConnections(1L, TimeUnit.MINUTES) + // enable retry on event POST (default: retry on GET only) + .withRetryHandler(new DefaultHttpRequestRetryHandler(3, true)) + .build(); + } - this.workerExecutor = Executors.newFixedThreadPool( - numWorkers, new NamedThreadFactory("optimizely-event-dispatcher-thread-%s", true)); + queueCapacity = validateInput("queueCapacity", queueCapacity, DEFAULT_QUEUE_CAPACITY); + numWorkers = validateInput("numWorkers", numWorkers, DEFAULT_NUM_WORKERS); - // create dispatch workers - for (int i = 0; i < numWorkers; i++) { - EventDispatchWorker worker = new EventDispatchWorker(); - workerExecutor.submit(worker); - } + NamedThreadFactory namedThreadFactory = new NamedThreadFactory("optimizely-event-dispatcher-thread-%s", true, threadFactory); + this.workerExecutor = new ThreadPoolExecutor(numWorkers, numWorkers, + 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(queueCapacity), + namedThreadFactory); + + this.closeTimeout = closeTimeout; + this.closeTimeoutUnit = closeTimeoutUnit; } - private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() - { - PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(); - poolingHttpClientConnectionManager.setMaxTotal(maxTotalConnections); - poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxPerRoute); - poolingHttpClientConnectionManager.setValidateAfterInactivity(validateAfterInactivity); - return poolingHttpClientConnectionManager; + @VisibleForTesting + public AsyncEventHandler(OptimizelyHttpClient httpClient, ExecutorService workerExecutor) { + this.httpClient = httpClient; + this.workerExecutor = workerExecutor; + this.closeTimeout = Long.MAX_VALUE; + this.closeTimeoutUnit = TimeUnit.MILLISECONDS; } @Override public void dispatchEvent(LogEvent logEvent) { - // attempt to enqueue the log event for processing - boolean submitted = logEventQueue.offer(logEvent); - if (!submitted) { - logger.error("unable to enqueue event because queue is full"); + try { + // attempt to enqueue the log event for processing + workerExecutor.execute(new EventDispatcher(logEvent)); + } catch (RejectedExecutionException e) { + logger.error("event dispatch rejected"); } } - @Override - public void close() throws IOException { - logger.info("closing event dispatcher"); + /** + * Attempts to gracefully terminate all event dispatch workers and close all resources. + * This method blocks, awaiting the completion of any queued or ongoing event dispatches. + * <p> + * Note: termination of ongoing event dispatching is best-effort. + * + * @param timeout maximum time to wait for event dispatches to complete + * @param unit the time unit of the timeout argument + */ + public void shutdownAndAwaitTermination(long timeout, TimeUnit unit) { + + // Disable new tasks from being submitted + logger.info("event handler shutting down. Attempting to dispatch previously submitted events"); + workerExecutor.shutdown(); - // "close" all workers and the http client try { - httpClient.close(); - } catch (IOException e) { - logger.error("unable to close the event handler httpclient cleanly", e); - } finally { + // Wait a while for existing tasks to terminate + if (!workerExecutor.awaitTermination(timeout, unit)) { + int unprocessedCount = workerExecutor.shutdownNow().size(); + logger.warn("timed out waiting for previously submitted events to be dispatched. " + + "{} events were dropped. " + + "Interrupting dispatch worker(s)", unprocessedCount); + // Cancel currently executing tasks + // Wait a while for tasks to respond to being cancelled + if (!workerExecutor.awaitTermination(timeout, unit)) { + logger.error("unable to gracefully shutdown event handler"); + } + } + } catch (InterruptedException ie) { + // (Re-)Cancel if current thread also interrupted workerExecutor.shutdownNow(); + // Preserve interrupt status + Thread.currentThread().interrupt(); + } finally { + try { + httpClient.close(); + } catch (IOException e) { + logger.error("unable to close event dispatcher http client", e); + } } + + logger.info("event handler shutdown complete"); + } + + @Override + public void close() { + shutdownAndAwaitTermination(closeTimeout, closeTimeoutUnit); } //======== Helper classes ========// - private class EventDispatchWorker implements Runnable { + /** + * Wrapper runnable for the actual event dispatch. + */ + private class EventDispatcher implements Runnable { + + private final LogEvent logEvent; + + EventDispatcher(LogEvent logEvent) { + this.logEvent = logEvent; + } @Override public void run() { - boolean terminate = false; - - logger.info("starting event dispatch worker"); - // event loop that'll block waiting for events to appear in the queue - //noinspection InfiniteLoopStatement - while (!terminate) { - try { - LogEvent event = logEventQueue.take(); - HttpRequestBase request; - if (event.getRequestMethod() == LogEvent.RequestMethod.GET) { - request = generateGetRequest(event); - } else { - request = generatePostRequest(event); - } - httpClient.execute(request, EVENT_RESPONSE_HANDLER); - } catch (InterruptedException e) { - logger.info("terminating event dispatcher event loop"); - terminate = true; - } catch (Throwable t) { - logger.error("event dispatcher threw exception but will continue", t); + if (logger.isDebugEnabled()) { + logger.debug("Dispatching event to URL {} with params {} and payload \"{}\".", + logEvent.getEndpointUrl(), logEvent.getRequestParams(), logEvent.getBody()); + } + + try { + HttpRequestBase request; + if (logEvent.getRequestMethod() == LogEvent.RequestMethod.GET) { + request = generateGetRequest(logEvent); + } else { + request = generatePostRequest(logEvent); } + httpClient.execute(request, EVENT_RESPONSE_HANDLER); + } catch (IOException e) { + logger.error("event dispatch failed", e); + } catch (URISyntaxException e) { + logger.error("unable to parse generated URI", e); } } @@ -181,12 +285,13 @@ private HttpPost generatePostRequest(LogEvent event) throws UnsupportedEncodingE } /** - * Handler for the event request that returns nothing (i.e., Void) + * Handler for the event request. */ private static final class ProjectConfigResponseHandler implements ResponseHandler<Void> { @Override - public @CheckForNull Void handleResponse(HttpResponse response) throws IOException { + @CheckForNull + public Void handleResponse(HttpResponse response) throws IOException { int status = response.getStatusLine().getStatusCode(); if (status >= 200 && status < 300) { // read the response, so we can close the connection @@ -197,4 +302,89 @@ private static final class ProjectConfigResponseHandler implements ResponseHandl } } } -} \ No newline at end of file + + //======== Builder ========// + + public static Builder builder() { return new Builder(); } + + public static class Builder { + + int queueCapacity = PropertyUtils.getInteger(CONFIG_QUEUE_CAPACITY, DEFAULT_QUEUE_CAPACITY); + int numWorkers = PropertyUtils.getInteger(CONFIG_NUM_WORKERS, DEFAULT_NUM_WORKERS); + int maxTotalConnections = PropertyUtils.getInteger(CONFIG_MAX_CONNECTIONS, HttpClientUtils.DEFAULT_MAX_CONNECTIONS); + int maxPerRoute = PropertyUtils.getInteger(CONFIG_MAX_PER_ROUTE, HttpClientUtils.DEFAULT_MAX_PER_ROUTE); + int validateAfterInactivity = PropertyUtils.getInteger(CONFIG_VALIDATE_AFTER_INACTIVITY, HttpClientUtils.DEFAULT_VALIDATE_AFTER_INACTIVITY); + private long closeTimeout = Long.MAX_VALUE; + private TimeUnit closeTimeoutUnit = TimeUnit.MILLISECONDS; + private OptimizelyHttpClient httpClient; + + public Builder withQueueCapacity(int queueCapacity) { + if (queueCapacity <= 0) { + logger.warn("Queue capacity cannot be <= 0. Keeping default value: {}", this.queueCapacity); + return this; + } + + this.queueCapacity = queueCapacity; + return this; + } + + public Builder withNumWorkers(int numWorkers) { + if (numWorkers <= 0) { + logger.warn("Number of workers cannot be <= 0. Keeping default value: {}", this.numWorkers); + return this; + } + + this.numWorkers = numWorkers; + return this; + } + + public Builder withMaxTotalConnections(int maxTotalConnections) { + this.maxTotalConnections = maxTotalConnections; + return this; + } + + public Builder withMaxPerRoute(int maxPerRoute) { + this.maxPerRoute = maxPerRoute; + return this; + } + + public Builder withValidateAfterInactivity(int validateAfterInactivity) { + this.validateAfterInactivity = validateAfterInactivity; + return this; + } + + public Builder withCloseTimeout(long closeTimeout, TimeUnit unit) { + this.closeTimeout = closeTimeout; + this.closeTimeoutUnit = unit; + return this; + } + + public Builder withOptimizelyHttpClient(OptimizelyHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public AsyncEventHandler build() { + return new AsyncEventHandler( + queueCapacity, + numWorkers, + maxTotalConnections, + maxPerRoute, + validateAfterInactivity, + closeTimeout, + closeTimeoutUnit, + httpClient, + null + ); + } + } + + private int validateInput(String name, int input, int fallback) { + if (input <= 0) { + logger.warn("Invalid value for {}: {}. Defaulting to {}", name, input, fallback); + return fallback; + } + + return input; + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java new file mode 100644 index 000000000..b733427de --- /dev/null +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -0,0 +1,265 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.annotations.VisibleForTesting; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParserFactory; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +public class DefaultODPApiManager implements ODPApiManager { + private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); + + @VisibleForTesting + public final OptimizelyHttpClient httpClientSegments; + @VisibleForTesting + public final OptimizelyHttpClient httpClientEvents; + + public DefaultODPApiManager() { + this(null); + } + + public DefaultODPApiManager(int segmentFetchTimeoutMillis, int eventDispatchTimeoutMillis) { + httpClientSegments = OptimizelyHttpClient.builder().setTimeoutMillis(segmentFetchTimeoutMillis).build(); + if (segmentFetchTimeoutMillis == eventDispatchTimeoutMillis) { + // If the timeouts are same, single httpClient can be used for both. + httpClientEvents = httpClientSegments; + } else { + httpClientEvents = OptimizelyHttpClient.builder().setTimeoutMillis(eventDispatchTimeoutMillis).build(); + } + } + + public DefaultODPApiManager(@Nullable OptimizelyHttpClient customHttpClient) { + OptimizelyHttpClient httpClient = customHttpClient; + if (httpClient == null) { + httpClient = OptimizelyHttpClient.builder().build(); + } + this.httpClientSegments = httpClient; + this.httpClientEvents = httpClient; + } + + @VisibleForTesting + String getSegmentsStringForRequest(Set<String> segmentsList) { + + StringBuilder segmentsString = new StringBuilder(); + Iterator<String> segmentsListIterator = segmentsList.iterator(); + for (int i = 0; i < segmentsList.size(); i++) { + if (i > 0) { + segmentsString.append(", "); + } + segmentsString.append("\"").append(segmentsListIterator.next()).append("\""); + } + return segmentsString.toString(); + } + + // ODP GraphQL API + // - https://api.zaius.com/v3/graphql + // - test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ" + /* + + [GraphQL Request] + + // fetch info with fs_user_id for ["has_email", "has_email_opted_in", "push_on_sale"] segments + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d '{"query":"query {customer(fs_user_id: \"tester-101\") {audiences(subset:[\"has_email\",\"has_email_opted_in\",\"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql + // fetch info with vuid for ["has_email", "has_email_opted_in", "push_on_sale"] segments + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d '{"query":"query {customer(vuid: \"d66a9d81923d4d2f99d8f64338976322\") {audiences(subset:[\"has_email\",\"has_email_opted_in\",\"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql + query MyQuery { + customer(vuid: "d66a9d81923d4d2f99d8f64338976322") { + audiences(subset:["has_email","has_email_opted_in","push_on_sale"]) { + edges { + node { + name + state + } + } + } + } + } + [GraphQL Response] + + { + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "has_email", + "state": "qualified", + } + }, + { + "node": { + "name": "has_email_opted_in", + "state": "qualified", + } + }, + ... + ] + } + } + } + } + + [GraphQL Error Response] + { + "errors": [ + { + "message": "Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "customer" + ], + "extensions": { + "code": "INVALID_IDENTIFIER_EXCEPTION", + "classification": "DataFetchingException" + } + } + ], + "data": { + "customer": null + } + } + */ + @Override + public List<String> fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, Set<String> segmentsToCheck) { + HttpPost request = new HttpPost(apiEndpoint); + String segmentsString = getSegmentsStringForRequest(segmentsToCheck); + + String query = String.format("query($userId: String, $audiences: [String]) {customer(%s: $userId) {audiences(subset: $audiences) {edges {node {name state}}}}}", userKey); + String variables = String.format("{\"userId\": \"%s\", \"audiences\": [%s]}", userValue, segmentsString); + String requestPayload = String.format("{\"query\": \"%s\", \"variables\": %s}", query, variables); + + try { + request.setEntity(new StringEntity(requestPayload)); + } catch (UnsupportedEncodingException e) { + logger.warn("Error encoding request payload", e); + } + request.setHeader("x-api-key", apiKey); + request.setHeader("content-type", "application/json"); + + CloseableHttpResponse response = null; + try { + response = httpClientSegments.execute(request); + } catch (IOException e) { + logger.error("Error retrieving response from ODP service", e); + return null; + } + + if (response.getStatusLine().getStatusCode() >= 400) { + StatusLine statusLine = response.getStatusLine(); + logger.error(String.format("Unexpected response from ODP server, Response code: %d, %s", statusLine.getStatusCode(), statusLine.getReasonPhrase())); + closeHttpResponse(response); + return null; + } + ResponseJsonParser parser = ResponseJsonParserFactory.getParser(); + try { + return parser.parseQualifiedSegments(EntityUtils.toString(response.getEntity())); + } catch (IOException e) { + logger.error("Error converting ODP segments response to string", e); + } catch (Exception e) { + logger.error("Audience segments fetch failed (Error Parsing Response)"); + logger.debug(e.getMessage()); + } finally { + closeHttpResponse(response); + } + return null; + } + + /* + eventPayload Format + [ + { + "action": "identified", + "identifiers": {"vuid": <vuid>, "fs_user_id": <userId>, ....}, + "data": {“source”: <source sdk>, ....}, + "type": " fullstack " + }, + { + "action": "client_initialized", + "identifiers": {"vuid": <vuid>, ....}, + "data": {“source”: <source sdk>, ....}, + "type": "fullstack" + } + ] + Returns: + 1. null, When there was a non-recoverable error and no retry is needed. + 2. 0 If an unexpected error occurred and retrying can be useful. + 3. HTTPStatus code if httpclient was able to make the request and was able to receive response. + It is recommended to retry if status code was 5xx. + */ + @Override + public Integer sendEvents(String apiKey, String apiEndpoint, String eventPayload) { + HttpPost request = new HttpPost(apiEndpoint); + + try { + request.setEntity(new StringEntity(eventPayload)); + } catch (UnsupportedEncodingException e) { + logger.error("ODP event send failed (Error encoding request payload)", e); + return null; + } + request.setHeader("x-api-key", apiKey); + request.setHeader("content-type", "application/json"); + + CloseableHttpResponse response = null; + try { + response = httpClientEvents.execute(request); + } catch (IOException e) { + logger.error("Error retrieving response from event request", e); + return 0; + } + + int statusCode = response.getStatusLine().getStatusCode(); + if ( statusCode >= 400) { + StatusLine statusLine = response.getStatusLine(); + logger.error(String.format("ODP event send failed (Response code: %d, %s)", statusLine.getStatusCode(), statusLine.getReasonPhrase())); + } else { + logger.debug("ODP Event Dispatched successfully"); + } + + closeHttpResponse(response); + return statusCode; + } + + private static void closeHttpResponse(CloseableHttpResponse response) { + if (response != null) { + try { + response.close(); + } catch (IOException e) { + logger.warn(e.getLocalizedMessage()); + } + } + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java new file mode 100644 index 000000000..a15085645 --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyFactoryTest.java @@ -0,0 +1,319 @@ +/** + * + * Copyright 2019-2020, 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import com.optimizely.ab.config.HttpProjectConfigManager; +import com.optimizely.ab.config.ProjectConfig; +import com.optimizely.ab.config.ProjectConfigManager; +import com.optimizely.ab.event.AsyncEventHandler; +import com.optimizely.ab.event.BatchEventProcessor; +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.notification.NotificationCenter; +import com.optimizely.ab.odp.DefaultODPApiManager; +import com.optimizely.ab.odp.ODPManager; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; + +public class OptimizelyFactoryTest { + + private Optimizely optimizely; + + @Before + public void setUp() { + PropertyUtils.clear(BatchEventProcessor.CONFIG_BATCH_SIZE); + PropertyUtils.clear(BatchEventProcessor.CONFIG_BATCH_INTERVAL); + PropertyUtils.clear(AsyncEventHandler.CONFIG_QUEUE_CAPACITY); + PropertyUtils.clear(AsyncEventHandler.CONFIG_NUM_WORKERS); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_POLLING_DURATION); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_POLLING_UNIT); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_BLOCKING_DURATION); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_BLOCKING_UNIT); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_EVICT_DURATION); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_EVICT_UNIT); + PropertyUtils.clear(HttpProjectConfigManager.CONFIG_SDK_KEY); + } + + @After + public void tearDown() { + if (optimizely == null) { + return; + } + + optimizely.close(); + } + + @Test + public void setMaxEventBatchSize() { + Integer batchSize = 10; + OptimizelyFactory.setMaxEventBatchSize(batchSize); + + assertEquals(batchSize, PropertyUtils.getInteger(BatchEventProcessor.CONFIG_BATCH_SIZE)); + } + + @Test + public void setInvalidMaxEventBatchSize() { + Integer batchSize = 0; + OptimizelyFactory.setMaxEventBatchSize(batchSize); + + assertNull(PropertyUtils.getInteger(BatchEventProcessor.CONFIG_BATCH_SIZE)); + } + + @Test + public void setMaxEventBatchInterval() { + Long batchInterval = 100L; + OptimizelyFactory.setMaxEventBatchInterval(batchInterval); + + assertEquals(batchInterval, PropertyUtils.getLong(BatchEventProcessor.CONFIG_BATCH_INTERVAL)); + } + + @Test + public void setInvalidMaxEventBatchInterval() { + Long batchInterval = 0L; + OptimizelyFactory.setMaxEventBatchInterval(batchInterval); + + assertNull(PropertyUtils.getLong(BatchEventProcessor.CONFIG_BATCH_INTERVAL)); + } + + @Test + public void setEventQueueParams() { + Integer capacity = 10; + Integer workers = 5; + OptimizelyFactory.setEventQueueParams(capacity, workers); + + assertEquals(capacity, PropertyUtils.getInteger(AsyncEventHandler.CONFIG_QUEUE_CAPACITY)); + assertEquals(workers, PropertyUtils.getInteger(AsyncEventHandler.CONFIG_NUM_WORKERS)); + } + + @Test + public void setInvalidEventQueueParams() { + OptimizelyFactory.setEventQueueParams(-1, 1); + assertNull(PropertyUtils.getInteger(AsyncEventHandler.CONFIG_QUEUE_CAPACITY)); + assertNull(PropertyUtils.getInteger(AsyncEventHandler.CONFIG_NUM_WORKERS)); + + OptimizelyFactory.setEventQueueParams(1, -1); + assertNull(PropertyUtils.getInteger(AsyncEventHandler.CONFIG_QUEUE_CAPACITY)); + assertNull(PropertyUtils.getInteger(AsyncEventHandler.CONFIG_NUM_WORKERS)); + } + + @Test + public void setPollingInterval() { + Long duration = 10L; + TimeUnit timeUnit = TimeUnit.MICROSECONDS; + OptimizelyFactory.setPollingInterval(duration, timeUnit); + + assertEquals(duration, PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_POLLING_DURATION)); + assertEquals(timeUnit, PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_POLLING_UNIT, TimeUnit.class)); + } + + @Test + public void setInvalidPollingInterval() { + OptimizelyFactory.setPollingInterval(-1, TimeUnit.MICROSECONDS); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_POLLING_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_POLLING_UNIT, TimeUnit.class)); + + OptimizelyFactory.setPollingInterval(10, null); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_POLLING_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_POLLING_UNIT, TimeUnit.class)); + } + + @Test + public void setBlockingTimeout() { + Long duration = 20L; + TimeUnit timeUnit = TimeUnit.NANOSECONDS; + OptimizelyFactory.setBlockingTimeout(duration, timeUnit); + + assertEquals(duration, PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_BLOCKING_DURATION)); + assertEquals(timeUnit, PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_BLOCKING_UNIT, TimeUnit.class)); + } + + @Test + public void setInvalidBlockingTimeout() { + OptimizelyFactory.setBlockingTimeout(-1, TimeUnit.MICROSECONDS); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_BLOCKING_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_BLOCKING_UNIT, TimeUnit.class)); + + OptimizelyFactory.setBlockingTimeout(10, null); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_BLOCKING_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_POLLING_UNIT, TimeUnit.class)); + } + + @Test + public void setEvictIdleConnections() { + Long duration = 2000L; + TimeUnit timeUnit = TimeUnit.SECONDS; + OptimizelyFactory.setEvictIdleConnections(duration, timeUnit); + + assertEquals(duration, PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_EVICT_DURATION)); + assertEquals(timeUnit, PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_EVICT_UNIT, TimeUnit.class)); + } + + @Test + public void setInvalidEvictIdleConnections() { + OptimizelyFactory.setEvictIdleConnections(-1, TimeUnit.MICROSECONDS); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_EVICT_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_EVICT_UNIT, TimeUnit.class)); + + OptimizelyFactory.setEvictIdleConnections(10, null); + assertNull(PropertyUtils.getLong(HttpProjectConfigManager.CONFIG_EVICT_DURATION)); + assertNull(PropertyUtils.getEnum(HttpProjectConfigManager.CONFIG_EVICT_UNIT, TimeUnit.class)); + } + + @Test + public void setSdkKey() { + String expected = "sdk-key"; + OptimizelyFactory.setSdkKey(expected); + + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_SDK_KEY)); + } + + @Test + public void setInvalidSdkKey() { + String expected = "sdk-key"; + OptimizelyFactory.setSdkKey(expected); + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_SDK_KEY)); + + OptimizelyFactory.setSdkKey(null); + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_SDK_KEY)); + } + + @Test + public void setDatafileAccessToken() { + String expected = "datafile-access-token"; + OptimizelyFactory.setDatafileAccessToken(expected); + + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN)); + } + + @Test + public void setInvalidDatafileAccessToken() { + String expected = "datafile-access-token"; + OptimizelyFactory.setDatafileAccessToken(expected); + OptimizelyFactory.setDatafileAccessToken(null); + assertEquals(expected, PropertyUtils.get(HttpProjectConfigManager.CONFIG_DATAFILE_AUTH_TOKEN)); + } + + @Test + public void newDefaultInstanceInvalid() { + optimizely = OptimizelyFactory.newDefaultInstance(); + assertFalse(optimizely.isValid()); + } + + @Test + public void newDefaultInstanceWithSdkKey() throws Exception { + // Set a blocking timeout so we don't block for too long. + OptimizelyFactory.setBlockingTimeout(5, TimeUnit.MICROSECONDS); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key"); + assertFalse(optimizely.isValid()); + } + + @Test + public void newDefaultInstanceWithFallback() throws Exception { + String datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString); + assertTrue(optimizely.isValid()); + } + + @Test + public void newDefaultInstanceWithDatafileAccessToken() throws Exception { + String datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token"); + assertTrue(optimizely.isValid()); + } + + @Test + public void newDefaultInstanceWithDatafileAccessTokenAndCustomHttpClient() throws Exception { + CloseableHttpClient customHttpClient = HttpClients.custom().build(); + + String datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); + optimizely = OptimizelyFactory.newDefaultInstance("sdk-key", datafileString, "auth-token", customHttpClient); + assertTrue(optimizely.isValid()); + + // HttpProjectConfigManager should be using the customHttpClient + + HttpProjectConfigManager projectConfigManager = (HttpProjectConfigManager) optimizely.projectConfigManager; + assert(doesUseCustomHttpClient(projectConfigManager.httpClient, customHttpClient)); + + // AsyncEventHandler should be using the customHttpClient + + BatchEventProcessor eventProcessor = (BatchEventProcessor) optimizely.eventProcessor; + AsyncEventHandler eventHandler = (AsyncEventHandler)eventProcessor.eventHandler; + assert(doesUseCustomHttpClient(eventHandler.httpClient, customHttpClient)); + + // ODPManager should be using the customHttpClient + + ODPManager odpManager = optimizely.getODPManager(); + assert odpManager != null; + DefaultODPApiManager odpApiManager = (DefaultODPApiManager) odpManager.getEventManager().apiManager; + assert(doesUseCustomHttpClient(odpApiManager.httpClientSegments, customHttpClient)); + assert(doesUseCustomHttpClient(odpApiManager.httpClientEvents, customHttpClient)); + } + + boolean doesUseCustomHttpClient(OptimizelyHttpClient optimizelyHttpClient, CloseableHttpClient customHttpClient) { + if (optimizelyHttpClient == null) { + return false; + } + return optimizelyHttpClient.getHttpClient() == customHttpClient; + } + + public ProjectConfigManager projectConfigManagerReturningNull = new ProjectConfigManager() { + @Override + public ProjectConfig getConfig() { + return null; + } + + @Override + public ProjectConfig getCachedConfig() { + return null; + } + + @Override + public String getSDKKey() { + return null; + } + }; + + @Test + public void newDefaultInstanceWithProjectConfig() throws Exception { + optimizely = OptimizelyFactory.newDefaultInstance(projectConfigManagerReturningNull); + assertFalse(optimizely.isValid()); + } + + @Test + public void newDefaultInstanceWithProjectConfigAndNotificationCenter() throws Exception { + NotificationCenter notificationCenter = new NotificationCenter(); + optimizely = OptimizelyFactory.newDefaultInstance(projectConfigManagerReturningNull, notificationCenter); + assertFalse(optimizely.isValid()); + assertEquals(notificationCenter, optimizely.getNotificationCenter()); + } + + @Test + public void newDefaultInstanceWithProjectConfigAndNotificationCenterAndEventHandler() { + NotificationCenter notificationCenter = new NotificationCenter(); + optimizely = OptimizelyFactory.newDefaultInstance(projectConfigManagerReturningNull, notificationCenter, logEvent -> {}); + assertFalse(optimizely.isValid()); + assertEquals(notificationCenter, optimizely.getNotificationCenter()); + } +} \ No newline at end of file diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java new file mode 100644 index 000000000..d80a4f1ef --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/OptimizelyHttpClientTest.java @@ -0,0 +1,192 @@ +/** + * + * Copyright 2019-2020, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab; + +import org.apache.http.HttpException; +import org.apache.http.client.HttpRequestRetryHandler; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.conn.HttpHostConnectException; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; +import org.apache.http.protocol.HttpContext; +import org.junit.*; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.ConnectionOptions; +import org.mockserver.model.HttpError; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static com.optimizely.ab.OptimizelyHttpClient.builder; +import static java.util.concurrent.TimeUnit.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; +import static org.mockserver.model.HttpForward.forward; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.*; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.verify.VerificationTimes.exactly; + +public class OptimizelyHttpClientTest { + @Before + public void setUp() { + System.setProperty("https.proxyHost", "localhost"); + // default port (80) returns 404 instead of HttpHostConnectException + System.setProperty("https.proxyPort", "12345"); + } + + @After + public void tearDown() { + System.clearProperty("https.proxyHost"); + } + + @Test + public void testDefaultConfiguration() { + OptimizelyHttpClient.Builder builder = builder(); + assertEquals(builder.validateAfterInactivity, 1000); + assertEquals(builder.maxTotalConnections, 200); + assertEquals(builder.maxPerRoute, 20); + assertNull(builder.customRetryHandler); + + OptimizelyHttpClient optimizelyHttpClient = builder.build(); + assertTrue(optimizelyHttpClient.getHttpClient() instanceof CloseableHttpClient); + } + + @Test + public void testNonDefaultConfiguration() { + OptimizelyHttpClient optimizelyHttpClient = builder() + .withValidateAfterInactivity(1) + .withMaxPerRoute(2) + .withMaxTotalConnections(3) + .withEvictIdleConnections(5, MINUTES) + .build(); + + assertTrue(optimizelyHttpClient.getHttpClient() instanceof CloseableHttpClient); + } + + @Test + public void testEvictTime() { + OptimizelyHttpClient.Builder builder = builder(); + long expectedPeriod = builder.evictConnectionIdleTimePeriod; + TimeUnit expectedTimeUnit = builder.evictConnectionIdleTimeUnit; + + assertEquals(expectedPeriod, 0L); + assertEquals(expectedTimeUnit, MILLISECONDS); + + builder.withEvictIdleConnections(10L, SECONDS); + assertEquals(10, builder.evictConnectionIdleTimePeriod); + assertEquals(SECONDS, builder.evictConnectionIdleTimeUnit); + } + + @Test(expected = HttpHostConnectException.class) + public void testProxySettings() throws IOException { + OptimizelyHttpClient optimizelyHttpClient = builder().build(); + + // If this request succeeds then the proxy config was not picked up. + HttpGet get = new HttpGet("https://www.optimizely.com"); + optimizelyHttpClient.execute(get); + } + + @Test + public void testExecute() throws IOException { + HttpUriRequest httpUriRequest = RequestBuilder.get().build(); + ResponseHandler<Boolean> responseHandler = response -> false; + + CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class); + when(mockHttpClient.execute(httpUriRequest, responseHandler)).thenReturn(true); + + OptimizelyHttpClient optimizelyHttpClient = new OptimizelyHttpClient(mockHttpClient); + assertTrue(optimizelyHttpClient.execute(httpUriRequest, responseHandler)); + } + + @Test + public void testRetriesWithCustomRetryHandler() throws IOException { + + // [NOTE] Request retries are all handled inside HttpClient. Not easy for unit test. + // - "DefaultHttpRetryHandler" in HttpClient retries only with special types of Exceptions + // like "NoHttpResponseException", etc. + // Other exceptions (SocketTimeout, ProtocolException, etc.) all ignored. + // - Not easy to force the specific exception type in the low-level. + // - This test just validates custom retry handler injected ok by validating the number of retries. + + class CustomRetryHandler implements HttpRequestRetryHandler { + private final int maxRetries; + + public CustomRetryHandler(int maxRetries) { + this.maxRetries = maxRetries; + } + + @Override + public boolean retryRequest(IOException exception, int executionCount, HttpContext context) { + // override to retry for any type of exceptions + return executionCount < maxRetries; + } + } + + int port = 9999; + ClientAndServer mockServer; + int retryCount; + + // default httpclient (retries enabled by default, but no retry for timeout connection) + + mockServer = ClientAndServer.startClientAndServer(port); + mockServer + .when(request().withMethod("GET").withPath("/")) + .error(HttpError.error()); + + OptimizelyHttpClient clientDefault = OptimizelyHttpClient.builder() + .setTimeoutMillis(100) + .build(); + + try { + clientDefault.execute(new HttpGet("http://localhost:" + port)); + fail(); + } catch (Exception e) { + retryCount = mockServer.retrieveRecordedRequests(request()).length; + assertEquals(1, retryCount); + } + mockServer.stop(); + + // httpclient with custom retry handler (5 times retries for any request) + + mockServer = ClientAndServer.startClientAndServer(port); + mockServer + .when(request().withMethod("GET").withPath("/")) + .error(HttpError.error()); + + OptimizelyHttpClient clientWithRetries = OptimizelyHttpClient.builder() + .withRetryHandler(new CustomRetryHandler(5)) + .setTimeoutMillis(100) + .build(); + + try { + clientWithRetries.execute(new HttpGet("http://localhost:" + port)); + fail(); + } catch (Exception e) { + retryCount = mockServer.retrieveRecordedRequests(request()).length; + assertEquals(5, retryCount); + } + mockServer.stop(); + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java new file mode 100644 index 000000000..77960d518 --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -0,0 +1,399 @@ +/** + * + * Copyright 2019, 2023, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.google.common.base.Charsets; +import com.google.common.io.Resources; +import com.optimizely.ab.OptimizelyHttpClient; +import org.apache.http.HttpHeaders; +import org.apache.http.ProtocolVersion; +import org.apache.http.StatusLine; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.entity.StringEntity; +import org.apache.http.message.BasicHttpResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import static com.optimizely.ab.config.HttpProjectConfigManager.*; +import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class HttpProjectConfigManagerTest { + + static class MyResponse extends BasicHttpResponse implements CloseableHttpResponse { + + public MyResponse(ProtocolVersion protocolVersion, Integer status, String body) { + super(protocolVersion, status, body); + } + + @Override + public void close() throws IOException { + + } + } + @Mock + private OptimizelyHttpClient mockHttpClient; + + private String datafileString; + private HttpProjectConfigManager projectConfigManager; + + @Before + public void setUp() throws Exception { + datafileString = Resources.toString(Resources.getResource("valid-project-config-v4.json"), Charsets.UTF_8); + CloseableHttpResponse httpResponse = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(new StringEntity(datafileString)); + + when(mockHttpClient.execute(any(HttpGet.class))) + .thenReturn(httpResponse); + + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + } + + @After + public void tearDown() { + if (projectConfigManager == null) { + return; + } + + projectConfigManager.close(); + + System.clearProperty("optimizely." + HttpProjectConfigManager.CONFIG_BLOCKING_UNIT); + System.clearProperty("optimizely." + HttpProjectConfigManager.CONFIG_BLOCKING_DURATION); + System.clearProperty("optimizely." + HttpProjectConfigManager.CONFIG_POLLING_UNIT); + System.clearProperty("optimizely." + HttpProjectConfigManager.CONFIG_POLLING_DURATION); + } + + @Test + public void testHttpGetBySdkKey() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI("https://cdn.optimizely.com/datafiles/sdk-key.json"), actual); + } + + @Test + public void testHttpGetByCustomFormat() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .withFormat("https://custom.optimizely.com/%s.json") + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI("https://custom.optimizely.com/sdk-key.json"), actual); + } + + @Test + public void testHttpGetByCustomUrl() throws Exception { + String expected = "https://custom.optimizely.com/custom-location.json"; + + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("custom-sdkKey") + .withUrl(expected) + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI(expected), actual); + } + + @Test + public void testHttpGetBySdkKeyForAuthDatafile() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .withDatafileAccessToken("auth-token") + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI("https://config.optimizely.com/datafiles/auth/sdk-key.json"), actual); + } + + @Test + public void testHttpGetByCustomUrlForAuthDatafile() throws Exception { + String expected = "https://custom.optimizely.com/custom-location.json"; + + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withUrl(expected) + .withSdkKey("sdk-key") + .withDatafileAccessToken("auth-token") + .build(); + + URI actual = projectConfigManager.getUri(); + assertEquals(new URI(expected), actual); + } + + @Test + public void testCreateHttpRequest() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + + HttpGet request = projectConfigManager.createHttpRequest(); + assertEquals(request.getURI().toString(), "https://cdn.optimizely.com/datafiles/sdk-key.json"); + assertEquals(request.getHeaders("Authorization").length, 0); + } + + @Test + public void testCreateHttpRequestForAuthDatafile() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .withDatafileAccessToken("auth-token") + .build(); + + HttpGet request = projectConfigManager.createHttpRequest(); + assertEquals(request.getURI().toString(), "https://config.optimizely.com/datafiles/auth/sdk-key.json"); + assertEquals(request.getHeaders("Authorization")[0].getValue(), "Bearer auth-token"); + } + + @Test + public void testPoll() throws Exception { + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + + assertEquals("1480511547", projectConfigManager.getConfig().getRevision()); + } + + @Test + public void testBuildDefer() throws Exception { + // always returns null so PollingProjectConfigManager will never resolve. + mockHttpClient = mock(OptimizelyHttpClient.class); + + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(true); + assertEquals("sdk-key", projectConfigManager.getSDKKey()); + } + + @Test + public void testInvalidPollingInterval() { + Builder builder = builder(); + long expectedPeriod = builder.period; + TimeUnit expectedTimeUnit = builder.timeUnit; + + builder.withPollingInterval(null, SECONDS); + assertEquals(expectedPeriod, builder.period); + assertEquals(expectedTimeUnit, builder.timeUnit); + + builder.withPollingInterval(-1L, SECONDS); + assertEquals(expectedPeriod, builder.period); + assertEquals(expectedTimeUnit, builder.timeUnit); + + builder.withPollingInterval(10L, null); + assertEquals(expectedPeriod, builder.period); + assertEquals(expectedTimeUnit, builder.timeUnit); + + builder.withPollingInterval(10L, SECONDS); + assertEquals(10, builder.period); + assertEquals(SECONDS, builder.timeUnit); + } + + @Test + public void testInvalidBlockingTimeout() { + Builder builder = builder(); + long expectedPeriod = builder.blockingTimeoutPeriod; + TimeUnit expectedTimeUnit = builder.blockingTimeoutUnit; + + builder.withBlockingTimeout(null, SECONDS); + assertEquals(expectedPeriod, builder.blockingTimeoutPeriod); + assertEquals(expectedTimeUnit, builder.blockingTimeoutUnit); + + builder.withBlockingTimeout(-1L, SECONDS); + assertEquals(expectedPeriod, builder.blockingTimeoutPeriod); + assertEquals(expectedTimeUnit, builder.blockingTimeoutUnit); + + builder.withBlockingTimeout(10L, null); + assertEquals(expectedPeriod, builder.blockingTimeoutPeriod); + assertEquals(expectedTimeUnit, builder.blockingTimeoutUnit); + + builder.withBlockingTimeout(10L, SECONDS); + assertEquals(10, builder.blockingTimeoutPeriod); + assertEquals(SECONDS, builder.blockingTimeoutUnit); + } + + @Test + public void testEvictTime() { + Builder builder = builder(); + long expectedPeriod = builder.evictConnectionIdleTimePeriod; + TimeUnit expectedTimeUnit = builder.evictConnectionIdleTimeUnit; + + assertEquals(expectedPeriod, 1L); + assertEquals(expectedTimeUnit, MINUTES); + + builder.withEvictIdleConnections(10L, SECONDS); + assertEquals(10, builder.evictConnectionIdleTimePeriod); + assertEquals(SECONDS, builder.evictConnectionIdleTimeUnit); + } + + @Test + @Ignore + public void testGetDatafileHttpResponse2XX() throws Exception { + String modifiedStamp = "Wed, 24 Apr 2019 07:07:07 GMT"; + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 200, "TEST"); + getResponse.setEntity(new StringEntity(datafileString)); + getResponse.setHeader(HttpHeaders.LAST_MODIFIED, modifiedStamp); + + String datafile = projectConfigManager.getDatafileFromResponse(getResponse); + assertNotNull(datafile); + + assertEquals("4", parseProjectConfig(datafile).getVersion()); + // Confirm last modified time is set + assertEquals(modifiedStamp, projectConfigManager.getLastModified()); + } + + @Test(expected = ClientProtocolException.class) + public void testGetDatafileHttpResponse3XX() throws Exception { + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 300, "TEST"); + getResponse.setEntity(new StringEntity(datafileString)); + + projectConfigManager.getDatafileFromResponse(getResponse); + } + + @Test + public void testGetDatafileHttpResponse304() throws Exception { + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 304, "TEST"); + getResponse.setEntity(new StringEntity(datafileString)); + + String datafile = projectConfigManager.getDatafileFromResponse(getResponse); + assertNull(datafile); + } + + @Test(expected = ClientProtocolException.class) + public void testGetDatafileHttpResponse4XX() throws Exception { + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 400, "TEST"); + getResponse.setEntity(new StringEntity(datafileString)); + + projectConfigManager.getDatafileFromResponse(getResponse); + } + + @Test(expected = ClientProtocolException.class) + public void testGetDatafileHttpResponse5XX() throws Exception { + CloseableHttpResponse getResponse = new MyResponse(new ProtocolVersion("TEST", 0, 0), 500, "TEST"); + getResponse.setEntity(new StringEntity(datafileString)); + + projectConfigManager.getDatafileFromResponse(getResponse); + } + + @Test + public void testInvalidPayload() throws Exception { + reset(mockHttpClient); + CloseableHttpResponse invalidPayloadResponse = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(invalidPayloadResponse.getStatusLine()).thenReturn(statusLine); + when(invalidPayloadResponse.getEntity()).thenReturn(new StringEntity("I am an invalid response!")); + + when(mockHttpClient.execute(any(HttpGet.class))) + .thenReturn(invalidPayloadResponse); + + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .withBlockingTimeout(10L, TimeUnit.MILLISECONDS) + .build(); + + assertNull(projectConfigManager.getConfig()); + } + + @Test + public void testInvalidPollingIntervalFromSystemProperties() throws Exception { + System.setProperty("optimizely." + HttpProjectConfigManager.CONFIG_POLLING_DURATION, "-1"); + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + } + + @Test + public void testInvalidBlockingIntervalFromSystemProperties() throws Exception { + reset(mockHttpClient); + CloseableHttpResponse invalidPayloadResponse = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(invalidPayloadResponse.getStatusLine()).thenReturn(statusLine); + when(invalidPayloadResponse.getEntity()).thenReturn(new StringEntity("I am an invalid response!")); + + when(mockHttpClient.execute(any(HttpGet.class))) + .thenReturn(invalidPayloadResponse); + + System.setProperty("optimizely." + HttpProjectConfigManager.CONFIG_BLOCKING_DURATION, "-1"); + projectConfigManager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("sdk-key") + .build(); + } + + @Test + @Ignore + public void testBasicFetch() throws Exception { + projectConfigManager = builder() + .withSdkKey("7vPf3v7zye3fY4PcbejeCz") + .build(); + + ProjectConfig actual = projectConfigManager.getConfig(); + assertNotNull(actual); + assertNotNull(actual.getVersion()); + } + + @Test + @Ignore + public void testBasicFetchTwice() throws Exception { + projectConfigManager = builder() + .withSdkKey("7vPf3v7zye3fY4PcbejeCz") + .build(); + + ProjectConfig actual = projectConfigManager.getConfig(); + assertNotNull(actual); + assertNotNull(actual.getVersion()); + + // Assert ProjectConfig when refetched as datafile has not changed + ProjectConfig latestConfig = projectConfigManager.getConfig(); + assertEquals(actual, latestConfig); + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java new file mode 100644 index 000000000..19f1faba9 --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/event/AsyncEventHandlerTest.java @@ -0,0 +1,180 @@ +/** + * + * Copyright 2016, 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.event; + +import com.google.common.util.concurrent.MoreExecutors; + +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.event.internal.payload.EventBatch; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpGet; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import org.mockito.runners.MockitoJUnitRunner; + +import static com.optimizely.ab.event.AsyncEventHandler.builder; +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link AsyncEventHandler}. + */ +@RunWith(MockitoJUnitRunner.class) +public class AsyncEventHandlerTest { + + @Mock + OptimizelyHttpClient mockHttpClient; + @Mock + ExecutorService mockExecutorService; + + @Test + public void testDispatch() throws Exception { + AsyncEventHandler eventHandler = new AsyncEventHandler(mockHttpClient, MoreExecutors.newDirectExecutorService()); + eventHandler.dispatchEvent(createLogEvent()); + verify(mockHttpClient).execute(any(HttpGet.class), any(ResponseHandler.class)); + } + + /** + * Verify that {@link RejectedExecutionException}s are caught, rather than being propagated. + */ + @Test + public void testRejectedExecutionsAreHandled() throws Exception { + AsyncEventHandler eventHandler = new AsyncEventHandler(mockHttpClient, mockExecutorService); + doThrow(RejectedExecutionException.class).when(mockExecutorService).execute(any(Runnable.class)); + eventHandler.dispatchEvent(createLogEvent()); + } + + /** + * Verify that {@link IOException}s are caught, rather than being propagated (which would cause a worker + * thread to die). + */ + @SuppressWarnings("unchecked") + @Test + public void testIOExceptionsCaughtInDispatch() throws Exception { + AsyncEventHandler eventHandler = new AsyncEventHandler(mockHttpClient, MoreExecutors.newDirectExecutorService()); + + // have the http client throw an IOException on execute + when(mockHttpClient.execute(any(HttpGet.class), any(ResponseHandler.class))).thenThrow(IOException.class); + eventHandler.dispatchEvent(createLogEvent()); + verify(mockHttpClient).execute(any(HttpGet.class), any(ResponseHandler.class)); + } + + /** + * Verifies the case where all queued events could be processed before the timeout is exceeded. + */ + @Test + public void testShutdownAndAwaitTermination() throws Exception { + AsyncEventHandler eventHandler = new AsyncEventHandler(mockHttpClient, mockExecutorService); + when(mockExecutorService.awaitTermination(anyLong(), any(TimeUnit.class))).thenReturn(true); + + eventHandler.shutdownAndAwaitTermination(1, TimeUnit.SECONDS); + verify(mockExecutorService).shutdown(); + verify(mockExecutorService, never()).shutdownNow(); + + verify(mockHttpClient).close(); + } + + /** + * Verify the case where all queued events count NOT be processed before the timeout was exceeded. + * {@link ExecutorService#shutdownNow()} should be called to drop the queued events and attempt to interrupt + * ongoing tasks. + */ + @Test + public void testShutdownAndForcedTermination() throws Exception { + AsyncEventHandler eventHandler = new AsyncEventHandler(mockHttpClient, mockExecutorService); + when(mockExecutorService.awaitTermination(anyLong(), any(TimeUnit.class))).thenReturn(false); + + eventHandler.shutdownAndAwaitTermination(1, TimeUnit.SECONDS); + verify(mockExecutorService).shutdown(); + verify(mockExecutorService).shutdownNow(); + verify(mockHttpClient).close(); + } + + @Test + public void testBuilderWithCustomHttpClient() { + OptimizelyHttpClient customHttpClient = OptimizelyHttpClient.builder().build(); + + AsyncEventHandler eventHandler = builder() + .withOptimizelyHttpClient(customHttpClient) + // these params will be ignored when customHttpClient is injected + .withMaxTotalConnections(1) + .withMaxPerRoute(2) + .withCloseTimeout(10, TimeUnit.SECONDS) + .build(); + + assert eventHandler.httpClient == customHttpClient; + } + + @Test + public void testBuilderWithDefaultHttpClient() { + AsyncEventHandler.Builder builder = builder(); + assertEquals(builder.validateAfterInactivity, 1000); + assertEquals(builder.maxTotalConnections, 200); + assertEquals(builder.maxPerRoute, 20); + + AsyncEventHandler eventHandler = builder.build(); + assert(eventHandler.httpClient != null); + } + + @Test + public void testBuilderWithDefaultHttpClientAndCustomParams() { + AsyncEventHandler eventHandler = builder() + .withMaxTotalConnections(3) + .withMaxPerRoute(4) + .withCloseTimeout(10, TimeUnit.SECONDS) + .build(); + assert(eventHandler.httpClient != null); + } + + @Test + public void testInvalidQueueCapacity() { + AsyncEventHandler.Builder builder = builder(); + int expected = builder.queueCapacity; + builder.withQueueCapacity(-1); + assertEquals(expected, builder.queueCapacity); + } + + @Test + public void testInvalidNumWorkers() { + AsyncEventHandler.Builder builder = builder(); + int expected = builder.numWorkers; + builder.withNumWorkers(-1); + assertEquals(expected, builder.numWorkers); + } + + //======== Helper methods ========// + + private LogEvent createLogEvent() { + Map<String, String> testParams = new HashMap<String, String>(); + testParams.put("test", "params"); + return new LogEvent(LogEvent.RequestMethod.GET, "test_url", testParams, new EventBatch()); + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java new file mode 100644 index 000000000..25154e97d --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java @@ -0,0 +1,168 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.AppenderBase; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.LoggerFactory; + +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; + +import static org.junit.Assert.fail; + +/** + * TODO As a usability improvement we should require expected messages be added after the message are expected to be + * logged. This will allow us to map the failure immediately back to the test line number as opposed to the async + * validation now that happens at the end of each individual test. + * + * From http://techblog.kenshoo.com/2013/08/junit-rule-for-verifying-logback-logging.html + */ +public class LogbackVerifier implements TestRule { + + private List<ExpectedLogEvent> expectedEvents = new LinkedList<ExpectedLogEvent>(); + + private CaptureAppender appender; + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + before(); + try { + base.evaluate(); + verify(); + } finally { + after(); + } + } + }; + } + + public void expectMessage(Level level) { + expectMessage(level, ""); + } + + public void expectMessage(Level level, String msg) { + expectMessage(level, msg, (Class<? extends Throwable>) null); + } + + public void expectMessage(Level level, String msg, Class<? extends Throwable> throwableClass) { + expectMessage(level, msg, null, 1); + } + + public void expectMessage(Level level, String msg, int times) { + expectMessage(level, msg, null, times); + } + + public void expectMessage(Level level, + String msg, + Class<? extends Throwable> throwableClass, + int times) { + for (int i = 0; i < times; i++) { + expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass)); + } + } + + private void before() { + appender = new CaptureAppender(); + appender.setName("MOCK"); + appender.start(); + ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).addAppender(appender); + } + + private void verify() throws Throwable { + ListIterator<ILoggingEvent> actualIterator = appender.getEvents().listIterator(); + + for (final ExpectedLogEvent expectedEvent : expectedEvents) { + boolean found = false; + while (actualIterator.hasNext()) { + ILoggingEvent actual = actualIterator.next(); + + if (expectedEvent.matches(actual)) { + found = true; + break; + } + } + + if (!found) { + fail(expectedEvent.toString()); + } + } + } + + private void after() { + ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).detachAppender(appender); + } + + private static class CaptureAppender extends AppenderBase<ILoggingEvent> { + + List<ILoggingEvent> actualLoggingEvent = new LinkedList<>(); + + @Override + protected void append(ILoggingEvent eventObject) { + actualLoggingEvent.add(eventObject); + } + + public List<ILoggingEvent> getEvents() { + return actualLoggingEvent; + } + } + + private final static class ExpectedLogEvent { + private final String message; + private final Level level; + private final Class<? extends Throwable> throwableClass; + + private ExpectedLogEvent(Level level, + String message, + Class<? extends Throwable> throwableClass) { + this.message = message; + this.level = level; + this.throwableClass = throwableClass; + } + + private boolean matches(ILoggingEvent actual) { + boolean match = actual.getFormattedMessage().contains(message); + match &= actual.getLevel().equals(level); + match &= matchThrowables(actual); + return match; + } + + private boolean matchThrowables(ILoggingEvent actual) { + IThrowableProxy eventProxy = actual.getThrowableProxy(); + return throwableClass == null || eventProxy != null && throwableClass.getName().equals(eventProxy.getClassName()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ExpectedLogEvent{"); + sb.append("level=").append(level); + sb.append(", message='").append(message).append('\''); + sb.append(", throwableClass=").append(throwableClass); + sb.append('}'); + return sb.toString(); + } + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java new file mode 100644 index 000000000..780831ff2 --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -0,0 +1,148 @@ +/** + * Copyright 2022-2023, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.internal.LogbackVerifier; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class DefaultODPApiManagerTest { + private static final List<String> validResponse = Arrays.asList(new String[] {"has_email", "has_email_opted_in"}); + private static final String validRequestResponse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + OptimizelyHttpClient mockHttpClient; + + @Before + public void setUp() throws Exception { + setupHttpClient(200); + } + + private void setupHttpClient(int statusCode) throws Exception { + mockHttpClient = mock(OptimizelyHttpClient.class); + CloseableHttpResponse httpResponse = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(statusCode); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(new StringEntity(validRequestResponse)); + + when(mockHttpClient.execute(any(HttpPost.class))) + .thenReturn(httpResponse); + } + + @Test + public void generateCorrectSegmentsStringWhenListHasOneItem() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String expected = "\"only_segment\""; + String actual = apiManager.getSegmentsStringForRequest(new HashSet<>(Arrays.asList("only_segment"))); + assertEquals(expected, actual); + } + + @Test + public void generateCorrectSegmentsStringWhenListHasMultipleItems() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String expected = "\"segment_1\", \"segment_3\", \"segment_2\""; + String actual = apiManager.getSegmentsStringForRequest(new HashSet<>(Arrays.asList("segment_1", "segment_2", "segment_3"))); + assertEquals(expected, actual); + } + + @Test + public void generateEmptyStringWhenGivenListIsEmpty() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String actual = apiManager.getSegmentsStringForRequest(new HashSet<>()); + assertEquals("", actual); + } + + @Test + public void generateCorrectRequestBody() throws Exception { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + + String expectedResponse = "{\"query\": \"query($userId: String, $audiences: [String]) {customer(fs_user_id: $userId) {audiences(subset: $audiences) {edges {node {name state}}}}}\", \"variables\": {\"userId\": \"test_user\", \"audiences\": [\"segment_1\", \"segment_2\"]}}"; + ArgumentCaptor<HttpPost> request = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(request.capture()); + assertEquals(expectedResponse, EntityUtils.toString(request.getValue().getEntity())); + } + + @Test + public void returnResponseStringWhenStatusIs200() throws Exception { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + List<String> response = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + assertEquals(validResponse, response); + } + + @Test + public void returnNullWhenStatusIsNot200AndLogError() throws Exception { + setupHttpClient(500); + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + List<String> response = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", new HashSet<>(Arrays.asList("segment_1", "segment_2"))); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + logbackVerifier.expectMessage(Level.ERROR, "Unexpected response from ODP server, Response code: 500, null"); + assertNull(response); + } + + @Test + public void eventDispatchSuccess() { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.sendEvents("testKey", "testEndpoint", "[]"); + logbackVerifier.expectMessage(Level.DEBUG, "ODP Event Dispatched successfully"); + } + + @Test + public void eventDispatchFailStatus() throws Exception { + setupHttpClient(400); + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.sendEvents("testKey", "testEndpoint", "[]]"); + logbackVerifier.expectMessage(Level.ERROR, "ODP event send failed (Response code: 400, null)"); + } + + @Test + public void apiTimeouts() { + // Default timeout is 10 seconds + new DefaultODPApiManager(); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 10000", 1); + + // Same timeouts result in single httpclient + new DefaultODPApiManager(2222, 2222); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 2222", 1); + + // Different timeouts result in different HttpClients + new DefaultODPApiManager(3333, 4444); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 3333", 1); + logbackVerifier.expectMessage(Level.DEBUG, "Creating HttpClient with timeout: 4444", 1); + } +} diff --git a/core-httpclient-impl/src/test/resources/valid-project-config-v4.json b/core-httpclient-impl/src/test/resources/valid-project-config-v4.json new file mode 100644 index 000000000..5d46cbbb5 --- /dev/null +++ b/core-httpclient-impl/src/test/resources/valid-project-config-v4.json @@ -0,0 +1,906 @@ +{ + "accountId": "2360254204", + "anonymizeIP": true, + "botFiltering": true, + "projectId": "3918735994", + "revision": "1480511547", + "sdkKey": "ValidProjectConfigV4", + "environmentKey": "production", + "version": "4", + "audiences": [ + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\":\"Gryffindor\"}]]]" + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"house\", \"type\": \"custom_attribute\", \"value\":\"Slytherin\"}]]]" + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\":\"English\"}]]]" + }, + { + "id": "2196265320", + "name": "audience_with_missing_value", + "conditions": "[\"and\", [\"or\", [\"or\", {\"name\": \"nationality\", \"type\": \"custom_attribute\", \"value\": \"English\"}, {\"name\": \"nationality\", \"type\": \"custom_attribute\"}]]]" + } + ], + "typedAudiences": [ + { + "id": "3468206643", + "name": "BOOL", + "conditions": ["and", ["or", ["or", {"name": "booleanKey", "type": "custom_attribute", "match":"exact", "value":true}]]] + }, + { + "id": "3468206646", + "name": "INTEXACT", + "conditions": ["and", ["or", ["or", {"name": "integerKey", "type": "custom_attribute", "match":"exact", "value":1.0}]]] + }, + { + "id": "3468206644", + "name": "INT", + "conditions": ["and", ["or", ["or", {"name": "integerKey", "type": "custom_attribute", "match":"gt", "value":1.0}]]] + }, + { + "id": "3468206645", + "name": "DOUBLE", + "conditions": ["and", ["or", ["or", {"name": "doubleKey", "type": "custom_attribute", "match":"lt", "value":100.0}]]] + }, + { + "id": "3468206642", + "name": "Gryffindors", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "match":"exact", "value":"Gryffindor"}]]] + }, + { + "id": "3988293898", + "name": "Slytherins", + "conditions": ["and", ["or", ["or", {"name": "house", "type": "custom_attribute", "match":"substring", "value":"Slytherin"}]]] + }, + { + "id": "4194404272", + "name": "english_citizens", + "conditions": ["and", ["or", ["or", {"name": "nationality", "type": "custom_attribute", "match":"exact", "value":"English"}]]] + }, + { + "id": "2196265320", + "name": "audience_with_missing_value", + "conditions": ["and", ["or", ["or", {"name": "nationality", "type": "custom_attribute", "value": "English"}, {"name": "nationality", "type": "custom_attribute"}]]] + } + ], + "attributes": [ + { + "id": "553339214", + "key": "house" + }, + { + "id": "58339410", + "key": "nationality" + }, + { + "id": "583394100", + "key": "$opt_test" + }, + { + "id": "323434545", + "key": "booleanKey" + }, + { + "id": "616727838", + "key": "integerKey" + }, + { + "id": "808797686", + "key": "doubleKey" + }, + { + "id": "808797686", + "key": "" + } + ], + "events": [ + { + "id": "3785620495", + "key": "basic_event", + "experimentIds": [ + "1323241596", + "2738374745", + "3042640549", + "3262035800", + "3072915611" + ] + }, + { + "id": "3195631717", + "key": "event_with_paused_experiment", + "experimentIds": [ + "2667098701" + ] + }, + { + "id": "1987018666", + "key": "event_with_launched_experiments_only", + "experimentIds": [ + "3072915611" + ] + } + ], + "experiments": [ + { + "id": "1323241596", + "key": "basic_experiment", + "layerId": "1630555626", + "status": "Running", + "variations": [ + { + "id": "1423767502", + "key": "A", + "variables": [] + }, + { + "id": "3433458314", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767502", + "endOfRange": 5000 + }, + { + "entityId": "3433458314", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + }, + { + "id": "1323241597", + "key": "typed_audience_experiment", + "layerId": "1630555627", + "status": "Running", + "variations": [ + { + "id": "1423767503", + "key": "A", + "variables": [] + }, + { + "id": "3433458315", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767503", + "endOfRange": 5000 + }, + { + "entityId": "3433458315", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206646", "3468206645"], + "audienceConditions" : ["or", "3468206643", "3468206644", "3468206646", "3468206645" ], + "forcedVariations": {} + }, + { + "id": "1323241598", + "key": "typed_audience_experiment_with_and", + "layerId": "1630555628", + "status": "Running", + "variations": [ + { + "id": "1423767504", + "key": "A", + "variables": [] + }, + { + "id": "3433458316", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767504", + "endOfRange": 5000 + }, + { + "entityId": "3433458316", + "endOfRange": 10000 + } + ], + "audienceIds": ["3468206643", "3468206644", "3468206645"], + "audienceConditions" : ["and", "3468206643", "3468206644", "3468206645"], + "forcedVariations": {} + }, + { + "id": "1323241599", + "key": "typed_audience_experiment_leaf_condition", + "layerId": "1630555629", + "status": "Running", + "variations": [ + { + "id": "1423767505", + "key": "A", + "variables": [] + }, + { + "id": "3433458317", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1423767505", + "endOfRange": 5000 + }, + { + "entityId": "3433458317", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions" : "3468206643", + "forcedVariations": {} + }, + { + "id": "3262035800", + "key": "multivariate_experiment", + "layerId": "3262035800", + "status": "Running", + "variations": [ + { + "id": "1880281238", + "key": "Fred", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "red" + } + ] + }, + { + "id": "3631049532", + "key": "Feorge", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "F" + }, + { + "id": "4052219963", + "value": "eorge" + } + ] + }, + { + "id": "4204375027", + "key": "Gred", + "featureEnabled": false, + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "red" + } + ] + }, + { + "id": "2099211198", + "key": "George", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "eorge" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1880281238", + "endOfRange": 2500 + }, + { + "entityId": "3631049532", + "endOfRange": 5000 + }, + { + "entityId": "4204375027", + "endOfRange": 7500 + }, + { + "entityId": "2099211198", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Fred": "Fred", + "Feorge": "Feorge", + "Gred": "Gred", + "George": "George" + } + }, + { + "id": "2201520193", + "key": "double_single_variable_feature_experiment", + "layerId": "1278722008", + "status": "Running", + "variations": [ + { + "id": "1505457580", + "key": "pi_variation", + "featureEnabled": true, + "variables": [ + { + "id": "4111654444", + "value": "3.14" + } + ] + }, + { + "id": "119616179", + "key": "euler_variation", + "variables": [ + { + "id": "4111654444", + "value": "2.718" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1505457580", + "endOfRange": 4000 + }, + { + "entityId": "119616179", + "endOfRange": 8000 + } + ], + "audienceIds": ["3988293898"], + "forcedVariations": {} + }, + { + "id": "2667098701", + "key": "paused_experiment", + "layerId": "3949273892", + "status": "Paused", + "variations": [ + { + "id": "391535909", + "key": "Control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "391535909", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": { + "Harry Potter": "Control" + } + }, + { + "id": "3072915611", + "key": "launched_experiment", + "layerId": "3587821424", + "status": "Launched", + "variations": [ + { + "id": "1647582435", + "key": "launch_control", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1647582435", + "endOfRange": 8000 + } + ], + "audienceIds": [], + "forcedVariations": {} + }, + { + "id": "748215081", + "key": "experiment_with_malformed_audience", + "layerId": "1238149537", + "status": "Running", + "variations": [ + { + "id": "535538389", + "key": "var1", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "535538389", + "endOfRange": 10000 + } + ], + "audienceIds": ["2196265320"], + "forcedVariations": {} + } + ], + "groups": [ + { + "id": "1015968292", + "policy": "random", + "experiments": [ + { + "id": "2738374745", + "key": "first_grouped_experiment", + "layerId": "3301900159", + "status": "Running", + "variations": [ + { + "id": "2377378132", + "key": "A", + "variables": [] + }, + { + "id": "1179171250", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "2377378132", + "endOfRange": 5000 + }, + { + "entityId": "1179171250", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Harry Potter": "A", + "Tom Riddle": "B" + } + }, + { + "id": "3042640549", + "key": "second_grouped_experiment", + "layerId": "2625300442", + "status": "Running", + "variations": [ + { + "id": "1558539439", + "key": "A", + "variables": [] + }, + { + "id": "2142748370", + "key": "B", + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1558539439", + "endOfRange": 5000 + }, + { + "entityId": "2142748370", + "endOfRange": 10000 + } + ], + "audienceIds": [ + "3468206642" + ], + "forcedVariations": { + "Hermione Granger": "A", + "Ronald Weasley": "B" + } + } + ], + "trafficAllocation": [ + { + "entityId": "2738374745", + "endOfRange": 4000 + }, + { + "entityId": "3042640549", + "endOfRange": 8000 + } + ] + }, + { + "id": "2606208781", + "policy": "random", + "experiments": [ + { + "id": "4138322202", + "key": "mutex_group_2_experiment_1", + "layerId": "3755588495", + "status": "Running", + "variations": [ + { + "id": "1394671166", + "key": "mutex_group_2_experiment_1_variation_1", + "featureEnabled": true, + "variables": [ + { + "id": "2059187672", + "value": "mutex_group_2_experiment_1_variation_1" + } + ] + } + ], + "audienceIds": [], + "forcedVariations": {}, + "trafficAllocation": [ + { + "entityId": "1394671166", + "endOfRange": 10000 + } + ] + }, + { + "id": "1786133852", + "key": "mutex_group_2_experiment_2", + "layerId": "3818002538", + "status": "Running", + "variations": [ + { + "id": "1619235542", + "key": "mutex_group_2_experiment_2_variation_2", + "featureEnabled": true, + "variables": [ + { + "id": "2059187672", + "value": "mutex_group_2_experiment_2_variation_2" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1619235542", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "forcedVariations": {} + } + ], + "trafficAllocation": [ + { + "entityId": "4138322202", + "endOfRange": 5000 + }, + { + "entityId": "1786133852", + "endOfRange": 10000 + } + ] + } + ], + "featureFlags": [ + { + "id": "4195505407", + "key": "boolean_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [] + }, + { + "id": "3926744821", + "key": "double_single_variable_feature", + "rolloutId": "", + "experimentIds": ["2201520193"], + "variables": [ + { + "id": "4111654444", + "key": "double_variable", + "type": "double", + "defaultValue": "14.99" + } + ] + }, + { + "id": "3281420120", + "key": "integer_single_variable_feature", + "rolloutId": "2048875663", + "experimentIds": [], + "variables": [ + { + "id": "593964691", + "key": "integer_variable", + "type": "integer", + "defaultValue": "7" + } + ] + }, + { + "id": "2591051011", + "key": "boolean_single_variable_feature", + "rolloutId": "", + "experimentIds": [], + "variables": [ + { + "id": "3974680341", + "key": "boolean_variable", + "type": "boolean", + "defaultValue": "true" + } + ] + }, + { + "id": "2079378557", + "key": "string_single_variable_feature", + "rolloutId": "1058508303", + "experimentIds": [], + "variables": [ + { + "id": "2077511132", + "key": "string_variable", + "type": "string", + "defaultValue": "wingardium leviosa" + } + ] + }, + { + "id": "3263342226", + "key": "multi_variate_feature", + "rolloutId": "813411034", + "experimentIds": ["3262035800"], + "variables": [ + { + "id": "675244127", + "key": "first_letter", + "type": "string", + "defaultValue": "H" + }, + { + "id": "4052219963", + "key": "rest_of_name", + "type": "string", + "defaultValue": "arry" + } + ] + }, + { + "id": "3263342226", + "key": "mutex_group_feature", + "rolloutId": "", + "experimentIds": ["4138322202", "1786133852"], + "variables": [ + { + "id": "2059187672", + "key": "correlating_variation_name", + "type": "string", + "defaultValue": "null" + } + ] + } + ], + "rollouts": [ + { + "id": "1058508303", + "experiments": [ + { + "id": "1785077004", + "key": "1785077004", + "status": "Running", + "layerId": "1058508303", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "1566407342", + "key": "1566407342", + "featureEnabled": true, + "variables": [ + { + "id": "2077511132", + "value": "lumos" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "1566407342", + "endOfRange": 5000 + } + ] + } + ] + }, + { + "id": "813411034", + "experiments": [ + { + "id": "3421010877", + "key": "3421010877", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["3468206642"], + "forcedVariations": {}, + "variations": [ + { + "id": "521740985", + "key": "521740985", + "variables": [ + { + "id": "675244127", + "value": "G" + }, + { + "id": "4052219963", + "value": "odric" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "521740985", + "endOfRange": 5000 + } + ] + }, + { + "id": "600050626", + "key": "600050626", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["3988293898"], + "forcedVariations": {}, + "variations": [ + { + "id": "180042646", + "key": "180042646", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "S" + }, + { + "id": "4052219963", + "value": "alazar" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "180042646", + "endOfRange": 5000 + } + ] + }, + { + "id": "2637642575", + "key": "2637642575", + "status": "Running", + "layerId": "813411034", + "audienceIds": ["4194404272"], + "forcedVariations": {}, + "variations": [ + { + "id": "2346257680", + "key": "2346257680", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "D" + }, + { + "id": "4052219963", + "value": "udley" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "2346257680", + "endOfRange": 5000 + } + ] + }, + { + "id": "828245624", + "key": "828245624", + "status": "Running", + "layerId": "813411034", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "3137445031", + "key": "3137445031", + "featureEnabled": true, + "variables": [ + { + "id": "675244127", + "value": "M" + }, + { + "id": "4052219963", + "value": "uggle" + } + ] + } + ], + "trafficAllocation": [ + { + "entityId": "3137445031", + "endOfRange": 5000 + } + ] + } + ] + }, + { + "id": "2048875663", + "experiments": [ + { + "id": "3794675122", + "key": "3794675122", + "status": "Running", + "layerId": "2048875663", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "589640735", + "key": "589640735", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "589640735", + "endOfRange": 10000 + } + ] + } + ] + } + ] +} diff --git a/gradle.properties b/gradle.properties index f5ac305dc..ef1dd8bfd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Maven version -version = 2.0.0-SNAPSHOT +version = 3.1.0-SNAPSHOT # Artifact paths mavenS3Bucket = optimizely-maven @@ -10,18 +10,23 @@ org.gradle.daemon = true org.gradle.parallel = true # Application Packages -gsonVersion = 2.6.1 -guavaVersion = 19.0 +gsonVersion = 2.10.1 +guavaVersion = 22.0 hamcrestVersion = 1.3 -jacksonVersion = 2.7.1 -jsonVersion = 20160212 +# NOTE: jackson 2.14+ uses Java8 stream apis not supported in android +jacksonVersion = 2.13.5 +jsonVersion = 20190722 jsonSimpleVersion = 1.1.1 -logbackVersion = 1.1.5 -slf4jVersion = 1.7.16 +logbackVersion = 1.2.3 +slf4jVersion = 1.7.30 +httpClientVersion = 4.5.14 +log4jVersion = 2.20.0 # Style Packages -findbugsVersion = 3.0.1 +findbugsAnnotationVersion = 3.0.1 +findbugsJsrVersion = 3.0.2 # Test Packages -junitVersion = 4.12 +junitVersion = 4.13 mockitoVersion = 1.10.19 +commonCodecVersion = 1.15 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c44b679ac..7454180f2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 568c50bf3..ffed3a254 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.5.1-bin.zip diff --git a/java-quickstart/README.md b/java-quickstart/README.md new file mode 100644 index 000000000..d0349747a --- /dev/null +++ b/java-quickstart/README.md @@ -0,0 +1,16 @@ +Optimizely Java QuickStart +=================== + +This package contains the Java QuickStart app that can be used to test that the Java SDK can communicate with the CDN and Optimizely results. +Simply create a new project with an experiment called "background_experiment" with 2 variations and a conversion event named "sample_conversion". +From there you can test different attributes setups and other Optimizely Java SDK APIs. +This is just a simple example that gets you up and running quickly! + +## Getting Started +Create an experiment on app.optimizely.com named "background_experiment" with variations "variation_a" and "variation_b" and one conversion event "sample_conversion.". +Use that SDK key in the [`com.optimizely.Example.java`](https://github.com/optimizely/java-sdk/blob/master/java-quickstart/src/main/java/com/optimizely/Example.java) +and you are set to test. + +### Run Example + +Running `./gradlew runExample` will build and execute [`com.optimizely.Example.java`](https://github.com/optimizely/java-sdk/blob/master/java-quickstart/src/main/java/com/optimizely/Example.java). diff --git a/java-quickstart/build.gradle b/java-quickstart/build.gradle new file mode 100644 index 000000000..a58fb090e --- /dev/null +++ b/java-quickstart/build.gradle @@ -0,0 +1,21 @@ +apply plugin: 'java' + +dependencies { + implementation project(':core-api') + implementation project(':core-httpclient-impl') + + implementation group: 'com.google.code.gson', name: 'gson', version: gsonVersion + implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: httpClientVersion + implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: log4jVersion + implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: log4jVersion + implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: log4jVersion + + testImplementation group: 'junit', name: 'junit', version: junitVersion +} + +task runExample(type: JavaExec) { + systemProperties System.properties + + main "com.optimizely.Example" + classpath sourceSets.test.runtimeClasspath +} diff --git a/java-quickstart/gradle/wrapper/gradle-wrapper.jar b/java-quickstart/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..01b8bf6b1 Binary files /dev/null and b/java-quickstart/gradle/wrapper/gradle-wrapper.jar differ diff --git a/java-quickstart/gradle/wrapper/gradle-wrapper.properties b/java-quickstart/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..3c46198fc --- /dev/null +++ b/java-quickstart/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/java-quickstart/libs/core-api-2.0.0-SNAPSHOT.jar b/java-quickstart/libs/core-api-2.0.0-SNAPSHOT.jar new file mode 100644 index 000000000..f23e06b81 Binary files /dev/null and b/java-quickstart/libs/core-api-2.0.0-SNAPSHOT.jar differ diff --git a/java-quickstart/libs/core-api.jar b/java-quickstart/libs/core-api.jar new file mode 100644 index 000000000..f23e06b81 Binary files /dev/null and b/java-quickstart/libs/core-api.jar differ diff --git a/java-quickstart/libs/core-httpclient-impl-2.0.0-SNAPSHOT.jar b/java-quickstart/libs/core-httpclient-impl-2.0.0-SNAPSHOT.jar new file mode 100644 index 000000000..3b34b982b Binary files /dev/null and b/java-quickstart/libs/core-httpclient-impl-2.0.0-SNAPSHOT.jar differ diff --git a/java-quickstart/libs/core-httpclient-impl.jar b/java-quickstart/libs/core-httpclient-impl.jar new file mode 100644 index 000000000..3b34b982b Binary files /dev/null and b/java-quickstart/libs/core-httpclient-impl.jar differ diff --git a/java-quickstart/src/main/java/com/optimizely/Example.java b/java-quickstart/src/main/java/com/optimizely/Example.java new file mode 100644 index 000000000..e3bccd483 --- /dev/null +++ b/java-quickstart/src/main/java/com/optimizely/Example.java @@ -0,0 +1,69 @@ +/**************************************************************************** + * Copyright 2018-2019, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely; + +import com.optimizely.ab.Optimizely; +import com.optimizely.ab.OptimizelyFactory; +import com.optimizely.ab.OptimizelyUserContext; +import com.optimizely.ab.optimizelydecision.OptimizelyDecision; +import com.optimizely.ab.optimizelyjson.OptimizelyJSON; + +import java.util.Collections; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +public class Example { + + private final Optimizely optimizely; + + private Example(Optimizely optimizely) { + this.optimizely = optimizely; + } + + private void processVisitor(String userId, Map<String, Object> attributes) { + OptimizelyUserContext user = optimizely.createUserContext(userId, attributes); + + OptimizelyDecision decision = user.decide("eet_feature"); + String variationKey = decision.getVariationKey(); + + if (variationKey != null) { + boolean enabled = decision.getEnabled(); + System.out.println("[Example] feature enabled: " + enabled); + + OptimizelyJSON variables = decision.getVariables(); + System.out.println("[Example] feature variables: " + variables.toString()); + + user.trackEvent("eet_conversion"); + } + else { + System.out.println("[Example] decision failed: " + decision.getReasons().toString()); + } + } + + public static void main(String[] args) throws InterruptedException { + Optimizely optimizely = OptimizelyFactory.newDefaultInstance("BX9Y3bTa4YErpHZEMpAwHm"); + + Example example = new Example(optimizely); + Random random = new Random(); + + for (int i = 0; i < 10; i++) { + String userId = String.valueOf(random.nextInt(Integer.MAX_VALUE)); + example.processVisitor(userId, Collections.emptyMap()); + TimeUnit.MILLISECONDS.sleep(500); + } + } +} diff --git a/java-quickstart/src/main/resources/log4j2.properties b/java-quickstart/src/main/resources/log4j2.properties new file mode 100644 index 000000000..d67078d5a --- /dev/null +++ b/java-quickstart/src/main/resources/log4j2.properties @@ -0,0 +1,10 @@ +# Set the root logger level to INFO and its appender to the console + +appender.console.type = Console +appender.console.name = STDOUT +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n + +# Specify the loggers +rootLogger.level = debug +rootLogger.appenderRef.stdout.ref = STDOUT diff --git a/resources/HEADER b/resources/HEADER new file mode 100644 index 000000000..873936ab8 --- /dev/null +++ b/resources/HEADER @@ -0,0 +1,13 @@ + Copyright ${year}, Optimizely Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/settings.gradle b/settings.gradle index 428cb40f1..1a782c438 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,3 +2,5 @@ rootProject.name = 'ab-sdk' include 'core-api' include 'core-httpclient-impl' +include 'java-quickstart' +