diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md index fec1174..21e9865 100644 --- a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md @@ -61,7 +61,7 @@ Adds authenticated REST endpoints and REST client support using WordPress applic ## Phase 5: Sync Engine and Content Handlers -**Plan to create after Phase 4:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-engine-handlers.md` +**Plan:** `docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md` Implements orchestration, content extraction/import handlers, conflict detection, retries, progress state, and operation logs. diff --git a/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md new file mode 100644 index 0000000..08d82b0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md @@ -0,0 +1,1011 @@ +# 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.