Commit 86be8dd011 for woocommerce
commit 86be8dd0111af9827f2e6d81b1ba040ce72d363b
Author: Luiz Reis <luiz.reis@automattic.com>
Date: Fri Dec 26 17:58:17 2025 -0300
Add API Client for Fraud Protection API communication (#62583)
* Add ApiClient for Fraud Protection API communication
Implements WOOSUBS-1247: Creates an API client that sends session data
to the WPCOM fraud protection endpoint via Jetpack Connection.
Key features:
- Fail-open pattern: returns "allow" on all error conditions
- Comprehensive error logging for debugging
- 30s timeout for API requests
- Validates verdict responses (allow/block)
Also adds LoggerSpyTrait for cleaner log assertions in tests.
* Fix lint and PHPStan errors
* Added extra checks for when the decoded json is not an array or is null
---------
Co-authored-by: Leonardo Lopes de Albuquerque <leonardo.lopes.albuquerque@gmail.com>
Co-authored-by: Leonardo Lopes de Albuquerque <leonardo.albuquerque@automattic.com>
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php b/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php
new file mode 100644
index 0000000000..ee3992205a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php
@@ -0,0 +1,240 @@
+<?php
+/**
+ * ApiClient class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\FraudProtection;
+
+use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Handles communication with the WPCOM fraud protection endpoint.
+ *
+ * Uses Jetpack Connection for authenticated requests to the WPCOM endpoint
+ * to get fraud protection verdicts (allow, block, or challenge).
+ *
+ * This class implements a fail-open pattern: if the endpoint is unreachable,
+ * times out, or returns an error, it returns an "allow" decision to ensure
+ * legitimate transactions are never blocked due to service issues.
+ *
+ * @since 10.5.0
+ * @internal This class is part of the internal API and is subject to change without notice.
+ */
+class ApiClient {
+
+ /**
+ * Default timeout for API requests in seconds.
+ */
+ private const DEFAULT_TIMEOUT = 30;
+
+ /**
+ * WPCOM API version.
+ */
+ private const WPCOM_API_VERSION = '2';
+
+ /**
+ * WPCOM fraud protection events endpoint path within Transact platform.
+ */
+ private const EVENTS_ENDPOINT = 'transact/fraud-protection/events';
+
+ /**
+ * Decision type: allow session.
+ */
+ public const DECISION_ALLOW = 'allow';
+
+ /**
+ * Decision type: block session.
+ */
+ public const DECISION_BLOCK = 'block';
+
+ /**
+ * Decision type: challenge session.
+ */
+ public const DECISION_CHALLENGE = 'challenge';
+
+ /**
+ * Valid decision values.
+ *
+ * @var array<string>
+ */
+ private const VALID_DECISIONS = array(
+ self::DECISION_ALLOW,
+ self::DECISION_BLOCK,
+ // Note: self::DECISION_CHALLENGE will be added here after the challenge flow is implemented (stretch goal for alpha).
+ );
+
+ /**
+ * Track a fraud protection event and get a decision from WPCOM endpoint.
+ *
+ * Implements fail-open pattern: if the endpoint is unreachable or times out,
+ * returns "allow" decision and logs the error.
+ *
+ * @since 10.5.0
+ *
+ * @param string $event_type Type of event being tracked (e.g., 'cart_updated', 'checkout_started').
+ * @param array<string, mixed> $session_data Session data to send to the endpoint.
+ * @return string Decision: "allow" or "block".
+ */
+ public function track_event( string $event_type, array $session_data ): string {
+ $payload = array_merge(
+ array( 'event_type' => $event_type ),
+ array_filter( $session_data, fn( $value ) => null !== $value )
+ );
+
+ FraudProtectionController::log(
+ 'info',
+ sprintf( 'Sending fraud protection event: %s', $event_type ),
+ array( 'payload' => $payload )
+ );
+
+ $response = $this->make_request( 'POST', self::EVENTS_ENDPOINT, $payload );
+
+ if ( is_wp_error( $response ) ) {
+ $error_data = $response->get_error_data() ?? array();
+ $error_data = is_array( $error_data ) ? $error_data : array( 'error' => $error_data );
+ FraudProtectionController::log(
+ 'error',
+ sprintf(
+ 'Event track request failed: %s. Failing open with "allow" decision.',
+ $response->get_error_message()
+ ),
+ $error_data
+ );
+ return self::DECISION_ALLOW;
+ }
+
+ if ( ! isset( $response['verdict'] ) ) {
+ FraudProtectionController::log(
+ 'error',
+ 'Response missing "verdict" field. Failing open with "allow" decision.',
+ array( 'response' => $response )
+ );
+ return self::DECISION_ALLOW;
+ }
+
+ $verdict = $response['verdict'];
+
+ if ( ! in_array( $verdict, self::VALID_DECISIONS, true ) ) {
+ FraudProtectionController::log(
+ 'error',
+ sprintf(
+ 'Invalid verdict value "%s". Failing open with "allow" decision.',
+ $verdict
+ ),
+ array( 'response' => $response )
+ );
+ return self::DECISION_ALLOW;
+ }
+
+ $session_id = $session_data['session_id'] ?? 'unknown';
+ FraudProtectionController::log(
+ 'info',
+ sprintf(
+ 'Fraud verdict received: %s | Event: %s | Session: %s',
+ $verdict,
+ $event_type,
+ $session_id
+ ),
+ array( 'response' => $response )
+ );
+
+ return $verdict;
+ }
+
+ /**
+ * Make an HTTP request to a WPCOM endpoint via Jetpack Connection.
+ *
+ * @param string $method HTTP method (GET, POST, etc.).
+ * @param string $path Endpoint path (relative to sites/{blog_id}/).
+ * @param array<string, mixed> $payload Request payload.
+ * @return array<string, mixed>|\WP_Error Parsed JSON response or WP_Error on failure.
+ */
+ private function make_request( string $method, string $path, array $payload ) {
+ if ( ! class_exists( Jetpack_Connection_Client::class ) ) {
+ return new \WP_Error(
+ 'jetpack_not_available',
+ 'Jetpack Connection is not available'
+ );
+ }
+
+ $blog_id = $this->get_blog_id();
+ if ( ! $blog_id ) {
+ return new \WP_Error(
+ 'no_blog_id',
+ 'Jetpack blog ID not found. Is the site connected to WordPress.com?'
+ );
+ }
+
+ $full_path = sprintf( 'sites/%d/%s', $blog_id, $path );
+
+ $body = \wp_json_encode( $payload );
+
+ if ( false === $body ) {
+ return new \WP_Error(
+ 'json_encode_error',
+ 'Failed to encode payload',
+ array( 'payload' => $payload )
+ );
+ }
+
+ $response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
+ $full_path,
+ self::WPCOM_API_VERSION,
+ array(
+ 'headers' => array( 'Content-Type' => 'application/json' ),
+ 'method' => $method,
+ 'timeout' => self::DEFAULT_TIMEOUT,
+ ),
+ $body,
+ 'wpcom'
+ );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ /**
+ * Type assertion for PHPStan - Jetpack returns array on success.
+ *
+ * @var array $response
+ */
+ $response_code = wp_remote_retrieve_response_code( $response );
+ $response_body = wp_remote_retrieve_body( $response );
+
+ $data = json_decode( $response_body, true );
+
+ if ( $response_code >= 300 ) {
+ return new \WP_Error(
+ 'api_error',
+ sprintf( 'Endpoint %s returned status code %d', "$method $path", $response_code ),
+ array( 'response' => JSON_ERROR_NONE === json_last_error() ? $data : $response_body )
+ );
+ }
+
+ if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
+ return new \WP_Error(
+ 'json_decode_error',
+ sprintf( 'Failed to decode JSON response: %s', json_last_error_msg() ),
+ array( 'response' => $response_body )
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the Jetpack blog ID.
+ *
+ * @return int|false Blog ID or false if not available.
+ */
+ private function get_blog_id() {
+ if ( ! class_exists( \Jetpack_Options::class ) ) {
+ return false;
+ }
+ return \Jetpack_Options::get_option( 'id' );
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/bootstrap.php b/plugins/woocommerce/tests/legacy/bootstrap.php
index 51112fcfca..a8ede90414 100644
--- a/plugins/woocommerce/tests/legacy/bootstrap.php
+++ b/plugins/woocommerce/tests/legacy/bootstrap.php
@@ -297,6 +297,7 @@ class WC_Unit_Tests_Bootstrap {
require_once $this->tests_dir . '/framework/traits/trait-wc-rest-api-complex-meta.php';
require_once dirname( $this->tests_dir ) . '/php/helpers/HPOSToggleTrait.php';
require_once dirname( $this->tests_dir ) . '/php/helpers/SerializingCacheTrait.php';
+ require_once dirname( $this->tests_dir ) . '/php/helpers/LoggerSpyTrait.php';
}
/**
diff --git a/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php b/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php
new file mode 100644
index 0000000000..033493db34
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php
@@ -0,0 +1,165 @@
+<?php
+/**
+ * LoggerSpyTrait file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\RestApi\UnitTests;
+
+/**
+ * Trait LoggerSpyTrait.
+ *
+ * Provides a spy logger that captures all log calls for later assertions.
+ * Uses PHPUnit mock with a callback to capture logs without constraining calls.
+ */
+trait LoggerSpyTrait {
+
+ /**
+ * Captured log entries.
+ *
+ * @var array<array{level: string, message: string, context: array}>
+ */
+ private array $captured_logs = array();
+
+ /**
+ * The mock logger instance.
+ *
+ * @var \WC_Logger_Interface|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $spy_logger;
+
+ /**
+ * Set up the spy logger.
+ *
+ * This method is called automatically before each test via the @before annotation.
+ *
+ * @before
+ * @return void
+ */
+ protected function set_up_spy_logger(): void {
+ $this->captured_logs = array();
+ $this->spy_logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+
+ $this->spy_logger->method( 'log' )->willReturnCallback(
+ function ( $level, $message, $context = array() ) {
+ $this->captured_logs[] = array(
+ 'level' => $level,
+ 'message' => $message,
+ 'context' => $context,
+ );
+ }
+ );
+
+ add_filter( 'woocommerce_logging_class', array( $this, 'get_spy_logger' ) );
+ }
+
+ /**
+ * Tear down the spy logger.
+ *
+ * This method is called automatically after each test via the @after annotation.
+ *
+ * @after
+ * @return void
+ */
+ protected function tear_down_spy_logger(): void {
+ remove_filter( 'woocommerce_logging_class', array( $this, 'get_spy_logger' ) );
+ $this->captured_logs = array();
+ }
+
+ /**
+ * Get the spy logger instance.
+ *
+ * @return \WC_Logger_Interface|\PHPUnit\Framework\MockObject\MockObject
+ */
+ public function get_spy_logger() {
+ return $this->spy_logger;
+ }
+
+ /**
+ * Assert that a log message was recorded at the given level containing the substring.
+ *
+ * @param string $level The log level to check (e.g., 'error', 'info', 'warning', 'debug').
+ * @param string $substring The substring to search for in messages.
+ * @param array|null $expected_context Optional expected context structure to match.
+ * @return void
+ */
+ protected function assertLogged( string $level, string $substring, ?array $expected_context = null ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit style.
+ $logs = $this->get_logs_by_level( $level );
+
+ foreach ( $logs as $log ) {
+ if ( str_contains( $log['message'], $substring ) ) {
+ if ( null !== $expected_context && ! $this->context_matches( $expected_context, $log['context'] ) ) {
+ continue;
+ }
+ $this->addToAssertionCount( 1 );
+ return;
+ }
+ }
+
+ $context_info = null !== $expected_context ? sprintf( ' with context %s', wp_json_encode( $expected_context ) ) : '';
+ $this->fail(
+ sprintf(
+ "Expected %s log containing '%s'%s.\nLogged %s entries: %s",
+ $level,
+ $substring,
+ $context_info,
+ $level,
+ wp_json_encode( $logs, JSON_PRETTY_PRINT )
+ )
+ );
+ }
+
+ /**
+ * Assert that no error was logged.
+ *
+ * @return void
+ */
+ protected function assertNoErrorLogged(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit style.
+ $errors = $this->get_logs_by_level( 'error' );
+ $this->assertEmpty(
+ $errors,
+ sprintf(
+ 'Expected no error logs, but found: %s',
+ wp_json_encode( $errors, JSON_PRETTY_PRINT )
+ )
+ );
+ }
+
+ /**
+ * Get captured logs filtered by level.
+ *
+ * @param string $level Log level to filter by.
+ * @return array<array{level: string, message: string, context: array}>
+ */
+ private function get_logs_by_level( string $level ): array {
+ return array_filter(
+ $this->captured_logs,
+ fn( $log ) => $log['level'] === $level
+ );
+ }
+
+ /**
+ * Check if the expected context is a subset of the actual context.
+ *
+ * @param array $expected The expected context structure.
+ * @param array $actual The actual logged context.
+ * @return bool True if all expected keys/values are found in actual.
+ */
+ private function context_matches( array $expected, array $actual ): bool {
+ foreach ( $expected as $key => $value ) {
+ if ( ! array_key_exists( $key, $actual ) ) {
+ return false;
+ }
+
+ if ( is_array( $value ) && is_array( $actual[ $key ] ) ) {
+ if ( ! $this->context_matches( $value, $actual[ $key ] ) ) {
+ return false;
+ }
+ } elseif ( $actual[ $key ] !== $value ) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php
new file mode 100644
index 0000000000..d0b7bdc1c3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php
@@ -0,0 +1,370 @@
+<?php
+/**
+ * ApiClientTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\FraudProtection;
+
+use Automattic\WooCommerce\Internal\FraudProtection\ApiClient;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for the ApiClient class.
+ */
+class ApiClientTest extends WC_Unit_Test_Case {
+
+ use LoggerSpyTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var ApiClient
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new ApiClient();
+
+ update_option( 'jetpack_options', array( 'id' => 12345 ) );
+ update_option( 'jetpack_private_options', array( 'blog_token' => 'IAM.AJETPACKBLOGTOKEN' ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_all_filters( 'pre_http_request' );
+ delete_option( 'jetpack_options' );
+ delete_option( 'jetpack_private_options' );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Track Event should return allow when Jetpack blog ID is not found.
+ */
+ public function test_track_event_returns_allow_when_blog_id_not_found(): void {
+ update_option( 'jetpack_options', array( 'id' => null ) );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged( 'error', 'Jetpack blog ID not found', array( 'source' => 'woo-fraud-protection' ) );
+ }
+
+ /**
+ * @testdox Track Event should return allow when HTTP request fails.
+ */
+ public function test_track_event_returns_allow_when_http_request_fails(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return new WP_Error( 'http_error', 'Connection failed', 'error_data' );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged(
+ 'error',
+ 'Connection failed',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'error' => 'error_data',
+ )
+ );
+ }
+
+ /**
+ * @testdox Track Event should return allow when API returns HTTP error status.
+ */
+ public function test_track_event_returns_allow_when_api_returns_http_error(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 500 ),
+ 'body' => 'Internal Server Error',
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged(
+ 'error',
+ 'Endpoint POST transact/fraud-protection/events returned status code 500',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => 'Internal Server Error',
+ )
+ );
+ }
+
+ /**
+ * @testdox Track Event should return allow when API returns HTTP error with JSON body.
+ */
+ public function test_track_event_returns_allow_when_api_returns_http_error_with_json_body(): void {
+ $response = array(
+ 'error' => 'invalid_request',
+ 'message' => 'Missing required field',
+ );
+
+ add_filter(
+ 'pre_http_request',
+ function () use ( $response ) {
+ return array(
+ 'response' => array( 'code' => 400 ),
+ 'body' => wp_json_encode( $response ),
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged(
+ 'error',
+ 'Endpoint POST transact/fraud-protection/events returned status code 400',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => $response,
+ )
+ );
+ }
+
+ /**
+ * @testdox Track Event should return allow when API returns invalid JSON.
+ */
+ public function test_track_event_returns_allow_when_api_returns_invalid_json(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => 'not valid json',
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged(
+ 'error',
+ 'Failed to decode JSON response: Syntax error',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => 'not valid json',
+ )
+ );
+ }
+
+ /**
+ * @testdox Track Event should return allow when API response is missing verdict field.
+ */
+ public function test_track_event_returns_allow_when_response_missing_verdict_field(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( array( 'fraud_event_id' => 123 ) ),
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged(
+ 'error',
+ 'missing "verdict" field',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => array( 'fraud_event_id' => 123 ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Track Event should return allow when API returns invalid verdict value.
+ */
+ public function test_track_event_returns_allow_when_invalid_verdict_value(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( array( 'verdict' => 'invalid_verdict' ) ),
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
+ $this->assertLogged(
+ 'error',
+ 'Invalid verdict value "invalid_verdict"',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => array( 'verdict' => 'invalid_verdict' ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Track Event should return allow verdict from API.
+ */
+ public function test_track_event_returns_allow_verdict_from_api(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 'fraud_event_id' => 123,
+ 'verdict' => 'allow',
+ 'risk_score' => 10,
+ )
+ ),
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should return allow verdict' );
+ $this->assertLogged(
+ 'info',
+ 'Fraud verdict received: allow',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => array(
+ 'fraud_event_id' => 123,
+ 'verdict' => 'allow',
+ 'risk_score' => 10,
+ ),
+ )
+ );
+ $this->assertNoErrorLogged();
+ }
+
+ /**
+ * @testdox Track Event should return block verdict from API.
+ */
+ public function test_track_event_returns_block_verdict_from_api(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 'fraud_event_id' => 123,
+ 'verdict' => 'block',
+ 'risk_score' => 95,
+ 'reason_tags' => array( 'failures_per_ip' ),
+ )
+ ),
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'checkout_started', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_BLOCK, $result, 'Should return block verdict' );
+ $this->assertLogged(
+ 'info',
+ 'Fraud verdict received: block',
+ array(
+ 'source' => 'woo-fraud-protection',
+ 'response' => array(
+ 'fraud_event_id' => 123,
+ 'verdict' => 'block',
+ 'risk_score' => 95,
+ 'reason_tags' => array( 'failures_per_ip' ),
+ ),
+ )
+ );
+ $this->assertNoErrorLogged();
+ }
+
+ /**
+ * @testdox Track Event should return allow when API returns challenge verdict (challenge flow not yet implemented).
+ */
+ public function test_track_event_returns_allow_when_challenge_verdict_from_api(): void {
+ add_filter(
+ 'pre_http_request',
+ function () {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode(
+ array(
+ 'fraud_event_id' => 123,
+ 'verdict' => 'challenge',
+ 'risk_score' => 65,
+ )
+ ),
+ );
+ }
+ );
+
+ $result = $this->sut->track_event( 'checkout_started', array( 'session_id' => 'test-session' ) );
+
+ $this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow until challenge flow is implemented' );
+ $this->assertLogged( 'error', 'Invalid verdict value "challenge"' );
+ }
+
+ /**
+ * @testdox Should filter out null values from session data payload.
+ */
+ public function test_filters_null_values_from_payload(): void {
+ $captured_request_body = null;
+
+ add_filter(
+ 'pre_http_request',
+ function ( $preempt, $args ) use ( &$captured_request_body ) {
+ $captured_request_body = $args['body'];
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( array( 'verdict' => 'allow' ) ),
+ );
+ },
+ 10,
+ 2
+ );
+
+ $session_data = array(
+ 'session_id' => 'test-session',
+ 'ip_address' => '192.168.1.1',
+ 'email' => null,
+ 'user_agent' => 'Mozilla/5.0',
+ 'billing_name' => null,
+ );
+
+ $this->sut->track_event( 'cart_updated', $session_data );
+
+ $this->assertNotNull( $captured_request_body, 'Request body should be captured' );
+
+ $decoded_body = json_decode( $captured_request_body, true );
+ $this->assertArrayHasKey( 'event_type', $decoded_body, 'Should include not-null event_type' );
+ $this->assertArrayHasKey( 'session_id', $decoded_body, 'Should include not-null session_id' );
+ $this->assertArrayHasKey( 'ip_address', $decoded_body, 'Should include not-null ip_address' );
+ $this->assertArrayHasKey( 'user_agent', $decoded_body, 'Should include not-null user_agent' );
+ $this->assertArrayNotHasKey( 'email', $decoded_body, 'Should filter out null email' );
+ $this->assertArrayNotHasKey( 'billing_name', $decoded_body, 'Should filter out null billing_name' );
+ }
+}