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' );
+	}
+}