diff --git a/.github/workflows/cmake.yml b/.github/workflows/cmake.yml index 21e63459..9022c0b5 100644 --- a/.github/workflows/cmake.yml +++ b/.github/workflows/cmake.yml @@ -105,6 +105,14 @@ jobs: echo "check lld..." ldd --version + - name: Cache vcpkg binaries + if: matrix.dependency_mode != 'no_deps' + uses: actions/cache@v5 + with: + path: ${{ runner.os == 'Windows' && '~/AppData/Local/vcpkg/archives' || '~/.cache/vcpkg/archives' }} + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} + restore-keys: vcpkg-${{ runner.os }}- + - name: Install vcpkg (Linux/macOS) if: runner.os != 'Windows' && matrix.dependency_mode != 'no_deps' shell: bash diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c1f122b1..43f66996 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,63 +1,102 @@ name: Test Coverage -on: [pull_request, workflow_dispatch] +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - build: + coverage: name: Report Test Coverage - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v6 with: submodules: true - - name: Install vcpkg (Linux/macOS) - if: runner.os != 'Windows' - shell: bash + - name: Cache vcpkg binaries + uses: actions/cache@v5 + with: + path: ~/.cache/vcpkg/archives + key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }} + restore-keys: vcpkg-${{ runner.os }}- + + - name: Install vcpkg run: | git clone https://github.com/microsoft/vcpkg.git "$HOME/vcpkg" "$HOME/vcpkg/bootstrap-vcpkg.sh" -disableMetrics echo "VCPKG_ROOT=$HOME/vcpkg" >> "$GITHUB_ENV" echo "$HOME/vcpkg" >> "$GITHUB_PATH" - - name: Install vcpkg (Windows) - if: runner.os == 'Windows' - shell: pwsh + - name: Build and test with coverage instrumentation + id: build + run: | + python3 setup.py --platform linux_x64 --compiler gcc --build --test --coverage --no_cov_report + mkdir -p coverage + + - name: Generate coverage report + uses: threeal/gcovr-action@v1.2.0 + with: + fail-under-line: 60 + fail-under-branch: 50 + fail-under-function: 80 + print-summary: true + txt-out: coverage/coverage.txt + html-out: coverage/index.html + html-details: true + cobertura-out: coverage/cobertura.xml + + - name: Publish coverage summary + if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: | - git clone https://github.com/microsoft/vcpkg.git "$env:USERPROFILE\vcpkg" - & "$env:USERPROFILE\vcpkg\bootstrap-vcpkg.bat" -disableMetrics - echo "VCPKG_ROOT=$env:USERPROFILE\vcpkg" >> $env:GITHUB_ENV - echo "$env:USERPROFILE\vcpkg" >> $env:GITHUB_PATH - - - name: Create Build Environment - run: cmake -E make_directory ${{github.workspace}}/build - - - name: Setup LCOV - uses: hrishikesh-kadam/setup-lcov@v1 - - - name: Configure CMake - shell: bash - working-directory: ${{github.workspace}}/build - run: cmake -DENABLE_COVERAGE=ON .. - - - name: Build - working-directory: ${{github.workspace}}/build - shell: bash - run: cmake --build . - - - name: Prepare coverage data - working-directory: ${{github.workspace}}/build - shell: bash - run: cmake --build . --target cov_data - - - name: Report code coverage - uses: zgosalvez/github-actions-report-lcov@v4 + { + echo '```' + cat coverage/coverage.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload to Codecov + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + uses: codecov/codecov-action@v5 + with: + files: coverage/cobertura.xml + use_oidc: true + + - name: Upload coverage report + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + uses: actions/upload-artifact@v7 with: - coverage-files: build/cov.info.cleaned - minimum-coverage: 30 - artifact-name: code-coverage-report - github-token: ${{ secrets.GITHUB_TOKEN }} - working-directory: ${{github.workspace}} - update-comment: true + name: code-coverage-report + path: coverage/ + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v4 + with: + path: coverage/ + + publish-pages: + name: Publish Report to GitHub Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: coverage + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 776038bb..de8a4ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ [Bb]uild_old/ /gtest_build .DS_Store +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ad4678b..cad50347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 5.4.1 + +### Fixed + +- **Remote configs caching** — fixed cached configs being re-downloaded on every launch instead of reused across sessions. +- **Remote configs listeners** — fixed a possible crash when adding or removing a listener during session start. +- **Custom fields** — fixed one invalid field dropping all other custom fields on the event. +- **Resource leaks** — fixed curl handle and header list leaks, and a per-event socket leak on Linux. + ## 5.4.0 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 52a5c52d..80554579 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ include("create_source_groups_macro") include("eval_condition_macro") # --------------------------- Options --------------------------- # -option(ENABLE_COVERAGE "Enable code coverage reporting" OFF) +option(ENABLE_COVERAGE "Build with code coverage instrumentation" OFF) option(GA_SHARED_LIB "Build GA as a shared library" OFF) option(GA_UWP_BUILD "Build GA for UWP (if targeting windows)" OFF) option(GA_BUILD_SAMPLE "Builds the GA Sample app" ON) @@ -275,93 +275,12 @@ else() message(STATUS "Skipping unit tests (not available for shared library builds)") endif() -# --------------------------- Code Coverage Setup --------------------------- # +# --------------------------- Code Coverage Instrumentation --------------------------- # -# Coverage requires tests, which are only available for static library builds -if (ENABLE_COVERAGE AND NOT GA_SHARED_LIB) - find_program(GCOV_PATH gcov) - if (NOT GCOV_PATH) - message(WARNING "program gcov not found") - endif() - - find_program(LCOV_PATH lcov) - if (NOT LCOV_PATH) - message(WARNING "program lcov not found") - endif() - - find_program(GENHTML_PATH genhtml) - if (NOT GENHTML_PATH) - message(WARNING "program genhtml not found") - endif() - - if (LCOV_PATH AND GCOV_PATH) - - target_compile_options( - GameAnalytics - PRIVATE - -g -O0 -fprofile-arcs -ftest-coverage - ) - - target_link_libraries( - GameAnalytics PRIVATE -fprofile-arcs -ftest-coverage - ) - - set(covname cov) - - add_custom_target(cov_data - # Cleanup lcov - COMMENT "Resetting code coverage counters to zero." - ${LCOV_PATH} --directory . --zerocounters - - # Run tests - COMMAND GameAnalyticsUnitTests - - # Capturing lcov counters and generating report - - COMMAND echo "Processing code coverage counters and generating report." - - COMMAND ${LCOV_PATH} --directory . --capture --output-file ${covname}.info --branch-coverage --rc geninfo_unexecuted_blocks=1 --rc no_exception_branch=1 - - COMMAND echo "Removing unwanted files from coverage report." - - COMMAND ${LCOV_PATH} --remove ${covname}.info - '${CMAKE_SOURCE_DIR}/source/dependencies/*' - '${CMAKE_SOURCE_DIR}/test/*' - '/usr/*' - '/Applications/Xcode.app/*' - --output-file ${covname}.info.cleaned - --ignore-errors unused - - COMMAND echo "Finished processing code coverage counters and generating report." - ) - - if (GENHTML_PATH) - add_custom_target(cov - - # Cleanup lcov - ${LCOV_PATH} --directory . --zerocounters - - # Run tests - COMMAND GameAnalyticsUnitTests - - # Capturing lcov counters and generating report - COMMAND ${LCOV_PATH} --directory . --capture --output-file ${covname}.info --rc lcov_branch_coverage=1 --rc derive_function_end_line=0 - COMMAND ${LCOV_PATH} --remove ${covname}.info - '${CMAKE_SOURCE_DIR}/source/dependencies/*' - '/usr/*' - --output-file ${covname}.info.cleaned - --rc lcov_branch_coverage=1 - --rc derive_function_end_line=0 - COMMAND ${GENHTML_PATH} -o ${covname} ${covname}.info.cleaned --rc lcov_branch_coverage=1 --rc derive_function_end_line=0 - COMMAND ${CMAKE_COMMAND} -E remove ${covname}.info ${covname}.info.cleaned - - COMMENT "Resetting code coverage counters to zero.\nProcessing code coverage counters and generating report." - ) - else() - message(WARNING "unable to generate coverage report: missing genhtml") - endif() - - else() - message(WARNING "unable to add coverage targets: missing coverage tools") +if(ENABLE_COVERAGE) + if(GA_SHARED_LIB) + message(FATAL_ERROR "ENABLE_COVERAGE requires a static library build (coverage is measured through the unit tests)") endif() + target_compile_options(GameAnalytics PRIVATE -g -O0 --coverage -fprofile-update=atomic) + target_link_options(GameAnalytics PUBLIC --coverage) endif() diff --git a/README.md b/README.md index 502b902e..425e346e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ GA-SDK-CPP ========== +[![codecov](https://codecov.io/gh/GameAnalytics/gameanalytics-sdk-cpp/branch/main/graph/badge.svg)](https://codecov.io/gh/GameAnalytics/gameanalytics-sdk-cpp) + GameAnalytics C++ SDK Documentation can be found [here](https://gameanalytics.com/docs/cpp-sdk). @@ -42,7 +44,7 @@ python setup.py --platform {linux_x64,linux_x86,osx,win32,win64,uwp} [--cfg {Rel | `--shared` | — | Build a shared library (`.dll`/`.so`/`.dylib`) instead of a static library | | `--build` | — | Execute the build step | | `--test` | — | Execute the test step (not available with `--shared`) | -| `--coverage` | — | Generate code coverage report (not available with `--shared`) | +| `--coverage` | — | Build with coverage instrumentation and generate an HTML report in `build/coverage/` (requires `--build --test`; not available with `--shared`; needs [gcovr](https://gcovr.com) installed) | #### Examples diff --git a/gcovr.cfg b/gcovr.cfg new file mode 100644 index 00000000..90152d24 --- /dev/null +++ b/gcovr.cfg @@ -0,0 +1,4 @@ +filter = source/gameanalytics/ +filter = include/GameAnalytics/ +exclude = source/gameanalytics/Platform/ +exclude-throw-branches = yes diff --git a/setup.py b/setup.py index dc72b465..6f451b4b 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ import shutil import glob import platform as Platform +import webbrowser def run_command(command, shell=True, cwd=None): if os.name == 'nt': # Check if the OS is Windows @@ -34,7 +35,8 @@ def main(): parser.add_argument('--shared', action='store_true', help='Build shared library instead of static') parser.add_argument('--build', action='store_true', help='Execute the build step') parser.add_argument('--test', action='store_true', help='Execute the test step') - parser.add_argument('--coverage', action='store_true', help='Generate code coverage report') + parser.add_argument('--coverage', action='store_true', help='Build with coverage instrumentation and generate a coverage report') + parser.add_argument('--no_cov_report', action='store_true', help='Skip the local coverage report generation (used on CI where gcovr runs separately)') parser.add_argument('--no_vcpkg', action='store_true', help='Do not download vcpkg packages') parser.add_argument('--no_curl', action='store_true', help='Compile the SDK without CURL (you will need to provide a custom HTTP client implementation)') @@ -44,10 +46,15 @@ def main(): if args.compiler and not args.platform.startswith('linux'): parser.error('--compiler can only be used with Linux platforms') - # Validate coverage is not used with shared library if args.coverage and args.shared: parser.error('--coverage cannot be used with --shared (coverage requires tests which need static library)') + if args.coverage and not (args.build and args.test): + parser.error('--coverage requires --build and --test') + + if args.no_cov_report and not args.coverage: + parser.error('--no_cov_report requires --coverage') + # Get compiler configuration for this platform (single compiler, like cmake.yml) compiler_config = get_compiler_for_platform(args.platform, args.compiler) c_compiler = compiler_config.get('c', '') @@ -123,7 +130,7 @@ def main(): cmake_command += f' -DPLATFORM:STRING={args.platform}' if args.coverage: cmake_command += ' -DENABLE_COVERAGE=ON' - + run_command(cmake_command) # Build @@ -138,10 +145,19 @@ def main(): else: exit(0) - # Code Coverage - if args.coverage: - # Prepare coverage data - run_command(f'cmake --build {build_output_dir} --target cov', cwd=build_output_dir) + # Code Coverage Report + if args.coverage and not args.no_cov_report: + coverage_dir = os.path.join(build_output_dir, 'coverage') + os.makedirs(coverage_dir, exist_ok=True) + report_path = os.path.join(coverage_dir, 'index.html') + gcovr_command = f'gcovr --print-summary --html-details {report_path}' + if args.platform == 'osx': + gcovr_command += ' --gcov-executable "xcrun llvm-cov gcov"' + elif cxx_compiler == 'clang++': + gcovr_command += ' --gcov-executable "llvm-cov gcov"' + run_command(gcovr_command) + print(f"\nCoverage report: {report_path}\n") + webbrowser.open(f'file://{report_path}') # Package Build Artifacts package_dir = os.path.join(build_output_dir, 'package') diff --git a/source/gameanalytics/GACommon.h b/source/gameanalytics/GACommon.h index a9ab3c66..2cfa1108 100644 --- a/source/gameanalytics/GACommon.h +++ b/source/gameanalytics/GACommon.h @@ -85,7 +85,7 @@ namespace gameanalytics class GAState; } - constexpr const char* GA_VERSION_STR = "cpp 5.4.0"; + constexpr const char* GA_VERSION_STR = "cpp 5.4.1"; constexpr int MAX_CUSTOM_FIELDS_COUNT = 50; constexpr int MAX_CUSTOM_FIELDS_KEY_LENGTH = 64; diff --git a/source/gameanalytics/GAState.cpp b/source/gameanalytics/GAState.cpp index ae6f694a..aa8a32de 100644 --- a/source/gameanalytics/GAState.cpp +++ b/source/gameanalytics/GAState.cpp @@ -624,8 +624,9 @@ namespace gameanalytics std::string lastUsedIdentifier = state_dict.contains("last_used_identifier") ? state_dict["last_used_identifier"].get() : ""; - if (!lastUsedIdentifier.empty()) + if (!lastUsedIdentifier.empty() && lastUsedIdentifier != _identifier) { + logging::GALogger::w("New identifier spotted compared to last one used, clearing cached configs hash!"); if (d.contains("configs_hash")) { d.erase("configs_hash"); @@ -886,6 +887,7 @@ namespace gameanalytics void GAState::addRemoteConfigsListener(const std::shared_ptr& listener) { + std::lock_guard lg(getInstance()._mtx); if(std::find(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener) == getInstance()._remoteConfigsListeners.end()) { getInstance()._remoteConfigsListeners.push_back(listener); @@ -894,10 +896,11 @@ namespace gameanalytics void GAState::removeRemoteConfigsListener(const std::shared_ptr& listener) { + std::lock_guard lg(getInstance()._mtx); if(std::find(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener) != getInstance()._remoteConfigsListeners.end()) { getInstance()._remoteConfigsListeners.erase( - std::remove(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener), + std::remove(getInstance()._remoteConfigsListeners.begin(), getInstance()._remoteConfigsListeners.end(), listener), getInstance()._remoteConfigsListeners.end() ); } @@ -978,12 +981,20 @@ namespace gameanalytics } } - buildRemoteConfigsJsons(_tempRemoteConfigsJson); + std::string configStr; + std::vector> listeners; + { + std::lock_guard lg(_mtx); - _remoteConfigsIsReady = true; - - std::string const configStr = _gameRemoteConfigsJson.dump(); - for (auto& listener : _remoteConfigsListeners) + buildRemoteConfigsJsons(_tempRemoteConfigsJson); + _remoteConfigsIsReady = true; + + configStr = _gameRemoteConfigsJson.dump(); + listeners = _remoteConfigsListeners; + } + + // notify outside the lock so a listener can safely call back into the SDK + for (auto& listener : listeners) { listener->onRemoteConfigsUpdated(configStr); } @@ -1072,8 +1083,8 @@ namespace gameanalytics else { constexpr const char* fmt = "validateAndCleanCustomFields: entry with key=%s, value=%s has been omitted because its key contains illegal character, is empty or exceeds the max number of characters (%d)"; - - const std::string value = fields[key].get(); + + const std::string value = fields[key].dump(); LogAndAddErrorEvent(EGAErrorSeverity::Warning, fmt, key.c_str(), value.c_str(), MAX_CUSTOM_FIELDS_KEY_LENGTH); } } diff --git a/source/gameanalytics/GAState.h b/source/gameanalytics/GAState.h index 0be43dd7..ef2056f7 100644 --- a/source/gameanalytics/GAState.h +++ b/source/gameanalytics/GAState.h @@ -79,6 +79,7 @@ namespace gameanalytics friend class logging::GALogger; friend class store::GAStore; friend class http::GAHTTPApi; + friend struct GAStateTestAccessor; public: diff --git a/test/GAEventsTests.cpp b/test/GAEventsTests.cpp new file mode 100644 index 00000000..0edf1c86 --- /dev/null +++ b/test/GAEventsTests.cpp @@ -0,0 +1,276 @@ +// +// GA-SDK-CPP +// Tests for the event store and send queue, using a mock HTTP client +// + +#include +#include + +#include +#include +#include +#include +#include "GameAnalytics/GAHttpClient.h" + +using namespace gameanalytics; + +namespace +{ + constexpr const char* kGameKey = "bd624ee6f8e6efb32a054f8d7ba11618"; + constexpr const char* kGameSecret = "7f5c3f682cbd217841efba92e92ffb1b3b6612bc"; + + class MockHttpClient : public GAHttpClient + { + public: + void initialize() override {} + void cleanup() override {} + + Response sendRequest( + std::string const& url, + std::string const& auth, + std::vector const& payloadData, + bool useGzip, + void* userData) override + { + lastUrl = url; + lastAuth = auth; + lastPayload = payloadData; + lastUseGzip = useGzip; + requestCount++; + + return configuredResponse; + } + + int requestCount = 0; + std::string lastUrl; + std::string lastAuth; + std::vector lastPayload; + bool lastUseGzip = false; + + Response configuredResponse = {}; + }; + + class GAEventsTest : public ::testing::Test + { + protected: + void SetUp() override + { + state::GAState::setKeys(kGameKey, kGameSecret); + ASSERT_TRUE(store::GAStore::ensureDatabase(false, kGameKey)); + + auto mockPtr = std::make_unique(); + mock = mockPtr.get(); + setResponse(200, R"({"status":"ok"})"); + http::GAHTTPApi::setCustomHttpImpl(std::move(mockPtr)); + + state::GAState::setEnabledEventSubmission(true); + state::GAState::internalInitialize(); + events::GAEvents::stopEventQueue(); + + clearEvents(); + mock->requestCount = 0; + } + + void TearDown() override + { + clearEvents(); + state::GAState::setEnabledEventSubmission(false); + http::GAHTTPApi::setCustomHttpImpl(nullptr); + } + + void setResponse(long code, std::string const& body) + { + mock->configuredResponse.code = code; + mock->configuredResponse.packet.assign(body.begin(), body.end()); + } + + static void clearEvents() + { + store::GAStore::executeQuerySync("DELETE FROM ga_events;"); + } + + // parsed event payloads currently in the store, optionally filtered by category + static std::vector storedEvents(std::string const& category = "", std::string const& status = "") + { + std::string sql = "SELECT event FROM ga_events"; + if (!category.empty()) + { + sql += " WHERE category='" + category + "'"; + } + if (!status.empty()) + { + sql += category.empty() ? " WHERE" : " AND"; + sql += " status='" + status + "'"; + } + sql += ";"; + + json rows; + store::GAStore::executeQuerySync(sql, rows); + + std::vector events; + if (rows.is_array()) + { + for (auto& row : rows) + { + events.push_back(json::parse(row["event"].get())); + } + } + return events; + } + + MockHttpClient* mock = nullptr; + }; +} + +// ---- storing events ---- + +TEST_F(GAEventsTest, testDesignEventIsStoredWithAnnotations) +{ + events::GAEvents::addDesignEvent("level:complete", 42.5, true, json(), false); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + + const json& ev = stored[0]; + ASSERT_EQ("design", ev["category"].get()); + ASSERT_EQ("level:complete", ev["event_id"].get()); + ASSERT_DOUBLE_EQ(42.5, ev["value"].get()); + + // shared annotations merged in by addEventToStore + ASSERT_EQ(2, ev["v"].get()); + ASSERT_FALSE(ev["user_id"].get().empty()); + ASSERT_FALSE(ev["session_id"].get().empty()); + ASSERT_TRUE(ev.contains("client_ts")); +} + +TEST_F(GAEventsTest, testDesignEventWithoutValueOmitsValue) +{ + events::GAEvents::addDesignEvent("level:skip", 0.0, false, json(), false); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + ASSERT_FALSE(stored[0].contains("value")); +} + +TEST_F(GAEventsTest, testInvalidDesignEventIsNotStored) +{ + // event id may have at most 5 segments + events::GAEvents::addDesignEvent("a:b:c:d:e:f", 0.0, false, json(), false); + + ASSERT_TRUE(storedEvents("design").empty()); +} + +TEST_F(GAEventsTest, testErrorEventStoresSeverityAndMessage) +{ + events::GAEvents::addErrorEvent(EGAErrorSeverity::Warning, "something happened", "update", 42, json(), false); + + auto stored = storedEvents("error"); + ASSERT_EQ(1u, stored.size()); + + const json& ev = stored[0]; + ASSERT_EQ("warning", ev["severity"].get()); + ASSERT_EQ("something happened", ev["message"].get()); + ASSERT_EQ("update", ev["function_name"].get()); + ASSERT_EQ(42, ev["line_number"].get()); +} + +TEST_F(GAEventsTest, testBusinessEventIncrementsTransactionNum) +{ + const int64_t before = state::GAState::getTransactionNum(); + + events::GAEvents::addBusinessEvent("USD", 499, "weapon", "sword", "shop", json(), false); + events::GAEvents::addBusinessEvent("USD", 199, "weapon", "shield", "shop", json(), false); + + auto stored = storedEvents("business"); + ASSERT_EQ(2u, stored.size()); + + ASSERT_EQ("weapon:sword", stored[0]["event_id"].get()); + ASSERT_EQ(499, stored[0]["amount"].get()); + ASSERT_EQ("USD", stored[0]["currency"].get()); + ASSERT_EQ(before + 1, stored[0]["transaction_num"].get()); + ASSERT_EQ(before + 2, stored[1]["transaction_num"].get()); +} + +TEST_F(GAEventsTest, testProgressionCompleteCarriesAttemptNum) +{ + const json noFields; + + events::GAEvents::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2", "", 0, false, noFields, false); + events::GAEvents::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2", "", 0, false, noFields, false); + events::GAEvents::addProgressionEvent(EGAProgressionStatus::Complete, "world1", "level2", "", 100, true, noFields, false); + + auto stored = storedEvents("progression"); + ASSERT_EQ(3u, stored.size()); + + const json& complete = stored[2]; + ASSERT_EQ("Complete:world1:level2", complete["event_id"].get()); + ASSERT_EQ(3, complete["attempt_num"].get()); + ASSERT_EQ(100, complete["score"].get()); + + // completing clears the attempt counter + ASSERT_EQ(0, state::GAState::getProgressionTries("world1:level2")); +} + +// ---- sending events ---- + +TEST_F(GAEventsTest, testProcessEventsSendsBatchAndClearsQueue) +{ + events::GAEvents::addDesignEvent("send:one", 0.0, false, json(), false); + events::GAEvents::addDesignEvent("send:two", 0.0, false, json(), false); + + setResponse(200, R"({"status":"ok"})"); + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(1, mock->requestCount); + ASSERT_NE(std::string::npos, mock->lastUrl.find(kGameKey)); + ASSERT_NE(std::string::npos, mock->lastUrl.find("/events")); + ASSERT_EQ(0u, mock->lastAuth.find("Authorization: ")); + ASSERT_FALSE(mock->lastPayload.empty()); + + // sent events are removed from the store + ASSERT_TRUE(storedEvents("design").empty()); +} + +TEST_F(GAEventsTest, testProcessEventsKeepsEventsWhenNoResponse) +{ + events::GAEvents::addDesignEvent("retry:later", 0.0, false, json(), false); + + setResponse(-1, ""); + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(1, mock->requestCount); + + // events go back to 'new' so the next flush retries them + ASSERT_EQ(1u, storedEvents("design", "new").size()); +} + +TEST_F(GAEventsTest, testProcessEventsDropsEventsOnServerError) +{ + events::GAEvents::addDesignEvent("dropped:event", 0.0, false, json(), false); + + setResponse(500, "Internal Server Error"); + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(1, mock->requestCount); + + // any answer other than no-response counts as processed + ASSERT_TRUE(storedEvents("design").empty()); +} + +TEST_F(GAEventsTest, testProcessEventsWithNoEventsSendsNothing) +{ + events::GAEvents::processEvents("design", false); + + ASSERT_EQ(0, mock->requestCount); +} + +TEST_F(GAEventsTest, testEventsAreNotStoredWhenSubmissionDisabled) +{ + state::GAState::setEnabledEventSubmission(false); + + events::GAEvents::addDesignEvent("blocked:event", 0.0, false, json(), false); + events::GAEvents::addErrorEvent(EGAErrorSeverity::Error, "blocked", "", -1, json(), false); + + state::GAState::setEnabledEventSubmission(true); + ASSERT_TRUE(storedEvents().empty()); +} diff --git a/test/GAStateTests.cpp b/test/GAStateTests.cpp index 7e763a37..60a13946 100644 --- a/test/GAStateTests.cpp +++ b/test/GAStateTests.cpp @@ -7,110 +7,105 @@ #include #include -//#include "rapidjson/document.h" -// -//#include "helpers/GATestHelpers.h" -// -//TEST(GAStateTest, testValidateAndCleanCustomFields) -//{ -// rapidjson::Document map; -// rapidjson::Value v; -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// while(map.MemberCount() < 100) -// { -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// } -// ASSERT_EQ(100, map.MemberCount()); -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 50); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// while(map.MemberCount() < 50) -// { -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// } -// ASSERT_EQ(50, map.MemberCount()); -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_EQ(50, v.MemberCount()); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value("", a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(257).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember("", rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value("___", a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 1); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value("_&_", a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(65).c_str(), a), rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(100), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 1); -// -// { -// v = rapidjson::Value(); -// map.SetObject(); -// rapidjson::Document::AllocatorType& a = map.GetAllocator(); -// map.AddMember(rapidjson::Value(GATestHelpers::getRandomString(4).c_str(), a), rapidjson::Value(true), a); -// } -// gameanalytics::state::GAState::validateAndCleanCustomFields(map, v); -// ASSERT_TRUE(v.MemberCount() == 0); -//} +#include + +#include "helpers/GAStateTestAccessor.h" + +using namespace gameanalytics; + +namespace +{ + constexpr const char* kGameKey = "bd624ee6f8e6efb32a054f8d7ba11618"; + + void seedCachedConfig(std::string const& lastUsedIdentifier, std::string const& configsHash) + { + ASSERT_TRUE(store::GAStore::ensureDatabase(false, kGameKey)); + + store::GAStore::setState("last_used_identifier", lastUsedIdentifier); + store::GAStore::setState("sdk_config_cached", std::string("{\"configs_hash\":\"") + configsHash + "\"}"); + } +} + +// Regression test: the cached configs_hash must survive a relaunch with the +// same user identifier, so the init request can tell the backend which config +// version it already has. It must only be cleared when the identifier changed +// since the config was cached (matches the iOS/C# SDK behavior). + +TEST(GAStateTest, testConfigsHashKeptWhenIdentifierUnchanged) +{ + seedCachedConfig("user-a", "hash-abc123"); + + state::GAStateTestAccessor::resetConfigState("user-a"); + state::GAStateTestAccessor::ensurePersistedStates(); + + ASSERT_EQ("hash-abc123", state::GAStateTestAccessor::configsHash()); +} + +TEST(GAStateTest, testConfigsHashClearedWhenIdentifierChanged) +{ + seedCachedConfig("user-a", "hash-abc123"); + + state::GAStateTestAccessor::resetConfigState("user-b"); + state::GAStateTestAccessor::ensurePersistedStates(); + + ASSERT_TRUE(state::GAStateTestAccessor::configsHash().empty()); +} + +// validateAndCleanCustomFields: keys must match ^[a-zA-Z0-9_]{1,64}$, values must be +// a number, a boolean or a non-empty string of at most 256 chars, capped at 50 fields + +static json cleanFields(const json& fields) +{ + return state::GAStateTestAccessor::validateAndCleanCustomFields(fields); +} + +TEST(GAStateTest, testCustomFieldsCappedAtMaxCount) +{ + json fields; + for (int i = 0; i < MAX_CUSTOM_FIELDS_COUNT * 2; ++i) + { + fields["key_" + std::to_string(i)] = "value"; + } + ASSERT_EQ(MAX_CUSTOM_FIELDS_COUNT, static_cast(cleanFields(fields).size())); + + fields.clear(); + for (int i = 0; i < MAX_CUSTOM_FIELDS_COUNT; ++i) + { + fields["key_" + std::to_string(i)] = "value"; + } + ASSERT_EQ(MAX_CUSTOM_FIELDS_COUNT, static_cast(cleanFields(fields).size())); +} + +TEST(GAStateTest, testCustomFieldsKeyValidation) +{ + ASSERT_EQ(1u, cleanFields({{"___", "value"}}).size()); + ASSERT_EQ(1u, cleanFields({{std::string(MAX_CUSTOM_FIELDS_KEY_LENGTH, 'k'), "value"}}).size()); + + ASSERT_TRUE(cleanFields({{"", "value"}}).empty()); + ASSERT_TRUE(cleanFields({{"_&_", "value"}}).empty()); + ASSERT_TRUE(cleanFields({{std::string(MAX_CUSTOM_FIELDS_KEY_LENGTH + 1, 'k'), "value"}}).empty()); +} + +TEST(GAStateTest, testCustomFieldsValueValidation) +{ + ASSERT_EQ(1u, cleanFields({{"key", 100}}).size()); + ASSERT_EQ(1u, cleanFields({{"key", 3.14}}).size()); + ASSERT_EQ(1u, cleanFields({{"key", true}}).size()); + ASSERT_EQ(1u, cleanFields({{"key", std::string(MAX_CUSTOM_FIELDS_VALUE_STRING_LENGTH, 'v')}}).size()); + + ASSERT_TRUE(cleanFields({{"key", ""}}).empty()); + ASSERT_TRUE(cleanFields({{"key", std::string(MAX_CUSTOM_FIELDS_VALUE_STRING_LENGTH + 1, 'v')}}).empty()); + ASSERT_TRUE(cleanFields({{"key", nullptr}}).empty()); + ASSERT_TRUE(cleanFields({{"key", json::object()}}).empty()); + ASSERT_TRUE(cleanFields({{"key", json::array()}}).empty()); +} + +// regression: a non-string value under an illegal key used to throw while logging +// the rejection, discarding every other field in the payload +TEST(GAStateTest, testCustomFieldsIllegalKeyWithNumberValueKeepsOtherFields) +{ + json out = cleanFields({{"bad&key", 100}, {"good_key", "value"}}); + + ASSERT_EQ(1u, out.size()); + ASSERT_TRUE(out.contains("good_key")); +} diff --git a/test/GameAnalyticsApiTests.cpp b/test/GameAnalyticsApiTests.cpp new file mode 100644 index 00000000..df83f90a --- /dev/null +++ b/test/GameAnalyticsApiTests.cpp @@ -0,0 +1,652 @@ +// +// GA-SDK-CPP +// Integration tests for the public GameAnalytics facade, driving the real GA +// thread and asserting on the observable outcome (state, event store, mock http) +// + +#include +#include + +#include "GameAnalytics/GameAnalytics.h" +#include "GameAnalytics/GAHttpClient.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "helpers/GAStateTestAccessor.h" + +#include +#include +#include +#include +#include +#include + +using namespace gameanalytics; + +namespace +{ + constexpr const char* kGameKey = "bd624ee6f8e6efb32a054f8d7ba11618"; + constexpr const char* kGameSecret = "7f5c3f682cbd217841efba92e92ffb1b3b6612bc"; + + // shared handle so the client installed through the public + // configureHttpClient() template stays inspectable from the test + struct MockHttpState + { + std::mutex mutex; + int requestCount = 0; + std::string lastUrl; + GAHttpClient::Response response; + }; + + class MockHttpClient : public GAHttpClient + { + public: + explicit MockHttpClient(std::shared_ptr state) : _state(std::move(state)) {} + + void initialize() override {} + void cleanup() override {} + + Response sendRequest( + std::string const& url, + std::string const&, + std::vector const&, + bool, + void*) override + { + std::lock_guard lock(_state->mutex); + _state->requestCount++; + _state->lastUrl = url; + return _state->response; + } + + private: + std::shared_ptr _state; + }; + + class RecordingConfigsListener : public IRemoteConfigsListener + { + public: + void onRemoteConfigsUpdated(std::string const& remoteConfigs) override + { + std::lock_guard lock(_mutex); + _updates.push_back(remoteConfigs); + } + + std::vector updates() + { + std::lock_guard lock(_mutex); + return _updates; + } + + private: + std::mutex _mutex; + std::vector _updates; + }; + + class GameAnalyticsApiTest : public ::testing::Test + { + protected: + void SetUp() override + { + events::GAEvents::stopEventQueue(); + state::GAStateTestAccessor::forceUninitialized(); + state::GAState::setEnabledEventSubmission(true); + + ASSERT_TRUE(store::GAStore::ensureDatabase(false, kGameKey)); + clearStoredEvents(); + + http = std::make_shared(); + setHttpResponse(200, json{{"server_ts", utilities::getTimestamp()}}.dump()); + GameAnalytics::configureHttpClient(http); + } + + void TearDown() override + { + EXPECT_TRUE(drainGAThread()); + events::GAEvents::stopEventQueue(); + state::GAState::setEnabledEventSubmission(false); + http::GAHTTPApi::setCustomHttpImpl(nullptr); + state::GAStateTestAccessor::forceUninitialized(); + clearStoredEvents(); + } + + // barrier: the GA thread runs queued blocks in FIFO order, so once this + // marker task has run every previously queued task has run too + [[nodiscard]] static bool drainGAThread() + { + std::promise done; + std::future drained = done.get_future(); + threading::GAThreading::performTaskOnGAThread([&done]() { done.set_value(); }); + return drained.wait_for(std::chrono::seconds(10)) == std::future_status::ready; + } + + void setHttpResponse(long code, std::string const& body) + { + std::lock_guard lock(http->mutex); + http->response.code = code; + http->response.packet.assign(body.begin(), body.end()); + } + + int requestCount() + { + std::lock_guard lock(http->mutex); + return http->requestCount; + } + + std::string lastRequestUrl() + { + std::lock_guard lock(http->mutex); + return http->lastUrl; + } + + void configureDefaults() + { + GameAnalytics::configureBuild("1.2.3"); + GameAnalytics::configureAvailableCustomDimensions01({"ninja", "samurai"}); + GameAnalytics::configureAvailableCustomDimensions02({"guild_a", "guild_b"}); + GameAnalytics::configureAvailableCustomDimensions03({"tier1", "tier2"}); + GameAnalytics::configureAvailableResourceCurrencies({"gems", "gold"}); + GameAnalytics::configureAvailableResourceItemTypes({"boost", "weapon"}); + } + + void initializeSdk() + { + configureDefaults(); + GameAnalytics::initialize(kGameKey, kGameSecret); + ASSERT_TRUE(drainGAThread()); + ASSERT_TRUE(state::GAState::isInitialized()); + ASSERT_TRUE(state::GAState::sessionIsStarted()); + events::GAEvents::stopEventQueue(); + } + + // ga_session rows survive until a session_end is successfully sent, and + // fixMissingSessionEndEvents synthesizes session_end events for stale rows, + // so both tables must be cleared for tests to stay independent + static void clearStoredEvents() + { + store::GAStore::executeQuerySync("DELETE FROM ga_events;"); + store::GAStore::executeQuerySync("DELETE FROM ga_session;"); + } + + static size_t storedSessionEndCount(std::string const& sessionId) + { + size_t count = 0; + for (const json& ev : storedEvents("session_end")) + { + if (ev.value("session_id", "") == sessionId) + { + ++count; + } + } + return count; + } + + static std::vector storedEvents(std::string const& category = "") + { + std::string sql = "SELECT event FROM ga_events"; + if (!category.empty()) + { + sql += " WHERE category='" + category + "'"; + } + sql += ";"; + + json rows; + store::GAStore::executeQuerySync(sql, rows); + + std::vector events; + if (rows.is_array()) + { + for (auto& row : rows) + { + events.push_back(json::parse(row["event"].get())); + } + } + return events; + } + + std::shared_ptr http; + }; +} + +// ---- configuration before initialize ---- + +TEST_F(GameAnalyticsApiTest, PreInitConfigurationIsApplied) +{ + configureDefaults(); + GameAnalytics::configureWritablePath(device::GADevice::getWritablePath()); + GameAnalytics::configureBuildPlatform("windows"); + GameAnalytics::configureDeviceModel("TestDeviceModel"); + GameAnalytics::configureDeviceManufacturer("TestManufacturer"); + GameAnalytics::configureGameEngineVersion("unity 2021.3"); + GameAnalytics::configureSdkGameEngineVersion("unity 6.1.0"); + GameAnalytics::configureUserId("custom_user"); + GameAnalytics::configureExternalUserId("ext-42"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("1.2.3", state::GAStateTestAccessor::build()); + EXPECT_TRUE(state::GAState::hasAvailableCustomDimensions01("ninja")); + EXPECT_FALSE(state::GAState::hasAvailableCustomDimensions01("pirate")); + EXPECT_TRUE(state::GAState::hasAvailableCustomDimensions02("guild_b")); + EXPECT_TRUE(state::GAState::hasAvailableCustomDimensions03("tier2")); + EXPECT_TRUE(state::GAState::hasAvailableResourceCurrency("gems")); + EXPECT_FALSE(state::GAState::hasAvailableResourceCurrency("diamonds")); + EXPECT_TRUE(state::GAState::hasAvailableResourceItemType("boost")); + + EXPECT_TRUE(device::GADevice::getWritablePathStatus()); + EXPECT_EQ("windows", device::GADevice::getBuildPlatform()); + EXPECT_EQ("TestDeviceModel", device::GADevice::getDeviceModel()); + EXPECT_EQ("TestManufacturer", device::GADevice::getDeviceManufacturer()); + EXPECT_EQ("unity 2021.3", device::GADevice::getGameEngineVersion()); + EXPECT_EQ("unity 6.1.0", device::GADevice::getRelevantSdkVersion()); + + EXPECT_EQ("custom_user", GameAnalytics::getUserId()); + EXPECT_EQ("ext-42", GameAnalytics::getExternalUserId()); +} + +TEST_F(GameAnalyticsApiTest, PreInitConfigurationRejectsInvalidValues) +{ + GameAnalytics::configureBuild("1.0.0"); + ASSERT_TRUE(drainGAThread()); + ASSERT_EQ("1.0.0", state::GAStateTestAccessor::build()); + + const std::string userIdBefore = GameAnalytics::getUserId(); + const std::string engineBefore = device::GADevice::getGameEngineVersion(); + const std::string sdkVersionBefore = device::GADevice::getRelevantSdkVersion(); + const std::string platformBefore = device::GADevice::getBuildPlatform(); + + GameAnalytics::configureBuild(std::string(33, 'b')); + GameAnalytics::configureUserId(""); + GameAnalytics::configureGameEngineVersion("notanengine 1.0"); + GameAnalytics::configureSdkGameEngineVersion("bogus"); + GameAnalytics::configureBuildPlatform(std::string(33, 'p')); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("1.0.0", state::GAStateTestAccessor::build()); + EXPECT_EQ(userIdBefore, GameAnalytics::getUserId()); + EXPECT_EQ(engineBefore, device::GADevice::getGameEngineVersion()); + EXPECT_EQ(sdkVersionBefore, device::GADevice::getRelevantSdkVersion()); + EXPECT_EQ(platformBefore, device::GADevice::getBuildPlatform()); +} + +// ---- initialize ---- + +TEST_F(GameAnalyticsApiTest, InitializeWithInvalidKeysIsRejected) +{ + GameAnalytics::initialize("invalid", "keys"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_FALSE(state::GAState::isInitialized()); + EXPECT_EQ(0, requestCount()); +} + +TEST_F(GameAnalyticsApiTest, InitializeStartsSessionAndRequestsInit) +{ + initializeSdk(); + + EXPECT_GE(requestCount(), 1); + EXPECT_NE(std::string::npos, lastRequestUrl().find(kGameKey)); + + const std::string sessionId = state::GAState::getSessionId(); + EXPECT_EQ(36u, sessionId.size()); + EXPECT_EQ(utilities::toLowerCase(sessionId), sessionId); + + EXPECT_FALSE(GameAnalytics::getUserId().empty()); + EXPECT_FALSE(GameAnalytics::isThreadEnding()); + + EXPECT_GE(GameAnalytics::getElapsedSessionTime(), 0); + EXPECT_GE(GameAnalytics::getElapsedTimeFromAllSessions(), 0); + + // the session start event is dispatched immediately: init request first, + // then the events request that carries it + EXPECT_GE(requestCount(), 2); + EXPECT_NE(std::string::npos, lastRequestUrl().find("/events")); +} + +TEST_F(GameAnalyticsApiTest, InitializeTwiceKeepsFirstSession) +{ + initializeSdk(); + const std::string firstSessionId = state::GAState::getSessionId(); + + GameAnalytics::initialize(kGameKey, kGameSecret); + ASSERT_TRUE(drainGAThread()); + + EXPECT_TRUE(state::GAState::isInitialized()); + EXPECT_EQ(firstSessionId, state::GAState::getSessionId()); +} + +TEST_F(GameAnalyticsApiTest, ConfigureAfterInitializeIsIgnored) +{ + initializeSdk(); + const std::string userIdBefore = GameAnalytics::getUserId(); + + GameAnalytics::configureBuild("9.9.9"); + GameAnalytics::configureAvailableCustomDimensions01({"pirate"}); + GameAnalytics::configureUserId("late_user"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("1.2.3", state::GAStateTestAccessor::build()); + EXPECT_FALSE(state::GAState::hasAvailableCustomDimensions01("pirate")); + EXPECT_EQ(userIdBefore, GameAnalytics::getUserId()); +} + +// ---- adding events ---- + +TEST_F(GameAnalyticsApiTest, AddDesignEventIsStoredWithValueAndFields) +{ + initializeSdk(); + + GameAnalytics::addDesignEvent("level:complete", 42.5, R"({"difficulty":"hard"})"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + EXPECT_EQ("level:complete", stored[0]["event_id"].get()); + EXPECT_DOUBLE_EQ(42.5, stored[0]["value"].get()); + EXPECT_EQ("hard", stored[0]["custom_fields"]["difficulty"].get()); +} + +TEST_F(GameAnalyticsApiTest, AddDesignEventWithMalformedFieldsIsStoredWithoutFields) +{ + initializeSdk(); + + GameAnalytics::addDesignEvent("level:skip", "{not valid json"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("design"); + ASSERT_EQ(1u, stored.size()); + EXPECT_FALSE(stored[0].contains("custom_fields")); +} + +TEST_F(GameAnalyticsApiTest, AddBusinessEventIsStored) +{ + initializeSdk(); + + GameAnalytics::addBusinessEvent("USD", 499, "weapon", "sword", "shop"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("business"); + ASSERT_EQ(1u, stored.size()); + EXPECT_EQ("weapon:sword", stored[0]["event_id"].get()); + EXPECT_EQ("USD", stored[0]["currency"].get()); + EXPECT_EQ(499, stored[0]["amount"].get()); + EXPECT_EQ("shop", stored[0]["cart_type"].get()); +} + +TEST_F(GameAnalyticsApiTest, AddResourceEventValidatesConfiguredCurrenciesAndItemTypes) +{ + initializeSdk(); + + GameAnalytics::addResourceEvent(EGAResourceFlowType::Source, "gems", 100.0f, "boost", "starter"); + GameAnalytics::addResourceEvent(EGAResourceFlowType::Sink, "gems", 25.0f, "boost", "starter"); + GameAnalytics::addResourceEvent(EGAResourceFlowType::Source, "diamonds", 10.0f, "boost", "starter"); + GameAnalytics::addResourceEvent(EGAResourceFlowType::Source, "gems", 10.0f, "hat", "starter"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("resource"); + ASSERT_EQ(2u, stored.size()); + EXPECT_EQ("Source:gems:boost:starter", stored[0]["event_id"].get()); + EXPECT_DOUBLE_EQ(100.0, stored[0]["amount"].get()); + EXPECT_EQ("Sink:gems:boost:starter", stored[1]["event_id"].get()); + EXPECT_DOUBLE_EQ(-25.0, stored[1]["amount"].get()); +} + +TEST_F(GameAnalyticsApiTest, AddProgressionEventTracksAttempts) +{ + initializeSdk(); + + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2"); + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Fail, "world1", "level2"); + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Complete, 100, "world1", "level2"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("progression"); + ASSERT_EQ(3u, stored.size()); + + const json& complete = stored[2]; + EXPECT_EQ("Complete:world1:level2", complete["event_id"].get()); + EXPECT_EQ(100, complete["score"].get()); + EXPECT_EQ(3, complete["attempt_num"].get()); + EXPECT_EQ(0, state::GAState::getProgressionTries("world1:level2")); +} + +TEST_F(GameAnalyticsApiTest, AddErrorEventStoresSeverityAndTrimsMessage) +{ + initializeSdk(); + + const std::string longMessage(9000, 'x'); + GameAnalytics::addErrorEvent(EGAErrorSeverity::Critical, longMessage); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("error"); + ASSERT_EQ(1u, stored.size()); + EXPECT_EQ("critical", stored[0]["severity"].get()); + EXPECT_EQ(8182u, stored[0]["message"].get().size()); +} + +TEST_F(GameAnalyticsApiTest, EventsBeforeInitializeAreNotStored) +{ + GameAnalytics::addDesignEvent("too:early"); + GameAnalytics::addBusinessEvent("USD", 100, "weapon", "sword", "shop"); + GameAnalytics::addErrorEvent(EGAErrorSeverity::Info, "too early"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_TRUE(storedEvents().empty()); +} + +TEST_F(GameAnalyticsApiTest, OversizedCustomFieldsRejectTheEvent) +{ + initializeSdk(); + + const std::string oversizedFields = R"({"k":")" + std::string(5000, 'v') + R"("})"; + GameAnalytics::addDesignEvent("a:b", oversizedFields); + GameAnalytics::addProgressionEvent(EGAProgressionStatus::Start, "world1", "", "", oversizedFields); + GameAnalytics::addErrorEvent(EGAErrorSeverity::Info, "message", oversizedFields); + ASSERT_TRUE(drainGAThread()); + + EXPECT_TRUE(storedEvents("design").empty()); + EXPECT_TRUE(storedEvents("progression").empty()); + EXPECT_TRUE(storedEvents("error").empty()); +} + +// ---- state changes while running ---- + +TEST_F(GameAnalyticsApiTest, SetCustomDimensionsAreValidatedAgainstAvailable) +{ + initializeSdk(); + + GameAnalytics::setCustomDimension01("ninja"); + GameAnalytics::setCustomDimension02("guild_a"); + GameAnalytics::setCustomDimension03("tier1"); + ASSERT_TRUE(drainGAThread()); + + EXPECT_EQ("ninja", state::GAState::getCurrentCustomDimension01()); + EXPECT_EQ("guild_a", state::GAState::getCurrentCustomDimension02()); + EXPECT_EQ("tier1", state::GAState::getCurrentCustomDimension03()); + + GameAnalytics::setCustomDimension01("pirate"); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ("ninja", state::GAState::getCurrentCustomDimension01()); + + GameAnalytics::setCustomDimension01(""); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ("", state::GAState::getCurrentCustomDimension01()); +} + +TEST_F(GameAnalyticsApiTest, GlobalCustomEventFieldsAreMergedIntoEvents) +{ + initializeSdk(); + + GameAnalytics::setGlobalCustomEventFields(R"({"team":"red","run":7})"); + ASSERT_TRUE(drainGAThread()); + + GameAnalytics::addDesignEvent("uses:globals"); + GameAnalytics::addDesignEvent("overrides:globals", R"({"team":"blue"})"); + ASSERT_TRUE(drainGAThread()); + + auto stored = storedEvents("design"); + ASSERT_EQ(2u, stored.size()); + EXPECT_EQ("red", stored[0]["custom_fields"]["team"].get()); + EXPECT_EQ(7, stored[0]["custom_fields"]["run"].get()); + EXPECT_EQ("blue", stored[1]["custom_fields"]["team"].get()); +} + +TEST_F(GameAnalyticsApiTest, EventSubmissionToggleThroughFacade) +{ + initializeSdk(); + + GameAnalytics::setEnabledEventSubmission(false); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::isEventSubmissionEnabled()); + + GameAnalytics::addDesignEvent("blocked:event"); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(storedEvents("design").empty()); + + GameAnalytics::setEnabledEventSubmission(true); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::isEventSubmissionEnabled()); + + GameAnalytics::addDesignEvent("allowed:event"); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ(1u, storedEvents("design").size()); +} + +TEST_F(GameAnalyticsApiTest, LoggingAndErrorReportingToggles) +{ + GameAnalytics::setEnabledInfoLog(true); + GameAnalytics::setEnabledVerboseLog(true); + GameAnalytics::setEnabledErrorReporting(false); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::useErrorReporting()); + + GameAnalytics::setEnabledInfoLog(false); + GameAnalytics::setEnabledVerboseLog(false); + GameAnalytics::setEnabledErrorReporting(true); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::useErrorReporting()); +} + +// ---- session lifecycle ---- + +TEST_F(GameAnalyticsApiTest, AutomaticSessionHandlingOnSuspendAndResume) +{ + initializeSdk(); + const std::string firstSessionId = state::GAState::getSessionId(); + + // fail the http dispatch so the session end event stays queued in the store + setHttpResponse(-1, ""); + + GameAnalytics::onSuspend(); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::sessionIsStarted()); + EXPECT_EQ(1u, storedSessionEndCount(firstSessionId)); + EXPECT_GE(GameAnalytics::getElapsedTimeForPreviousSession(), 0); + + GameAnalytics::onResume(); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::sessionIsStarted()); + EXPECT_NE(firstSessionId, state::GAState::getSessionId()); + + // resuming an already running session must not start another one + const std::string currentSessionId = state::GAState::getSessionId(); + GameAnalytics::onResume(); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ(currentSessionId, state::GAState::getSessionId()); +} + +TEST_F(GameAnalyticsApiTest, ManualSessionHandlingControlsSessionExplicitly) +{ + initializeSdk(); + GameAnalytics::setEnabledManualSessionHandling(true); + ASSERT_TRUE(drainGAThread()); + ASSERT_TRUE(state::GAState::useManualSessionHandling()); + + const std::string firstSessionId = state::GAState::getSessionId(); + + // fail the http dispatch so the session end event stays queued in the store + setHttpResponse(-1, ""); + + GameAnalytics::endSession(); + ASSERT_TRUE(drainGAThread()); + EXPECT_FALSE(state::GAState::sessionIsStarted()); + EXPECT_EQ(1u, storedSessionEndCount(firstSessionId)); + + GameAnalytics::startSession(); + ASSERT_TRUE(drainGAThread()); + EXPECT_TRUE(state::GAState::sessionIsStarted()); + EXPECT_NE(firstSessionId, state::GAState::getSessionId()); +} + +// ---- remote configs ---- + +TEST_F(GameAnalyticsApiTest, RemoteConfigsFromInitReachListenerAndGetters) +{ + auto listener = std::make_shared(); + GameAnalytics::addRemoteConfigsListener(listener); + + const json initResponse = { + {"server_ts", utilities::getTimestamp()}, + {"configs", json::array({ + {{"key", "speed"}, {"value", "fast"}, {"id", "cfg1"}, {"vsn", 1}} + })}, + {"configs_hash", "hash-1"}, + {"ab_id", "ab1"}, + {"ab_variant_id", "var1"} + }; + setHttpResponse(201, initResponse.dump()); + initializeSdk(); + + EXPECT_TRUE(GameAnalytics::isRemoteConfigsReady()); + EXPECT_EQ("fast", GameAnalytics::getRemoteConfigsValueAsString("speed")); + EXPECT_EQ("slow", GameAnalytics::getRemoteConfigsValueAsString("missing", "slow")); + EXPECT_NE(std::string::npos, GameAnalytics::getRemoteConfigsContentAsString().find("speed")); + EXPECT_EQ("ab1", GameAnalytics::getABTestingId()); + EXPECT_EQ("var1", GameAnalytics::getABTestingVariantId()); + EXPECT_EQ("hash-1", state::GAStateTestAccessor::configsHash()); + + auto updates = listener->updates(); + ASSERT_EQ(1u, updates.size()); + EXPECT_NE(std::string::npos, updates[0].find("speed")); + + // a removed listener is not notified by the next config refresh + GameAnalytics::removeRemoteConfigsListener(listener); + GameAnalytics::onSuspend(); + GameAnalytics::onResume(); + ASSERT_TRUE(drainGAThread()); + EXPECT_EQ(1u, listener->updates().size()); +} + +TEST_F(GameAnalyticsApiTest, RemoteConfigsFallBackToDefaultsWhenAbsent) +{ + initializeSdk(); + + EXPECT_TRUE(GameAnalytics::isRemoteConfigsReady()); + EXPECT_EQ("fallback", GameAnalytics::getRemoteConfigsValueAsString("absent", "fallback")); + EXPECT_EQ("", GameAnalytics::getRemoteConfigsValueAsJson("absent")); +} + +// ---- health tracking facade ---- + +TEST_F(GameAnalyticsApiTest, HealthTrackingtogglesAreForwardedToTracker) +{ + initializeSdk(); + + GAHealth* tracker = device::GADevice::getHealthTracker(); + ASSERT_NE(nullptr, tracker); + + GameAnalytics::enableSDKInitEvent(true); + GameAnalytics::enableMemoryHistogram(true); + GameAnalytics::enableFPSHistogram([]() { return 60.0f; }, true); + GameAnalytics::enableHardwareTracking(true); + + EXPECT_TRUE(tracker->enableAppBootTimeTracking); + EXPECT_TRUE(tracker->enableMemoryTracking); + EXPECT_TRUE(tracker->enableFPSTracking); + EXPECT_TRUE(tracker->enableHardwareTracking); +} diff --git a/test/helpers/GAStateTestAccessor.h b/test/helpers/GAStateTestAccessor.h new file mode 100644 index 00000000..49519461 --- /dev/null +++ b/test/helpers/GAStateTestAccessor.h @@ -0,0 +1,93 @@ +#pragma once + +#include + +namespace gameanalytics +{ + namespace state + { + // friend of GAState (see GAState.h) exposing the private bits needed + // to exercise state transitions deterministically from tests + struct GAStateTestAccessor + { + static void resetConfigState(std::string const& customUserId) + { + GAState& s = GAState::getInstance(); + + s._sdkConfig = json(); + s._sdkConfigCached = json(); + s._configsHash.clear(); + s._defaultUserId.clear(); + + s._customUserId = customUserId; + s.cacheIdentifier(); + } + + static void ensurePersistedStates() + { + GAState::getInstance().ensurePersistedStates(); + } + + static std::string configsHash() + { + return GAState::getInstance()._configsHash; + } + + static std::string build() + { + return GAState::getInstance()._build; + } + + static json validateAndCleanCustomFields(const json& fields) + { + json out; + GAState::getInstance().validateAndCleanCustomFields(fields, out); + return out; + } + + // returns the SDK to its pre-initialize() state so every test can + // drive the public API from a known starting point + static void forceUninitialized() + { + GAState& s = GAState::getInstance(); + std::lock_guard lg(s._mtx); + + s._initialized = false; + s._initAuthorized = false; + s._enabled = false; + + s._sessionStart = 0; + s._sessionId.clear(); + + s._build.clear(); + s._customUserId.clear(); + s._externalUserId.clear(); + s._identifier.clear(); + + s._configsHash.clear(); + s._abId.clear(); + s._abVariantId.clear(); + s._sdkConfig = json(); + s._sdkConfigCached = json(); + + s._gameRemoteConfigsJson = json::array(); + s._trackingRemoteConfigsJson = json::array(); + s._remoteConfigsIsReady = false; + s._remoteConfigsListeners.clear(); + + s._currentCustomDimension01.clear(); + s._currentCustomDimension02.clear(); + s._currentCustomDimension03.clear(); + s._currentGlobalCustomEventFields = json(); + + s._availableCustomDimensions01.clear(); + s._availableCustomDimensions02.clear(); + s._availableCustomDimensions03.clear(); + s._availableResourceCurrencies.clear(); + s._availableResourceItemTypes.clear(); + + s._useManualSessionHandling = false; + } + }; + } +} diff --git a/test/main.cpp b/test/main.cpp index 4be4f35e..b1272765 100644 --- a/test/main.cpp +++ b/test/main.cpp @@ -1,8 +1,13 @@ #include +#include + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); - + + // tests must never send events to the GA servers + gameanalytics::state::GAState::setEnabledEventSubmission(false); + if (sizeof(void*) == 8) { std::cout << "64-bit architecture" << std::endl; } else if (sizeof(void*) == 4) {