# 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 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 */ private array $errors; /** * @param array $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 $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 $errors Error messages. */ public static function failure( array $errors ): self { return new self( false, 0, 0, 0, 0, $errors ); } /** * @param array $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 */ public function errors(): array { return $this->errors; } /** * @return array */ 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 '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 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_` 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 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 > $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 '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`. - [ ] **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.