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

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 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
/**
 * 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, 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:

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
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:

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
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
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
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 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
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. 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
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:

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.