31 KiB
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 withSyncEngine.src/Rest/RestPackageController.php: applies validated REST packages withSyncEngine.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
/**
* 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
/**
* 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
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:
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
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
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:
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:
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
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
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
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
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
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
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, andmeta. - Term records normalize
id,taxonomy,name,slug,description,parent, andmeta. - Media records normalize
id,post_title,post_mime_type,source_url,metadata, andmeta.
Run: composer test -- --filter ContentRecordNormalizerTest
Expected: FAIL with missing class.
- Step 2: Implement the normalizer
Implement methods:
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 forMetadataUrlTransformer. -
Normalize
metatoarray<string, mixed>. -
Step 3: Run tests to verify they pass
Run: composer test -- --filter ContentRecordNormalizerTest
Expected: PASS.
- Step 4: Commit
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_idmeta matches. - Updating an existing post when
_wpcs_source_idmeta matches and conflict strategy islast_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:
public function __construct(
ContentRecordNormalizer $normalizer,
UrlTransformer $url_transformer,
MetadataUrlTransformer $metadata_transformer,
LoggerInterface $logger
) {}
Behavior:
-
bucket()returnsposts. -
Match existing posts by
_wpcs_source_id. -
Create/update with
wp_insert_post()andwp_update_post(). -
Store
_wpcs_source_idand_wpcs_source_sitemeta. -
Transform
post_contentandpost_excerptwithUrlTransformer. -
Transform each meta value with
MetadataUrlTransformer. -
Log conflicts with
warning()and successful imports withinfo(). -
Step 3: Run tests to verify they pass
Run: composer test -- --filter PostContentHandlerTest
Expected: PASS.
- Step 4: Commit
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()returnsterms. -
Match terms by
_wpcs_source_idterm meta if available, otherwise taxonomy/slug. -
Use
wp_insert_term()andwp_update_term(). -
Store
_wpcs_source_idand_wpcs_source_siteterm 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
SyncResultcounts. -
Step 3: Run tests to verify they pass
Run: composer test -- --filter TermContentHandlerTest
Expected: PASS.
- Step 4: Commit
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()returnsmedia. -
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_urlmeta. -
Do not sideload binary media files in this task; log
warning()when a record hassource_urlbut no local file. -
Step 3: Run tests to verify they pass
Run: composer test -- --filter MediaContentHandlerTest
Expected: PASS.
- Step 4: Commit
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:
public function __construct(
ContentHandlerRegistry $handlers,
SyncStateRepository $state_repository,
SettingsRepository $settings_repository,
LoggerInterface $logger
) {}
Method:
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
runningstate before each bucket andcompletedstate at the end. -
Catch
ContentImportException, logerror(), 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
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:
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.withSyncResult::toArray(). - REST receive includes
import => $result->toArray(). - If
SyncResult::isSuccessful()is false, file import redirects withwpcs_import_error, and REST returnsaccepted => falsewith 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
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/statusstill requires authentication. -
Authenticated valid REST package POST returns
accepted: truewith animportresult. -
Authenticated invalid REST package POST returns
accepted: falsewith validation errors. -
Step 6: Commit verification docs only if files changed
If documentation or smoke notes are updated:
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_winsandmanual_reviewhandler tests. - Progress state is covered by
SyncOperationStateandSyncStateRepository. - REST/file mutation paths are covered when controllers call
SyncEngine. - Partial failure logging is covered by
ContentImportExceptionhandling andSyncResulterrors. - 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.