Files
WP-Content-Sync/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md
T
2026-04-28 13:37:31 -05:00

1012 lines
31 KiB
Markdown

# Sync Engine and Content Handlers 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 first real content mutation path so validated packages can be imported through deterministic content handlers with progress, logging, URL rewriting, and conflict-aware results.
**Architecture:** Phase 5 starts import-first: REST/file transports continue producing `ContentPackage` objects, and a new `SyncEngine` applies those packages through ordered handlers. Export orchestration, retry/backoff, and media sideloading remain later slices so the first mutation path stays reviewable and safe.
**Tech Stack:** PHP 7.4, WordPress APIs, PHPUnit, PHPStan, PHPCS/WPCS, existing package/transport/url/logging services.
---
## File Structure
- `src/Sync/SyncResult.php`: immutable result object with success, created, updated, skipped, conflict, and error counts.
- `src/Sync/SyncContext.php`: immutable context for direction, operation ID, source/destination URLs, URL mappings, and conflict strategy.
- `src/Sync/SyncOperationState.php`: immutable progress snapshot for current operation status and bucket offsets.
- `src/Sync/SyncStateRepository.php`: option/transient-backed state persistence for resumable imports.
- `src/Sync/SyncEngine.php`: validates package records and calls content handlers in deterministic order.
- `src/Content/ContentHandlerInterface.php`: shared handler boundary for package bucket imports.
- `src/Content/ContentHandlerRegistry.php`: ordered, keyed content handler collection.
- `src/Content/ContentImportException.php`: typed content import failure with handler/bucket context.
- `src/Content/ContentRecordNormalizer.php`: sanitizes package records into predictable post/term/media shapes.
- `src/Content/PostContentHandler.php`: imports posts, pages, and custom post type records using last-write-wins or manual-review behavior.
- `src/Content/TermContentHandler.php`: imports taxonomy terms and term metadata.
- `src/Content/MediaContentHandler.php`: imports attachment records and attachment metadata without sideloading remote files in this slice.
- `src/Admin/FileImportController.php`: applies validated packages with `SyncEngine`.
- `src/Rest/RestPackageController.php`: applies validated REST packages with `SyncEngine`.
- `src/Plugin.php`: registers sync/content services.
- `tests/bootstrap.php`: adds WordPress post, meta, term, attachment, and transient stubs.
---
## Task 1: Sync Result Value Object
**Files:**
- Create: `src/Sync/SyncResult.php`
- Create: `tests/Unit/Sync/SyncResultTest.php`
- [ ] **Step 1: Write the failing tests**
Create `tests/Unit/Sync/SyncResultTest.php`:
```php
<?php
/**
* Tests for sync result summaries.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncResult;
class SyncResultTest extends TestCase {
public function test_it_tracks_successful_counts(): void {
$result = SyncResult::success(
array(
'created' => 2,
'updated' => 3,
'skipped' => 1,
'conflicts' => 1,
)
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 2, $result->created() );
self::assertSame( 3, $result->updated() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( array(), $result->errors() );
}
public function test_it_tracks_failed_results(): void {
$result = SyncResult::failure( array( 'posts import failed.' ) );
self::assertFalse( $result->isSuccessful() );
self::assertSame( array( 'posts import failed.' ), $result->errors() );
}
public function test_it_merges_multiple_results(): void {
$result = SyncResult::merge(
array(
SyncResult::success( array( 'created' => 1 ) ),
SyncResult::success( array( 'updated' => 2, 'skipped' => 1 ) ),
SyncResult::failure( array( 'terms import failed.' ) ),
)
);
self::assertFalse( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 2, $result->updated() );
self::assertSame( 1, $result->skipped() );
self::assertSame( array( 'terms import failed.' ), $result->errors() );
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `composer test -- --filter SyncResultTest`
Expected: FAIL with class `WPContentSync\Sync\SyncResult` not found.
- [ ] **Step 3: Implement `SyncResult`**
Create `src/Sync/SyncResult.php`:
```php
<?php
/**
* Immutable sync operation result.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncResult {
private bool $successful;
private int $created;
private int $updated;
private int $skipped;
private int $conflicts;
/** @var array<int, string> */
private array $errors;
/**
* @param array<int, string> $errors Error messages.
*/
private function __construct( bool $successful, int $created, int $updated, int $skipped, int $conflicts, array $errors ) {
$this->successful = $successful;
$this->created = max( 0, $created );
$this->updated = max( 0, $updated );
$this->skipped = max( 0, $skipped );
$this->conflicts = max( 0, $conflicts );
$this->errors = array_values( array_map( 'strval', $errors ) );
}
/**
* @param array<string, int> $counts Result counts.
*/
public static function success( array $counts = array() ): self {
return new self(
true,
(int) ( $counts['created'] ?? 0 ),
(int) ( $counts['updated'] ?? 0 ),
(int) ( $counts['skipped'] ?? 0 ),
(int) ( $counts['conflicts'] ?? 0 ),
array()
);
}
/**
* @param array<int, string> $errors Error messages.
*/
public static function failure( array $errors ): self {
return new self( false, 0, 0, 0, 0, $errors );
}
/**
* @param array<int, self> $results Results to merge.
*/
public static function merge( array $results ): self {
$successful = true;
$created = 0;
$updated = 0;
$skipped = 0;
$conflicts = 0;
$errors = array();
foreach ( $results as $result ) {
$successful = $successful && $result->isSuccessful();
$created += $result->created();
$updated += $result->updated();
$skipped += $result->skipped();
$conflicts += $result->conflicts();
$errors = array_merge( $errors, $result->errors() );
}
return new self( $successful, $created, $updated, $skipped, $conflicts, $errors );
}
public function isSuccessful(): bool {
return $this->successful;
}
public function created(): int {
return $this->created;
}
public function updated(): int {
return $this->updated;
}
public function skipped(): int {
return $this->skipped;
}
public function conflicts(): int {
return $this->conflicts;
}
/**
* @return array<int, string>
*/
public function errors(): array {
return $this->errors;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return array(
'successful' => $this->successful,
'created' => $this->created,
'updated' => $this->updated,
'skipped' => $this->skipped,
'conflicts' => $this->conflicts,
'errors' => $this->errors,
);
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `composer test -- --filter SyncResultTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Sync/SyncResult.php tests/Unit/Sync/SyncResultTest.php
git commit -m "feat: add sync result value object"
```
---
## Task 2: Sync Context and Operation State
**Files:**
- Create: `src/Sync/SyncContext.php`
- Create: `src/Sync/SyncOperationState.php`
- Create: `src/Sync/SyncStateRepository.php`
- Modify: `tests/bootstrap.php`
- Create: `tests/Unit/Sync/SyncContextTest.php`
- Create: `tests/Unit/Sync/SyncStateRepositoryTest.php`
- [ ] **Step 1: Add transient test stubs**
Add to `tests/bootstrap.php` near the existing `delete_transient()` stub:
```php
if ( ! function_exists( 'get_transient' ) ) {
/**
* Minimal WordPress transient reader for unit tests.
*
* @param string $name Transient name.
* @return mixed
*/
function get_transient( $name ) {
return $GLOBALS['wpcs_test_transients'][ $name ] ?? false;
}
}
if ( ! function_exists( 'set_transient' ) ) {
/**
* Minimal WordPress transient writer for unit tests.
*
* @param string $name Transient name.
* @param mixed $value Transient value.
* @param int $expiration Expiration in seconds.
* @return bool
*/
function set_transient( $name, $value, $expiration = 0 ) {
$GLOBALS['wpcs_test_transients'][ $name ] = $value;
$GLOBALS['wpcs_test_transient_expiration'][ $name ] = $expiration;
return true;
}
}
```
- [ ] **Step 2: Write failing context/state tests**
Create `tests/Unit/Sync/SyncContextTest.php`:
```php
<?php
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncContext;
class SyncContextTest extends TestCase {
public function test_it_builds_import_context_from_package_sites(): void {
$context = SyncContext::forImport(
array( 'site_url' => 'https://source.test' ),
array( 'site_url' => 'https://destination.test' ),
'last_write_wins',
'operation-1'
);
self::assertSame( 'import', $context->direction() );
self::assertSame( 'operation-1', $context->operationId() );
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
self::assertSame( 'https://source.test', $context->sourceUrl() );
self::assertSame( 'https://destination.test', $context->destinationUrl() );
self::assertSame(
array( 'https://source.test' => 'https://destination.test' ),
$context->urlMappings()
);
}
public function test_it_falls_back_to_last_write_wins_for_invalid_strategy(): void {
$context = SyncContext::forImport( array(), array(), 'surprise', 'operation-2' );
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
}
}
```
Create `tests/Unit/Sync/SyncStateRepositoryTest.php`:
```php
<?php
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncOperationState;
use WPContentSync\Sync\SyncStateRepository;
class SyncStateRepositoryTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_test_transients'], $GLOBALS['wpcs_test_transient_expiration'] );
parent::tearDown();
}
public function test_it_saves_and_reads_operation_state(): void {
$repository = new SyncStateRepository();
$state = SyncOperationState::running( 'operation-1', 'posts', 2, 10 );
$repository->save( $state );
$loaded = $repository->get( 'operation-1' );
self::assertInstanceOf( SyncOperationState::class, $loaded );
self::assertSame( 'operation-1', $loaded->operationId() );
self::assertSame( 'posts', $loaded->currentBucket() );
self::assertSame( 2, $loaded->processed() );
self::assertSame( 10, $loaded->total() );
self::assertSame( 'running', $loaded->status() );
}
public function test_it_deletes_operation_state(): void {
$repository = new SyncStateRepository();
$repository->save( SyncOperationState::completed( 'operation-1', 10, 10 ) );
$repository->delete( 'operation-1' );
self::assertNull( $repository->get( 'operation-1' ) );
}
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `composer test -- --filter "SyncContextTest|SyncStateRepositoryTest"`
Expected: FAIL with missing `SyncContext`, `SyncOperationState`, and `SyncStateRepository`.
- [ ] **Step 4: Implement context and state classes**
Create `src/Sync/SyncContext.php` with `forImport()`, getters, URL mapping normalization, and conflict strategy fallback. Create `src/Sync/SyncOperationState.php` with static `running()` and `completed()` constructors plus `fromArray()` / `toArray()`. Create `src/Sync/SyncStateRepository.php` that persists state with transient key `wpcs_sync_state_<operation_id>` and expiration `DAY_IN_SECONDS` if defined or `86400` otherwise.
The repository implementation must use this exact key method:
```php
private function key( string $operation_id ): string {
return 'wpcs_sync_state_' . sanitize_key( $operation_id );
}
```
If `sanitize_key()` is not yet stubbed, add this to `tests/bootstrap.php`:
```php
if ( ! function_exists( 'sanitize_key' ) ) {
function sanitize_key( $key ) {
return strtolower( preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $key ) );
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `composer test -- --filter "SyncContextTest|SyncStateRepositoryTest"`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/Sync/SyncContext.php src/Sync/SyncOperationState.php src/Sync/SyncStateRepository.php tests/Unit/Sync/SyncContextTest.php tests/Unit/Sync/SyncStateRepositoryTest.php tests/bootstrap.php
git commit -m "feat: add sync context and operation state"
```
---
## Task 3: Content Handler Boundary and Registry
**Files:**
- Create: `src/Content/ContentHandlerInterface.php`
- Create: `src/Content/ContentHandlerRegistry.php`
- Create: `src/Content/ContentImportException.php`
- Create: `tests/Unit/Content/ContentHandlerRegistryTest.php`
- [ ] **Step 1: Write failing registry tests**
Create `tests/Unit/Content/ContentHandlerRegistryTest.php`:
```php
<?php
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentHandlerInterface;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
class ContentHandlerRegistryTest extends TestCase {
public function test_it_returns_handlers_in_package_order(): void {
$posts = $this->handler( 'posts' );
$terms = $this->handler( 'terms' );
$media = $this->handler( 'media' );
$registry = new ContentHandlerRegistry( array( $media, $posts, $terms ) );
self::assertSame( array( $posts, $terms, $media ), $registry->ordered() );
}
public function test_it_rejects_duplicate_buckets(): void {
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'Handler bucket "posts" is already registered.' );
new ContentHandlerRegistry( array( $this->handler( 'posts' ), $this->handler( 'posts' ) ) );
}
private function handler( string $bucket ): ContentHandlerInterface {
return new class( $bucket ) implements ContentHandlerInterface {
private string $bucket;
public function __construct( string $bucket ) {
$this->bucket = $bucket;
}
public function bucket(): string {
return $this->bucket;
}
public function importRecords( array $records, SyncContext $context ): SyncResult {
return SyncResult::success( array( 'skipped' => count( $records ) ) );
}
};
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `composer test -- --filter ContentHandlerRegistryTest`
Expected: FAIL with missing content handler classes.
- [ ] **Step 3: Implement interface, registry, and exception**
Create `src/Content/ContentHandlerInterface.php`:
```php
<?php
namespace WPContentSync\Content;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
interface ContentHandlerInterface {
public function bucket(): string;
/**
* @param array<int, array<string, mixed>> $records Package records for this handler bucket.
*/
public function importRecords( array $records, SyncContext $context ): SyncResult;
}
```
Create `ContentHandlerRegistry` with package order `custom_post_types`, `terms`, `posts`, `media`; then return only registered handlers that exist in that order. Create `ContentImportException` with `bucket()` and `record()` accessors.
- [ ] **Step 4: Run the test to verify it passes**
Run: `composer test -- --filter ContentHandlerRegistryTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Content/ContentHandlerInterface.php src/Content/ContentHandlerRegistry.php src/Content/ContentImportException.php tests/Unit/Content/ContentHandlerRegistryTest.php
git commit -m "feat: add content handler registry"
```
---
## Task 4: WordPress Content Test Stubs
**Files:**
- Modify: `tests/bootstrap.php`
- Create: `tests/Unit/Content/WordPressContentStubTest.php`
- [ ] **Step 1: Write failing stub tests**
Create `tests/Unit/Content/WordPressContentStubTest.php`:
```php
<?php
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
class WordPressContentStubTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_test_posts'], $GLOBALS['wpcs_test_post_meta'], $GLOBALS['wpcs_test_terms'] );
parent::tearDown();
}
public function test_post_stubs_insert_update_and_read_posts(): void {
$post_id = wp_insert_post( array( 'post_title' => 'Hello', 'post_type' => 'post' ), true );
wp_update_post( array( 'ID' => $post_id, 'post_title' => 'Updated' ), true );
self::assertSame( 'Updated', get_post( $post_id )['post_title'] );
}
public function test_meta_stubs_replace_values(): void {
update_post_meta( 10, '_source_url', 'https://source.test/page' );
update_post_meta( 10, '_source_url', 'https://destination.test/page' );
self::assertSame( array( 'https://destination.test/page' ), get_post_meta( 10, '_source_url' ) );
}
public function test_term_stubs_insert_update_and_read_terms(): void {
$result = wp_insert_term( 'News', 'category', array( 'slug' => 'news' ) );
wp_update_term( $result['term_id'], 'category', array( 'name' => 'Latest News' ) );
$term = get_term_by( 'slug', 'news', 'category' );
self::assertSame( 'Latest News', $term['name'] );
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `composer test -- --filter WordPressContentStubTest`
Expected: FAIL with missing WordPress content functions.
- [ ] **Step 3: Add stubs**
Add deterministic stubs to `tests/bootstrap.php` for:
- `wp_insert_post( array $postarr, $wp_error = false )`
- `wp_update_post( array $postarr, $wp_error = false )`
- `get_post( $post = null, $output = OBJECT, $filter = 'raw' )`
- `get_posts( array $args = array() )`
- `update_post_meta( $post_id, $meta_key, $meta_value )`
- `get_post_meta( $post_id, $key = '', $single = false )`
- `delete_post_meta( $post_id, $meta_key )`
- `wp_insert_term( $term, $taxonomy, array $args = array() )`
- `wp_update_term( $term_id, $taxonomy, array $args = array() )`
- `get_term_by( $field, $value, $taxonomy )`
- `wp_set_object_terms( $object_id, $terms, $taxonomy )`
- `wp_insert_attachment( array $args, $file = false, $parent_post_id = 0, $wp_error = false )`
- `wp_update_attachment_metadata( $attachment_id, $data )`
- `wp_get_attachment_metadata( $attachment_id )`
Use `$GLOBALS['wpcs_test_posts']`, `$GLOBALS['wpcs_test_next_post_id']`, `$GLOBALS['wpcs_test_post_meta']`, `$GLOBALS['wpcs_test_terms']`, and `$GLOBALS['wpcs_test_next_term_id']`. Return `WP_Error` only when `$wp_error` is true and required fields are missing.
- [ ] **Step 4: Run the test to verify it passes**
Run: `composer test -- --filter WordPressContentStubTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add tests/bootstrap.php tests/Unit/Content/WordPressContentStubTest.php
git commit -m "test: add wordpress content stubs"
```
---
## Task 5: Content Record Normalizer
**Files:**
- Create: `src/Content/ContentRecordNormalizer.php`
- Create: `tests/Unit/Content/ContentRecordNormalizerTest.php`
- [ ] **Step 1: Write failing normalizer tests**
Create tests that assert:
- Post records normalize `id`, `post_type`, `post_title`, `post_content`, `post_excerpt`, `post_status`, `post_name`, `post_parent`, `menu_order`, and `meta`.
- Term records normalize `id`, `taxonomy`, `name`, `slug`, `description`, `parent`, and `meta`.
- Media records normalize `id`, `post_title`, `post_mime_type`, `source_url`, `metadata`, and `meta`.
Run: `composer test -- --filter ContentRecordNormalizerTest`
Expected: FAIL with missing class.
- [ ] **Step 2: Implement the normalizer**
Implement methods:
```php
public function post( array $record ): array;
public function term( array $record ): array;
public function media( array $record ): array;
```
Rules:
- Use `sanitize_text_field()` for scalar text fields.
- Use `esc_url_raw()` for URL fields.
- Cast IDs, parents, and menu order to integers.
- Preserve `post_content`, metadata values, and serialized structures for `MetadataUrlTransformer`.
- Normalize `meta` to `array<string, mixed>`.
- [ ] **Step 3: Run tests to verify they pass**
Run: `composer test -- --filter ContentRecordNormalizerTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/Content/ContentRecordNormalizer.php tests/Unit/Content/ContentRecordNormalizerTest.php
git commit -m "feat: normalize content records"
```
---
## Task 6: Post Content Handler
**Files:**
- Create: `src/Content/PostContentHandler.php`
- Create: `tests/Unit/Content/PostContentHandlerTest.php`
- [ ] **Step 1: Write failing handler tests**
Create tests for:
- Creating a new post when no existing `_wpcs_source_id` meta matches.
- Updating an existing post when `_wpcs_source_id` meta matches and conflict strategy is `last_write_wins`.
- Skipping an existing post and recording a conflict when conflict strategy is `manual_review`.
- Rewriting post content and meta URLs from source to destination.
Use `SyncContext::forImport()` with source `https://source.test` and destination `https://destination.test`.
Run: `composer test -- --filter PostContentHandlerTest`
Expected: FAIL with missing `PostContentHandler`.
- [ ] **Step 2: Implement the handler**
Constructor dependencies:
```php
public function __construct(
ContentRecordNormalizer $normalizer,
UrlTransformer $url_transformer,
MetadataUrlTransformer $metadata_transformer,
LoggerInterface $logger
) {}
```
Behavior:
- `bucket()` returns `posts`.
- Match existing posts by `_wpcs_source_id`.
- Create/update with `wp_insert_post()` and `wp_update_post()`.
- Store `_wpcs_source_id` and `_wpcs_source_site` meta.
- Transform `post_content` and `post_excerpt` with `UrlTransformer`.
- Transform each meta value with `MetadataUrlTransformer`.
- Log conflicts with `warning()` and successful imports with `info()`.
- [ ] **Step 3: Run tests to verify they pass**
Run: `composer test -- --filter PostContentHandlerTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/Content/PostContentHandler.php tests/Unit/Content/PostContentHandlerTest.php
git commit -m "feat: import post content records"
```
---
## Task 7: Term Content Handler
**Files:**
- Create: `src/Content/TermContentHandler.php`
- Create: `tests/Unit/Content/TermContentHandlerTest.php`
- [ ] **Step 1: Write failing term tests**
Create tests for:
- Creating a new term by taxonomy/slug.
- Updating an existing term under `last_write_wins`.
- Skipping and logging conflict under `manual_review`.
- Transforming term description and term meta URL values.
Run: `composer test -- --filter TermContentHandlerTest`
Expected: FAIL with missing `TermContentHandler`.
- [ ] **Step 2: Implement the handler**
Constructor dependencies mirror `PostContentHandler`. Behavior:
- `bucket()` returns `terms`.
- Match terms by `_wpcs_source_id` term meta if available, otherwise taxonomy/slug.
- Use `wp_insert_term()` and `wp_update_term()`.
- Store `_wpcs_source_id` and `_wpcs_source_site` term meta if term meta stubs are added; otherwise store source tracking in `$GLOBALS['wpcs_test_terms']` for tests and use WordPress term meta in production.
- Return `SyncResult` counts.
- [ ] **Step 3: Run tests to verify they pass**
Run: `composer test -- --filter TermContentHandlerTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/Content/TermContentHandler.php tests/Unit/Content/TermContentHandlerTest.php tests/bootstrap.php
git commit -m "feat: import taxonomy term records"
```
---
## Task 8: Media Content Handler
**Files:**
- Create: `src/Content/MediaContentHandler.php`
- Create: `tests/Unit/Content/MediaContentHandlerTest.php`
- [ ] **Step 1: Write failing media tests**
Create tests for:
- Creating an attachment record without downloading files.
- Updating attachment metadata under `last_write_wins`.
- Transforming `source_url`, attachment metadata, and attachment meta.
- Skipping conflicts under `manual_review`.
Run: `composer test -- --filter MediaContentHandlerTest`
Expected: FAIL with missing `MediaContentHandler`.
- [ ] **Step 2: Implement the handler**
Behavior:
- `bucket()` returns `media`.
- Use `wp_insert_attachment()` for new attachment records.
- Use `wp_update_post()` for existing attachment records.
- Use `wp_update_attachment_metadata()` for metadata.
- Store `_wpcs_source_id`, `_wpcs_source_site`, and `_wpcs_source_url` meta.
- Do not sideload binary media files in this task; log `warning()` when a record has `source_url` but no local file.
- [ ] **Step 3: Run tests to verify they pass**
Run: `composer test -- --filter MediaContentHandlerTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/Content/MediaContentHandler.php tests/Unit/Content/MediaContentHandlerTest.php
git commit -m "feat: import media metadata records"
```
---
## Task 9: Sync Engine Import Orchestration
**Files:**
- Create: `src/Sync/SyncEngine.php`
- Create: `tests/Unit/Sync/SyncEngineTest.php`
- [ ] **Step 1: Write failing engine tests**
Create tests for:
- Calling registered handlers in registry order with records from the matching package bucket.
- Merging handler results into one `SyncResult`.
- Saving running/completed state through `SyncStateRepository`.
- Logging operation start and completion.
- Returning failure when a handler throws `ContentImportException`.
Run: `composer test -- --filter SyncEngineTest`
Expected: FAIL with missing `SyncEngine`.
- [ ] **Step 2: Implement `SyncEngine`**
Constructor dependencies:
```php
public function __construct(
ContentHandlerRegistry $handlers,
SyncStateRepository $state_repository,
SettingsRepository $settings_repository,
LoggerInterface $logger
) {}
```
Method:
```php
public function importPackage( ContentPackage $package ): SyncResult;
```
Behavior:
- Build `SyncContext::forImport()` from package source/destination and current settings conflict strategy.
- Generate an operation ID with `uniqid( 'wpcs_', true )`.
- Calculate total record count across manifest buckets.
- Save `running` state before each bucket and `completed` state at the end.
- Catch `ContentImportException`, log `error()`, save failed state, and merge a failure result.
- Never mutate records outside handlers.
- [ ] **Step 3: Run tests to verify they pass**
Run: `composer test -- --filter SyncEngineTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/Sync/SyncEngine.php tests/Unit/Sync/SyncEngineTest.php
git commit -m "feat: orchestrate package imports"
```
---
## Task 10: Wire Engine Into File and REST Imports
**Files:**
- Modify: `src/Admin/FileImportController.php`
- Modify: `src/Rest/RestPackageController.php`
- Modify: `src/Plugin.php`
- Modify: `tests/Unit/Admin/FileImportControllerTest.php`
- Modify: `tests/Unit/Rest/RestPackageControllerTest.php`
- Modify: `tests/Unit/PluginTest.php`
- [ ] **Step 1: Write failing wiring tests**
Update file import tests so a valid uploaded package calls `SyncEngine::importPackage()` and redirects with `wpcs_imported=1` only when the result is successful.
Update REST controller tests so a valid package returns:
```php
array(
'accepted' => true,
'schema_version' => '1.0',
'manifest' => array(
'posts' => 0,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'import' => array(
'successful' => true,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'conflicts' => 0,
'errors' => array(),
),
)
```
Update `PluginTest` to assert `SyncEngine`, `ContentHandlerRegistry`, and the three handlers are registered.
Run: `composer test -- --filter "FileImportControllerTest|RestPackageControllerTest|PluginTest"`
Expected: FAIL because controllers and container do not accept/use `SyncEngine`.
- [ ] **Step 2: Wire production services**
Update constructors:
- `FileImportController( FileTransportInterface $transport, LoggerInterface $logger, SyncEngine $sync_engine )`
- `RestPackageController( PackageValidator $validator, SyncEngine $sync_engine )`
Update successful import paths:
- File import logs `Imported content package.` with `SyncResult::toArray()`.
- REST receive includes `import => $result->toArray()`.
- If `SyncResult::isSuccessful()` is false, file import redirects with `wpcs_import_error`, and REST returns `accepted => false` with result errors.
Update `Plugin::create()` factories for normalizer, handlers, registry, state repository, and engine.
- [ ] **Step 3: Run focused tests**
Run: `composer test -- --filter "FileImportControllerTest|RestPackageControllerTest|PluginTest"`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/Admin/FileImportController.php src/Rest/RestPackageController.php src/Plugin.php tests/Unit/Admin/FileImportControllerTest.php tests/Unit/Rest/RestPackageControllerTest.php tests/Unit/PluginTest.php
git commit -m "feat: apply packages during imports"
```
---
## Task 11: Full Phase 5 Verification
**Files:**
- Verify all files created or modified in Tasks 1-10.
- [ ] **Step 1: Run Composer validation**
Run: `composer validate --strict`
Expected: PASS with `./composer.json is valid`.
- [ ] **Step 2: Run PHPCS**
Run: `composer lint`
Expected: PASS with no PHPCS errors.
- [ ] **Step 3: Run PHPStan**
Run: `composer stan`
Expected: PASS with `[OK] No errors`.
- [ ] **Step 4: Run PHPUnit**
Run: `composer test`
Expected: PASS with all foundation, URL, file transport, REST transport, sync, and content handler tests.
- [ ] **Step 5: Live WordPress smoke**
Copy runtime files into the Herd test plugin and verify:
- Plugin still activates.
- Admin page still loads.
- Invalid file import still redirects with an actionable dashboard error.
- Valid empty package import redirects with success and logs a completed import.
- `GET /wp-json/wp-content-sync/v1/status` still requires authentication.
- Authenticated valid REST package POST returns `accepted: true` with an `import` result.
- Authenticated invalid REST package POST returns `accepted: false` with validation errors.
- [ ] **Step 6: Commit verification docs only if files changed**
If documentation or smoke notes are updated:
```bash
git add docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md
git commit -m "docs: update sync engine verification notes"
```
---
## Spec Coverage
- Sync manager orchestration is covered by `SyncEngine`.
- Content handlers are covered for posts/pages/custom post type post records, terms, and media metadata.
- URL transformation is covered in post/term/media fields and metadata through existing URL transformer services.
- Conflict behavior is covered by `last_write_wins` and `manual_review` handler tests.
- Progress state is covered by `SyncOperationState` and `SyncStateRepository`.
- REST/file mutation paths are covered when controllers call `SyncEngine`.
- Partial failure logging is covered by `ContentImportException` handling and `SyncResult` errors.
- Retry/backoff, binary media sideloading, export orchestration, and background queues are intentionally deferred to later Phase 5 slices after the first safe import mutation path lands.
## Placeholder Scan
- No unresolved implementation markers are intentionally included.
- Each task names exact files, test commands, expected failures, implementation responsibilities, and commit messages.
- Deferred work is explicitly named as out of scope rather than left ambiguous.