From 592e6e740314c048f0e9aa9246aa8ca5b9d06a4a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 29 Apr 2026 20:32:56 -0500 Subject: [PATCH] feat: import taxonomy term records --- src/Content/TermContentHandler.php | 224 ++++++++++++++++++ tests/Unit/Content/TermContentHandlerTest.php | 209 ++++++++++++++++ .../Unit/Content/WordPressContentStubTest.php | 3 +- tests/bootstrap.php | 102 +++++++- 4 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 src/Content/TermContentHandler.php create mode 100644 tests/Unit/Content/TermContentHandlerTest.php diff --git a/src/Content/TermContentHandler.php b/src/Content/TermContentHandler.php new file mode 100644 index 0000000..5aac700 --- /dev/null +++ b/src/Content/TermContentHandler.php @@ -0,0 +1,224 @@ +normalizer = $normalizer; + $this->url_transformer = $url_transformer; + $this->metadata_transformer = $metadata_transformer; + $this->logger = $logger; + } + + public function bucket(): string { + return 'terms'; + } + + /** + * @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->term( $record ); + $existing = $this->findExistingTermId( $normalized ); + + if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) { + ++$skipped; + ++$conflicts; + $this->logger->warning( + 'Skipped term import because manual review is required.', + array( + 'source_id' => $normalized['id'], + 'term_id' => $existing, + 'taxonomy' => $normalized['taxonomy'], + ) + ); + continue; + } + + try { + $term_id = $this->saveTerm( $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( $term_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 taxonomy term records.', + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + + return SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + } + + /** + * @param array $record Normalized term record. + */ + private function findExistingTermId( array $record ): int { + $source_id = (int) $record['id']; + + if ( $source_id > 0 ) { + $terms = get_terms( + array( + 'taxonomy' => (string) $record['taxonomy'], + 'hide_empty' => false, + 'number' => 1, + // 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 ( ! is_wp_error( $terms ) && array() !== $terms ) { + return (int) $terms[0]->term_id; + } + } + + $term = get_term_by( 'slug', (string) $record['slug'], (string) $record['taxonomy'] ); + + return false === $term ? 0 : (int) $term->term_id; + } + + /** + * @param array $record Normalized term record. + * @param int $existing Existing term ID. + */ + private function saveTerm( array $record, int $existing, UrlMappingCollection $mappings ): int { + $args = array( + 'slug' => $record['slug'], + 'description' => $this->url_transformer->transformString( (string) $record['description'], $mappings ), + 'parent' => $record['parent'], + ); + + if ( $existing > 0 ) { + $args['name'] = $record['name']; + + return $this->termIdFromResult( + wp_update_term( $existing, (string) $record['taxonomy'], $args ), + $record + ); + } + + return $this->termIdFromResult( + wp_insert_term( (string) $record['name'], (string) $record['taxonomy'], $args ), + $record + ); + } + + /** + * @param array|\WP_Error $result Term save result. + * @param array $record Normalized term record. + */ + private function termIdFromResult( $result, array $record ): int { + if ( is_wp_error( $result ) || ! is_array( $result ) || (int) ( $result['term_id'] ?? 0 ) <= 0 ) { + throw new ContentImportException( + $this->bucket(), + $record, + sprintf( 'Term import failed for source ID %d.', (int) $record['id'] ) + ); + } + + return (int) $result['term_id']; + } + + /** + * @param array $record Normalized term record. + */ + private function saveMeta( int $term_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void { + update_term_meta( $term_id, '_wpcs_source_id', (int) $record['id'] ); + update_term_meta( $term_id, '_wpcs_source_site', $context->sourceUrl() ); + + foreach ( $record['meta'] as $key => $value ) { + update_term_meta( + $term_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/TermContentHandlerTest.php b/tests/Unit/Content/TermContentHandlerTest.php new file mode 100644 index 0000000..b0c5394 --- /dev/null +++ b/tests/Unit/Content/TermContentHandlerTest.php @@ -0,0 +1,209 @@ +> */ + private array $logs = array(); + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_terms'], + $GLOBALS['wpcs_test_next_term_id'], + $GLOBALS['wpcs_test_term_meta'] + ); + + $this->logs = array(); + + parent::tearDown(); + } + + public function test_it_creates_new_terms_by_taxonomy_and_slug(): void { + $result = $this->handler()->importRecords( + array( + $this->termRecord(), + ), + $this->context( 'last_write_wins' ) + ); + + $term = get_term_by( 'slug', 'news', 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'News', $term->name ); + self::assertSame( 42, get_term_meta( $term->term_id, '_wpcs_source_id', true ) ); + self::assertSame( 'https://source.test', get_term_meta( $term->term_id, '_wpcs_source_site', true ) ); + } + + public function test_it_updates_existing_terms_with_last_write_wins(): void { + $existing = wp_insert_term( 'Old News', 'category', array( 'slug' => 'news' ) ); + update_term_meta( $existing['term_id'], '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->termRecord( array( 'name' => 'Updated News' ) ), + ), + $this->context( 'last_write_wins' ) + ); + + $term = get_term_by( 'id', $existing['term_id'], 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->updated() ); + self::assertSame( 'Updated News', $term->name ); + } + + public function test_it_skips_existing_terms_with_manual_review_conflict(): void { + $existing = wp_insert_term( 'Old News', 'category', array( 'slug' => 'news' ) ); + update_term_meta( $existing['term_id'], '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->termRecord( array( 'name' => 'Updated News' ) ), + ), + $this->context( 'manual_review' ) + ); + + $term = get_term_by( 'id', $existing['term_id'], 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( 'Old News', $term->name ); + self::assertSame( 'Skipped term import because manual review is required.', $this->logs[0]['message'] ); + } + + public function test_it_rewrites_term_description_and_meta_urls(): void { + $result = $this->handler()->importRecords( + array( + $this->termRecord( + array( + 'description' => 'News', + 'meta' => array( + 'landing_url' => 'https://source.test/news', + 'json_links' => '{"url":"https://source.test/news"}', + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + $term = get_term_by( 'slug', 'news', 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 'News', $term->description ); + self::assertSame( 'https://destination.test/news', get_term_meta( $term->term_id, 'landing_url', true ) ); + self::assertSame( '{"url":"https:\/\/destination.test\/news"}', get_term_meta( $term->term_id, 'json_links', true ) ); + } + + public function test_it_returns_failure_when_wordpress_rejects_term_save(): void { + $result = $this->handler()->importRecords( + array( + $this->termRecord( + array( + 'id' => 0, + 'taxonomy' => '', + 'name' => '', + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Term import failed for source ID 0.' ), $result->errors() ); + self::assertSame( array(), get_term_meta( 0, '_wpcs_source_id', false ) ); + } + + private function handler(): TermContentHandler { + return new TermContentHandler( + 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 termRecord( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 42, + 'taxonomy' => 'category', + 'name' => 'News', + 'slug' => 'news', + 'description' => 'News description', + 'parent' => 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 d9b0eab..9c9dc49 100644 --- a/tests/Unit/Content/WordPressContentStubTest.php +++ b/tests/Unit/Content/WordPressContentStubTest.php @@ -17,6 +17,7 @@ class WordPressContentStubTest extends TestCase { $GLOBALS['wpcs_test_post_meta'], $GLOBALS['wpcs_test_terms'], $GLOBALS['wpcs_test_next_term_id'], + $GLOBALS['wpcs_test_term_meta'], $GLOBALS['wpcs_test_object_terms'], $GLOBALS['wpcs_test_attachment_files'], $GLOBALS['wpcs_test_attachment_metadata'], @@ -61,7 +62,7 @@ class WordPressContentStubTest extends TestCase { wp_update_term( $result['term_id'], 'category', array( 'name' => 'Latest News' ) ); $term = get_term_by( 'slug', 'news', 'category' ); - self::assertSame( 'Latest News', $term['name'] ); + self::assertSame( 'Latest News', $term->name ); } public function test_attachment_stubs_store_metadata(): void { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0d625cc..523f2a4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -811,6 +811,47 @@ if ( ! function_exists( 'wp_update_term' ) ) { } } +if ( ! function_exists( 'get_terms' ) ) { + /** + * Minimal terms query for unit tests. + * + * @param array $args Query args. + * @return array + */ + function get_terms( array $args = array() ) { + $terms = array_values( $GLOBALS['wpcs_test_terms'] ?? array() ); + + if ( isset( $args['taxonomy'] ) ) { + $taxonomies = is_array( $args['taxonomy'] ) ? $args['taxonomy'] : array( $args['taxonomy'] ); + $terms = array_filter( + $terms, + static function ( array $term ) use ( $taxonomies ): bool { + return in_array( $term['taxonomy'] ?? '', $taxonomies, true ); + } + ); + } + + if ( isset( $args['meta_key'], $args['meta_value'] ) ) { + $terms = array_filter( + $terms, + static function ( array $term ) use ( $args ): bool { + $values = $GLOBALS['wpcs_test_term_meta'][ (int) $term['term_id'] ][ (string) $args['meta_key'] ] ?? array(); + + foreach ( $values as $value ) { + if ( (string) $args['meta_value'] === (string) $value ) { + return true; + } + } + + return false; + } + ); + } + + return array_values( array_map( static fn( array $term ): object => (object) $term, $terms ) ); + } +} + if ( ! function_exists( 'get_term_by' ) ) { /** * Minimal term reader for unit tests. @@ -821,13 +862,15 @@ if ( ! function_exists( 'get_term_by' ) ) { * @return array|false */ function get_term_by( $field, $value, $taxonomy ) { + $field = 'id' === $field ? 'term_id' : $field; + foreach ( $GLOBALS['wpcs_test_terms'] ?? array() as $term ) { if ( (string) ( $term['taxonomy'] ?? '' ) !== (string) $taxonomy ) { continue; } if ( isset( $term[ $field ] ) && (string) $value === (string) $term[ $field ] ) { - return $term; + return (object) $term; } } @@ -835,6 +878,63 @@ if ( ! function_exists( 'get_term_by' ) ) { } } +if ( ! function_exists( 'update_term_meta' ) ) { + /** + * Minimal term meta updater for unit tests. + * + * @param int $term_id Term ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return bool + */ + function update_term_meta( $term_id, $meta_key, $meta_value ) { + $GLOBALS['wpcs_test_term_meta'][ (int) $term_id ][ (string) $meta_key ] = array( $meta_value ); + + return true; + } +} + +if ( ! function_exists( 'get_term_meta' ) ) { + /** + * Minimal term meta reader for unit tests. + * + * @param int $term_id Term ID. + * @param string $key Meta key. + * @param bool $single Whether to return single value. + * @return mixed + */ + function get_term_meta( $term_id, $key = '', $single = false ) { + $meta = $GLOBALS['wpcs_test_term_meta'][ (int) $term_id ] ?? array(); + + if ( '' === $key ) { + return $meta; + } + + $values = $meta[ $key ] ?? array(); + + if ( $single ) { + return $values[0] ?? ''; + } + + return $values; + } +} + +if ( ! function_exists( 'delete_term_meta' ) ) { + /** + * Minimal term meta deleter for unit tests. + * + * @param int $term_id Term ID. + * @param string $meta_key Meta key. + * @return bool + */ + function delete_term_meta( $term_id, $meta_key ) { + unset( $GLOBALS['wpcs_test_term_meta'][ (int) $term_id ][ (string) $meta_key ] ); + + return true; + } +} + if ( ! function_exists( 'wp_set_object_terms' ) ) { /** * Minimal object term relationship setter for unit tests.