From d1e01ca11b641dbb6ddb748a0be979db0135e66f Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:46:27 +0000 Subject: [PATCH] feat: mautic add-on --- forms-bridge/addons/mautic/assets/logo.png | Bin 0 -> 15980 bytes .../addons/mautic/class-mautic-addon.php | 239 ++++++ .../mautic/class-mautic-form-bridge.php | 139 ++++ forms-bridge/addons/mautic/hooks.php | 157 ++++ .../addons/mautic/templates/contacts.php | 107 +++ tests/addons/test-mautic.php | 752 ++++++++++++++++++ 6 files changed, 1394 insertions(+) create mode 100644 forms-bridge/addons/mautic/assets/logo.png create mode 100644 forms-bridge/addons/mautic/class-mautic-addon.php create mode 100644 forms-bridge/addons/mautic/class-mautic-form-bridge.php create mode 100644 forms-bridge/addons/mautic/hooks.php create mode 100644 forms-bridge/addons/mautic/templates/contacts.php create mode 100644 tests/addons/test-mautic.php diff --git a/forms-bridge/addons/mautic/assets/logo.png b/forms-bridge/addons/mautic/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d9b5b0f60696469dfe8a95dace95dada86da6325 GIT binary patch literal 15980 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 + */ + private const ENDPOINTS = array( + 'POST' => array( + '/api/contacts/new', + '/api/companies/new', + ), + 'GET' => array( + '/api/contacts', + '/api/companies', + '/api/segments', + '/api/fields/contact', + '/api/fields/company', + ), + ); + + /** + * Performs a request against the backend to check the connexion status. + * + * @param string $backend Backend name. + * + * @return boolean + */ + public function ping( $backend ) { + $bridge = new Mautic_Form_Bridge( + array( + 'name' => '__mautic-' . time(), + 'endpoint' => '/api/contacts', + 'method' => 'GET', + 'backend' => $backend, + ) + ); + + $response = $bridge->submit( array( 'limit' => 1 ) ); + + if ( is_wp_error( $response ) ) { + Logger::log( 'Mautic backend ping error response', Logger::ERROR ); + Logger::log( $response, Logger::ERROR ); + return false; + } + + return true; + } + + /** + * Returns the list of available API endpoints. + * + * @param string $backend Backend name. + * @param string|null $method HTTP method. + * + * @return array + */ + public function get_endpoints( $backend, $method = null ) { + if ( $method ) { + return self::ENDPOINTS[ strtoupper( $method ) ] ?? array(); + } + + return array_values( + array_unique( array_merge( ...array_values( self::ENDPOINTS ) ) ) + ); + } + + /** + * Performs an introspection of the backend endpoint and returns API fields. + * + * The contact and company endpoints accept the instance's custom fields by + * alias, so the fields are fetched from the Mautic fields API. + * + * @param string $endpoint API endpoint. + * @param string $backend Backend name. + * @param string|null $method HTTP method. + * + * @return array List of fields and content type of the endpoint. + */ + public function get_endpoint_schema( $endpoint, $backend, $method = null ) { + if ( 'POST' !== strtoupper( $method ?? 'POST' ) ) { + return array(); + } + + if ( '/api/contacts/new' === $endpoint ) { + $object = 'contact'; + } elseif ( '/api/companies/new' === $endpoint ) { + $object = 'company'; + } else { + return array(); + } + + $bridge = new Mautic_Form_Bridge( + array( + 'name' => '__mautic-endpoint-introspection', + 'backend' => $backend, + 'endpoint' => "/api/fields/{$object}", + 'method' => 'GET', + ) + ); + + $response = $bridge->submit( array( 'limit' => 1000 ) ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $fields = array(); + foreach ( $response['data']['fields'] ?? array() as $field ) { + if ( empty( $field['alias'] ) || ( isset( $field['isPublished'] ) && ! $field['isPublished'] ) ) { + continue; + } + + switch ( $field['type'] ?? 'text' ) { + case 'number': + $schema = array( 'type' => 'number' ); + break; + case 'boolean': + $schema = array( 'type' => 'boolean' ); + break; + case 'multiselect': + $schema = array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ); + break; + default: + $schema = array( 'type' => 'string' ); + break; + } + + $fields[] = array( + 'name' => $field['alias'], + 'schema' => $schema, + 'required' => ! empty( $field['isRequired'] ), + ); + } + + if ( 'contact' === $object ) { + $fields[] = array( + 'name' => 'ipAddress', + 'schema' => array( 'type' => 'string' ), + ); + + $fields[] = array( + 'name' => 'tags', + 'schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ); + + $fields[] = array( + 'name' => 'segments', + 'schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + ), + ); + } + + $fields[] = array( + 'name' => 'owner', + 'schema' => array( 'type' => 'integer' ), + ); + + $fields[] = array( + 'name' => 'overwriteWithBlank', + 'schema' => array( 'type' => 'boolean' ), + ); + + return OpenAPI::expand_fields_schema( $fields ); + } + + /** + * Gets expiration time for introspection cache based on the introspection + * method. + * + * @param string $method Introspection method (ping, endpoints, schema). + * + * @return int Time in seconds. + */ + public function introspection_cache_expiration( $method ) { + if ( Logger::is_active() ) { + return 0; + } + + switch ( $method ) { + case 'ping': + case 'endpoints': + case 'schema': + return 60 * 60 * 24; + default: + return 0; + } + } +} + +Mautic_Addon::setup(); diff --git a/forms-bridge/addons/mautic/class-mautic-form-bridge.php b/forms-bridge/addons/mautic/class-mautic-form-bridge.php new file mode 100644 index 00000000..7f9d9f43 --- /dev/null +++ b/forms-bridge/addons/mautic/class-mautic-form-bridge.php @@ -0,0 +1,139 @@ +method && '/api/contacts/new' === $this->endpoint ) { + $segments = self::segment_ids( $payload['segments'] ?? array() ); + unset( $payload['segments'] ); + } + + $response = parent::submit( $payload, $attachments ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( ! empty( $response['data']['errors'] ) ) { + return new WP_Error( + 'mautic_api_error', + 'Mautic bridge response error', + array( 'response' => $response ) + ); + } + + if ( 'GET' === $this->method && is_array( $response['data'] ) ) { + foreach ( self::COLLECTIONS as $collection ) { + if ( isset( $response['data'][ $collection ] ) && is_array( $response['data'][ $collection ] ) ) { + $response['data'][ $collection ] = array_values( $response['data'][ $collection ] ); + } + } + } + + if ( empty( $segments ) ) { + return $response; + } + + $contact_id = $response['data']['contact']['id'] ?? null; + if ( ! $contact_id ) { + return new WP_Error( + 'mautic_segments_error', + 'Mautic contact ID not found on the API response', + array( 'response' => $response ) + ); + } + + $backend = $this->backend(); + foreach ( $segments as $segment_id ) { + $segment_response = $backend->post( + "/api/segments/{$segment_id}/contact/{$contact_id}/add" + ); + + if ( is_wp_error( $segment_response ) ) { + return $segment_response; + } + + if ( empty( $segment_response['data']['success'] ) ) { + return new WP_Error( + 'mautic_segments_error', + 'Mautic can not add the contact to the segment', + array( + 'segment' => $segment_id, + 'response' => $segment_response, + ) + ); + } + } + + return $response; + } + + /** + * Normalizes the segments payload field value to a list of segment IDs. + * + * @param mixed $value Segments field value. + * + * @return int[] + */ + private static function segment_ids( $value ) { + if ( is_string( $value ) ) { + $value = explode( ',', $value ); + } elseif ( ! is_array( $value ) ) { + $value = array( $value ); + } + + $ids = array(); + foreach ( $value as $id ) { + $id = (int) $id; + if ( $id > 0 && ! in_array( $id, $ids, true ) ) { + $ids[] = $id; + } + } + + return $ids; + } +} diff --git a/forms-bridge/addons/mautic/hooks.php b/forms-bridge/addons/mautic/hooks.php new file mode 100644 index 00000000..b5b405be --- /dev/null +++ b/forms-bridge/addons/mautic/hooks.php @@ -0,0 +1,157 @@ + array( + array( + 'ref' => '#backend', + 'name' => 'name', + 'description' => __( + 'Label of the Mautic API backend connection', + 'forms-bridge' + ), + 'default' => 'Mautic API', + ), + array( + 'ref' => '#backend', + 'name' => 'base_url', + 'description' => __( + 'Base URL of your Mautic instance', + 'forms-bridge' + ), + 'type' => 'url', + 'default' => 'https://mautic.example.coop', + ), + array( + 'ref' => '#credential', + 'name' => 'name', + 'label' => __( 'Name', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'schema', + 'type' => 'text', + 'value' => 'Basic', + ), + array( + 'ref' => '#credential', + 'name' => 'client_id', + 'label' => __( 'User', 'forms-bridge' ), + 'description' => __( + 'Username of a Mautic user with API permissions. The API and the HTTP basic auth have to be enabled on the API Settings section of your Mautic configuration', + 'forms-bridge' + ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#credential', + 'name' => 'client_secret', + 'label' => __( 'Password', 'forms-bridge' ), + 'type' => 'text', + 'required' => true, + ), + array( + 'ref' => '#bridge', + 'name' => 'method', + 'value' => 'POST', + ), + ), + 'bridge' => array( + 'method' => 'POST', + ), + 'backend' => array( + 'name' => 'Mautic API', + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'Accept', + 'value' => 'application/json', + ), + ), + ), + 'credential' => array( + 'name' => '', + 'schema' => 'Basic', + 'client_id' => '', + 'client_secret' => '', + ), + ), + $defaults, + $schema + ); + }, + 10, + 3 +); + +add_filter( + 'forms_bridge_template_data', + function ( $data, $template_id ) { + if ( strpos( $template_id, 'mautic-' ) !== 0 ) { + return $data; + } + + $index = array_search( + 'segments', + array_column( $data['bridge']['custom_fields'], 'name' ), + true, + ); + + if ( false !== $index ) { + $field = $data['bridge']['custom_fields'][ $index ]; + $value = is_array( $field['value'] ) ? $field['value'] : array( $field['value'] ); + + array_splice( $data['bridge']['custom_fields'], $index, 1 ); + + $i = 0; + foreach ( $value as $segment_id ) { + if ( '' === $segment_id || null === $segment_id ) { + continue; + } + + $data['bridge']['custom_fields'][] = array( + 'name' => "segments[{$i}]", + 'value' => (int) $segment_id, + ); + + $data['bridge']['mutations'][0][] = array( + 'from' => "segments[{$i}]", + 'to' => "segments[{$i}]", + 'cast' => 'integer', + ); + + ++$i; + } + + $data['bridge']['custom_fields'] = array_values( + $data['bridge']['custom_fields'] + ); + } + + return $data; + }, + 10, + 2 +); diff --git a/forms-bridge/addons/mautic/templates/contacts.php b/forms-bridge/addons/mautic/templates/contacts.php new file mode 100644 index 00000000..1f95523b --- /dev/null +++ b/forms-bridge/addons/mautic/templates/contacts.php @@ -0,0 +1,107 @@ + __( 'Contacts', 'forms-bridge' ), + 'description' => __( + 'Contact form template. The resulting bridge will convert form submissions into Mautic contacts and, optionally, add them to segments.', + 'forms-bridge' + ), + 'fields' => array( + array( + 'ref' => '#bridge', + 'name' => 'endpoint', + 'value' => '/api/contacts/new', + ), + array( + 'ref' => '#bridge', + 'name' => 'method', + 'value' => 'POST', + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'segments', + 'label' => __( 'Segments', 'forms-bridge' ), + 'description' => __( + 'Segments the contacts will be added to', + 'forms-bridge' + ), + 'type' => 'select', + 'options' => array( + 'endpoint' => '/api/segments', + 'finger' => array( + 'value' => 'lists[].id', + 'label' => 'lists[].name', + ), + ), + 'is_multi' => true, + ), + array( + 'ref' => '#bridge/custom_fields[]', + 'name' => 'tags', + 'label' => __( 'Tags', 'forms-bridge' ), + 'description' => __( + 'Comma separated list of tags to assign to the contacts', + 'forms-bridge' + ), + 'type' => 'text', + ), + array( + 'ref' => '#form', + 'name' => 'title', + 'default' => __( 'Contacts', 'forms-bridge' ), + ), + ), + 'form' => array( + 'title' => __( 'Contacts', 'forms-bridge' ), + 'fields' => array( + array( + 'name' => 'your-email', + 'label' => __( 'Your email', 'forms-bridge' ), + 'type' => 'email', + 'required' => true, + ), + array( + 'name' => 'your-first-name', + 'label' => __( 'Your first name', 'forms-bridge' ), + 'type' => 'text', + ), + array( + 'name' => 'your-last-name', + 'label' => __( 'Your last name', 'forms-bridge' ), + 'type' => 'text', + ), + ), + ), + 'bridge' => array( + 'method' => 'POST', + 'endpoint' => '/api/contacts/new', + 'mutations' => array( + array( + array( + 'from' => 'your-email', + 'to' => 'email', + 'cast' => 'string', + ), + array( + 'from' => '?your-first-name', + 'to' => 'firstname', + 'cast' => 'string', + ), + array( + 'from' => '?your-last-name', + 'to' => 'lastname', + 'cast' => 'string', + ), + ), + ), + ), +); diff --git a/tests/addons/test-mautic.php b/tests/addons/test-mautic.php new file mode 100644 index 00000000..4ab9661b --- /dev/null +++ b/tests/addons/test-mautic.php @@ -0,0 +1,752 @@ + self::CREDENTIAL_NAME, + 'schema' => 'Basic', + 'client_id' => 'test-user', + 'client_secret' => 'test-password', + ) + ), + ); + } + + /** + * Test backend provider. + * + * @return Backend[] + */ + public static function backends_provider() { + return array( + new Backend( + array( + 'name' => self::BACKEND_NAME, + 'base_url' => self::BACKEND_URL, + 'credential' => self::CREDENTIAL_NAME, + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'Accept', + 'value' => 'application/json', + ), + ), + ) + ), + ); + } + + /** + * HTTP requests interceptor. + * + * @param mixed $pre Initial pre hook value. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public static function pre_http_request( $pre, $args, $url ) { + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + $method = $args['method'] ?? 'POST'; + + $body = array(); + if ( ! empty( $args['body'] ) ) { + $body = is_string( $args['body'] ) ? json_decode( $args['body'], true ) : $args['body']; + } + + self::$requests[] = array( + 'args' => $args, + 'url' => $url, + 'path' => $path, + 'method' => $method, + 'body' => $body, + ); + + $http = array( + 'code' => 200, + 'message' => 'OK', + ); + + if ( isset( self::$mock_responses[ $path ] ) ) { + $response_body = self::$mock_responses[ $path ]; + $http = $response_body['http'] ?? $http; + unset( $response_body['http'] ); + } else { + $response_body = self::get_mock_response( $path, $body ); + } + + return array( + 'response' => $http, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'cookies' => array(), + 'body' => wp_json_encode( $response_body ), + 'http_response' => null, + ); + } + + /** + * Get mock response based on API endpoint. + * + * @param string $path API endpoint path. + * @param array $body Request body. + * + * @return array Mock response. + */ + private static function get_mock_response( $path, $body ) { + if ( preg_match( '/^\/api\/segments\/\d+\/contact\/\d+\/add$/', $path ) ) { + return array( 'success' => 1 ); + } + + switch ( $path ) { + case '/api/contacts/new': + return array( + 'contact' => array( + 'id' => 47, + 'fields' => array( + 'all' => array( + 'id' => 47, + 'email' => $body['email'] ?? null, + ), + ), + ), + ); + case '/api/contacts': + return array( + 'total' => '1', + 'contacts' => array( + '47' => array( + 'id' => 47, + ), + ), + ); + case '/api/segments': + return array( + 'total' => 2, + 'lists' => array( + '3' => array( + 'id' => 3, + 'name' => 'Newsletter', + ), + '5' => array( + 'id' => 5, + 'name' => 'Leads', + ), + ), + ); + case '/api/fields/contact': + return array( + 'total' => 4, + 'fields' => array( + array( + 'id' => 1, + 'alias' => 'email', + 'type' => 'email', + 'isRequired' => false, + 'isPublished' => true, + ), + array( + 'id' => 2, + 'alias' => 'firstname', + 'type' => 'text', + 'isRequired' => true, + 'isPublished' => true, + ), + array( + 'id' => 3, + 'alias' => 'points', + 'type' => 'number', + 'isRequired' => false, + 'isPublished' => true, + ), + array( + 'id' => 4, + 'alias' => 'legacy', + 'type' => 'text', + 'isRequired' => false, + 'isPublished' => false, + ), + ), + ); + default: + return array(); + } + } + + /** + * Returns the intercepted requests to the given path. + * + * @param string $path Request path. + * + * @return array + */ + private static function requests_to( $path ) { + return array_values( + array_filter( + self::$requests, + function ( $request ) use ( $path ) { + return $request['path'] === $path; + } + ) + ); + } + + /** + * Returns the contact email used on the payloads. + * + * @return string + */ + private static function email() { + return sprintf( '%s@%s', 'john.doe', 'example.coop' ); + } + + /** + * Returns a contact creation bridge. + * + * @return Mautic_Form_Bridge + */ + private static function contacts_bridge() { + return new Mautic_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/contacts/new', + 'method' => 'POST', + ) + ); + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$requests = array(); + self::$mock_responses = array(); + + tests_add_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + tests_add_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + tests_add_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + } + + /** + * Tear down test filters. + */ + public function tear_down() { + remove_filter( 'http_bridge_credentials', array( self::class, 'credentials_provider' ), 10, 0 ); + remove_filter( 'http_bridge_backends', array( self::class, 'backends_provider' ), 10, 0 ); + remove_filter( 'pre_http_request', array( self::class, 'pre_http_request' ), 10, 3 ); + + parent::tear_down(); + } + + /** + * Test that the addon class exists and has correct constants. + */ + public function test_addon_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Mautic_Addon' ) ); + $this->assertEquals( 'Mautic', Mautic_Addon::TITLE ); + $this->assertEquals( 'mautic', Mautic_Addon::NAME ); + $this->assertEquals( '\FORMS_BRIDGE\Mautic_Form_Bridge', Mautic_Addon::BRIDGE ); + $this->assertInstanceOf( Mautic_Addon::class, Addon::addon( 'mautic' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = self::contacts_bridge(); + + $this->assertTrue( $bridge->is_valid ); + $this->assertEquals( 'mautic', $bridge->addon ); + } + + /** + * Test contact creation with basic auth. + */ + public function test_post_create_contact() { + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'firstname' => 'John', + ) + ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertEquals( 47, $response['data']['contact']['id'] ); + + $this->assertCount( 1, self::$requests ); + $request = self::$requests[0]; + + $this->assertEquals( self::BACKEND_URL . '/api/contacts/new', $request['url'] ); + $this->assertEquals( self::email(), $request['body']['email'] ); + $this->assertEquals( + 'Basic ' . base64_encode( 'test-user:test-password' ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $request['args']['headers']['Authorization'] + ); + } + + /** + * Test that the contact is added to each payload segment after its creation. + */ + public function test_post_create_contact_with_segments() { + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'segments' => array( 3, '5', 3 ), + ) + ); + + $this->assertFalse( is_wp_error( $response ) ); + + $contact_request = self::requests_to( '/api/contacts/new' )[0]; + $this->assertArrayNotHasKey( 'segments', $contact_request['body'] ); + + $this->assertEquals( + array( + '/api/contacts/new', + '/api/segments/3/contact/47/add', + '/api/segments/5/contact/47/add', + ), + array_column( self::$requests, 'path' ) + ); + } + + /** + * Test that the template custom fields reach the API as contact segments. + */ + public function test_custom_fields_segments() { + $bridge = new Mautic_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/contacts/new', + 'method' => 'POST', + 'custom_fields' => array( + array( + 'name' => 'segments[0]', + 'value' => '3', + ), + array( + 'name' => 'segments[1]', + 'value' => '5', + ), + ), + 'mutations' => array( + array( + array( + 'from' => 'segments[0]', + 'to' => 'segments[0]', + 'cast' => 'integer', + ), + array( + 'from' => 'segments[1]', + 'to' => 'segments[1]', + 'cast' => 'integer', + ), + ), + ), + ) + ); + + $payload = $bridge->add_custom_fields( array( 'email' => self::email() ) ); + $payload = $bridge->apply_mutation( $payload ); + + $this->assertSame( array( 3, 5 ), $payload['segments'] ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertEquals( + array( + '/api/contacts/new', + '/api/segments/3/contact/47/add', + '/api/segments/5/contact/47/add', + ), + array_column( self::$requests, 'path' ) + ); + } + + /** + * Test that comma separated segments are supported. + */ + public function test_post_create_contact_with_csv_segments() { + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'segments' => '3, 5', + ) + ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertCount( 1, self::requests_to( '/api/segments/3/contact/47/add' ) ); + $this->assertCount( 1, self::requests_to( '/api/segments/5/contact/47/add' ) ); + } + + /** + * Test that empty segments does not perform segment requests. + */ + public function test_post_create_contact_without_segments() { + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'segments' => array(), + ) + ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertEquals( array( '/api/contacts/new' ), array_column( self::$requests, 'path' ) ); + $this->assertArrayNotHasKey( 'segments', self::$requests[0]['body'] ); + } + + /** + * Test that segments field is not removed from other endpoints payloads. + */ + public function test_segments_untouched_on_other_endpoints() { + $bridge = new Mautic_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/companies/new', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( + array( + 'companyname' => 'Codec', + 'segments' => array( 3 ), + ) + ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertCount( 1, self::$requests ); + $this->assertEquals( array( 3 ), self::$requests[0]['body']['segments'] ); + } + + /** + * Test segment error status codes are returned as errors. + */ + public function test_segment_error_response() { + self::$mock_responses['/api/segments/3/contact/47/add'] = array( + 'http' => array( + 'code' => 404, + 'message' => 'Not Found', + ), + 'errors' => array( + array( + 'code' => 404, + 'message' => 'Item was not found.', + ), + ), + ); + + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'segments' => array( 3 ), + ) + ); + + $this->assertTrue( is_wp_error( $response ) ); + } + + /** + * Test unsuccessful segment responses are returned as errors. + */ + public function test_segment_unsuccessful_response() { + self::$mock_responses['/api/segments/3/contact/47/add'] = array( 'success' => 0 ); + + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'segments' => array( 3 ), + ) + ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'mautic_segments_error', $response->get_error_code() ); + } + + /** + * Test contact creation errors skip segment requests. + */ + public function test_error_response_handling() { + self::$mock_responses['/api/contacts/new'] = array( + 'http' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'errors' => array( + array( + 'code' => 401, + 'message' => 'API authorization denied.', + ), + ), + ); + + $response = self::contacts_bridge()->submit( + array( + 'email' => self::email(), + 'segments' => array( 3 ), + ) + ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertCount( 1, self::$requests ); + } + + /** + * Test errors reported with a success status code. + */ + public function test_error_body_with_success_status() { + self::$mock_responses['/api/contacts/new'] = array( + 'errors' => array( + array( + 'code' => 400, + 'message' => 'email: A valid email is required.', + ), + ), + ); + + $response = self::contacts_bridge()->submit( array( 'email' => 'invalid' ) ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'mautic_api_error', $response->get_error_code() ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new Mautic_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/api/contacts/new', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array( 'segments' => array( 3 ) ) ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test that collections indexed by ID are returned as lists. + */ + public function test_get_segments_as_list() { + $response = Addon::addon( 'mautic' )->fetch( '/api/segments', self::BACKEND_NAME ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertTrue( wp_is_numeric_array( $response['data']['lists'] ) ); + + $finger = new FORMS_BRIDGE\JSON_Finger( $response['data'] ); + $this->assertEquals( array( 3, 5 ), $finger->get( 'lists[].id' ) ); + $this->assertEquals( array( 'Newsletter', 'Leads' ), $finger->get( 'lists[].name' ) ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $this->assertTrue( Addon::addon( 'mautic' )->ping( self::BACKEND_NAME ) ); + + $this->assertEquals( '/api/contacts', self::$requests[0]['path'] ); + $this->assertEquals( 'GET', self::$requests[0]['method'] ); + } + + /** + * Test addon ping method on error. + */ + public function test_addon_ping_error() { + self::$mock_responses['/api/contacts'] = array( + 'http' => array( + 'code' => 403, + 'message' => 'Forbidden', + ), + ); + + $this->assertFalse( Addon::addon( 'mautic' )->ping( self::BACKEND_NAME ) ); + } + + /** + * Test addon get_endpoints method. + */ + public function test_addon_get_endpoints() { + $addon = Addon::addon( 'mautic' ); + + $this->assertContains( '/api/contacts/new', $addon->get_endpoints( self::BACKEND_NAME ) ); + $this->assertContains( '/api/segments', $addon->get_endpoints( self::BACKEND_NAME ) ); + $this->assertEquals( + array( '/api/contacts/new', '/api/companies/new' ), + $addon->get_endpoints( self::BACKEND_NAME, 'POST' ) + ); + $this->assertNotContains( '/api/contacts/new', $addon->get_endpoints( self::BACKEND_NAME, 'get' ) ); + } + + /** + * Test addon get_endpoint_schema method. + */ + public function test_addon_get_endpoint_schema() { + $schema = Addon::addon( 'mautic' )->get_endpoint_schema( + '/api/contacts/new', + self::BACKEND_NAME, + 'POST' + ); + + $fields = array(); + foreach ( $schema as $field ) { + $fields[ $field['name'] ] = $field; + } + + $this->assertArrayHasKey( 'email', $fields ); + $this->assertArrayHasKey( 'firstname', $fields ); + $this->assertTrue( $fields['firstname']['required'] ); + $this->assertEquals( 'number', $fields['points']['schema']['type'] ); + $this->assertArrayNotHasKey( 'legacy', $fields ); + $this->assertArrayHasKey( 'segments', $fields ); + $this->assertArrayHasKey( 'tags', $fields ); + + $this->assertEquals( '/api/fields/contact', self::$requests[0]['path'] ); + } + + /** + * Test addon get_endpoint_schema method for unknown endpoints. + */ + public function test_addon_get_endpoint_schema_unknown() { + $addon = Addon::addon( 'mautic' ); + + $this->assertEquals( array(), $addon->get_endpoint_schema( '/api/unknown', self::BACKEND_NAME, 'POST' ) ); + $this->assertEquals( array(), $addon->get_endpoint_schema( '/api/contacts/new', self::BACKEND_NAME, 'GET' ) ); + $this->assertEmpty( self::$requests ); + } + + /** + * Test that the template segments are expanded as integer custom fields. + */ + public function test_template_data_segments() { + $data = array( + 'bridge' => array( + 'custom_fields' => array( + array( + 'name' => 'segments', + 'value' => array( '3', '5' ), + ), + array( + 'name' => 'tags', + 'value' => 'web', + ), + ), + 'mutations' => array( array() ), + ), + ); + + $data = apply_filters( 'forms_bridge_template_data', $data, 'mautic-contacts' ); + + $this->assertEquals( + array( + array( + 'name' => 'tags', + 'value' => 'web', + ), + array( + 'name' => 'segments[0]', + 'value' => 3, + ), + array( + 'name' => 'segments[1]', + 'value' => 5, + ), + ), + $data['bridge']['custom_fields'] + ); + + $this->assertEquals( 'segments[1]', $data['bridge']['mutations'][0][1]['to'] ); + $this->assertEquals( 'integer', $data['bridge']['mutations'][0][1]['cast'] ); + } + + /** + * Test that the contacts template is registered and valid. + */ + public function test_contacts_template() { + $data = include FORMS_BRIDGE_ADDONS_DIR . '/mautic/templates/contacts.php'; + $data['name'] = 'contacts'; + + $template = new FORMS_BRIDGE\Form_Bridge_Template( $data, 'mautic' ); + + $this->assertTrue( $template->is_valid ); + $this->assertEquals( '/api/contacts/new', $template->bridge['endpoint'] ); + $this->assertEquals( 'Basic', $template->credential['schema'] ); + } +}