diff --git a/forms-bridge/addons/airtable/class-airtable-form-bridge.php b/forms-bridge/addons/airtable/class-airtable-form-bridge.php index 4afd2311..7135069d 100644 --- a/forms-bridge/addons/airtable/class-airtable-form-bridge.php +++ b/forms-bridge/addons/airtable/class-airtable-form-bridge.php @@ -275,7 +275,7 @@ function ( $name ) use ( $attachment_name ) { $uploads = Forms_Bridge::attachments( FBAPI::get_uploads() ); foreach ( $attachments as $attachment ) { - $filetype = array( 'type' => 'octet/stream' ); + $filetype = array( 'type' => 'application/octet-stream' ); $filename = $attachment['name']; foreach ( $uploads as $upload_name => $path ) { @@ -283,7 +283,7 @@ function ( $name ) use ( $attachment_name ) { $filename = basename( $path ); $filetype = wp_check_filetype( $path ); if ( empty( $filetype['type'] ) ) { - $filetype['type'] = mime_content_type( $path ) ?: 'octet/stream'; + $filetype['type'] = mime_content_type( $path ) ?: 'application/octet-stream'; } } } @@ -296,7 +296,7 @@ function ( $name ) use ( $attachment_name ) { )->post( "/v0/{$base_id}/{$record_id}/{$attachment['id']}/uploadAttachment", array( - 'contentType' => $filetype['type'] ?? 'octet/stream', + 'contentType' => $filetype['type'] ?? 'application/octet-stream', 'file' => $attachment['file'], 'filename' => $filename, ), diff --git a/forms-bridge/addons/nextcloud/class-nextcloud-form-bridge.php b/forms-bridge/addons/nextcloud/class-nextcloud-form-bridge.php index d5d5d57b..dd63a4f0 100644 --- a/forms-bridge/addons/nextcloud/class-nextcloud-form-bridge.php +++ b/forms-bridge/addons/nextcloud/class-nextcloud-form-bridge.php @@ -38,7 +38,7 @@ private function filepath( &$touched = false ) { $uploads = Forms_Bridge::upload_dir() . '/nextcloud'; if ( ! is_dir( $uploads ) ) { - if ( ! mkdir( $uploads, 755 ) ) { + if ( ! wp_mkdir_p( $uploads, 755 ) ) { return; } } @@ -229,14 +229,12 @@ public function submit( $payload = array(), $attachments = array() ) { if ( ! $backend ) { return new WP_Error( - 'invalid_bridge', + 'invalid_backend', 'Bridge has no valid backend', (array) $this->data, ); } - $payload = self::flatten_payload( $payload ); - add_filter( 'http_bridge_backend_url', function ( $url, $backend ) { @@ -260,62 +258,68 @@ function ( $url, $backend ) { 2 ); - $filepath = $this->filepath( $touched ); + if ( 'PUT' === $this->method ) { + $payload = self::flatten_payload( $payload ); - if ( is_wp_error( $filepath ) ) { - return $filepath; - } + $filepath = $this->filepath( $touched ); - $dav_modified = $this->get_dav_modified_date( $backend ); - if ( is_wp_error( $dav_modified ) ) { - return $dav_modified; - } + if ( is_wp_error( $filepath ) ) { + return $filepath; + } - if ( ! $dav_modified ) { - $headers = $this->payload_to_headers( $payload ); - $row = $this->payload_to_row( $payload ); - $csv = implode( "\n", array( $headers, $row ) ); + $dav_modified = $this->get_dav_modified_date( $backend ); + if ( is_wp_error( $dav_modified ) ) { + return $dav_modified; + } - file_put_contents( $filepath, $csv ); - $response = parent::submit( $csv ); - } elseif ( $touched ) { + if ( ! $dav_modified ) { $headers = $this->payload_to_headers( $payload ); $row = $this->payload_to_row( $payload ); $csv = implode( "\n", array( $headers, $row ) ); file_put_contents( $filepath, $csv ); $response = parent::submit( $csv ); - } else { - $local_modified = filemtime( $filepath ); - - if ( $dav_modified > $local_modified ) { - $response = $backend->get( - $this->endpoint, - array(), - array(), - array( - 'stream' => true, - 'filename' => $filepath, - ) - ); - - if ( is_wp_error( $response ) ) { - return $response; + } elseif ( $touched ) { + $headers = $this->payload_to_headers( $payload ); + $row = $this->payload_to_row( $payload ); + $csv = implode( "\n", array( $headers, $row ) ); + + file_put_contents( $filepath, $csv ); + $response = parent::submit( $csv ); + } else { + $local_modified = filemtime( $filepath ); + + if ( $dav_modified > $local_modified ) { + $response = $backend->get( + $this->endpoint, + array(), + array(), + array( + 'stream' => true, + 'filename' => $filepath, + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } } - } - $this->add_row( $payload ); + $this->add_row( $payload ); - $csv = file_get_contents( $filepath ); - $response = parent::submit( $csv ); - } + $csv = file_get_contents( $filepath ); + $response = parent::submit( $csv ); + } - if ( is_wp_error( $response ) ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + touch( $filepath, time() ); return $response; } - touch( $filepath, time() ); - return $response; + return parent::submit( $payload ); } /** diff --git a/forms-bridge/addons/nextcloud/hooks.php b/forms-bridge/addons/nextcloud/hooks.php index 76e75862..20447542 100644 --- a/forms-bridge/addons/nextcloud/hooks.php +++ b/forms-bridge/addons/nextcloud/hooks.php @@ -30,7 +30,7 @@ function ( $schema, $addon ) { ); $schema['properties']['endpoint']['pattern'] = '.+\.csv$'; - $schema['properties']['method']['enum'] = array( 'PUT' ); + $schema['properties']['method']['enum'] = array( 'GET', 'PUT', 'DELETE', 'MOVE', 'MKCOL', 'PROPFIND' ); $schema['properties']['method']['default'] = 'PUT'; return $schema; diff --git a/tests/addons/test-holded.php b/tests/addons/test-holded.php index f1aad85b..574987fc 100644 --- a/tests/addons/test-holded.php +++ b/tests/addons/test-holded.php @@ -6,9 +6,763 @@ */ use FORMS_BRIDGE\Holded_Form_Bridge; +use FORMS_BRIDGE\Holded_Addon; +use FORMS_BRIDGE\Addon; +use HTTP_BRIDGE\Backend; +use HTTP_BRIDGE\Credential; /** * Holded test case. */ class HoldedTest extends WP_UnitTestCase { + + /** + * Handles the last intercepted http request data. + * + * @var array + */ + private static $request; + + /** + * Handles the mock response to return. + * + * @var array|null + */ + private static $mock_response; + + /** + * Holds the mocked backend name. + * + * @var string + */ + private const BACKEND_NAME = 'test-holded-backend'; + + /** + * Holds the mocked backend base URL. + * + * @var string + */ + private const BACKEND_URL = 'https://api.holded.com'; + + /** + * Holds the mocked bridge name. + * + * @var string + */ + private const BRIDGE_NAME = 'test-holded-bridge'; + + /** + * Test backend provider. + * + * @return Backend[] + */ + public static function backends_provider() { + return array( + new Backend( + array( + 'name' => self::BACKEND_NAME, + 'base_url' => self::BACKEND_URL, + 'headers' => array( + array( + 'name' => 'Content-Type', + 'value' => 'application/json', + ), + array( + 'name' => 'key', + 'value' => 'test-holded-api-key', + ), + ), + ) + ), + ); + } + + /** + * 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 ) { + self::$request = array( + 'args' => $args, + 'url' => $url, + ); + + $http = array( + 'code' => 200, + 'message' => 'OK', + ); + + $method = $args['method'] ?? 'GET'; + + // Parse URL to determine the endpoint being called. + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + + // Parse the body to determine the method being called. + $body = array(); + if ( ! empty( $args['body'] ) ) { + if ( is_string( $args['body'] ) ) { + $body = json_decode( $args['body'], true ); + } else { + $body = $args['body']; + } + } + + // Return appropriate mock response based on endpoint. + if ( self::$mock_response ) { + $http = self::$mock_response['http'] ?? $http; + unset( self::$mock_response['http'] ); + $response_body = self::$mock_response; + + self::$mock_response = null; + } else { + $response_body = self::get_mock_response( $method, $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 $method HTTP method. + * @param string $path API endpoint path. + * @param array $body Request body. + * + * @return array Mock response. + */ + private static function get_mock_response( $method, $path, $body ) { + switch ( $path ) { + case '/api/invoicing/v1/contacts': + if ( 'POST' === $method ) { + return array( + 'status' => 1, + 'info' => 'Created', + 'id' => '123456789', + ); + } + + return array( + array( + 'id' => '123456789', + 'name' => 'John Doe', + 'email' => 'john.doe@example.coop', + 'phone' => '+1234567890', + 'isPerson' => true, + ), + ); + + case '/api/invoicing/v1/documents/invoices': + if ( 'POST' === $method ) { + return array( + 'status' => 1, + 'info' => 'Created', + 'id' => '987654321', + 'contactId' => $body['contactId'], + ); + } + + return array( + array( + 'id' => '987654321', + 'docNumber' => 'INV-001', + 'contact' => '123456789', + 'date' => '2024-01-01', + 'dueDate' => '2024-01-31', + 'tax' => 234.61, + 'subtotal' => 1117.18, + 'discount' => 0, + 'total' => 1351.79, + 'products' => array( + array( + 'name' => 'Product 1', + 'sku' => 'product-1', + 'tax' => 21, + 'subtotal' => 100, + 'discount' => 10, + ), + ), + ), + ); + + case '/api/crm/v1/leads': + if ( 'POST' === $method ) { + return array( + 'status' => 1, + 'info' => 'Created', + 'id' => '456789123', + ); + } + + return array( + array( + 'id' => '456789123', + 'name' => 'John Doe Opportunity', + 'funnelId' => '5ab13e373697ac00e305333b', + 'stageId' => '5ab13e373697ac00e3053336', + 'contactId' => '123456789', + 'person' => 1, + 'personName' => 'John Doe', + 'value' => 4000, + 'potential' => 100, + 'dueDate' => 1521646788, + 'createdAt' => 1521646788, + ), + ); + + case '/api/projects/v1/projects': + if ( 'POST' === $method ) { + return array( + 'id' => '789123456', + 'info' => 'Created', + 'status' => 1, + ); + } + + return array( + array( + 'id' => 789123456, + 'name' => 'Website Redesign', + 'desc' => 'Redesign company website', + 'status' => 2, + 'tags' => array( 'A', 'B' ), + ), + ); + + case '/holded/api-next/v2/branches/1.0/reference/list-contacts-1': + return array( + 'data' => array( + 'api' => array( + 'schema' => array( + 'openapi' => '3.0.1', + 'paths' => array( + '/contacts' => array( + 'post' => array( + 'requestBody' => array( + 'content' => array( + 'application/json' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'email' => array( 'type' => 'string' ), + 'phone' => array( 'type' => 'string' ), + 'mobile' => array( 'type' => 'string' ), + 'isPerson' => array( 'type' => 'boolean' ), + ), + ), + ), + ), + ), + ), + ), + '/documents/{docType}' => array( + 'parameters' => array( + array( + 'name' => 'docType', + 'in' => 'path', + 'schema' => array( 'type' => 'string' ), + 'required' => true, + ), + ), + 'post' => array( + 'requestBody' => array( + 'content' => array( + 'application/json' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'notes' => array( 'type' => 'string' ), + 'contactId' => array( 'type' => 'string' ), + 'contactName' => array( 'type' => 'string' ), + 'contactEmail' => array( 'type' => 'string' ), + 'date' => array( 'type' => 'integer' ), + 'items' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'desc' => array( 'type' => 'string' ), + 'sku' => array( 'type' => 'string' ), + 'tax' => array( 'type' => 'integer' ), + 'subtotal' => array( 'type' => 'number' ), + 'discount' => array( 'type' => 'number' ), + ), + ), + ), + ), + 'required' => array( 'date' ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + case '/holded/api-next/v2/branches/1.0/reference/list-leads-1': + return array( + 'data' => array( + 'api' => array( + 'schema' => array( + 'openapi' => '3.0.1', + 'paths' => array( + '/leads' => array( + 'post' => array( + 'requestBody' => array( + 'content' => array( + 'application/json' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + 'value' => array( 'type' => 'integer' ), + 'potential' => array( 'type' => 'integer' ), + 'contactName' => array( 'type' => 'string' ), + 'contactId' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + case '/holded/api-next/v2/branches/1.0/reference/list-projects': + return array( + 'data' => array( + 'api' => array( + 'schema' => array( + 'openapi' => '3.0.1', + 'paths' => array( + '/projects' => array( + 'post' => array( + 'requestBody' => array( + 'content' => array( + 'application/json' => array( + 'schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( 'type' => 'string' ), + ), + 'required' => array( 'name' ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + default: + return array(); + } + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$request = null; + self::$mock_response = null; + + 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\Holded_Addon' ) ); + $this->assertEquals( 'Holded', Holded_Addon::TITLE ); + $this->assertEquals( 'holded', Holded_Addon::NAME ); + $this->assertEquals( '\FORMS_BRIDGE\Holded_Form_Bridge', Holded_Addon::BRIDGE ); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Holded_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'POST', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => 'invalid-bridge', + // Missing required fields. + ) + ); + + $this->assertFalse( $bridge->is_valid ); + } + + /** + * Test POST request to create a contact. + */ + public function test_post_create_contact() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'POST', + ) + ); + + $payload = array( + 'name' => 'John Doe', + 'email' => 'john.doe@example.coop', + 'phone' => '+1234567890', + 'isPerson' => true, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( '123456789', $response['data']['id'] ); + $this->assertEquals( 'Created', $response['data']['info'] ); + $this->assertEquals( 1, $response['data']['status'] ); + } + + /** + * Test GET request to fetch contacts. + */ + public function test_get_contacts() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'GET', + ) + ); + + $response = $bridge->submit(); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertNotEmpty( $response['data'] ); + $this->assertEquals( 'John Doe', $response['data'][0]['name'] ); + $this->assertEquals( 'john.doe@example.coop', $response['data'][0]['email'] ); + $this->assertEquals( '+1234567890', $response['data'][0]['phone'] ); + $this->assertTrue( $response['data'][0]['isPerson'] ); + } + + /** + * Test POST request to create an invoice. + */ + public function test_post_create_invoice() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/documents/invoices', + 'method' => 'POST', + ) + ); + + $payload = array( + 'contactId' => '123456789', + 'date' => 1769868644, + 'dueDate' => 1770473468, + 'items' => array( + array( + 'name' => 'Product 1', + 'sku' => 'product-1', + 'tax' => 21, + 'subtotal' => 100, + 'discount' => 10, + ), + ), + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( '987654321', $response['data']['id'] ); + $this->assertEquals( 'Created', $response['data']['info'] ); + $this->assertEquals( 1, $response['data']['status'] ); + } + + /** + * Test POST request to create a lead. + */ + public function test_post_create_lead() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/crm/v1/leads', + 'method' => 'POST', + ) + ); + + $payload = array( + 'name' => 'Jane Smith', + 'email' => 'jane.smith@example.coop', + 'status' => 'New', + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertEquals( '456789123', $response['data']['id'] ); + $this->assertEquals( 'Created', $response['data']['info'] ); + $this->assertEquals( 1, $response['data']['status'] ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'holded' ); + $response = $addon->ping( self::BACKEND_NAME ); + + $this->assertTrue( $response ); + } + + /** + * Test addon get_endpoints method. + */ + public function test_addon_get_endpoints() { + $addon = Addon::addon( 'holded' ); + $endpoints = $addon->get_endpoints( self::BACKEND_NAME ); + + $this->assertIsArray( $endpoints ); + $this->assertNotEmpty( $endpoints ); + $this->assertContains( '/api/invoicing/v1/contacts', $endpoints ); + $this->assertContains( '/api/invoicing/v1/documents/{docType}', $endpoints ); + $this->assertContains( '/api/crm/v1/leads', $endpoints ); + $this->assertContains( '/api/projects/v1/projects', $endpoints ); + } + + /** + * Test addon get_endpoint_schema method for contacts. + */ + public function test_addon_get_endpoint_schema_contacts() { + $addon = Addon::addon( 'holded' ); + $schema = $addon->get_endpoint_schema( '/api/invoicing/v1/contacts', self::BACKEND_NAME, 'POST' ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'name', $field_names ); + $this->assertContains( 'email', $field_names ); + $this->assertContains( 'phone', $field_names ); + $this->assertContains( 'mobile', $field_names ); + $this->assertContains( 'isPerson', $field_names ); + + // Check field types. + $schema_map = array(); + foreach ( $schema as $field ) { + $schema_map[ $field['name'] ] = $field['schema']['type']; + } + + $this->assertEquals( 'string', $schema_map['name'] ); + $this->assertEquals( 'string', $schema_map['email'] ); + $this->assertEquals( 'string', $schema_map['phone'] ); + $this->assertEquals( 'string', $schema_map['mobile'] ); + $this->assertEquals( 'boolean', $schema_map['isPerson'] ); + } + + /** + * Test addon get_endpoint_schema method for leads. + */ + public function test_addon_get_endpoint_schema_leads() { + $addon = Addon::addon( 'holded' ); + $schema = $addon->get_endpoint_schema( '/api/crm/v1/leads', self::BACKEND_NAME, 'POST' ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'name', $field_names ); + $this->assertContains( 'value', $field_names ); + $this->assertContains( 'potential', $field_names ); + $this->assertContains( 'contactName', $field_names ); + $this->assertContains( 'contactId', $field_names ); + + // Check field types. + $schema_map = array(); + foreach ( $schema as $field ) { + $schema_map[ $field['name'] ] = $field['schema']['type']; + } + + $this->assertEquals( 'string', $schema_map['name'] ); + $this->assertEquals( 'integer', $schema_map['value'] ); + $this->assertEquals( 'integer', $schema_map['potential'] ); + $this->assertEquals( 'string', $schema_map['contactName'] ); + $this->assertEquals( 'string', $schema_map['contactId'] ); + } + + /** + * Test error response handling. + */ + public function test_error_response_handling() { + self::$mock_response = array( + 'http' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'error' => array( + 'code' => 'unauthorized', + 'message' => 'Invalid API key', + ), + ); + + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test validation error handling. + */ + public function test_validation_error_handling() { + self::$mock_response = array( + 'http' => array( + 'code' => 422, + 'message' => 'Unprocessable Entity', + ), + 'error' => array( + 'code' => 'validation_error', + 'message' => 'Validation failed', + 'details' => array( + 'email' => 'Invalid email format', + ), + ), + ); + + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'POST', + ) + ); + + $response = $bridge->submit( array( 'email' => 'invalid-email' ) ); + + $this->assertTrue( is_wp_error( $response ) ); + + $response_body = json_decode( $response->get_error_data()['response']['body'], true ); + $this->assertEquals( 'validation_error', $response_body['error']['code'] ); + } + + /** + * Test authorization header transformation. + */ + public function test_authorization_header_transformation() { + $bridge = new Holded_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/api/invoicing/v1/contacts', + 'method' => 'GET', + ) + ); + + $bridge->submit(); + + // Check that the request was made + $this->assertNotNull( self::$request ); + + // Verify the Authorization header was transformed + $headers = self::$request['args']['headers'] ?? array(); + $this->assertArrayHasKey( 'Key', $headers ); + $this->assertEquals( 'test-holded-api-key', $headers['Key'] ); + } } diff --git a/tests/addons/test-nextcloud.php b/tests/addons/test-nextcloud.php index fc243e17..aaf4f372 100644 --- a/tests/addons/test-nextcloud.php +++ b/tests/addons/test-nextcloud.php @@ -6,9 +6,542 @@ */ use FORMS_BRIDGE\Nextcloud_Form_Bridge; +use FORMS_BRIDGE\Nextcloud_Addon; +use FORMS_BRIDGE\Addon; +use HTTP_BRIDGE\Backend; +use HTTP_BRIDGE\Credential; /** * Nextcloud test case. */ class NextcloudTest extends WP_UnitTestCase { + + /** + * Handles the last intercepted http request data. + * + * @var array + */ + private static $request; + + /** + * Handles the mock response to return. + * + * @var array|null + */ + private static $mock_response; + + /** + * Holds the mocked backend name. + * + * @var string + */ + private const BACKEND_NAME = 'test-nextcloud-backend'; + + /** + * Holds the mocked backend base URL. + * + * @var string + */ + private const BACKEND_URL = 'https://nextcloud.example.com'; + + /** + * Holds the mocked credential name. + * + * @var string + */ + private const CREDENTIAL_NAME = 'test-nextcloud-credential'; + + /** + * Holds the mocked bridge name. + * + * @var string + */ + private const BRIDGE_NAME = 'test-nextcloud-bridge'; + + /** + * Holds the mocked user ID. + * + * @var string + */ + private const USER_ID = 'testuser'; + + /** + * Test credential provider. + * + * @return Credential[] + */ + public static function credentials_provider() { + return array( + new Credential( + array( + 'name' => self::CREDENTIAL_NAME, + 'schema' => 'Basic', + 'client_id' => self::USER_ID, + 'client_secret' => 'test-user-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/octet-stream', + ), + ), + ) + ), + ); + } + + /** + * 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 ) { + self::$request = array( + 'args' => $args, + 'url' => $url, + ); + + $response = array( + 'response' => array( + 'code' => 201, + 'message' => 'Created', + ), + 'headers' => array( 'Content-Type' => 'text/html' ), + 'cookies' => array(), + 'body' => '', + 'http_response' => null, + ); + + $method = $args['method'] ?? 'PUT'; + + // Parse URL to determine the endpoint being called. + $parsed_url = wp_parse_url( $url ); + $path = $parsed_url['path'] ?? ''; + + // Parse the body to determine the method being called. + $body = ''; + if ( ! empty( $args['body'] ) ) { + $body = $args['body']; + } + + // Return appropriate mock response based on endpoint. + if ( self::$mock_response ) { + if ( ! empty( self::$mock_response['http'] ) ) { + $response['response'] = self::$mock_response['http']; + } + + $response_body = self::$mock_response['body']; + + self::$mock_response = null; + } else { + $response_body = self::get_mock_response( $method, $path, $body ); + } + + if ( $response_body ) { + $response['headers'] = array( 'Content-Type' => 'text/xml' ); + $response['body'] = $response_body; + } + + return $response; + } + + /** + * Get mock response based on API endpoint. + * + * @param string $method HTTP method. + * @param string $path API endpoint path. + * @param array $body Request body. + * + * @return array Mock response. + */ + private static function get_mock_response( $method, $path, $body ) { + // Mock PROPFIND response for file listing + if ( false !== strpos( $path, '/remote.php/dav/files/' ) && 'PROPFIND' === $method ) { + return ' + + + /remote.php/dav/files/' . self::USER_ID . '/ + + + + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/' . self::USER_ID . '/test.csv + + + + 1024 + Mon, 01 Jan 2024 00:00:00 GMT + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/' . self::USER_ID . '/directory/ + + + + + HTTP/1.1 200 OK + + + '; + } + + // Default empty response + return ''; + } + + /** + * Set up test fixtures. + */ + public function set_up() { + parent::set_up(); + + self::$request = null; + self::$mock_response = null; + + 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 ); + + $uploads_path = FORMS_BRIDGE\Forms_Bridge::upload_dir() . '/nextcloud'; + $test_file = $uploads_path . '/test.csv'; + + if ( is_file( $test_file ) ) { + unlink( $test_file ); + } + + 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\Nextcloud_Addon' ) ); + $this->assertEquals( 'Nextcloud', Nextcloud_Addon::TITLE ); + $this->assertEquals( 'nextcloud', Nextcloud_Addon::NAME ); + $this->assertEquals( '\FORMS_BRIDGE\Nextcloud_Form_Bridge', Nextcloud_Addon::BRIDGE ); + } + + /** + * Test that the form bridge class exists. + */ + public function test_form_bridge_class_exists() { + $this->assertTrue( class_exists( 'FORMS_BRIDGE\Nextcloud_Form_Bridge' ) ); + } + + /** + * Test bridge validation with valid data. + */ + public function test_bridge_validation() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/test.csv', + 'method' => 'PUT', + ) + ); + + $this->assertTrue( $bridge->is_valid ); + } + + /** + * Test bridge validation with invalid data. + */ + public function test_bridge_validation_invalid() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => 'invalid-bridge', + // Missing required fields. + ) + ); + + $this->assertFalse( $bridge->is_valid ); + } + + /** + * Test PUT request to upload CSV file. + */ + public function test_put_upload_csv() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/test.csv', + 'method' => 'PUT', + ) + ); + + $payload = array( + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.com', + 'Score' => 99, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertEquals( 201, $response['response']['code'] ); + $this->assertEmpty( $response['body'] ); + } + + /** + * Test addon ping method. + */ + public function test_addon_ping() { + $addon = Addon::addon( 'nextcloud' ); + $response = $addon->ping( self::BACKEND_NAME ); + + $this->assertTrue( $response ); + } + + /** + * Test addon fetch method to get files. + */ + public function test_addon_fetch_files() { + $addon = Addon::addon( 'nextcloud' ); + $response = $addon->fetch( 'files', self::BACKEND_NAME ); + + $this->assertFalse( is_wp_error( $response ) ); + $this->assertArrayHasKey( 'data', $response ); + $this->assertArrayHasKey( 'files', $response['data'] ); + $this->assertCount( 1, $response['data']['files'] ); + $this->assertEquals( 'test.csv', $response['data']['files'][0]['path'] ); + } + + /** + * Test addon get_endpoints method. + */ + public function test_addon_get_endpoints() { + $addon = Addon::addon( 'nextcloud' ); + $endpoints = $addon->get_endpoints( self::BACKEND_NAME ); + + $this->assertIsArray( $endpoints ); + $this->assertNotEmpty( $endpoints ); + $this->assertCount( 2, $endpoints ); + $this->assertContains( 'test.csv', $endpoints ); + $this->assertContains( 'directory/', $endpoints ); + } + + /** + * Test addon get_endpoint_schema method for PUT. + */ + public function test_addon_get_endpoint_schema_put() { + // Create a temporary CSV file for testing + $uploads_path = FORMS_BRIDGE\Forms_Bridge::upload_dir() . '/nextcloud'; + $test_file = $uploads_path . '/test.csv'; + + // Create test CSV file + if ( ! is_dir( dirname( $test_file ) ) ) { + wp_mkdir_p( dirname( $test_file ) ); + } + + $csv_content = '"Name","Email","Score" +"John Doe","john.doe@example.com","99"'; + + file_put_contents( $test_file, $csv_content ); + + $addon = Addon::addon( 'nextcloud' ); + $schema = $addon->get_endpoint_schema( '/test.csv', self::BACKEND_NAME, 'PUT' ); + + $this->assertIsArray( $schema ); + $this->assertNotEmpty( $schema ); + + // Check that schema contains expected fields + $field_names = array_column( $schema, 'name' ); + $this->assertContains( 'Name', $field_names ); + $this->assertContains( 'Email', $field_names ); + $this->assertContains( 'Score', $field_names ); + + // Check field types + $schema_map = array(); + foreach ( $schema as $field ) { + $schema_map[ $field['name'] ] = $field['schema']['type']; + } + + $this->assertEquals( 'string', $schema_map['Name'] ); + $this->assertEquals( 'string', $schema_map['Email'] ); + $this->assertEquals( 'string', $schema_map['Score'] ); + } + + /** + * Test addon get_endpoint_schema method for non-PUT methods. + */ + public function test_addon_get_endpoint_schema_non_put() { + $addon = Addon::addon( 'nextcloud' ); + $schema = $addon->get_endpoint_schema( '/test.csv', self::BACKEND_NAME, 'GET' ); + + // Should return empty array for non-PUT methods + $this->assertIsArray( $schema ); + $this->assertEmpty( $schema ); + } + + /** + * Test error response handling. + */ + public function test_error_response_handling() { + self::$mock_response = array( + 'http' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'body' => ' + + Sabre\DAV\Exception\NotAuthenticated + No public access to this resource., AppAPIAuth has not passed, This request is not for a federated calendar, Username or password was incorrect, No \'Authorization: Basic\' header found. Either the client didn\'t send one, or the server is mis-configured, Username or password was incorrect + ', + ); + + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/test.csv', + 'method' => 'PUT', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + } + + /** + * Test invalid backend handling. + */ + public function test_invalid_backend() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => 'test-invalid-backend-bridge', + 'backend' => 'non-existent-backend', + 'endpoint' => '/test.csv', + 'method' => 'PUT', + ) + ); + + $response = $bridge->submit( array() ); + + $this->assertTrue( is_wp_error( $response ) ); + $this->assertEquals( 'invalid_backend', $response->get_error_code() ); + } + + /** + * Test authorization header transformation. + */ + public function test_authorization_header_transformation() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/test.csv', + 'method' => 'GET', + ) + ); + + $bridge->submit(); + + // Check that the request was made + $this->assertNotNull( self::$request ); + + // Verify the Authorization header was transformed + $headers = self::$request['args']['headers'] ?? array(); + $this->assertArrayHasKey( 'Authorization', $headers ); + $this->assertStringContainsString( 'Basic', $headers['Authorization'] ); + } + + /** + * Test CSV encoding and decoding. + */ + public function test_csv_encoding_decoding() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/test.csv', + 'method' => 'PUT', + ) + ); + + $payload = array( + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.coop', + 'Score' => 99, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + + $body = '"Name","Email","Score" +"John Doe","john.doe@example.coop",99'; + + $this->assertEquals( $body, self::$request['args']['body'] ); + } + + /** + * Test payload flattening. + */ + public function test_payload_flattening() { + $bridge = new Nextcloud_Form_Bridge( + array( + 'name' => self::BRIDGE_NAME, + 'backend' => self::BACKEND_NAME, + 'endpoint' => '/test.csv', + 'method' => 'PUT', + ) + ); + + $payload = array( + 'Name' => 'John Doe', + 'Contact' => array( + 'Email' => 'john.doe@example.coop', + 'Phone' => '1234567890', + ), + 'Score' => 99, + ); + + $response = $bridge->submit( $payload ); + + $this->assertFalse( is_wp_error( $response ) ); + + $body = '"Name","Contact.Email","Contact.Phone","Score" +"John Doe","john.doe@example.coop","1234567890",99'; + + $this->assertEquals( $body, self::$request['args']['body'] ); + } }