From 3b09c3f4102401da056ca218ce999039c75ee582 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:51:21 -0500 Subject: [PATCH] feat: add metadata url transformer --- src/Url/MetadataUrlTransformer.php | 92 +++++++++++++++++++ tests/Unit/Url/MetadataUrlTransformerTest.php | 82 +++++++++++++++++ tests/bootstrap.php | 13 +++ 3 files changed, 187 insertions(+) create mode 100644 src/Url/MetadataUrlTransformer.php create mode 100644 tests/Unit/Url/MetadataUrlTransformerTest.php diff --git a/src/Url/MetadataUrlTransformer.php b/src/Url/MetadataUrlTransformer.php new file mode 100644 index 0000000..f160338 --- /dev/null +++ b/src/Url/MetadataUrlTransformer.php @@ -0,0 +1,92 @@ +url_transformer = $url_transformer; + } + + /** + * Transform URLs within a metadata value. + * + * @param mixed $value Metadata value. + * @param UrlMappingCollection $mappings URL mappings. + * @return mixed + */ + public function transformValue( $value, UrlMappingCollection $mappings ) { + if ( is_array( $value ) ) { + return $this->transformArray( $value, $mappings ); + } + + if ( ! is_string( $value ) ) { + return $value; + } + + if ( $this->isSerialized( $value ) ) { + // phpcs:ignore -- Required to transform serialized metadata while preserving valid string lengths. + $unserialized = unserialize( $value, array( 'allowed_classes' => false ) ); + + // phpcs:ignore -- Required to reserialize metadata with updated string lengths. + return serialize( $this->transformValue( $unserialized, $mappings ) ); + } + + if ( $this->isJsonObjectOrArray( $value ) ) { + $decoded = json_decode( $value, true ); + + if ( is_array( $decoded ) && JSON_ERROR_NONE === json_last_error() ) { + $encoded = wp_json_encode( $this->transformArray( $decoded, $mappings ) ); + + if ( is_string( $encoded ) ) { + return $encoded; + } + } + } + + return $this->url_transformer->transformString( $value, $mappings ); + } + + /** + * Transform URLs within a metadata array. + * + * @param array $value Metadata array. + * @param UrlMappingCollection $mappings URL mappings. + * @return array + */ + private function transformArray( array $value, UrlMappingCollection $mappings ): array { + $transformed = array(); + + foreach ( $value as $key => $item ) { + $transformed[ $key ] = $this->transformValue( $item, $mappings ); + } + + return $transformed; + } + + private function isSerialized( string $value ): bool { + $trimmed = trim( $value ); + + if ( 'N;' === $trimmed ) { + return true; + } + + if ( 'b:0;' === $trimmed ) { + return true; + } + + if ( ! preg_match( '/^(?:a|O|s|i|d|b):/', $trimmed ) ) { + return false; + } + + // phpcs:ignore -- Validation must parse PHP serialized metadata. + return false !== @unserialize( $trimmed, array( 'allowed_classes' => false ) ); + } + + private function isJsonObjectOrArray( string $value ): bool { + $trimmed = trim( $value ); + + return '' !== $trimmed && in_array( $trimmed[0], array( '{', '[' ), true ); + } +} diff --git a/tests/Unit/Url/MetadataUrlTransformerTest.php b/tests/Unit/Url/MetadataUrlTransformerTest.php new file mode 100644 index 0000000..32562a6 --- /dev/null +++ b/tests/Unit/Url/MetadataUrlTransformerTest.php @@ -0,0 +1,82 @@ + array( + 'url' => 'https://staging.example.test/uploads/hero.jpg', + ), + 'count' => 3, + 'flag' => true, + ), + $transformer->transformValue( + array( + 'hero' => array( + 'url' => 'https://example.test/uploads/hero.jpg', + ), + 'count' => 3, + 'flag' => true, + ), + $this->mappings() + ) + ); + } + + public function test_it_transforms_json_strings(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $result = $transformer->transformValue( + '{"url":"https:\/\/example.test\/uploads\/hero.jpg"}', + $this->mappings() + ); + + self::assertSame( '{"url":"https:\/\/staging.example.test\/uploads\/hero.jpg"}', $result ); + } + + public function test_it_preserves_serialized_data_validity(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + // phpcs:ignore -- Test fixture requires native serialized metadata. + $serialized = serialize( + array( + 'url' => 'https://example.test/uploads/hero.jpg', + ) + ); + + $result = $transformer->transformValue( $serialized, $this->mappings() ); + + self::assertSame( + array( + 'url' => 'https://staging.example.test/uploads/hero.jpg', + ), + // phpcs:ignore -- Assertion verifies the transformed serialized metadata remains valid. + unserialize( $result ) + ); + } + + public function test_it_transforms_plain_string_metadata(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + + self::assertSame( + 'https://staging.example.test/contact', + $transformer->transformValue( 'https://example.test/contact', $this->mappings() ) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 88fbd06..586b930 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -138,6 +138,19 @@ if ( ! function_exists( 'wp_parse_url' ) ) { } } +if ( ! function_exists( 'wp_json_encode' ) ) { + /** + * Minimal JSON encoder for unit tests. + * + * @param mixed $value Value to encode. + * @return string|false + */ + function wp_json_encode( $value ) { + // phpcs:ignore -- Test stub for WordPress' wp_json_encode(). + return json_encode( $value ); + } +} + if ( ! function_exists( 'get_option' ) ) { /** * Minimal WordPress option reader for unit tests.