Index: src/wp-admin/includes/ajax-actions.php
===================================================================
--- src/wp-admin/includes/ajax-actions.php	(revision 44785)
+++ src/wp-admin/includes/ajax-actions.php	(working copy)
@@ -4513,7 +4513,7 @@
 		}
 
 		if ( $exporter_index > count( $exporters ) ) {
-			wp_send_json_error( __( 'Exporter index out of range.' ) );
+			wp_send_json_error( __( 'Exporter index is out of range.' ) );
 		}
 
 		if ( $page < 1 ) {
@@ -4526,33 +4526,35 @@
 
 		if ( ! is_array( $exporter ) ) {
 			wp_send_json_error(
-				/* translators: %s: array index */
+				/* translators: %s: exporter array index */
 				sprintf( __( 'Expected an array describing the exporter at index %s.' ), $exporter_key )
 			);
 		}
 		if ( ! array_key_exists( 'exporter_friendly_name', $exporter ) ) {
 			wp_send_json_error(
-				/* translators: %s: array index */
+				/* translators: %s: exporter array index */
 				sprintf( __( 'Exporter array at index %s does not include a friendly name.' ), $exporter_key )
 			);
 		}
+
+		$exporter_friendly_name = $exporter['exporter_friendly_name'];
+
 		if ( ! array_key_exists( 'callback', $exporter ) ) {
 			wp_send_json_error(
 				/* translators: %s: exporter friendly name */
-				sprintf( __( 'Exporter does not include a callback: %s.' ), esc_html( $exporter['exporter_friendly_name'] ) )
+				sprintf( __( 'Exporter does not include a callback: %s.' ), esc_html( $exporter_friendly_name ) )
 			);
 		}
 		if ( ! is_callable( $exporter['callback'] ) ) {
 			wp_send_json_error(
 				/* translators: %s: exporter friendly name */
-				sprintf( __( 'Exporter callback is not a valid callback: %s.' ), esc_html( $exporter['exporter_friendly_name'] ) )
+				sprintf( __( 'Exporter callback is not a valid callback: %s.' ), esc_html( $exporter_friendly_name ) )
 			);
 		}
 
-		$callback               = $exporter['callback'];
-		$exporter_friendly_name = $exporter['exporter_friendly_name'];
+		$callback = $exporter['callback'];
+		$response = call_user_func( $callback, $email_address, $page );
 
-		$response = call_user_func( $callback, $email_address, $page );
 		if ( is_wp_error( $response ) ) {
 			wp_send_json_error( $response );
 		}
@@ -4643,7 +4645,7 @@
 	$request = wp_get_user_request_data( $request_id );
 
 	if ( ! $request || 'remove_personal_data' !== $request->action_name ) {
-		wp_send_json_error( __( 'Invalid request ID.' ) );
+		wp_send_json_error( __( 'Invalid request type.' ) );
 	}
 
 	$email_address = $request->email;
@@ -4706,28 +4708,38 @@
 		$eraser      = $erasers[ $eraser_key ];
 
 		if ( ! is_array( $eraser ) ) {
-			/* translators: %d: array index */
+			/* translators: %d: eraser array index */
 			wp_send_json_error( sprintf( __( 'Expected an array describing the eraser at index %d.' ), $eraser_index ) );
 		}
 
+		if ( ! array_key_exists( 'eraser_friendly_name', $eraser ) ) {
+			/* translators: %d: eraser array index */
+			wp_send_json_error( sprintf( __( 'Eraser array at index %d does not include a friendly name.' ), $eraser_index ) );
+		}
+
+		$eraser_friendly_name = $eraser['eraser_friendly_name'];
+
 		if ( ! array_key_exists( 'callback', $eraser ) ) {
-			/* translators: %d: array index */
-			wp_send_json_error( sprintf( __( 'Eraser array at index %d does not include a callback.' ), $eraser_index ) );
+			wp_send_json_error(
+				sprintf(
+					/* translators: %s: eraser friendly name */
+					__( 'Eraser does not include a callback: %s.' ),
+					esc_html( $eraser_friendly_name )
+				)
+			);
 		}
 
 		if ( ! is_callable( $eraser['callback'] ) ) {
-			/* translators: %d: array index */
-			wp_send_json_error( sprintf( __( 'Eraser callback at index %d is not a valid callback.' ), $eraser_index ) );
+			wp_send_json_error(
+				sprintf(
+					/* translators: %s: eraser friendly name */
+					__( 'Eraser callback is not valid: %s.' ),
+					esc_html( $eraser_friendly_name )
+				)
+			);
 		}
 
-		if ( ! array_key_exists( 'eraser_friendly_name', $eraser ) ) {
-			/* translators: %d: array index */
-			wp_send_json_error( sprintf( __( 'Eraser array at index %d does not include a friendly name.' ), $eraser_index ) );
-		}
-
-		$callback             = $eraser['callback'];
-		$eraser_friendly_name = $eraser['eraser_friendly_name'];
-
+		$callback = $eraser['callback'];
 		$response = call_user_func( $callback, $email_address, $page );
 
 		if ( is_wp_error( $response ) ) {
@@ -4737,7 +4749,7 @@
 		if ( ! is_array( $response ) ) {
 			wp_send_json_error(
 				sprintf(
-					/* translators: 1: eraser friendly name, 2: array index */
+					/* translators: 1: eraser friendly name, 2: eraser array index */
 					__( 'Did not receive array from %1$s eraser (index %2$d).' ),
 					esc_html( $eraser_friendly_name ),
 					$eraser_index
@@ -4748,7 +4760,7 @@
 		if ( ! array_key_exists( 'items_removed', $response ) ) {
 			wp_send_json_error(
 				sprintf(
-					/* translators: 1: eraser friendly name, 2: array index */
+					/* translators: 1: eraser friendly name, 2: eraser array index */
 					__( 'Expected items_removed key in response array from %1$s eraser (index %2$d).' ),
 					esc_html( $eraser_friendly_name ),
 					$eraser_index
@@ -4759,7 +4771,7 @@
 		if ( ! array_key_exists( 'items_retained', $response ) ) {
 			wp_send_json_error(
 				sprintf(
-					/* translators: 1: eraser friendly name, 2: array index */
+					/* translators: 1: eraser friendly name, 2: eraser array index */
 					__( 'Expected items_retained key in response array from %1$s eraser (index %2$d).' ),
 					esc_html( $eraser_friendly_name ),
 					$eraser_index
@@ -4770,7 +4782,7 @@
 		if ( ! array_key_exists( 'messages', $response ) ) {
 			wp_send_json_error(
 				sprintf(
-					/* translators: 1: eraser friendly name, 2: array index */
+					/* translators: 1: eraser friendly name, 2: eraser array index */
 					__( 'Expected messages key in response array from %1$s eraser (index %2$d).' ),
 					esc_html( $eraser_friendly_name ),
 					$eraser_index
@@ -4781,7 +4793,7 @@
 		if ( ! is_array( $response['messages'] ) ) {
 			wp_send_json_error(
 				sprintf(
-					/* translators: 1: eraser friendly name, 2: array index */
+					/* translators: 1: eraser friendly name, 2: eraser array index */
 					__( 'Expected messages key to reference an array in response array from %1$s eraser (index %2$d).' ),
 					esc_html( $eraser_friendly_name ),
 					$eraser_index
@@ -4792,7 +4804,7 @@
 		if ( ! array_key_exists( 'done', $response ) ) {
 			wp_send_json_error(
 				sprintf(
-					/* translators: 1: eraser friendly name, 2: array index */
+					/* translators: 1: eraser friendly name, 2: eraser array index */
 					__( 'Expected done flag in response array from %1$s eraser (index %2$d).' ),
 					esc_html( $eraser_friendly_name ),
 					$eraser_index
Index: tests/phpunit/includes/testcase-ajax.php
===================================================================
--- tests/phpunit/includes/testcase-ajax.php	(revision 44785)
+++ tests/phpunit/includes/testcase-ajax.php	(working copy)
@@ -119,6 +119,8 @@
 		'delete-theme',
 		'install-theme',
 		'get-post-thumbnail-html',
+		'wp-privacy-export-personal-data',
+		'wp-privacy-erase-personal-data',
 	);
 
 	public static function setUpBeforeClass() {
Index: tests/phpunit/tests/ajax/PrivacyErasePersonalData.php
===================================================================
--- tests/phpunit/tests/ajax/PrivacyErasePersonalData.php	(revision 0)
+++ tests/phpunit/tests/ajax/PrivacyErasePersonalData.php	(working copy)
@@ -0,0 +1,815 @@
+<?php
+/**
+ * Testing Ajax handler for erasing personal data.
+ *
+ * @package WordPress\UnitTests
+ *
+ * @since 5.2.0
+ */
+
+/**
+ * Tests_Ajax_PrivacyExportPersonalData class.
+ *
+ * @since 5.2.0
+ *
+ * @group ajax
+ * @group privacy
+ *
+ * @covers ::wp_ajax_wp_privacy_erase_personal_data
+ */
+class Tests_Ajax_PrivacyErasePersonalData extends WP_Ajax_UnitTestCase {
+
+	/**
+	 * User Request ID.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var int $request_id
+	 */
+	protected static $request_id;
+
+	/**
+	 * User Request Email.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $request_email
+	 */
+	protected static $request_email;
+
+	/**
+	 * Ajax Action.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $action
+	 */
+	protected static $action;
+
+	/**
+	 * Eraser Index.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var int $eraser
+	 */
+	protected static $eraser;
+
+	/**
+	 * Eraser Key.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $eraser_key
+	 */
+	protected static $eraser_key;
+
+	/**
+	 * Eraser Friendly Name.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $eraser_friendly_name
+	 */
+	protected static $eraser_friendly_name;
+
+	/**
+	 * Page Index.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var int $page
+	 */
+	protected static $page;
+
+	/**
+	 * Last response parsed.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var array $_last_response_parsed
+	 */
+	protected $_last_response_parsed;
+
+	/**
+	 * An array key in the test eraser to unset.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $key_to_unset
+	 */
+	protected $key_to_unset;
+
+	/**
+	 * A value to change the test eraser callback to.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $new_callback_value
+	 */
+	protected $new_callback_value;
+
+	/**
+	 * Create user erase request fixtures.
+	 *
+	 * @param WP_UnitTest_Factory $factory Factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ) {
+		self::$request_email        = 'requester@example.com';
+		self::$request_id           = wp_create_user_request( self::$request_email, 'remove_personal_data' );
+		self::$action               = 'wp-privacy-erase-personal-data';
+		self::$eraser               = 1;
+		self::$eraser_key           = 'custom-eraser';
+		self::$eraser_friendly_name = 'Custom Eraser';
+		self::$page                 = 1;
+	}
+
+	/**
+	 * Register a custom personal data eraser.
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->key_to_unset = '';
+
+		// Make sure the erasers response is not modified and avoid sending emails.
+		remove_all_filters( 'wp_privacy_personal_data_erasure_page' );
+		remove_all_actions( 'wp_privacy_personal_data_erased' );
+
+		// Only use our custom privacy personal data eraser.
+		remove_all_filters( 'wp_privacy_personal_data_erasers' );
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_custom_personal_data_eraser' ) );
+
+		$this->_setRole( 'administrator' );
+	}
+
+	/**
+	 * Clean up after each test method.
+	 */
+	public function tearDown() {
+		remove_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_custom_personal_data_eraser' ) );
+		$this->new_callback_value = '';
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Helper method for changing the test eraser's callback function.
+	 *
+	 * @param string|array $callback New test eraser callback index value.
+	 */
+	protected function _set_eraser_callback( $callback ) {
+		$this->new_callback_value = $callback;
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_eraser_callback_value' ), 20 );
+	}
+
+	/**
+	 * Change the test eraser callback to a specified value.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $erasers List of data erasers.
+	 *
+	 * @return array $erasersList of data erasers.
+	 */
+	public function filter_eraser_callback_value( $erasers ) {
+		$erasers[ self::$eraser_key ]['callback'] = $this->new_callback_value;
+
+		return $erasers;
+	}
+
+	/**
+	 * Helper method for unsetting an array index in the test eraser.
+	 *
+	 * @param string|bool $key Test eraser key to unset.
+	 */
+	protected function _unset_eraser_key( $key ) {
+		$this->key_to_unset = $key;
+		add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'filter_unset_eraser_index' ), 20 );
+	}
+
+	/**
+	 * Unsets an array key in the test eraser.
+	 *
+	 * If the key is false, the eraser is set to false.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $erasers Erasers.
+	 *
+	 * @return array $erasers Erasers.
+	 */
+	public function filter_unset_eraser_index( $erasers ) {
+		if ( false === $this->key_to_unset ) {
+			$erasers[ self::$eraser_key ] = false;
+		} elseif ( ! empty( $this->key_to_unset ) ) {
+			unset( $erasers[ self::$eraser_key ][ $this->key_to_unset ] );
+		}
+
+		return $erasers;
+	}
+
+	/**
+	 * Helper method for erasing a key from the eraser response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $key Response key to unset.
+	 */
+	protected function _unset_response_key( $key ) {
+		$this->key_to_unset = $key;
+		$this->_set_eraser_callback( array( $this, 'filter_unset_response_index' ) );
+	}
+
+	/**
+	 * Unsets an array index in a response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 *
+	 * @return array $return Export data.
+	 */
+	public function filter_unset_response_index( $email_address, $page = 1 ) {
+		$response = $this->callback_personal_data_eraser( $email_address, $page );
+
+		if ( ! empty( $this->key_to_unset ) ) {
+			unset( $response[ $this->key_to_unset ] );
+		}
+
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when the request ID is missing.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @ticket 43438
+	 */
+	public function test_error_when_missing_request_id() {
+		$this->assertNotWPError( self::$request_id );
+
+		// Set up a request.
+		$this->_make_ajax_call(
+			array(
+				'id' => null, // Missing request ID.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the request ID is less than 1.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @ticket 43438
+	 */
+	public function test_error_when_request_id_invalid() {
+		$this->assertNotWPError( self::$request_id );
+
+		// Set up a request.
+		$this->_make_ajax_call(
+			array(
+				'id' => -1, // Invalid request ID.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the current user is missing required capabilities.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @ticket 43438
+	 */
+	public function test_error_when_current_user_missing_required_capabilities() {
+		$this->_setRole( 'author' );
+
+		$this->assertFalse( current_user_can( 'erase_others_personal_data' ) );
+		$this->assertFalse( current_user_can( 'delete_users' ) );
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Sorry, you are not allowed to perform this action.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the nonce does not validate.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_failure_with_invalid_nonce() {
+		$this->setExpectedException( 'WPAjaxDieStopException', '-1' );
+
+		$this->_make_ajax_call(
+			array(
+				'security' => 'invalid-nonce',
+			)
+		);
+	}
+
+	/**
+	 * The function should send an error when the request type is incorrect.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_incorrect_request_type() {
+		$request_id = wp_create_user_request(
+			'export-request@example.com',
+			'export_personal_data' // Incorrect request type, expects 'remove_personal_data'.
+		);
+
+		$this->_make_ajax_call(
+			array(
+				'security' => wp_create_nonce( 'wp-privacy-erase-personal-data-' . $request_id ),
+				'id'       => $request_id,
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request type.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the request email is invalid.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_invalid_email() {
+		wp_update_post(
+			array(
+				'ID'         => self::$request_id,
+				'post_title' => '', // Invalid requester's email address.
+			)
+		);
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid email address in request.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the eraser index is missing.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_missing_eraser_index() {
+		$this->_make_ajax_call(
+			array(
+				'eraser' => null, // Missing eraser index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing eraser index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is missing.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_missing_page_index() {
+		$this->_make_ajax_call(
+			array(
+				'page' => null, // Missing page index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing page index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the eraser index is negative.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_negative_eraser_index() {
+		$this->_make_ajax_call(
+			array(
+				'eraser' => -1, // Negative eraser index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Eraser index cannot be less than one.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the eraser index is out of range.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_index_out_of_range() {
+		$this->_make_ajax_call(
+			array(
+				'eraser' => PHP_INT_MAX, // Out of range eraser index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Eraser index is out of range.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is less than one.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_page_index_less_than_one() {
+		$this->_make_ajax_call(
+			array(
+				'page' => 0, // Page index less than one.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Page index cannot be less than one.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when an eraser is not an array.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_not_array() {
+		$this->_unset_eraser_key( false );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected an array describing the eraser at index %s.',
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser is missing a friendly name.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_missing_friendly_name() {
+		$this->_unset_eraser_key( 'eraser_friendly_name' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Eraser array at index %s does not include a friendly name.',
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser is missing a callback.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_missing_callback() {
+		$this->_unset_eraser_key( 'callback' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Eraser does not include a callback: %s.',
+				self::$eraser_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser, at a given index, has an invalid callback.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_index_invalid_callback() {
+		$this->_set_eraser_callback( false );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Eraser callback is not valid: %s.',
+				self::$eraser_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an eraser, at a given index, is missing an array response.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_index_invalid_response() {
+		$this->_set_eraser_callback( '__return_null' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Did not receive array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when missing an items_removed index.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_items_removed_missing() {
+		$this->_unset_response_key( 'items_removed' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected items_removed key in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when missing an items_retained index.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_items_retained_missing() {
+		$this->_unset_response_key( 'items_retained' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected items_retained key in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when missing a messages index.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_messages_missing() {
+		$this->_unset_response_key( 'messages' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected messages key in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when the messages index is not an array.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_messages_not_array() {
+		$this->_set_eraser_callback( array( $this, 'filter_response_messages_invalid' ) );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected messages key to reference an array in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Change the messages index to an invalid value (not an array).
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 *
+	 * @return array $return Export data.
+	 */
+	public function filter_response_messages_invalid( $email_address, $page = 1 ) {
+		$response             = $this->callback_personal_data_eraser( $email_address, $page );
+		$response['messages'] = true;
+
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when an eraser is missing 'done' in array response.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_eraser_missing_done_response() {
+		$this->_unset_response_key( 'done' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected done flag in response array from %1$s eraser (index %2$d).',
+				self::$eraser_friendly_name,
+				self::$eraser
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should successfully send erasers response data when the current user has the required
+	 * capabilities.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @ticket 43438
+	 */
+	public function test_success_when_current_user_has_required_capabilities() {
+		$this->assertTrue( current_user_can( 'erase_others_personal_data' ) );
+		$this->assertTrue( current_user_can( 'delete_users' ) );
+
+		$this->_make_ajax_call();
+
+		$this->assertSame(
+			sprintf( 'A message regarding retained data for %s.', self::$request_email ),
+			$this->_last_response_parsed['data']['messages'][0]
+		);
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['items_removed'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['items_retained'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['done'] );
+	}
+
+	/**
+	 * The function should successfully send erasers response data when no items to erase.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @ticket 43438
+	 */
+	public function test_success_when_no_items_to_erase() {
+
+		$this->_make_ajax_call( array( 'page' => 2 ) );
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertFalse( $this->_last_response_parsed['data']['items_removed'] );
+		$this->assertFalse( $this->_last_response_parsed['data']['items_retained'] );
+		$this->assertEmpty( $this->_last_response_parsed['data']['messages'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['done'] );
+	}
+
+	/**
+	 * Test that the function's output should be filterable with the `wp_privacy_personal_data_erasure_page` filter.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_output_should_be_filterable() {
+		add_filter( 'wp_privacy_personal_data_erasure_page', array( $this, 'filter_eraser_data_response' ), 20, 6 );
+		$this->_make_ajax_call();
+
+		$expected_new_index = self::$request_email . '-' . self::$request_id . '-' . self::$eraser_key;
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'filtered removed', $this->_last_response_parsed['data']['items_removed'] );
+		$this->assertSame( 'filtered retained', $this->_last_response_parsed['data']['items_retained'] );
+		$this->assertSame( array( 'filtered messages' ), $this->_last_response_parsed['data']['messages'] );
+		$this->assertSame( 'filtered done', $this->_last_response_parsed['data']['done'] );
+		$this->assertSame( $expected_new_index, $this->_last_response_parsed['data']['new_index'] );
+	}
+
+	/**
+	 * Filters the eraser response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array  $response        The personal data for the given eraser and page.
+	 * @param int    $eraser_index    The index of the eraser that provided this data.
+	 * @param string $email_address   The email address associated with this personal data.
+	 * @param int    $page            The page for this response.
+	 * @param int    $request_id      The privacy request post ID associated with this request.
+	 * @param string $eraser_key      The key (slug) of the eraser that provided this data.
+	 *
+	 * @return array Filtered erase response.
+	 */
+	public function filter_eraser_data_response( $response, $eraser_index, $email_address, $page, $request_id, $eraser_key ) {
+		$response['items_removed']  = 'filtered removed';
+		$response['items_retained'] = 'filtered retained';
+		$response['messages']       = array( 'filtered messages' );
+		$response['done']           = 'filtered done';
+		$response['new_index']      = $email_address . '-' . $request_id . '-' . $eraser_key;
+
+		return $response;
+	}
+
+	/**
+	 * Register handler for a custom personal data eraser.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $erasers An array of personal data erasers.
+	 *
+	 * @return array $erasers An array of personal data erasers.
+	 */
+	public function register_custom_personal_data_eraser( $erasers ) {
+		$erasers[ self::$eraser_key ] = array(
+			'eraser_friendly_name' => self::$eraser_friendly_name,
+			'callback'             => array( $this, 'callback_personal_data_eraser' ),
+		);
+		return $erasers;
+	}
+
+	/**
+	 * Custom Personal Data Eraser.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param  string $email_address The comment author email address.
+	 * @param  int    $page          Page number.
+	 *
+	 * @return array  $return Erase data.
+	 */
+	public function callback_personal_data_eraser( $email_address, $page = 1 ) {
+		if ( 1 === $page ) {
+			return array(
+				'items_removed'  => true,
+				'items_retained' => true,
+				'messages'       => array( sprintf( 'A message regarding retained data for %s.', $email_address ) ),
+				'done'           => true,
+			);
+		}
+
+		return array(
+			'items_removed'  => false,
+			'items_retained' => false,
+			'messages'       => array(),
+			'done'           => true,
+		);
+	}
+
+	/**
+	 * Helper function for ajax handler.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $args Ajax request arguments.
+	 */
+	protected function _make_ajax_call( $args = array() ) {
+		$this->_last_response_parsed = null;
+		$this->_last_response        = '';
+
+		$defaults = array(
+			'action'   => self::$action,
+			'security' => wp_create_nonce( self::$action . '-' . self::$request_id ),
+			'page'     => self::$page,
+			'id'       => self::$request_id,
+			'eraser'   => self::$eraser,
+		);
+
+		$_POST = wp_parse_args( $args, $defaults );
+
+		try {
+			$this->_handleAjax( self::$action );
+		} catch ( WPAjaxDieContinueException $e ) {
+			unset( $e );
+		}
+
+		if ( $this->_last_response ) {
+			$this->_last_response_parsed = json_decode( $this->_last_response, true );
+		}
+	}
+}
Index: tests/phpunit/tests/ajax/PrivacyExportPersonalData.php
===================================================================
--- tests/phpunit/tests/ajax/PrivacyExportPersonalData.php	(revision 0)
+++ tests/phpunit/tests/ajax/PrivacyExportPersonalData.php	(working copy)
@@ -0,0 +1,819 @@
+<?php
+/**
+ * Testing Ajax handler for exporting personal data.
+ *
+ * @package WordPress\UnitTests
+ *
+ * @since 5.2.0
+ */
+
+/**
+ * Tests_Ajax_PrivacyExportPersonalData class.
+ *
+ * @since 5.2.0
+ *
+ * @group ajax
+ * @group privacy
+ *
+ * @covers ::wp_ajax_wp_privacy_export_personal_data
+ */
+class Tests_Ajax_PrivacyExportPersonalData extends WP_Ajax_UnitTestCase {
+
+	/**
+	 * User Request ID.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var int $request_id
+	 */
+	protected static $request_id;
+
+	/**
+	 * User Request Email.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $request_email
+	 */
+	protected static $request_email;
+
+	/**
+	 * Ajax Action.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $action
+	 */
+	protected static $action;
+
+	/**
+	 * Exporter Index.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var int $exporter
+	 */
+	protected static $exporter;
+
+	/**
+	 * Exporter Key.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $exporter_key
+	 */
+	protected static $exporter_key;
+
+	/**
+	 * Exporter Friendly Name.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $exporter_friendly_name
+	 */
+	protected static $exporter_friendly_name;
+
+	/**
+	 * Page Index.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var int $page
+	 */
+	protected static $page;
+
+	/**
+	 * Send As Email.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var bool $send_as_email
+	 */
+	protected static $send_as_email;
+
+	/**
+	 * Last response parsed.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var array $_last_response_parsed
+	 */
+	protected $_last_response_parsed;
+
+	/**
+	 * An array key in the test exporter to unset.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $key_to_unset
+	 */
+	protected $key_to_unset;
+
+	/**
+	 * A value to change the test exporter callback to.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @var string $new_callback_value
+	 */
+	protected $new_callback_value;
+
+	/**
+	 * Create user export request fixtures.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param WP_UnitTest_Factory $factory Factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ) {
+		self::$request_email          = 'requester@example.com';
+		self::$request_id             = wp_create_user_request( self::$request_email, 'export_personal_data' );
+		self::$action                 = 'wp-privacy-export-personal-data';
+		self::$exporter               = 1;
+		self::$exporter_key           = 'custom-exporter';
+		self::$exporter_friendly_name = 'Custom Exporter';
+		self::$page                   = 1;
+		self::$send_as_email          = false;
+	}
+
+	/**
+	 * Setup before each test method.
+	 *
+	 * @since 5.2.0
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		$this->key_to_unset       = '';
+		$this->new_callback_value = '';
+
+		// Make sure the exporter response is not modified and avoid e.g. writing export file to disk.
+		remove_all_filters( 'wp_privacy_personal_data_export_page' );
+
+		// Only use our custom privacy personal data exporter.
+		remove_all_filters( 'wp_privacy_personal_data_exporters' );
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_register_custom_personal_data_exporter' ) );
+
+		$this->_setRole( 'administrator' );
+	}
+
+	/**
+	 * Clean up after each test method.
+	 */
+	public function tearDown() {
+		remove_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_register_custom_personal_data_exporter' ) );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Helper method for changing the test exporter's callback function.
+	 *
+	 * @param string|array $callback New test exporter callback function.
+	 */
+	protected function _set_exporter_callback( $callback ) {
+		$this->new_callback_value = $callback;
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_exporter_callback_value' ), 20 );
+	}
+
+	/**
+	 * Change the test exporter callback to a specified value.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $exporters List of data exporters.
+	 * @return array $exporters List of data exporters.
+	 */
+	public function filter_exporter_callback_value( $exporters ) {
+		$exporters[ self::$exporter_key ]['callback'] = $this->new_callback_value;
+
+		return $exporters;
+	}
+
+	/**
+	 * Helper method for unsetting an array index in the test exporter.
+	 *
+	 * @param string $key Test exporter key to unset.
+	 */
+	protected function _unset_exporter_key( $key ) {
+		$this->key_to_unset = $key;
+		add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'filter_unset_exporter_key' ), 20 );
+	}
+
+	/**
+	 * Unset a specified key in the test exporter array.
+	 *
+	 * @param array $exporters List of data exporters.
+	 *
+	 * @return array $exporters List of data exporters.
+	 */
+	public function filter_unset_exporter_key( $exporters ) {
+		if ( false === $this->key_to_unset ) {
+			$exporters[ self::$exporter_key ] = false;
+		} elseif ( ! empty( $this->key_to_unset ) ) {
+			unset( $exporters[ self::$exporter_key ][ $this->key_to_unset ] );
+		}
+
+		return $exporters;
+	}
+
+	/**
+	 * The function should send an error when the request ID is missing.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_missing_request_id() {
+		$this->_make_ajax_call(
+			array(
+				'id' => null, // Missing request ID.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the request ID is less than 1.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_invalid_id() {
+		$this->_make_ajax_call(
+			array(
+				'id' => -1, // Invalid request ID, less than 1.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request ID.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the current user is missing the required capability.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_current_user_missing_required_capability() {
+		$this->_setRole( 'author' );
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertFalse( current_user_can( 'export_others_personal_data' ) );
+		$this->assertSame( 'Sorry, you are not allowed to perform this action.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the nonce does not validate.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_failure_with_invalid_nonce() {
+		$this->setExpectedException( 'WPAjaxDieStopException', '-1' );
+
+		$this->_make_ajax_call(
+			array(
+				'security' => 'invalid-nonce',
+			)
+		);
+	}
+
+	/**
+	 * The function should send an error when the request type is incorrect.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_incorrect_request_type() {
+		$request_id = wp_create_user_request(
+			'erase-request@example.com',
+			'remove_personal_data' // Incorrect request type, expects 'export_personal_data'.
+		);
+
+		$this->_make_ajax_call(
+			array(
+				'security' => wp_create_nonce( 'wp-privacy-export-personal-data-' . $request_id ),
+				'id'       => $request_id,
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Invalid request type.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the requester's email address is invalid.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_invalid_email_address() {
+		wp_update_post(
+			array(
+				'ID'         => self::$request_id,
+				'post_title' => '', // Invalid requester's email address.
+			)
+		);
+
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'A valid email address must be given.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the exporter index is missing.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_missing_exporter_index() {
+		$this->_make_ajax_call(
+			array(
+				'exporter' => null, // Missing exporter index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing exporter index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is missing.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_missing_page_index() {
+		$this->_make_ajax_call(
+			array(
+				'page' => null, // Missing page index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Missing page index.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when an exporter has improperly used the `wp_privacy_personal_data_exporters` filter.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_has_improperly_used_exporters_filter() {
+		// Improper filter usage: returns false instead of an expected array.
+		add_filter( 'wp_privacy_personal_data_exporters', '__return_false', 999 );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'An exporter has improperly used the registration filter.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the exporter index is negative.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_negative_exporter_index() {
+		$this->_make_ajax_call(
+			array(
+				'exporter' => -1, // Negative exporter index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Exporter index cannot be negative.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the exporter index is out of range.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_index_out_of_range() {
+		$this->_make_ajax_call(
+			array(
+				'exporter' => PHP_INT_MAX, // Out of range exporter index.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Exporter index is out of range.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when the page index is less than one.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_page_index_less_than_one() {
+		$this->_make_ajax_call(
+			array(
+				'page' => 0, // Page index less than one.
+			)
+		);
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'Page index cannot be less than one.', $this->_last_response_parsed['data'] );
+	}
+
+	/**
+	 * The function should send an error when an exporter is not an array.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_not_array() {
+		$this->_unset_exporter_key( false );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected an array describing the exporter at index %s.',
+				self::$exporter_key
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing a friendly name.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_missing_friendly_name() {
+		$this->_unset_exporter_key( 'exporter_friendly_name' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Exporter array at index %s does not include a friendly name.',
+				self::$exporter_key
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing a callback.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_missing_callback() {
+		$this->_unset_exporter_key( 'callback' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Exporter does not include a callback: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter, at a given index, has an invalid callback.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_index_invalid_callback() {
+		$this->_set_exporter_callback( false );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Exporter callback is not a valid callback: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * When an exporter callback returns a WP_Error, it should be passed as the error.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_callback_returns_wp_error() {
+		$this->_set_exporter_callback( array( $this, 'callback_return_wp_error' ) );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'passed_message', $this->_last_response_parsed['data'][0]['code'] );
+		$this->assertSame( 'This is a WP_Error message.', $this->_last_response_parsed['data'][0]['message'] );
+	}
+
+	/**
+	 * Callback for exporter's response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 * @return WP_Error WP_Error instance.
+	 */
+	public function callback_return_wp_error( $email_address, $page = 1 ) {
+		return new WP_Error( 'passed_message', 'This is a WP_Error message.' );
+	}
+
+	/**
+	 * The function should send an error when an exporter, at a given index, is missing an array response.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_index_invalid_response() {
+		$this->_set_exporter_callback( '__return_null' );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected response as an array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing data in array response.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_missing_data_response() {
+		$this->_set_exporter_callback( array( $this, 'callback_missing_data_response' ) );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected data in response array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Callback for exporter's response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 *
+	 * @return array $return Export data.
+	 */
+	public function callback_missing_data_response( $email_address, $page = 1 ) {
+		$response = $this->callback_custom_personal_data_exporter( $email_address, $page );
+		unset( $response['data'] ); // Missing data part of response.
+
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing 'data' array in array response.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_function_should_error_when_exporter_missing_data_array_response() {
+		$this->_set_exporter_callback( array( $this, 'callback_missing_data_array_response' ) );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected data array in response array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Callback for exporter's response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param  string $email_address The requester's email address.
+	 * @param  int    $page          Page number.
+	 *
+	 * @return array $return Export data.
+	 */
+	public function callback_missing_data_array_response( $email_address, $page = 1 ) {
+		$response         = $this->callback_custom_personal_data_exporter( $email_address, $page );
+		$response['data'] = false; // Not an array.
+		return $response;
+	}
+
+	/**
+	 * The function should send an error when an exporter is missing 'done' in array response.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_error_when_exporter_missing_done_response() {
+		$this->_set_exporter_callback( array( $this, 'callback_missing_done_response' ) );
+		$this->_make_ajax_call();
+
+		$this->assertFalse( $this->_last_response_parsed['success'] );
+		$this->assertSame(
+			sprintf(
+				'Expected done (boolean) in response array from exporter: %s.',
+				self::$exporter_friendly_name
+			),
+			$this->_last_response_parsed['data']
+		);
+	}
+
+	/**
+	 * Remove the response's done flag.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 *
+	 * @return array $return Export data.
+	 */
+	public function callback_missing_done_response( $email_address, $page = 1 ) {
+		$response = $this->callback_custom_personal_data_exporter( $email_address, $page );
+		unset( $response['done'] );
+
+		return $response;
+	}
+
+	/**
+	 * The function should successfully send exporter data response when the current user has the required capability.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_succeeds_when_current_user_has_required_capability() {
+		$this->assertTrue( current_user_can( 'export_others_personal_data' ) );
+
+		$this->_make_ajax_call();
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertSame( 'custom-exporter-item-id', $this->_last_response_parsed['data']['data']['item_id'] );
+		$this->assertSame( 'Email', $this->_last_response_parsed['data']['data']['data'][0]['name'] );
+		$this->assertSame( self::$request_email, $this->_last_response_parsed['data']['data']['data'][0]['value'] );
+	}
+
+	/**
+	 * The function should successfully send exporter data response when no items to export.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_success_when_no_items_to_export() {
+
+		$this->_make_ajax_call( array( 'page' => 2 ) );
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertEmpty( $this->_last_response_parsed['data']['data'] );
+		$this->assertTrue( $this->_last_response_parsed['data']['done'] );
+	}
+
+	/**
+	 * The function's output should be filterable with the `wp_privacy_personal_data_export_page` filter.
+	 *
+	 * @since 5.2.0
+	 */
+	public function test_output_should_be_filterable() {
+		add_filter( 'wp_privacy_personal_data_export_page', array( $this, 'filter_exporter_data_response' ), 20, 7 );
+		$this->_make_ajax_call();
+
+		$expected_group_label = sprintf(
+			'%s-%s-%s-%s-%s-%s',
+			self::$exporter,
+			self::$page,
+			self::$request_email,
+			self::$request_id,
+			self::$send_as_email,
+			self::$exporter_key
+		);
+
+		$this->assertTrue( $this->_last_response_parsed['success'] );
+		$this->assertSame( $expected_group_label, $this->_last_response_parsed['data']['group_label'] );
+		$this->assertSame( 'filtered_group_id', $this->_last_response_parsed['data']['group_id'] );
+		$this->assertSame( 'filtered_item_id', $this->_last_response_parsed['data']['item_id'] );
+		$this->assertSame( 'filtered_name', $this->_last_response_parsed['data']['data'][0]['name'] );
+		$this->assertSame( 'filtered_value', $this->_last_response_parsed['data']['data'][0]['value'] );
+	}
+
+	/**
+	 * Filter exporter's data response.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array  $response        The personal data for the given exporter and page.
+	 * @param int    $exporter_index  The index of the exporter that provided this data.
+	 * @param string $email_address   The email address associated with this personal data.
+	 * @param int    $page            The page for this response.
+	 * @param int    $request_id      The privacy request post ID associated with this request.
+	 * @param bool   $send_as_email   Whether the final results of the export should be emailed to the user.
+	 * @param string $exporter_key    The key (slug) of the exporter that provided this data.
+	 *
+	 * @return array $response The personal data for the given exporter and page.
+	 */
+	public function filter_exporter_data_response( $response, $exporter_index, $email_address, $page, $request_id, $send_as_email, $exporter_key ) {
+		$group_label                  = sprintf(
+			'%s-%s-%s-%s-%s-%s',
+			$exporter_index,
+			$page,
+			$email_address,
+			$request_id,
+			$send_as_email,
+			$exporter_key
+		);
+		$response['group_label']      = $group_label;
+		$response['group_id']         = 'filtered_group_id';
+		$response['item_id']          = 'filtered_item_id';
+		$response['data'][0]['name']  = 'filtered_name';
+		$response['data'][0]['value'] = 'filtered_value';
+
+		return $response;
+	}
+
+	/**
+	 * Filter to register a custom personal data exporter.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $exporters An array of personal data exporters.
+	 *
+	 * @return array $exporters An array of personal data exporters.
+	 */
+	public function filter_register_custom_personal_data_exporter( $exporters ) {
+		$exporters[ self::$exporter_key ] = array(
+			'exporter_friendly_name' => self::$exporter_friendly_name,
+			'callback'               => array( $this, 'callback_custom_personal_data_exporter' ),
+		);
+		return $exporters;
+	}
+
+	/**
+	 * Callback for a custom personal data exporter.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param string $email_address The requester's email address.
+	 * @param int    $page          Page number.
+	 *
+	 * @return array $response Export data response.
+	 */
+	public function callback_custom_personal_data_exporter( $email_address, $page = 1 ) {
+		$data_to_export = array();
+
+		if ( 1 === $page ) {
+			$data_to_export = array(
+				'group_id'    => self::$exporter_key . '-group-id',
+				'group_label' => self::$exporter_key . '-group-label',
+				'item_id'     => self::$exporter_key . '-item-id',
+				'data'        => array(
+					array(
+						'name'  => 'Email',
+						'value' => $email_address,
+					),
+				),
+			);
+		}
+
+		return array(
+			'data' => $data_to_export,
+			'done' => true,
+		);
+	}
+
+	/**
+	 * Helper function for ajax handler.
+	 *
+	 * @since 5.2.0
+	 *
+	 * @param array $args Ajax request arguments.
+	 */
+	protected function _make_ajax_call( $args = array() ) {
+		$this->_last_response_parsed = null;
+		$this->_last_response        = '';
+
+		$defaults = array(
+			'action'      => self::$action,
+			'security'    => wp_create_nonce( self::$action . '-' . self::$request_id ),
+			'exporter'    => self::$exporter,
+			'page'        => self::$page,
+			'sendAsEmail' => self::$send_as_email,
+			'id'          => self::$request_id,
+		);
+
+		$_POST = wp_parse_args( $args, $defaults );
+
+		try {
+			$this->_handleAjax( self::$action );
+		} catch ( WPAjaxDieContinueException $e ) {
+			unset( $e );
+		}
+
+		if ( $this->_last_response ) {
+			$this->_last_response_parsed = json_decode( $this->_last_response, true );
+		}
+	}
+}
