Merge pull request 'docs: add url transformer implementation plan' (#1) from feature/url-transformer-plan into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Url;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Url\UrlMapping;
|
||||
use WPContentSync\Url\UrlMappingCollection;
|
||||
|
||||
class UrlMappingCollectionTest extends TestCase {
|
||||
public function test_it_normalizes_mapping_urls_without_trailing_slashes(): void {
|
||||
$mapping = new UrlMapping( 'https://example.test/', 'https://staging.example.test/' );
|
||||
|
||||
self::assertSame( 'https://example.test', $mapping->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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class UrlMapping {
|
||||
private string $source_url;
|
||||
private string $destination_url;
|
||||
|
||||
public function __construct( string $source_url, string $destination_url ) {
|
||||
$source_url = $this->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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class UrlMappingCollection {
|
||||
/**
|
||||
* @var array<int, UrlMapping>
|
||||
*/
|
||||
private array $mappings;
|
||||
|
||||
/**
|
||||
* @param array<int, UrlMapping> $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<int, UrlMapping>
|
||||
*/
|
||||
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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Url;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Url\UrlMapping;
|
||||
use WPContentSync\Url\UrlMappingCollection;
|
||||
use WPContentSync\Url\UrlTransformer;
|
||||
|
||||
class UrlTransformerTest extends TestCase {
|
||||
public function test_it_rewrites_plain_urls(): void {
|
||||
$transformer = new UrlTransformer();
|
||||
$mappings = new UrlMappingCollection(
|
||||
array(
|
||||
new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
|
||||
)
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
'Visit https://staging.example.test/about for details.',
|
||||
$transformer->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(
|
||||
'<a href="https://staging.example.test/about"><img src="https://staging.example.test/image.jpg"></a>',
|
||||
$transformer->transformString(
|
||||
'<a href="https://example.test/about"><img src="https://example.test/image.jpg"></a>',
|
||||
$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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class UrlTransformer {
|
||||
public function transformString( string $value, UrlMappingCollection $mappings ): string {
|
||||
$transformed = $value;
|
||||
|
||||
foreach ( $mappings->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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Url;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Url\MetadataUrlTransformer;
|
||||
use WPContentSync\Url\UrlMapping;
|
||||
use WPContentSync\Url\UrlMappingCollection;
|
||||
use WPContentSync\Url\UrlTransformer;
|
||||
|
||||
class MetadataUrlTransformerTest extends TestCase {
|
||||
private function mappings(): UrlMappingCollection {
|
||||
return new UrlMappingCollection(
|
||||
array(
|
||||
new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_recursively_transforms_array_metadata(): void {
|
||||
$transformer = new MetadataUrlTransformer( new UrlTransformer() );
|
||||
|
||||
self::assertSame(
|
||||
array(
|
||||
'hero' => 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
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class MetadataUrlTransformer {
|
||||
private UrlTransformer $url_transformer;
|
||||
|
||||
public function __construct( UrlTransformer $url_transformer ) {
|
||||
$this->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<mixed> $value Metadata array.
|
||||
* @return array<mixed>
|
||||
*/
|
||||
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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class MetadataUrlTransformer {
|
||||
private UrlTransformer $url_transformer;
|
||||
|
||||
public function __construct( UrlTransformer $url_transformer ) {
|
||||
$this->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->looksSerialized( trim( $value ) ) && trim( $value ) !== $value ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ( $this->looksSerialized( $value ) ) {
|
||||
$unserialized = $this->unserializeValue( $value );
|
||||
|
||||
if ( ! $unserialized['valid'] || $this->containsObject( $unserialized['value'] ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// phpcs:ignore -- Required to reserialize metadata with updated string lengths.
|
||||
return serialize( $this->transformValue( $unserialized['value'], $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 $value;
|
||||
}
|
||||
|
||||
return $this->url_transformer->transformString( $value, $mappings );
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform URLs within a metadata array.
|
||||
*
|
||||
* @param array<mixed> $value Metadata array.
|
||||
* @param UrlMappingCollection $mappings URL mappings.
|
||||
* @return array<mixed>
|
||||
*/
|
||||
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 looksSerialized( string $value ): bool {
|
||||
return 'N;' === $value || 'b:0;' === $value || 1 === preg_match( '/^(?:a|O|s|i|d|b):/', $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, value: mixed}
|
||||
*/
|
||||
private function unserializeValue( string $value ): array {
|
||||
if ( 'N;' === $value ) {
|
||||
return array(
|
||||
'valid' => true,
|
||||
'value' => null,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'valid' => true,
|
||||
'value' => $unserialized,
|
||||
);
|
||||
}
|
||||
|
||||
private function isJsonObjectOrArray( string $value ): bool {
|
||||
$trimmed = trim( $value );
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class UrlMapping {
|
||||
private string $source_url;
|
||||
private string $destination_url;
|
||||
|
||||
public function __construct( string $source_url, string $destination_url ) {
|
||||
$source_url = $this->normalizeUrl( $source_url );
|
||||
$destination_url = $this->normalizeUrl( $destination_url );
|
||||
|
||||
if ( '' === $source_url || '' === $destination_url ) {
|
||||
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;
|
||||
}
|
||||
|
||||
public function sourceUrl(): string {
|
||||
return $this->source_url;
|
||||
}
|
||||
|
||||
public function destinationUrl(): string {
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
private function normalizeUrl( string $url ): string {
|
||||
$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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class UrlMappingCollection {
|
||||
/**
|
||||
* @var array<int, UrlMapping>
|
||||
*/
|
||||
private array $mappings;
|
||||
|
||||
/**
|
||||
* @param array<int, UrlMapping> $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 );
|
||||
|
||||
usort(
|
||||
$this->mappings,
|
||||
static function ( UrlMapping $left, UrlMapping $right ): int {
|
||||
return strlen( $right->sourceUrl() ) <=> strlen( $left->sourceUrl() );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, UrlMapping>
|
||||
*/
|
||||
public function all(): array {
|
||||
return $this->mappings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Url;
|
||||
|
||||
final class UrlTransformer {
|
||||
public function transformString( string $value, UrlMappingCollection $mappings ): string {
|
||||
$replacements = array();
|
||||
|
||||
foreach ( $mappings->all() as $mapping ) {
|
||||
$replacements = array_merge( $replacements, $this->replacementsForMapping( $mapping ) );
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function replacementsForMapping( UrlMapping $mapping ): array {
|
||||
$source = $mapping->sourceUrl();
|
||||
$destination = $mapping->destinationUrl();
|
||||
|
||||
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<int, string> $sources Replacement sources.
|
||||
*/
|
||||
private function replacementPattern( array $sources ): string {
|
||||
usort(
|
||||
$sources,
|
||||
static function ( string $left, string $right ): int {
|
||||
return strlen( $right ) <=> strlen( $left );
|
||||
}
|
||||
);
|
||||
|
||||
$quoted = array_map(
|
||||
static function ( string $source ): string {
|
||||
return preg_quote( $source, '~' );
|
||||
},
|
||||
$sources
|
||||
);
|
||||
|
||||
return '~(?<![A-Za-z0-9.-])(?:' . implode( '|', $quoted ) . ')(?![A-Za-z0-9.@-])~';
|
||||
}
|
||||
|
||||
private function toProtocolRelative( string $url ): string {
|
||||
return preg_replace( '#^https?:#', '', $url ) ?? $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Container;
|
||||
use WPContentSync\Plugin;
|
||||
use WPContentSync\Url\MetadataUrlTransformer;
|
||||
use WPContentSync\Url\UrlTransformer;
|
||||
|
||||
class PluginTest extends TestCase {
|
||||
public function test_it_registers_url_transformation_services(): void {
|
||||
$container = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Url;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Url\MetadataUrlTransformer;
|
||||
use WPContentSync\Url\UrlMapping;
|
||||
use WPContentSync\Url\UrlMappingCollection;
|
||||
use WPContentSync\Url\UrlTransformer;
|
||||
|
||||
class MetadataUrlTransformerTest extends TestCase {
|
||||
private function mappings(): UrlMappingCollection {
|
||||
return new UrlMappingCollection(
|
||||
array(
|
||||
new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_recursively_transforms_array_metadata(): void {
|
||||
$transformer = new MetadataUrlTransformer( new UrlTransformer() );
|
||||
|
||||
self::assertSame(
|
||||
array(
|
||||
'hero' => 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_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: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:27:"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"';
|
||||
|
||||
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() );
|
||||
|
||||
self::assertSame(
|
||||
'https://staging.example.test/contact',
|
||||
$transformer->transformValue( 'https://example.test/contact', $this->mappings() )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Url;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Url\UrlMapping;
|
||||
use WPContentSync\Url\UrlMappingCollection;
|
||||
|
||||
class UrlMappingCollectionTest extends TestCase {
|
||||
public function test_it_normalizes_mapping_urls_without_trailing_slashes(): void {
|
||||
$mapping = new UrlMapping( 'https://example.test/', 'https://staging.example.test/' );
|
||||
|
||||
self::assertSame( 'https://example.test', $mapping->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_rejects_mapping_urls_without_scheme_and_host(): void {
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$this->expectExceptionMessage( 'Source and destination URLs must include a scheme and host.' );
|
||||
|
||||
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 {
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$this->expectExceptionMessage( 'URL mapping collections only accept UrlMapping instances.' );
|
||||
|
||||
new UrlMappingCollection( array( new \stdClass() ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Url;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Url\UrlMapping;
|
||||
use WPContentSync\Url\UrlMappingCollection;
|
||||
use WPContentSync\Url\UrlTransformer;
|
||||
|
||||
class UrlTransformerTest extends TestCase {
|
||||
public function test_it_rewrites_plain_urls(): void {
|
||||
$transformer = new UrlTransformer();
|
||||
$mappings = new UrlMappingCollection(
|
||||
array(
|
||||
new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
|
||||
)
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
'Visit https://staging.example.test/about for details.',
|
||||
$transformer->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(
|
||||
'<a href="https://staging.example.test/about"><img src="https://staging.example.test/image.jpg"></a>',
|
||||
$transformer->transformString(
|
||||
'<a href="https://example.test/about"><img src="https://example.test/image.jpg"></a>',
|
||||
$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 )
|
||||
);
|
||||
}
|
||||
|
||||
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_does_not_rewrite_userinfo_host_lookalikes(): void {
|
||||
$transformer = new UrlTransformer();
|
||||
$mappings = new UrlMappingCollection(
|
||||
array(
|
||||
new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
|
||||
)
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
'https://example.test@evil.test/path https://staging.example.test/path',
|
||||
$transformer->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(
|
||||
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 )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,32 @@ 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<string, mixed>|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( '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.
|
||||
|
||||
Reference in New Issue
Block a user