diff --git a/src/Admin/FileImportController.php b/src/Admin/FileImportController.php index bd93a24..e98be42 100644 --- a/src/Admin/FileImportController.php +++ b/src/Admin/FileImportController.php @@ -8,15 +8,18 @@ namespace WPContentSync\Admin; use WPContentSync\Logging\LoggerInterface; +use WPContentSync\Sync\SyncEngine; use WPContentSync\Transport\FileTransportInterface; final class FileImportController { private FileTransportInterface $transport; private LoggerInterface $logger; + private SyncEngine $sync_engine; - public function __construct( FileTransportInterface $transport, LoggerInterface $logger ) { - $this->transport = $transport; - $this->logger = $logger; + public function __construct( FileTransportInterface $transport, LoggerInterface $logger, SyncEngine $sync_engine ) { + $this->transport = $transport; + $this->logger = $logger; + $this->sync_engine = $sync_engine; } public function register(): void { @@ -67,13 +70,23 @@ final class FileImportController { return; } - $this->logger->info( - 'Validated imported content package.', - array( - 'schema_version' => $package->schemaVersion(), - 'manifest' => $package->manifest(), - ) - ); + $result = $this->sync_engine->importPackage( $package ); + + if ( ! $result->isSuccessful() ) { + $this->logger->error( + 'Imported content package failed.', + $result->toArray() + ); + + $this->redirectToDashboard( + array( + 'wpcs_import_error' => implode( ' ', $result->errors() ), + ) + ); + return; + } + + $this->logger->info( 'Imported content package.', $result->toArray() ); $this->redirectToDashboard( array( diff --git a/src/Plugin.php b/src/Plugin.php index 7681ad3..e5dfa05 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,11 +9,18 @@ namespace WPContentSync; use WPContentSync\Admin\AdminPage; use WPContentSync\Admin\FileImportController; +use WPContentSync\Content\ContentHandlerRegistry; +use WPContentSync\Content\ContentRecordNormalizer; +use WPContentSync\Content\MediaContentHandler; +use WPContentSync\Content\PostContentHandler; +use WPContentSync\Content\TermContentHandler; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Logging\OptionLogger; use WPContentSync\Package\PackageValidator; use WPContentSync\Rest\RestPackageController; use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncStateRepository; use WPContentSync\Transport\FileTransportInterface; use WPContentSync\Transport\JsonFileTransport; use WPContentSync\Transport\RestTransportClient; @@ -67,6 +74,81 @@ final class Plugin { } ); + $container->factory( + ContentRecordNormalizer::class, + static function (): ContentRecordNormalizer { + return new ContentRecordNormalizer(); + } + ); + + $container->factory( + PostContentHandler::class, + static function () use ( $container ): PostContentHandler { + return new PostContentHandler( + $container->get( ContentRecordNormalizer::class ), + $container->get( UrlTransformer::class ), + $container->get( MetadataUrlTransformer::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + + $container->factory( + TermContentHandler::class, + static function () use ( $container ): TermContentHandler { + return new TermContentHandler( + $container->get( ContentRecordNormalizer::class ), + $container->get( UrlTransformer::class ), + $container->get( MetadataUrlTransformer::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + + $container->factory( + MediaContentHandler::class, + static function () use ( $container ): MediaContentHandler { + return new MediaContentHandler( + $container->get( ContentRecordNormalizer::class ), + $container->get( UrlTransformer::class ), + $container->get( MetadataUrlTransformer::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + + $container->factory( + ContentHandlerRegistry::class, + static function () use ( $container ): ContentHandlerRegistry { + return new ContentHandlerRegistry( + array( + $container->get( PostContentHandler::class ), + $container->get( TermContentHandler::class ), + $container->get( MediaContentHandler::class ), + ) + ); + } + ); + + $container->factory( + SyncStateRepository::class, + static function (): SyncStateRepository { + return new SyncStateRepository(); + } + ); + + $container->factory( + SyncEngine::class, + static function () use ( $container ): SyncEngine { + return new SyncEngine( + $container->get( ContentHandlerRegistry::class ), + $container->get( SyncStateRepository::class ), + $container->get( SettingsRepository::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + $container->factory( FileTransportInterface::class, static function () use ( $container ): FileTransportInterface { @@ -81,7 +163,8 @@ final class Plugin { static function () use ( $container ): FileImportController { return new FileImportController( $container->get( FileTransportInterface::class ), - $container->get( LoggerInterface::class ) + $container->get( LoggerInterface::class ), + $container->get( SyncEngine::class ) ); } ); @@ -97,7 +180,8 @@ final class Plugin { RestPackageController::class, static function () use ( $container ): RestPackageController { return new RestPackageController( - $container->get( PackageValidator::class ) + $container->get( PackageValidator::class ), + $container->get( SyncEngine::class ) ); } ); diff --git a/src/Rest/RestPackageController.php b/src/Rest/RestPackageController.php index 4e4dcd4..6babc2a 100644 --- a/src/Rest/RestPackageController.php +++ b/src/Rest/RestPackageController.php @@ -9,12 +9,15 @@ namespace WPContentSync\Rest; use WPContentSync\Package\ContentPackage; use WPContentSync\Package\PackageValidator; +use WPContentSync\Sync\SyncEngine; final class RestPackageController { private PackageValidator $validator; + private SyncEngine $sync_engine; - public function __construct( PackageValidator $validator ) { - $this->validator = $validator; + public function __construct( PackageValidator $validator, SyncEngine $sync_engine ) { + $this->validator = $validator; + $this->sync_engine = $sync_engine; } public function register(): void { @@ -82,11 +85,13 @@ final class RestPackageController { } $package = ContentPackage::fromArray( $data['package'] ); + $import = $this->sync_engine->importPackage( $package ); return array( - 'accepted' => true, + 'accepted' => $import->isSuccessful(), 'schema_version' => $package->schemaVersion(), 'manifest' => $package->manifest(), + 'import' => $import->toArray(), ); } diff --git a/tests/Unit/Admin/FileImportControllerTest.php b/tests/Unit/Admin/FileImportControllerTest.php index 8e7898c..9b68767 100644 --- a/tests/Unit/Admin/FileImportControllerTest.php +++ b/tests/Unit/Admin/FileImportControllerTest.php @@ -4,15 +4,25 @@ namespace WPContentSync\Tests\Unit\Admin; use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; +use WPContentSync\Content\ContentHandlerInterface; +use WPContentSync\Content\ContentHandlerRegistry; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Package\PackageChecksum; use WPContentSync\Package\PackageValidator; +use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Sync\SyncContext; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncResult; +use WPContentSync\Sync\SyncStateRepository; use WPContentSync\Transport\JsonFileTransport; class FileImportControllerTest extends TestCase { /** @var array */ private array $temporary_files = array(); + /** @var array> */ + private array $logs = array(); + protected function tearDown(): void { foreach ( $this->temporary_files as $file ) { if ( is_file( $file ) ) { @@ -21,8 +31,17 @@ class FileImportControllerTest extends TestCase { } } - unset( $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_nonce_valid'], $GLOBALS['wpcs_redirect_location'] ); - $_FILES = array(); + unset( + $GLOBALS['wpcs_current_user_can'], + $GLOBALS['wpcs_nonce_valid'], + $GLOBALS['wpcs_redirect_location'], + $GLOBALS['wpcs_test_options'], + $GLOBALS['wpcs_test_option_autoloads'], + $GLOBALS['wpcs_test_transients'], + $GLOBALS['wpcs_test_transient_expiration'] + ); + $_FILES = array(); + $this->logs = array(); parent::tearDown(); } @@ -69,7 +88,7 @@ class FileImportControllerTest extends TestCase { $controller->handleImport(); } - public function test_it_imports_valid_uploaded_packages_without_mutating_content(): void { + public function test_it_imports_valid_uploaded_packages_with_sync_engine(): void { $file = $this->createTemporaryPackageFile( $this->validJson() ); $_FILES['wpcs_package_file'] = array( @@ -80,6 +99,22 @@ class FileImportControllerTest extends TestCase { $this->controller()->handleImport(); self::assertStringContainsString( 'wpcs_imported=1', $GLOBALS['wpcs_redirect_location'] ); + self::assertSame( 'Imported content package.', $this->logs[2]['message'] ); + self::assertSame( 0, $this->logs[2]['context']['created'] ); + } + + public function test_it_redirects_with_error_when_sync_engine_import_fails(): void { + $file = $this->createTemporaryPackageFile( $this->validJson() ); + + $_FILES['wpcs_package_file'] = array( + 'tmp_name' => $file, + 'error' => UPLOAD_ERR_OK, + ); + + $this->controller( SyncResult::failure( array( 'Posts failed.' ) ) )->handleImport(); + + self::assertStringContainsString( 'wpcs_import_error=', $GLOBALS['wpcs_redirect_location'] ); + self::assertStringContainsString( 'Posts+failed.', $GLOBALS['wpcs_redirect_location'] ); } public function test_it_redirects_with_error_for_invalid_uploaded_packages(): void { @@ -96,33 +131,84 @@ class FileImportControllerTest extends TestCase { self::assertStringContainsString( 'not+valid+JSON', $GLOBALS['wpcs_redirect_location'] ); } - private function controller(): FileImportController { + private function controller( ?SyncResult $result = null ): FileImportController { + $logger = $this->logger(); + return new FileImportController( new JsonFileTransport( new PackageValidator() ), - new class() implements LoggerInterface { - /** - * @param array $context Context. - */ - public function error( string $message, array $context = array() ): void {} - - /** - * @param array $context Context. - */ - public function warning( string $message, array $context = array() ): void {} - - /** - * @param array $context Context. - */ - public function info( string $message, array $context = array() ): void {} - - /** - * @param array $context Context. - */ - public function debug( string $message, array $context = array() ): void {} - } + $logger, + $this->syncEngine( $result ?? SyncResult::success() ) ); } + private function syncEngine( SyncResult $result ): SyncEngine { + return new SyncEngine( + new ContentHandlerRegistry( array( $this->handler( $result ) ) ), + new SyncStateRepository(), + new SettingsRepository(), + $this->logger() + ); + } + + private function handler( SyncResult $result ): ContentHandlerInterface { + return new class( $result ) implements ContentHandlerInterface { + private SyncResult $result; + + public function __construct( SyncResult $result ) { + $this->result = $result; + } + + public function bucket(): string { + return 'posts'; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + return $this->result; + } + }; + } + + private function logger(): LoggerInterface { + return new class( $this->logs ) implements LoggerInterface { + /** @var array> */ + private array $logs; + + /** + * @param array> $logs Logs. + */ + public function __construct( array &$logs ) { + $this->logs = &$logs; + } + + public function error( string $message, array $context = array() ): void { + $this->record( 'error', $message, $context ); + } + + public function warning( string $message, array $context = array() ): void { + $this->record( 'warning', $message, $context ); + } + + public function info( string $message, array $context = array() ): void { + $this->record( 'info', $message, $context ); + } + + public function debug( string $message, array $context = array() ): void { + $this->record( 'debug', $message, $context ); + } + + /** + * @param array $context Context. + */ + private function record( string $level, string $message, array $context ): void { + $this->logs[] = array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ); + } + }; + } + private function validJson(): string { $records = array( 'posts' => array(), diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index a597111..5427d14 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -4,9 +4,16 @@ namespace WPContentSync\Tests\Unit; use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; +use WPContentSync\Content\ContentHandlerRegistry; +use WPContentSync\Content\ContentRecordNormalizer; +use WPContentSync\Content\MediaContentHandler; +use WPContentSync\Content\PostContentHandler; +use WPContentSync\Content\TermContentHandler; use WPContentSync\Container; use WPContentSync\Plugin; use WPContentSync\Rest\RestPackageController; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncStateRepository; use WPContentSync\Transport\FileTransportInterface; use WPContentSync\Transport\RestTransportClient; use WPContentSync\Url\MetadataUrlTransformer; @@ -57,6 +64,18 @@ class PluginTest extends TestCase { ); } + public function test_it_registers_sync_engine_and_content_handlers(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( ContentRecordNormalizer::class, $container->get( ContentRecordNormalizer::class ) ); + self::assertInstanceOf( PostContentHandler::class, $container->get( PostContentHandler::class ) ); + self::assertInstanceOf( TermContentHandler::class, $container->get( TermContentHandler::class ) ); + self::assertInstanceOf( MediaContentHandler::class, $container->get( MediaContentHandler::class ) ); + self::assertInstanceOf( ContentHandlerRegistry::class, $container->get( ContentHandlerRegistry::class ) ); + self::assertInstanceOf( SyncStateRepository::class, $container->get( SyncStateRepository::class ) ); + self::assertInstanceOf( SyncEngine::class, $container->get( SyncEngine::class ) ); + } + public function test_it_hooks_rest_package_controller_on_register(): void { unset( $GLOBALS['wpcs_test_actions'] ); diff --git a/tests/Unit/Rest/RestPackageControllerTest.php b/tests/Unit/Rest/RestPackageControllerTest.php index d7683c7..fa99da3 100644 --- a/tests/Unit/Rest/RestPackageControllerTest.php +++ b/tests/Unit/Rest/RestPackageControllerTest.php @@ -8,9 +8,17 @@ namespace WPContentSync\Tests\Unit\Rest; use PHPUnit\Framework\TestCase; +use WPContentSync\Content\ContentHandlerInterface; +use WPContentSync\Content\ContentHandlerRegistry; +use WPContentSync\Logging\LoggerInterface; use WPContentSync\Package\PackageChecksum; use WPContentSync\Package\PackageValidator; use WPContentSync\Rest\RestPackageController; +use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Sync\SyncContext; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncResult; +use WPContentSync\Sync\SyncStateRepository; class RestPackageControllerTest extends TestCase { protected function setUp(): void { @@ -20,13 +28,21 @@ class RestPackageControllerTest extends TestCase { } protected function tearDown(): void { - unset( $GLOBALS['wpcs_rest_routes'], $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_test_actions'] ); + unset( + $GLOBALS['wpcs_rest_routes'], + $GLOBALS['wpcs_current_user_can'], + $GLOBALS['wpcs_test_actions'], + $GLOBALS['wpcs_test_options'], + $GLOBALS['wpcs_test_option_autoloads'], + $GLOBALS['wpcs_test_transients'], + $GLOBALS['wpcs_test_transient_expiration'] + ); parent::tearDown(); } public function test_it_hooks_route_registration_to_rest_api_init(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); $controller->register(); self::assertSame( @@ -36,7 +52,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_registers_status_and_package_routes(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); $controller->registerRoutes(); self::assertArrayHasKey( 'wp-content-sync/v1/status', $GLOBALS['wpcs_rest_routes'] ); @@ -45,13 +61,13 @@ class RestPackageControllerTest extends TestCase { public function test_it_requires_manage_options_permission(): void { $GLOBALS['wpcs_current_user_can']['manage_options'] = false; - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertFalse( $controller->canReceivePackage() ); } public function test_it_returns_status_payload(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertSame( array( @@ -64,7 +80,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_accepts_valid_packages(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertSame( $this->acceptedResponse(), @@ -77,7 +93,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_accepts_rest_request_like_objects(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); $request = new class( array( 'package' => $this->validPackage(), @@ -105,7 +121,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_rejects_invalid_package_shapes(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertSame( array( @@ -116,6 +132,36 @@ class RestPackageControllerTest extends TestCase { ); } + public function test_it_returns_rejected_response_when_sync_import_fails(): void { + $controller = $this->controller( SyncResult::failure( array( 'Posts failed.' ) ) ); + + self::assertSame( + array( + 'accepted' => false, + 'schema_version' => '1.0', + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'import' => array( + 'successful' => false, + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'conflicts' => 0, + 'errors' => array( 'Posts failed.' ), + ), + ), + $controller->receivePackage( + array( + 'package' => $this->validPackage(), + ) + ) + ); + } + /** * @return array */ @@ -129,9 +175,63 @@ class RestPackageControllerTest extends TestCase { 'media' => 0, 'custom_post_types' => 0, ), + 'import' => array( + 'successful' => true, + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'conflicts' => 0, + 'errors' => array(), + ), ); } + private function controller( ?SyncResult $result = null ): RestPackageController { + return new RestPackageController( + new PackageValidator(), + $this->syncEngine( $result ?? SyncResult::success() ) + ); + } + + private function syncEngine( SyncResult $result ): SyncEngine { + return new SyncEngine( + new ContentHandlerRegistry( array( $this->handler( $result ) ) ), + new SyncStateRepository(), + new SettingsRepository(), + $this->logger() + ); + } + + private function handler( SyncResult $result ): ContentHandlerInterface { + return new class( $result ) implements ContentHandlerInterface { + private SyncResult $result; + + public function __construct( SyncResult $result ) { + $this->result = $result; + } + + public function bucket(): string { + return 'posts'; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + return $this->result; + } + }; + } + + private function logger(): LoggerInterface { + return new class() implements LoggerInterface { + public function error( string $message, array $context = array() ): void {} + + public function warning( string $message, array $context = array() ): void {} + + public function info( string $message, array $context = array() ): void {} + + public function debug( string $message, array $context = array() ): void {} + }; + } + /** * @return array */