From 425d42e4bb743a231624f3e13aa4058bf1d9a124 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Fri, 1 May 2026 14:57:10 -0500 Subject: [PATCH] feat: import media metadata records --- src/Content/MediaContentHandler.php | 238 +++++++++++++++ .../Unit/Content/MediaContentHandlerTest.php | 276 ++++++++++++++++++ tests/bootstrap.php | 4 + 3 files changed, 518 insertions(+) create mode 100644 src/Content/MediaContentHandler.php create mode 100644 tests/Unit/Content/MediaContentHandlerTest.php diff --git a/src/Content/MediaContentHandler.php b/src/Content/MediaContentHandler.php new file mode 100644 index 0000000..a8d846d --- /dev/null +++ b/src/Content/MediaContentHandler.php @@ -0,0 +1,238 @@ +normalizer = $normalizer; + $this->url_transformer = $url_transformer; + $this->metadata_transformer = $metadata_transformer; + $this->logger = $logger; + } + + public function bucket(): string { + return 'media'; + } + + /** + * @param array> $records Package records. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult { + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $errors = array(); + $mappings = $this->mappings( $context ); + + foreach ( $records as $record ) { + $normalized = $this->normalizer->media( $record ); + $existing = $this->findExistingAttachmentId( (int) $normalized['id'], $context->sourceUrl() ); + + if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) { + ++$skipped; + ++$conflicts; + $this->logger->warning( + 'Skipped media import because manual review is required.', + array( + 'source_id' => $normalized['id'], + 'attachment_id' => $existing, + ) + ); + continue; + } + + try { + $attachment_id = $this->saveAttachment( $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->saveMetadata( $attachment_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 media metadata records.', + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + + return SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + } + + private function findExistingAttachmentId( int $source_id, string $source_site ): int { + if ( $source_id <= 0 ) { + return 0; + } + + $attachments = get_posts( + array( + 'post_type' => 'attachment', + // 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() === $attachments ) { + return 0; + } + + foreach ( $attachments as $attachment ) { + $attachment_id = (int) $attachment->ID; + + if ( get_post_meta( $attachment_id, '_wpcs_source_site', true ) === $source_site ) { + return $attachment_id; + } + } + + return 0; + } + + /** + * @param array $record Normalized media record. + * @param int $existing Existing attachment ID. + */ + private function saveAttachment( array $record, int $existing, UrlMappingCollection $mappings ): int { + $attachment_data = array( + 'post_title' => $record['post_title'], + 'post_mime_type' => $record['post_mime_type'], + 'post_type' => 'attachment', + ); + + if ( '' !== $record['source_url'] ) { + $this->logger->warning( + 'Skipped media binary download; importing attachment metadata only.', + array( + 'source_id' => $record['id'], + 'source_url' => $this->url_transformer->transformString( (string) $record['source_url'], $mappings ), + ) + ); + } + + if ( $existing > 0 ) { + $attachment_data['ID'] = $existing; + + return $this->attachmentIdFromResult( wp_update_post( $attachment_data, true ), $record ); + } + + return $this->attachmentIdFromResult( wp_insert_attachment( $attachment_data, false, 0, true ), $record ); + } + + /** + * @param int|\WP_Error $result Attachment save result. + * @param array $record Normalized media record. + */ + private function attachmentIdFromResult( $result, array $record ): int { + if ( is_wp_error( $result ) || (int) $result <= 0 ) { + throw new ContentImportException( + $this->bucket(), + $record, + sprintf( 'Media import failed for source ID %d.', (int) $record['id'] ) + ); + } + + return (int) $result; + } + + /** + * @param array $record Normalized media record. + */ + private function saveMetadata( int $attachment_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void { + update_post_meta( $attachment_id, '_wpcs_source_id', (int) $record['id'] ); + update_post_meta( $attachment_id, '_wpcs_source_site', $context->sourceUrl() ); + update_post_meta( + $attachment_id, + '_wpcs_source_url', + $this->url_transformer->transformString( (string) $record['source_url'], $mappings ) + ); + + wp_update_attachment_metadata( + $attachment_id, + $this->metadata_transformer->transformValue( $record['metadata'], $mappings ) + ); + + foreach ( $record['meta'] as $key => $value ) { + update_post_meta( + $attachment_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/MediaContentHandlerTest.php b/tests/Unit/Content/MediaContentHandlerTest.php new file mode 100644 index 0000000..191ee54 --- /dev/null +++ b/tests/Unit/Content/MediaContentHandlerTest.php @@ -0,0 +1,276 @@ +> */ + private array $logs = array(); + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_posts'], + $GLOBALS['wpcs_test_next_post_id'], + $GLOBALS['wpcs_test_post_meta'], + $GLOBALS['wpcs_test_attachment_files'], + $GLOBALS['wpcs_test_attachment_metadata'] + ); + + $this->logs = array(); + + parent::tearDown(); + } + + public function test_it_creates_attachment_records_without_downloading_files(): void { + $result = $this->handler()->importRecords( + array( + $this->mediaRecord(), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'Imported Image', get_post( 1 )['post_title'] ); + self::assertSame( 'attachment', get_post( 1 )['post_type'] ); + self::assertSame( 'image/jpeg', get_post( 1 )['post_mime_type'] ); + self::assertSame( 42, get_post_meta( 1, '_wpcs_source_id', true ) ); + self::assertSame( 'https://source.test', get_post_meta( 1, '_wpcs_source_site', true ) ); + self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_wpcs_source_url', true ) ); + self::assertSame( array( false ), $GLOBALS['wpcs_test_attachment_files'] ); + self::assertSame( 'Skipped media binary download; importing attachment metadata only.', $this->logs[0]['message'] ); + } + + public function test_it_updates_attachment_metadata_with_last_write_wins(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Old Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 0, + true + ); + update_post_meta( $attachment_id, '_wpcs_source_id', 42 ); + update_post_meta( $attachment_id, '_wpcs_source_site', 'https://source.test' ); + + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( + array( + 'post_title' => 'Updated Image', + 'metadata' => array( + 'width' => 1200, + 'height' => 800, + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->updated() ); + self::assertSame( 'Updated Image', get_post( $attachment_id )['post_title'] ); + self::assertSame( + array( + 'width' => 1200, + 'height' => 800, + ), + wp_get_attachment_metadata( $attachment_id ) + ); + } + + public function test_it_does_not_match_existing_media_from_a_different_source_site(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Other Site Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 0, + true + ); + update_post_meta( $attachment_id, '_wpcs_source_id', 42 ); + update_post_meta( $attachment_id, '_wpcs_source_site', 'https://other-source.test' ); + + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( array( 'post_title' => 'Current Site Image' ) ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'Other Site Image', get_post( $attachment_id )['post_title'] ); + self::assertSame( 'Current Site Image', get_post( 2 )['post_title'] ); + } + + public function test_it_rewrites_source_url_metadata_and_meta_urls(): void { + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( + array( + 'metadata' => array( + 'file' => 'https://source.test/uploads/image.jpg', + 'sizes' => array( + 'thumbnail' => array( + 'url' => 'https://source.test/uploads/image-150x150.jpg', + ), + ), + ), + 'meta' => array( + '_source_url' => 'https://source.test/uploads/image.jpg', + '_json_links' => '{"url":"https://source.test/uploads/image.jpg"}', + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + $metadata = wp_get_attachment_metadata( 1 ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_wpcs_source_url', true ) ); + self::assertSame( 'https://destination.test/uploads/image.jpg', $metadata['file'] ); + self::assertSame( 'https://destination.test/uploads/image-150x150.jpg', $metadata['sizes']['thumbnail']['url'] ); + self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_source_url', true ) ); + self::assertSame( '{"url":"https:\/\/destination.test\/uploads\/image.jpg"}', get_post_meta( 1, '_json_links', true ) ); + } + + public function test_it_skips_existing_media_with_manual_review_conflict(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Old Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 0, + true + ); + update_post_meta( $attachment_id, '_wpcs_source_id', 42 ); + update_post_meta( $attachment_id, '_wpcs_source_site', 'https://source.test' ); + + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( array( 'post_title' => 'Updated Image' ) ), + ), + $this->context( 'manual_review' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( 'Old Image', get_post( $attachment_id )['post_title'] ); + self::assertSame( 'Skipped media import because manual review is required.', $this->logs[0]['message'] ); + } + + public function test_it_returns_failure_when_wordpress_rejects_attachment_save(): void { + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( + array( + 'id' => 0, + 'post_mime_type' => '', + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Media import failed for source ID 0.' ), $result->errors() ); + self::assertSame( array(), get_post_meta( 0, '_wpcs_source_id', false ) ); + } + + private function handler(): MediaContentHandler { + return new MediaContentHandler( + 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 mediaRecord( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 42, + 'post_title' => 'Imported Image', + 'post_mime_type' => 'image/jpeg', + 'source_url' => 'https://source.test/uploads/image.jpg', + 'metadata' => array(), + '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/bootstrap.php b/tests/bootstrap.php index 523f2a4..eb077ca 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -963,6 +963,10 @@ if ( ! function_exists( 'wp_insert_attachment' ) ) { * @return int|\WP_Error */ function wp_insert_attachment( array $args, $file = false, $parent_post_id = 0, $wp_error = false ) { + if ( empty( $args['post_mime_type'] ) ) { + return $wp_error ? new WP_Error( 'invalid_attachment_mime_type', 'Attachment mime type is required.' ) : 0; + } + $GLOBALS['wpcs_test_attachment_files'][] = $file; $args['post_type'] = 'attachment'; $args['post_parent'] = (int) $parent_post_id;