From 6d11934fcc85d8e64e1642a42989fa326543c9d0 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 18:22:01 -0500 Subject: [PATCH] feat: import post content records --- src/Content/PostContentHandler.php | 215 +++++++++++++++++ tests/Unit/Content/PostContentHandlerTest.php | 218 ++++++++++++++++++ .../Unit/Content/WordPressContentStubTest.php | 2 +- tests/bootstrap.php | 16 +- 4 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 src/Content/PostContentHandler.php create mode 100644 tests/Unit/Content/PostContentHandlerTest.php diff --git a/src/Content/PostContentHandler.php b/src/Content/PostContentHandler.php new file mode 100644 index 0000000..31b23e0 --- /dev/null +++ b/src/Content/PostContentHandler.php @@ -0,0 +1,215 @@ +normalizer = $normalizer; + $this->url_transformer = $url_transformer; + $this->metadata_transformer = $metadata_transformer; + $this->logger = $logger; + } + + public function bucket(): string { + return 'posts'; + } + + /** + * @param array> $records Package records. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult { + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $mappings = $this->mappings( $context ); + $errors = array(); + + foreach ( $records as $record ) { + $normalized = $this->normalizer->post( $record ); + $existing = $this->findExistingPostId( (int) $normalized['id'] ); + + if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) { + ++$skipped; + ++$conflicts; + $this->logger->warning( + 'Skipped post import because manual review is required.', + array( + 'source_id' => $normalized['id'], + 'post_id' => $existing, + ) + ); + continue; + } + + try { + $post_id = $this->savePost( $normalized, $existing, $mappings ); + } catch ( ContentImportException $exception ) { + $errors[] = $exception->getMessage(); + $this->logger->error( + $exception->getMessage(), + array( + 'bucket' => $exception->bucket(), + 'record' => $exception->record(), + ) + ); + continue; + } + + if ( $existing > 0 ) { + ++$updated; + } else { + ++$created; + } + + $this->saveMeta( $post_id, $normalized, $context, $mappings ); + } + + if ( array() !== $errors ) { + return SyncResult::merge( + array( + SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ), + SyncResult::failure( $errors ), + ) + ); + } + + $this->logger->info( + 'Imported post content records.', + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + + return SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + } + + private function findExistingPostId( int $source_id ): int { + if ( $source_id <= 0 ) { + return 0; + } + + $posts = get_posts( + array( + 'post_type' => 'any', + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity. + 'meta_key' => '_wpcs_source_id', + 'meta_value' => (string) $source_id, + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + if ( array() === $posts ) { + return 0; + } + + return (int) $posts[0]->ID; + } + + /** + * @param array $record Normalized post record. + * @param int $existing Existing post ID. + */ + private function savePost( array $record, int $existing, UrlMappingCollection $mappings ): int { + $post_data = array( + 'post_type' => $record['post_type'], + 'post_title' => $record['post_title'], + 'post_content' => $this->url_transformer->transformString( (string) $record['post_content'], $mappings ), + 'post_excerpt' => $this->url_transformer->transformString( (string) $record['post_excerpt'], $mappings ), + 'post_status' => $record['post_status'], + 'post_name' => $record['post_name'], + 'post_parent' => $record['post_parent'], + 'menu_order' => $record['menu_order'], + ); + + if ( $existing > 0 ) { + $post_data['ID'] = $existing; + + return $this->postIdFromResult( wp_update_post( $post_data, true ), $record ); + } + + return $this->postIdFromResult( wp_insert_post( $post_data, true ), $record ); + } + + /** + * @param int|\WP_Error $result Post save result. + * @param array $record Normalized post record. + */ + private function postIdFromResult( $result, array $record ): int { + if ( is_wp_error( $result ) || (int) $result <= 0 ) { + throw new ContentImportException( + $this->bucket(), + $record, + sprintf( 'Post import failed for source ID %d.', (int) $record['id'] ) + ); + } + + return (int) $result; + } + + /** + * @param array $record Normalized post record. + */ + private function saveMeta( int $post_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void { + update_post_meta( $post_id, '_wpcs_source_id', (int) $record['id'] ); + update_post_meta( $post_id, '_wpcs_source_site', $context->sourceUrl() ); + + foreach ( $record['meta'] as $key => $value ) { + update_post_meta( + $post_id, + (string) $key, + $this->metadata_transformer->transformValue( $value, $mappings ) + ); + } + } + + private function mappings( SyncContext $context ): UrlMappingCollection { + $mappings = array(); + + foreach ( $context->urlMappings() as $source => $destination ) { + $mappings[] = new UrlMapping( $source, $destination ); + } + + return new UrlMappingCollection( $mappings ); + } +} diff --git a/tests/Unit/Content/PostContentHandlerTest.php b/tests/Unit/Content/PostContentHandlerTest.php new file mode 100644 index 0000000..b2d4bfa --- /dev/null +++ b/tests/Unit/Content/PostContentHandlerTest.php @@ -0,0 +1,218 @@ +> */ + private array $logs = array(); + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_posts'], + $GLOBALS['wpcs_test_next_post_id'], + $GLOBALS['wpcs_test_post_meta'] + ); + + $this->logs = array(); + + parent::tearDown(); + } + + public function test_it_creates_new_post_records(): void { + $result = $this->handler()->importRecords( + array( + $this->postRecord(), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'Imported Title', get_post( 1 )['post_title'] ); + self::assertSame( 42, get_post_meta( 1, '_wpcs_source_id', true ) ); + self::assertSame( 'https://source.test', get_post_meta( 1, '_wpcs_source_site', true ) ); + } + + public function test_it_updates_existing_posts_with_last_write_wins(): void { + $post_id = wp_insert_post( + array( + 'post_title' => 'Old Title', + 'post_type' => 'post', + ), + true + ); + update_post_meta( $post_id, '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->postRecord( array( 'post_title' => 'New Title' ) ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->updated() ); + self::assertSame( 'New Title', get_post( $post_id )['post_title'] ); + } + + public function test_it_skips_existing_posts_with_manual_review_conflict(): void { + $post_id = wp_insert_post( + array( + 'post_title' => 'Old Title', + 'post_type' => 'post', + ), + true + ); + update_post_meta( $post_id, '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->postRecord( array( 'post_title' => 'New Title' ) ), + ), + $this->context( 'manual_review' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( 'Old Title', get_post( $post_id )['post_title'] ); + self::assertSame( 'Skipped post import because manual review is required.', $this->logs[0]['message'] ); + } + + public function test_it_rewrites_post_content_excerpt_and_meta_urls(): void { + $result = $this->handler()->importRecords( + array( + $this->postRecord( + array( + 'post_content' => 'Page', + 'post_excerpt' => 'Read https://source.test/page', + 'meta' => array( + '_source_url' => 'https://source.test/page', + '_json_links' => '{"url":"https://source.test/page"}', + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 'Page', get_post( 1 )['post_content'] ); + self::assertSame( 'Read https://destination.test/page', get_post( 1 )['post_excerpt'] ); + self::assertSame( 'https://destination.test/page', get_post_meta( 1, '_source_url', true ) ); + self::assertSame( '{"url":"https:\/\/destination.test\/page"}', get_post_meta( 1, '_json_links', true ) ); + } + + public function test_it_returns_failure_when_wordpress_rejects_post_save(): void { + $result = $this->handler()->importRecords( + array( + $this->postRecord( + array( + 'id' => 0, + 'post_type' => '', + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Post import failed for source ID 0.' ), $result->errors() ); + self::assertSame( array(), get_post_meta( 0, '_wpcs_source_id', false ) ); + } + + + private function handler(): PostContentHandler { + return new PostContentHandler( + new ContentRecordNormalizer(), + new UrlTransformer(), + new MetadataUrlTransformer( new UrlTransformer() ), + $this->logger() + ); + } + + private function context( string $conflict_strategy ): SyncContext { + return SyncContext::forImport( + array( 'site_url' => 'https://source.test' ), + array( 'site_url' => 'https://destination.test' ), + $conflict_strategy, + 'operation-1' + ); + } + + /** + * @param array $overrides Record overrides. + * @return array + */ + private function postRecord( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 42, + 'post_type' => 'post', + 'post_title' => 'Imported Title', + 'post_content' => 'Imported content', + 'post_excerpt' => 'Imported excerpt', + 'post_status' => 'publish', + 'post_name' => 'imported-title', + 'post_parent' => 0, + 'menu_order' => 0, + 'meta' => array(), + ), + $overrides + ); + } + + 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, + ); + } + }; + } +} diff --git a/tests/Unit/Content/WordPressContentStubTest.php b/tests/Unit/Content/WordPressContentStubTest.php index 13a00f8..d9b0eab 100644 --- a/tests/Unit/Content/WordPressContentStubTest.php +++ b/tests/Unit/Content/WordPressContentStubTest.php @@ -119,7 +119,7 @@ class WordPressContentStubTest extends TestCase { ); self::assertCount( 1, $posts ); - self::assertSame( $first_post_id, $posts[0]['ID'] ); + self::assertSame( $first_post_id, $posts[0]->ID ); self::assertSame( array(), get_post_meta( $second_post_id, '_wpcs_source_id', false ) ); self::assertNull( get_post( $second_post_id ) ); self::assertTrue( $GLOBALS['wpcs_test_force_delete'][ $second_post_id ] ); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 110d6d4..0d625cc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -545,6 +545,10 @@ if ( ! function_exists( 'wp_insert_post' ) ) { * @return int|\WP_Error */ function wp_insert_post( array $postarr, $wp_error = false ) { + if ( empty( $postarr['post_type'] ) ) { + return $wp_error ? new WP_Error( 'invalid_post_type', 'Post type is required.' ) : 0; + } + if ( isset( $postarr['ID'] ) && (int) $postarr['ID'] > 0 ) { $post_id = (int) $postarr['ID']; } else { @@ -629,7 +633,7 @@ if ( ! function_exists( 'get_posts' ) ) { * Minimal posts query for unit tests. * * @param array $args Query args. - * @return array> + * @return array */ function get_posts( array $args = array() ) { $posts = array_values( $GLOBALS['wpcs_test_posts'] ?? array() ); @@ -650,12 +654,18 @@ if ( ! function_exists( 'get_posts' ) ) { static function ( array $post ) use ( $args ): bool { $values = $GLOBALS['wpcs_test_post_meta'][ (int) $post['ID'] ][ (string) $args['meta_key'] ] ?? array(); - return in_array( $args['meta_value'], $values, true ); + foreach ( $values as $value ) { + if ( (string) $args['meta_value'] === (string) $value ) { + return true; + } + } + + return false; } ); } - return array_values( $posts ); + return array_values( array_map( static fn( array $post ): object => (object) $post, $posts ) ); } }