From 3c254a34c908bca138d6233dc1bac05e51c34426 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 12:51:55 -0500 Subject: [PATCH 01/11] docs: add url transformer implementation plan --- ...-wordpress-content-sync-url-transformer.md | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-wordpress-content-sync-url-transformer.md diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-url-transformer.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-url-transformer.md new file mode 100644 index 0000000..c557025 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-url-transformer.md @@ -0,0 +1,634 @@ +# WordPress Content Sync URL Transformer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the URL transformation layer that rewrites source-domain URLs to destination-domain URLs across content strings, HTML attributes, JSON strings, and serialized metadata. + +**Architecture:** URL transformation is implemented as a focused service under `src/Url/` with immutable mapping objects and a recursive metadata transformer. The transformer accepts explicit source/destination mappings, normalizes trailing slashes, preserves serialized data validity, and avoids WordPress database mutation so it can be tested with PHPUnit in isolation. + +**Tech Stack:** PHP 7.4+, WordPress 5.6+, PHPUnit, PHPStan level 6+, PHPCS with project `phpcs.xml`. + +--- + +## File Structure + +- Create: `src/Url/UrlMapping.php` as an immutable source/destination mapping value object. +- Create: `src/Url/UrlMappingCollection.php` as a validated list of mappings. +- Create: `src/Url/UrlTransformer.php` for string, HTML, escaped URL, and protocol-relative replacement. +- Create: `src/Url/MetadataUrlTransformer.php` for recursive arrays, JSON strings, and serialized PHP data. +- Modify: `src/Plugin.php` to register `UrlTransformer` and `MetadataUrlTransformer` services. +- Create: `tests/Unit/Url/UrlMappingCollectionTest.php`. +- Create: `tests/Unit/Url/UrlTransformerTest.php`. +- Create: `tests/Unit/Url/MetadataUrlTransformerTest.php`. + +## Task 1: URL Mapping Value Objects + +**Files:** +- Create: `src/Url/UrlMapping.php` +- Create: `src/Url/UrlMappingCollection.php` +- Create: `tests/Unit/Url/UrlMappingCollectionTest.php` + +- [ ] **Step 1: Write failing mapping tests** + +Create `tests/Unit/Url/UrlMappingCollectionTest.php`: + +```php +sourceUrl() ); + self::assertSame( 'https://staging.example.test', $mapping->destinationUrl() ); + } + + public function test_it_rejects_empty_mapping_urls(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Source and destination URLs are required.' ); + + new UrlMapping( '', 'https://staging.example.test' ); + } + + public function test_it_returns_mappings_in_order(): void { + $first = new UrlMapping( 'https://example.test', 'https://staging.example.test' ); + $second = new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ); + $collection = new UrlMappingCollection( array( $first, $second ) ); + + self::assertSame( array( $first, $second ), $collection->all() ); + } + + public function test_it_rejects_non_mapping_items(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'URL mapping collections only accept UrlMapping instances.' ); + + new UrlMappingCollection( array( new \stdClass() ) ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `composer test -- --filter UrlMappingCollectionTest` + +Expected: FAIL because `WPContentSync\Url\UrlMapping` does not exist. + +- [ ] **Step 3: Implement `UrlMapping`** + +Create `src/Url/UrlMapping.php`: + +```php +normalizeUrl( $source_url ); + $destination_url = $this->normalizeUrl( $destination_url ); + + if ( '' === $source_url || '' === $destination_url ) { + throw new \InvalidArgumentException( 'Source and destination URLs are required.' ); + } + + $this->source_url = $source_url; + $this->destination_url = $destination_url; + } + + public function sourceUrl(): string { + return $this->source_url; + } + + public function destinationUrl(): string { + return $this->destination_url; + } + + private function normalizeUrl( string $url ): string { + return rtrim( trim( $url ), '/' ); + } +} +``` + +- [ ] **Step 4: Implement `UrlMappingCollection`** + +Create `src/Url/UrlMappingCollection.php`: + +```php + + */ + private array $mappings; + + /** + * @param array $mappings URL mappings. + */ + public function __construct( array $mappings ) { + foreach ( $mappings as $mapping ) { + if ( ! $mapping instanceof UrlMapping ) { + throw new \InvalidArgumentException( 'URL mapping collections only accept UrlMapping instances.' ); + } + } + + $this->mappings = array_values( $mappings ); + } + + /** + * @return array + */ + public function all(): array { + return $this->mappings; + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `composer test -- --filter UrlMappingCollectionTest` + +Expected: PASS with 4 tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/Url/UrlMapping.php src/Url/UrlMappingCollection.php tests/Unit/Url/UrlMappingCollectionTest.php +git commit -m "feat: add url mapping value objects" +``` + +## Task 2: Plain and HTML URL Transformation + +**Files:** +- Create: `src/Url/UrlTransformer.php` +- Create: `tests/Unit/Url/UrlTransformerTest.php` + +- [ ] **Step 1: Write failing transformer tests** + +Create `tests/Unit/Url/UrlTransformerTest.php`: + +```php +transformString( 'Visit https://example.test/about for details.', $mappings ) + ); + } + + public function test_it_rewrites_html_attribute_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + ) + ); + + self::assertSame( + '', + $transformer->transformString( + '', + $mappings + ) + ); + } + + public function test_it_rewrites_escaped_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test/path?a=1&b=2', 'https://staging.example.test/path?a=1&b=2' ), + ) + ); + + self::assertSame( + 'https://staging.example.test/path?a=1&b=2', + $transformer->transformString( 'https://example.test/path?a=1&b=2', $mappings ) + ); + } + + public function test_it_rewrites_protocol_relative_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + ) + ); + + self::assertSame( + '//staging.example.test/uploads/file.pdf', + $transformer->transformString( '//example.test/uploads/file.pdf', $mappings ) + ); + } + + public function test_it_supports_multiple_mappings(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ), + ) + ); + + self::assertSame( + 'https://staging.example.test https://cdn.staging.example.test/image.jpg', + $transformer->transformString( 'https://example.test https://cdn.example.test/image.jpg', $mappings ) + ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `composer test -- --filter UrlTransformerTest` + +Expected: FAIL because `WPContentSync\Url\UrlTransformer` does not exist. + +- [ ] **Step 3: Implement `UrlTransformer`** + +Create `src/Url/UrlTransformer.php`: + +```php +all() as $mapping ) { + $transformed = $this->replaceMapping( $transformed, $mapping ); + } + + return $transformed; + } + + private function replaceMapping( string $value, UrlMapping $mapping ): string { + $source = $mapping->sourceUrl(); + $destination = $mapping->destinationUrl(); + + $replacements = array( + $source => $destination, + str_replace( '&', '&', $source ) => str_replace( '&', '&', $destination ), + $this->toProtocolRelative( $source ) => $this->toProtocolRelative( $destination ), + ); + + return strtr( $value, $replacements ); + } + + private function toProtocolRelative( string $url ): string { + return preg_replace( '#^https?:#', '', $url ) ?? $url; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `composer test -- --filter UrlTransformerTest` + +Expected: PASS with 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/Url/UrlTransformer.php tests/Unit/Url/UrlTransformerTest.php +git commit -m "feat: add url string transformer" +``` + +## Task 3: Metadata URL Transformation + +**Files:** +- Create: `src/Url/MetadataUrlTransformer.php` +- Create: `tests/Unit/Url/MetadataUrlTransformerTest.php` + +- [ ] **Step 1: Write failing metadata tests** + +Create `tests/Unit/Url/MetadataUrlTransformerTest.php`: + +```php + array( + 'url' => 'https://staging.example.test/uploads/hero.jpg', + ), + ), + $transformer->transformValue( + array( + 'hero' => array( + 'url' => 'https://example.test/uploads/hero.jpg', + ), + ), + $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() ); + $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', + ), + 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() ) + ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `composer test -- --filter MetadataUrlTransformerTest` + +Expected: FAIL because `WPContentSync\Url\MetadataUrlTransformer` does not exist. + +- [ ] **Step 3: Implement `MetadataUrlTransformer`** + +Create `src/Url/MetadataUrlTransformer.php`: + +```php +url_transformer = $url_transformer; + } + + /** + * @param mixed $value Metadata value. + * @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 ) ) { + $unserialized = unserialize( $value ); + return serialize( $this->transformValue( $unserialized, $mappings ) ); + } + + if ( $this->isJsonObjectOrArray( $value ) ) { + $decoded = json_decode( $value, true ); + if ( is_array( $decoded ) ) { + return wp_json_encode( $this->transformArray( $decoded, $mappings ) ); + } + } + + return $this->url_transformer->transformString( $value, $mappings ); + } + + /** + * @param array $value Metadata array. + * @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 { + return false !== @unserialize( $value ); + } + + private function isJsonObjectOrArray( string $value ): bool { + $trimmed = trim( $value ); + + return '' !== $trimmed && in_array( $trimmed[0], array( '{', '[' ), true ); + } +} +``` + +- [ ] **Step 4: Add `wp_json_encode` test stub** + +Modify `tests/bootstrap.php` by appending: + +```php +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $value ) { + return json_encode( $value ); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `composer test -- --filter MetadataUrlTransformerTest` + +Expected: PASS with 4 tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/Url/MetadataUrlTransformer.php tests/Unit/Url/MetadataUrlTransformerTest.php tests/bootstrap.php +git commit -m "feat: add metadata url transformer" +``` + +## Task 4: Service Wiring + +**Files:** +- Modify: `src/Plugin.php` + +- [ ] **Step 1: Wire URL services into the plugin container** + +Modify `src/Plugin.php` imports: + +```php +use WPContentSync\Url\MetadataUrlTransformer; +use WPContentSync\Url\UrlTransformer; +``` + +Inside `Plugin::create()`, before the `AdminPage::class` factory, add: + +```php +$container->factory( + UrlTransformer::class, + static function (): UrlTransformer { + return new UrlTransformer(); + } +); + +$container->factory( + MetadataUrlTransformer::class, + static function () use ( $container ): MetadataUrlTransformer { + return new MetadataUrlTransformer( + $container->get( UrlTransformer::class ) + ); + } +); +``` + +- [ ] **Step 2: Run static analysis** + +Run: `composer stan` + +Expected: PASS with no PHPStan errors. + +- [ ] **Step 3: Run tests** + +Run: `composer test` + +Expected: PASS with all tests. + +- [ ] **Step 4: Commit** + +```bash +git add src/Plugin.php +git commit -m "feat: register url transformation services" +``` + +## Task 5: Full URL Transformer Verification + +**Files:** +- Verify all files created or modified in Tasks 1-4. + +- [ ] **Step 1: Run Composer validation** + +Run: `composer validate --strict` + +Expected: PASS. + +- [ ] **Step 2: Run PHPCS** + +Run: `composer lint` + +Expected: PASS. + +- [ ] **Step 3: Run PHPStan** + +Run: `composer stan` + +Expected: PASS. + +- [ ] **Step 4: Run PHPUnit** + +Run: `composer test` + +Expected: PASS with the existing foundation tests plus URL transformer tests. + +- [ ] **Step 5: Manual smoke check in a PHP shell** + +Run: + +```bash +php -r "require 'vendor/autoload.php'; $m=new WPContentSync\Url\UrlMappingCollection([new WPContentSync\Url\UrlMapping('https://example.test','https://staging.example.test')]); $t=new WPContentSync\Url\UrlTransformer(); echo $t->transformString('https://example.test/about',$m), PHP_EOL;" +``` + +Expected output: + +```text +https://staging.example.test/about +``` + +- [ ] **Step 6: Commit any verification fixes** + +```bash +git status --short +git add src tests +git commit -m "chore: verify url transformer" +``` + +## Self-Review + +**Spec coverage in this URL transformer plan:** +- Plain text URL replacement is covered in Task 2. +- HTML attribute URL replacement is covered in Task 2 because the transformer rewrites URLs inside HTML strings without parsing/mutating markup structure. +- Escaped URLs are covered in Task 2 through `&` replacement. +- Protocol-relative URLs are covered in Task 2. +- Multiple source/destination mappings are covered in Tasks 1 and 2. +- JSON strings, serialized PHP arrays, and nested metadata arrays are covered in Task 3. +- GUID and permalink transformation rules are represented by the same string transformer used for any URL-bearing field; later content-handler phases will decide which post fields should be passed through it. + +**Deferred by design:** +- Admin UI for editing URL mappings remains in the later admin workflow phase. +- Database mutation and post/meta import logic remain in content-handler phases. +- Large-scale search/replace progress reporting remains in sync engine phases. From 8d7abc85369a629b4b72b7f7d56174824e21ef4a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:29:53 -0500 Subject: [PATCH 02/11] feat: add url mapping value objects --- src/Url/UrlMapping.php | 32 +++++++++++++++++ src/Url/UrlMappingCollection.php | 30 ++++++++++++++++ tests/Unit/Url/UrlMappingCollectionTest.php | 38 +++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 src/Url/UrlMapping.php create mode 100644 src/Url/UrlMappingCollection.php create mode 100644 tests/Unit/Url/UrlMappingCollectionTest.php diff --git a/src/Url/UrlMapping.php b/src/Url/UrlMapping.php new file mode 100644 index 0000000..bb0048a --- /dev/null +++ b/src/Url/UrlMapping.php @@ -0,0 +1,32 @@ +normalizeUrl( $source_url ); + $destination_url = $this->normalizeUrl( $destination_url ); + + if ( '' === $source_url || '' === $destination_url ) { + throw new \InvalidArgumentException( 'Source and destination URLs are required.' ); + } + + $this->source_url = $source_url; + $this->destination_url = $destination_url; + } + + public function sourceUrl(): string { + return $this->source_url; + } + + public function destinationUrl(): string { + return $this->destination_url; + } + + private function normalizeUrl( string $url ): string { + return rtrim( trim( $url ), '/' ); + } +} diff --git a/src/Url/UrlMappingCollection.php b/src/Url/UrlMappingCollection.php new file mode 100644 index 0000000..60093e4 --- /dev/null +++ b/src/Url/UrlMappingCollection.php @@ -0,0 +1,30 @@ + + */ + private array $mappings; + + /** + * @param array $mappings URL mappings. + */ + public function __construct( array $mappings ) { + foreach ( $mappings as $mapping ) { + if ( ! $mapping instanceof UrlMapping ) { + throw new \InvalidArgumentException( 'URL mapping collections only accept UrlMapping instances.' ); + } + } + + $this->mappings = array_values( $mappings ); + } + + /** + * @return array + */ + public function all(): array { + return $this->mappings; + } +} diff --git a/tests/Unit/Url/UrlMappingCollectionTest.php b/tests/Unit/Url/UrlMappingCollectionTest.php new file mode 100644 index 0000000..c56a04d --- /dev/null +++ b/tests/Unit/Url/UrlMappingCollectionTest.php @@ -0,0 +1,38 @@ +sourceUrl() ); + self::assertSame( 'https://staging.example.test', $mapping->destinationUrl() ); + } + + public function test_it_rejects_empty_mapping_urls(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Source and destination URLs are required.' ); + + new UrlMapping( '', 'https://staging.example.test' ); + } + + public function test_it_returns_mappings_in_order(): void { + $first = new UrlMapping( 'https://example.test', 'https://staging.example.test' ); + $second = new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ); + $collection = new UrlMappingCollection( array( $first, $second ) ); + + self::assertSame( array( $first, $second ), $collection->all() ); + } + + public function test_it_rejects_non_mapping_items(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'URL mapping collections only accept UrlMapping instances.' ); + + new UrlMappingCollection( array( new \stdClass() ) ); + } +} From 4880613b67aa34d5c6b6c0961c46d554d9cab65e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:34:44 -0500 Subject: [PATCH 03/11] fix: harden url mapping validation --- src/Url/UrlMapping.php | 54 ++++++++++++++++++++- src/Url/UrlMappingCollection.php | 7 +++ tests/Unit/Url/UrlMappingCollectionTest.php | 27 +++++++++-- tests/bootstrap.php | 13 +++++ 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/Url/UrlMapping.php b/src/Url/UrlMapping.php index bb0048a..0bd7e38 100644 --- a/src/Url/UrlMapping.php +++ b/src/Url/UrlMapping.php @@ -14,6 +14,10 @@ final class UrlMapping { throw new \InvalidArgumentException( 'Source and destination URLs are required.' ); } + if ( ! $this->isAbsoluteUrl( $source_url ) || ! $this->isAbsoluteUrl( $destination_url ) ) { + throw new \InvalidArgumentException( 'Source and destination URLs must include a scheme and host.' ); + } + $this->source_url = $source_url; $this->destination_url = $destination_url; } @@ -27,6 +31,54 @@ final class UrlMapping { } private function normalizeUrl( string $url ): string { - return rtrim( trim( $url ), '/' ); + $url = trim( $url ); + $parts = wp_parse_url( $url ); + + if ( ! is_array( $parts ) || ! isset( $parts['path'] ) || '/' !== $parts['path'] ) { + return $url; + } + + unset( $parts['path'] ); + + return $this->buildUrl( $parts ); + } + + private function isAbsoluteUrl( string $url ): bool { + $parts = wp_parse_url( $url ); + + return is_array( $parts ) && ! empty( $parts['scheme'] ) && ! empty( $parts['host'] ); + } + + /** + * @param array $parts URL parts. + */ + private function buildUrl( array $parts ): string { + $url = (string) $parts['scheme'] . '://'; + + if ( isset( $parts['user'] ) ) { + $url .= (string) $parts['user']; + + if ( isset( $parts['pass'] ) ) { + $url .= ':' . (string) $parts['pass']; + } + + $url .= '@'; + } + + $url .= (string) $parts['host']; + + if ( isset( $parts['port'] ) ) { + $url .= ':' . (string) $parts['port']; + } + + if ( isset( $parts['query'] ) ) { + $url .= '?' . (string) $parts['query']; + } + + if ( isset( $parts['fragment'] ) ) { + $url .= '#' . (string) $parts['fragment']; + } + + return $url; } } diff --git a/src/Url/UrlMappingCollection.php b/src/Url/UrlMappingCollection.php index 60093e4..744a480 100644 --- a/src/Url/UrlMappingCollection.php +++ b/src/Url/UrlMappingCollection.php @@ -19,6 +19,13 @@ final class UrlMappingCollection { } $this->mappings = array_values( $mappings ); + + usort( + $this->mappings, + static function ( UrlMapping $left, UrlMapping $right ): int { + return strlen( $right->sourceUrl() ) <=> strlen( $left->sourceUrl() ); + } + ); } /** diff --git a/tests/Unit/Url/UrlMappingCollectionTest.php b/tests/Unit/Url/UrlMappingCollectionTest.php index c56a04d..af5a471 100644 --- a/tests/Unit/Url/UrlMappingCollectionTest.php +++ b/tests/Unit/Url/UrlMappingCollectionTest.php @@ -21,12 +21,29 @@ class UrlMappingCollectionTest extends TestCase { new UrlMapping( '', 'https://staging.example.test' ); } - public function test_it_returns_mappings_in_order(): void { - $first = new UrlMapping( 'https://example.test', 'https://staging.example.test' ); - $second = new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ); - $collection = new UrlMappingCollection( array( $first, $second ) ); + public function test_it_rejects_mapping_urls_without_scheme_and_host(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Source and destination URLs must include a scheme and host.' ); - self::assertSame( array( $first, $second ), $collection->all() ); + new UrlMapping( 'https://', 'https://staging.example.test' ); + } + + public function test_it_preserves_query_and_fragment_trailing_slashes(): void { + $mapping = new UrlMapping( + 'https://example.test/?redirect=/', + 'https://staging.example.test/#/' + ); + + self::assertSame( 'https://example.test?redirect=/', $mapping->sourceUrl() ); + self::assertSame( 'https://staging.example.test#/', $mapping->destinationUrl() ); + } + + public function test_it_returns_mappings_by_longest_source_url_first(): void { + $short = new UrlMapping( 'https://example.test', 'https://staging.example.test' ); + $long = new UrlMapping( 'https://example.test/uploads', 'https://staging.example.test/uploads' ); + $collection = new UrlMappingCollection( array( $short, $long ) ); + + self::assertSame( array( $long, $short ), $collection->all() ); } public function test_it_rejects_non_mapping_items(): void { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index dbea835..88fbd06 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -125,6 +125,19 @@ if ( ! function_exists( 'esc_url_raw' ) ) { } } +if ( ! function_exists( 'wp_parse_url' ) ) { + /** + * Minimal URL parser for unit tests. + * + * @param string $url URL to parse. + * @return array|false + */ + function wp_parse_url( $url ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Test stub for WordPress' wp_parse_url(). + return parse_url( $url ); + } +} + if ( ! function_exists( 'get_option' ) ) { /** * Minimal WordPress option reader for unit tests. From ddb0e12a9baa34e14c799b357985bb9ad26f0125 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:39:00 -0500 Subject: [PATCH 04/11] feat: add url string transformer --- src/Url/UrlTransformer.php | 32 ++++++++++ tests/Unit/Url/UrlTransformerTest.php | 84 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/Url/UrlTransformer.php create mode 100644 tests/Unit/Url/UrlTransformerTest.php diff --git a/src/Url/UrlTransformer.php b/src/Url/UrlTransformer.php new file mode 100644 index 0000000..99db928 --- /dev/null +++ b/src/Url/UrlTransformer.php @@ -0,0 +1,32 @@ +all() as $mapping ) { + $transformed = $this->replaceMapping( $transformed, $mapping ); + } + + return $transformed; + } + + private function replaceMapping( string $value, UrlMapping $mapping ): string { + $source = $mapping->sourceUrl(); + $destination = $mapping->destinationUrl(); + + $replacements = array( + $source => $destination, + str_replace( '&', '&', $source ) => str_replace( '&', '&', $destination ), + $this->toProtocolRelative( $source ) => $this->toProtocolRelative( $destination ), + ); + + return strtr( $value, $replacements ); + } + + private function toProtocolRelative( string $url ): string { + return preg_replace( '#^https?:#', '', $url ) ?? $url; + } +} diff --git a/tests/Unit/Url/UrlTransformerTest.php b/tests/Unit/Url/UrlTransformerTest.php new file mode 100644 index 0000000..1d3f71d --- /dev/null +++ b/tests/Unit/Url/UrlTransformerTest.php @@ -0,0 +1,84 @@ +transformString( 'Visit https://example.test/about for details.', $mappings ) + ); + } + + public function test_it_rewrites_html_attribute_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + ) + ); + + self::assertSame( + '', + $transformer->transformString( + '', + $mappings + ) + ); + } + + public function test_it_rewrites_escaped_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test/path?a=1&b=2', 'https://staging.example.test/path?a=1&b=2' ), + ) + ); + + self::assertSame( + 'https://staging.example.test/path?a=1&b=2', + $transformer->transformString( 'https://example.test/path?a=1&b=2', $mappings ) + ); + } + + public function test_it_rewrites_protocol_relative_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + ) + ); + + self::assertSame( + '//staging.example.test/uploads/file.pdf', + $transformer->transformString( '//example.test/uploads/file.pdf', $mappings ) + ); + } + + public function test_it_supports_multiple_mappings(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ), + ) + ); + + self::assertSame( + 'https://staging.example.test https://cdn.staging.example.test/image.jpg', + $transformer->transformString( 'https://example.test https://cdn.example.test/image.jpg', $mappings ) + ); + } +} From badd58ada645cacc184f6fc927e26550a446d143 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:42:11 -0500 Subject: [PATCH 05/11] fix: prevent unsafe url rewrites --- src/Url/UrlTransformer.php | 45 +++++++++++++++++--- tests/Unit/Url/UrlTransformerTest.php | 61 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/Url/UrlTransformer.php b/src/Url/UrlTransformer.php index 99db928..23e77ce 100644 --- a/src/Url/UrlTransformer.php +++ b/src/Url/UrlTransformer.php @@ -4,26 +4,59 @@ namespace WPContentSync\Url; final class UrlTransformer { public function transformString( string $value, UrlMappingCollection $mappings ): string { - $transformed = $value; + $replacements = array(); foreach ( $mappings->all() as $mapping ) { - $transformed = $this->replaceMapping( $transformed, $mapping ); + $replacements = array_merge( $replacements, $this->replacementsForMapping( $mapping ) ); } - return $transformed; + if ( array() === $replacements ) { + return $value; + } + + return preg_replace_callback( + $this->replacementPattern( array_keys( $replacements ) ), + static function ( array $matches ) use ( $replacements ): string { + return $replacements[ $matches[0] ]; + }, + $value + ) ?? $value; } - private function replaceMapping( string $value, UrlMapping $mapping ): string { + /** + * @return array + */ + private function replacementsForMapping( UrlMapping $mapping ): array { $source = $mapping->sourceUrl(); $destination = $mapping->destinationUrl(); - $replacements = array( + return array( $source => $destination, str_replace( '&', '&', $source ) => str_replace( '&', '&', $destination ), $this->toProtocolRelative( $source ) => $this->toProtocolRelative( $destination ), + str_replace( '&', '&', $this->toProtocolRelative( $source ) ) => str_replace( '&', '&', $this->toProtocolRelative( $destination ) ), + ); + } + + /** + * @param array $sources Replacement sources. + */ + private function replacementPattern( array $sources ): string { + usort( + $sources, + static function ( string $left, string $right ): int { + return strlen( $right ) <=> strlen( $left ); + } ); - return strtr( $value, $replacements ); + $quoted = array_map( + static function ( string $source ): string { + return preg_quote( $source, '~' ); + }, + $sources + ); + + return '~(?transformString( 'https://example.test https://cdn.example.test/image.jpg', $mappings ) ); } + + public function test_it_does_not_cascade_mapping_destinations_into_other_sources(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://a.example.test', 'https://b.example.test' ), + new UrlMapping( 'https://b.example.test', 'https://c.example.test' ), + ) + ); + + self::assertSame( + 'https://b.example.test/page https://c.example.test/page', + $transformer->transformString( 'https://a.example.test/page https://b.example.test/page', $mappings ) + ); + } + + public function test_it_does_not_rewrite_partial_host_matches(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + ) + ); + + self::assertSame( + 'https://example.test.evil/path https://staging.example.test/path', + $transformer->transformString( + 'https://example.test.evil/path https://example.test/path', + $mappings + ) + ); + } + + public function test_it_prefers_more_specific_overlapping_mappings(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test', 'https://staging.example.test' ), + new UrlMapping( 'https://example.test/uploads', 'https://media.staging.example.test/uploads' ), + ) + ); + + self::assertSame( + 'https://media.staging.example.test/uploads/image.jpg', + $transformer->transformString( 'https://example.test/uploads/image.jpg', $mappings ) + ); + } + + public function test_it_rewrites_escaped_protocol_relative_urls(): void { + $transformer = new UrlTransformer(); + $mappings = new UrlMappingCollection( + array( + new UrlMapping( 'https://example.test/path?a=1&b=2', 'https://staging.example.test/path?a=1&b=2' ), + ) + ); + + self::assertSame( + '//staging.example.test/path?a=1&b=2', + $transformer->transformString( '//example.test/path?a=1&b=2', $mappings ) + ); + } } From e7447543891a429dd0c1541ad96cb154eb44f1a6 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:45:17 -0500 Subject: [PATCH 06/11] fix: avoid userinfo url rewrites --- src/Url/UrlTransformer.php | 2 +- tests/Unit/Url/UrlTransformerTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Url/UrlTransformer.php b/src/Url/UrlTransformer.php index 23e77ce..c64cdce 100644 --- a/src/Url/UrlTransformer.php +++ b/src/Url/UrlTransformer.php @@ -56,7 +56,7 @@ final class UrlTransformer { $sources ); - return '~(?transformString( + 'https://example.test@evil.test/path https://example.test/path', + $mappings + ) + ); + } + public function test_it_prefers_more_specific_overlapping_mappings(): void { $transformer = new UrlTransformer(); $mappings = new UrlMappingCollection( From 3b09c3f4102401da056ca218ce999039c75ee582 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:51:21 -0500 Subject: [PATCH 07/11] 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. From 4cfc1036bb62a16efbae5c36f50eb69853d59702 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:55:07 -0500 Subject: [PATCH 08/11] fix: preserve invalid metadata payloads --- src/Url/MetadataUrlTransformer.php | 57 ++++++++++++++----- tests/Unit/Url/MetadataUrlTransformerTest.php | 45 +++++++++++++++ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/Url/MetadataUrlTransformer.php b/src/Url/MetadataUrlTransformer.php index f160338..89ea04a 100644 --- a/src/Url/MetadataUrlTransformer.php +++ b/src/Url/MetadataUrlTransformer.php @@ -25,12 +25,19 @@ final class MetadataUrlTransformer { 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 ) ); + if ( $this->looksSerialized( trim( $value ) ) && trim( $value ) !== $value ) { + return $value; + } + + if ( $this->looksSerialized( $value ) ) { + $unserialized = $this->unserializeValue( $value ); + + if ( ! $unserialized['valid'] || is_object( $unserialized['value'] ) ) { + return $value; + } // phpcs:ignore -- Required to reserialize metadata with updated string lengths. - return serialize( $this->transformValue( $unserialized, $mappings ) ); + return serialize( $this->transformValue( $unserialized['value'], $mappings ) ); } if ( $this->isJsonObjectOrArray( $value ) ) { @@ -43,6 +50,8 @@ final class MetadataUrlTransformer { return $encoded; } } + + return $value; } return $this->url_transformer->transformString( $value, $mappings ); @@ -65,23 +74,43 @@ final class MetadataUrlTransformer { return $transformed; } - private function isSerialized( string $value ): bool { - $trimmed = trim( $value ); + private function looksSerialized( string $value ): bool { + return 'N;' === $value || 'b:0;' === $value || 1 === preg_match( '/^(?:a|O|s|i|d|b):/', $value ); + } - if ( 'N;' === $trimmed ) { - return true; + /** + * @return array{valid: bool, value: mixed} + */ + private function unserializeValue( string $value ): array { + if ( 'N;' === $value ) { + return array( + 'valid' => true, + 'value' => null, + ); } - if ( 'b:0;' === $trimmed ) { - return true; + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Metadata may be stored in PHP serialized format. + $unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); + + if ( false === $unserialized && 'b:0;' !== $value ) { + return array( + 'valid' => false, + 'value' => null, + ); } - if ( ! preg_match( '/^(?:a|O|s|i|d|b):/', $trimmed ) ) { - return false; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Strict validation avoids transforming malformed serialized-looking strings. + if ( serialize( $unserialized ) !== $value ) { + return array( + 'valid' => false, + 'value' => null, + ); } - // phpcs:ignore -- Validation must parse PHP serialized metadata. - return false !== @unserialize( $trimmed, array( 'allowed_classes' => false ) ); + return array( + 'valid' => true, + 'value' => $unserialized, + ); } private function isJsonObjectOrArray( string $value ): bool { diff --git a/tests/Unit/Url/MetadataUrlTransformerTest.php b/tests/Unit/Url/MetadataUrlTransformerTest.php index 32562a6..3995a65 100644 --- a/tests/Unit/Url/MetadataUrlTransformerTest.php +++ b/tests/Unit/Url/MetadataUrlTransformerTest.php @@ -71,6 +71,51 @@ class MetadataUrlTransformerTest extends TestCase { ); } + public function test_it_leaves_invalid_serialized_strings_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = 'a:1:{s:3:"url";s:37:"https://example.test/uploads/hero.jpg";} trailing'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_leaves_whitespace_wrapped_serialized_strings_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + // phpcs:ignore -- Test fixture requires native serialized metadata. + $value = ' ' . serialize( array( 'url' => 'https://example.test/uploads/hero.jpg' ) ) . ' '; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_preserves_serialized_false_and_null_values(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + + self::assertSame( 'b:0;', $transformer->transformValue( 'b:0;', $this->mappings() ) ); + self::assertSame( 'N;', $transformer->transformValue( 'N;', $this->mappings() ) ); + } + + public function test_it_leaves_serialized_objects_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = 'O:8:"stdClass":1:{s:3:"url";s:28:"https://example.test/inside";}'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_leaves_invalid_json_strings_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = '{"url":"https://example.test/missing"'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_transforms_nested_json_arrays(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + + self::assertSame( + '[{"url":"https:\/\/staging.example.test\/one"},{"count":2}]', + $transformer->transformValue( '[{"url":"https:\/\/example.test\/one"},{"count":2}]', $this->mappings() ) + ); + } + public function test_it_transforms_plain_string_metadata(): void { $transformer = new MetadataUrlTransformer( new UrlTransformer() ); From d50917d82707b86607d0bc256a69c300902871d0 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 14:57:48 -0500 Subject: [PATCH 09/11] fix: preserve serialized object payloads --- src/Url/MetadataUrlTransformer.php | 23 ++++++++++++++++++- tests/Unit/Url/MetadataUrlTransformerTest.php | 7 ++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Url/MetadataUrlTransformer.php b/src/Url/MetadataUrlTransformer.php index 89ea04a..9142c14 100644 --- a/src/Url/MetadataUrlTransformer.php +++ b/src/Url/MetadataUrlTransformer.php @@ -32,7 +32,7 @@ final class MetadataUrlTransformer { if ( $this->looksSerialized( $value ) ) { $unserialized = $this->unserializeValue( $value ); - if ( ! $unserialized['valid'] || is_object( $unserialized['value'] ) ) { + if ( ! $unserialized['valid'] || $this->containsObject( $unserialized['value'] ) ) { return $value; } @@ -118,4 +118,25 @@ final class MetadataUrlTransformer { return '' !== $trimmed && in_array( $trimmed[0], array( '{', '[' ), true ); } + + /** + * @param mixed $value Value to inspect. + */ + private function containsObject( $value ): bool { + if ( is_object( $value ) ) { + return true; + } + + if ( ! is_array( $value ) ) { + return false; + } + + foreach ( $value as $item ) { + if ( $this->containsObject( $item ) ) { + return true; + } + } + + return false; + } } diff --git a/tests/Unit/Url/MetadataUrlTransformerTest.php b/tests/Unit/Url/MetadataUrlTransformerTest.php index 3995a65..f73fa1c 100644 --- a/tests/Unit/Url/MetadataUrlTransformerTest.php +++ b/tests/Unit/Url/MetadataUrlTransformerTest.php @@ -100,6 +100,13 @@ class MetadataUrlTransformerTest extends TestCase { self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); } + public function test_it_leaves_serialized_payloads_with_nested_objects_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = 'a:2:{s:3:"url";s:28:"https://example.test/inside";s:6:"object";O:8:"stdClass":0:{}}'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + public function test_it_leaves_invalid_json_strings_unchanged(): void { $transformer = new MetadataUrlTransformer( new UrlTransformer() ); $value = '{"url":"https://example.test/missing"'; From 4f845bdbf9f7af70fba42923b23733a9cb39bc77 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 15:00:36 -0500 Subject: [PATCH 10/11] feat: register url transformation services --- src/Plugin.php | 18 ++++++++++++++++++ tests/Unit/PluginTest.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/Unit/PluginTest.php diff --git a/src/Plugin.php b/src/Plugin.php index 36c9e4e..4b1838a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,8 @@ use WPContentSync\Admin\AdminPage; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Logging\OptionLogger; use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Url\MetadataUrlTransformer; +use WPContentSync\Url\UrlTransformer; final class Plugin { private Container $container; @@ -36,6 +38,22 @@ final class Plugin { } ); + $container->factory( + UrlTransformer::class, + static function (): UrlTransformer { + return new UrlTransformer(); + } + ); + + $container->factory( + MetadataUrlTransformer::class, + static function () use ( $container ): MetadataUrlTransformer { + return new MetadataUrlTransformer( + $container->get( UrlTransformer::class ) + ); + } + ); + $container->factory( AdminPage::class, static function () use ( $container ): AdminPage { diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php new file mode 100644 index 0000000..9c5a1e5 --- /dev/null +++ b/tests/Unit/PluginTest.php @@ -0,0 +1,34 @@ +getPluginContainer( Plugin::create() ); + + $url_transformer = $container->get( UrlTransformer::class ); + $metadata_transformer = $container->get( MetadataUrlTransformer::class ); + + self::assertInstanceOf( UrlTransformer::class, $url_transformer ); + self::assertInstanceOf( MetadataUrlTransformer::class, $metadata_transformer ); + self::assertSame( $url_transformer, $container->get( UrlTransformer::class ) ); + self::assertSame( $metadata_transformer, $container->get( MetadataUrlTransformer::class ) ); + } + + private function getPluginContainer( Plugin $plugin ): Container { + $reflection = new \ReflectionClass( $plugin ); + $property = $reflection->getProperty( 'container' ); + $property->setAccessible( true ); + + /** @var Container $container */ + $container = $property->getValue( $plugin ); + + return $container; + } +} From 2ede677bd27f5cd7002c7a1c8231895f36c229b1 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 19:39:46 -0500 Subject: [PATCH 11/11] test: fix serialized object fixtures --- tests/Unit/Url/MetadataUrlTransformerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Url/MetadataUrlTransformerTest.php b/tests/Unit/Url/MetadataUrlTransformerTest.php index f73fa1c..52c446d 100644 --- a/tests/Unit/Url/MetadataUrlTransformerTest.php +++ b/tests/Unit/Url/MetadataUrlTransformerTest.php @@ -95,14 +95,14 @@ class MetadataUrlTransformerTest extends TestCase { public function test_it_leaves_serialized_objects_unchanged(): void { $transformer = new MetadataUrlTransformer( new UrlTransformer() ); - $value = 'O:8:"stdClass":1:{s:3:"url";s:28:"https://example.test/inside";}'; + $value = 'O:8:"stdClass":1:{s:3:"url";s:27:"https://example.test/inside";}'; self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); } public function test_it_leaves_serialized_payloads_with_nested_objects_unchanged(): void { $transformer = new MetadataUrlTransformer( new UrlTransformer() ); - $value = 'a:2:{s:3:"url";s:28:"https://example.test/inside";s:6:"object";O:8:"stdClass":0:{}}'; + $value = 'a:2:{s:3:"url";s:27:"https://example.test/inside";s:6:"object";O:8:"stdClass":0:{}}'; self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); }