39 KiB
WordPress Content Sync File Transport 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 a versioned JSON content package format and file transport boundary that can export packages, validate imported files, and reject invalid admin uploads before any content mutation occurs.
Architecture: This phase introduces a package layer under src/Package/ and a file transport layer under src/Transport/. The package layer owns schema normalization, validation, and checksums; the transport layer owns JSON encode/decode and upload handling. Actual database writes remain in the later sync engine/content handler phase, so this phase imports into a validated ContentPackage object only.
Tech Stack: PHP 7.4, WordPress admin hooks/nonces/capabilities, PHPUnit, PHPStan, PHPCS/WPCS.
File Structure
- Create:
src/Package/ContentPackage.phpas the immutable package value object. - Create:
src/Package/PackageValidationResult.phpas a small result object for schema errors. - Create:
src/Package/PackageValidator.phpto validate versioned package arrays before object hydration. - Create:
src/Package/PackageChecksum.phpto calculate and verify deterministic SHA-256 checksums. - Create:
src/Transport/FileTransportInterface.phpfor export/import boundaries. - Create:
src/Transport/JsonFileTransport.phpto encode packages and parse uploaded JSON text. - Create:
src/Admin/FileImportController.phpto guard admin file imports with capability, nonce, upload checks, and validation. - Modify:
src/Plugin.phpto register package, transport, and file import services. - Modify:
src/Admin/AdminPage.phpto register the file import controller. - Modify:
templates/admin/dashboard.phpto add an import form shell. - Modify:
tests/bootstrap.phpto add WordPress stubs for admin post/upload behavior. - Test:
tests/Unit/Package/ContentPackageTest.php - Test:
tests/Unit/Package/PackageValidatorTest.php - Test:
tests/Unit/Package/PackageChecksumTest.php - Test:
tests/Unit/Transport/JsonFileTransportTest.php - Test:
tests/Unit/Admin/FileImportControllerTest.php - Test:
tests/Unit/PluginTest.php
Package Schema
The file transport package is a JSON object with this top-level shape:
{
"schema_version": "1.0",
"generated_at": "2026-04-26T20:30:00+00:00",
"source": {
"site_url": "https://example.test",
"name": "Example Production"
},
"destination": {
"site_url": "https://staging.example.test",
"name": "Example Staging"
},
"manifest": {
"posts": 1,
"terms": 1,
"media": 1,
"custom_post_types": 1
},
"records": {
"posts": [],
"terms": [],
"media": [],
"custom_post_types": []
},
"checksums": {
"records": "sha256:..."
}
}
Record arrays can be empty in this phase. Later content handlers will populate them with richer fields, but this schema must already reserve stable buckets for posts, terms, media, and custom post types.
Task 1: Content Package Value Object
Files:
-
Create:
tests/Unit/Package/ContentPackageTest.php -
Create:
src/Package/ContentPackage.php -
Step 1: Write the failing value object test
Create tests/Unit/Package/ContentPackageTest.php:
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
class ContentPackageTest extends TestCase {
public function test_it_normalizes_package_arrays(): void {
$package = ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 1,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => array(
'posts' => array( array( 'id' => 123, 'type' => 'post' ) ),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
),
'checksums' => array(
'records' => 'sha256:abc123',
),
)
);
self::assertSame( '1.0', $package->schemaVersion() );
self::assertSame( '2026-04-26T20:30:00+00:00', $package->generatedAt() );
self::assertSame( 'https://example.test', $package->source()['site_url'] );
self::assertSame( 'https://staging.example.test', $package->destination()['site_url'] );
self::assertSame( 1, $package->manifest()['posts'] );
self::assertSame( 123, $package->records()['posts'][0]['id'] );
self::assertSame( 'sha256:abc123', $package->checksums()['records'] );
self::assertSame( $package->toArray(), ContentPackage::fromArray( $package->toArray() )->toArray() );
}
}
- Step 2: Run the test to verify it fails
Run: composer test -- --filter ContentPackageTest
Expected: FAIL with class WPContentSync\Package\ContentPackage not found.
- Step 3: Implement the value object
Create src/Package/ContentPackage.php:
<?php
/**
* Immutable sync package value object.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class ContentPackage {
public const SCHEMA_VERSION = '1.0';
/** @var array<string, mixed> */
private array $data;
/**
* @param array<string, mixed> $data Package data.
*/
private function __construct( array $data ) {
$this->data = $data;
}
/**
* @param array<string, mixed> $data Package data.
*/
public static function fromArray( array $data ): self {
return new self(
array(
'schema_version' => (string) ( $data['schema_version'] ?? self::SCHEMA_VERSION ),
'generated_at' => (string) ( $data['generated_at'] ?? '' ),
'source' => self::arrayValue( $data['source'] ?? array() ),
'destination' => self::arrayValue( $data['destination'] ?? array() ),
'manifest' => self::arrayValue( $data['manifest'] ?? array() ),
'records' => self::arrayValue( $data['records'] ?? array() ),
'checksums' => self::arrayValue( $data['checksums'] ?? array() ),
)
);
}
public function schemaVersion(): string {
return $this->data['schema_version'];
}
public function generatedAt(): string {
return $this->data['generated_at'];
}
/**
* @return array<string, mixed>
*/
public function source(): array {
return $this->data['source'];
}
/**
* @return array<string, mixed>
*/
public function destination(): array {
return $this->data['destination'];
}
/**
* @return array<string, mixed>
*/
public function manifest(): array {
return $this->data['manifest'];
}
/**
* @return array<string, mixed>
*/
public function records(): array {
return $this->data['records'];
}
/**
* @return array<string, mixed>
*/
public function checksums(): array {
return $this->data['checksums'];
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return $this->data;
}
/**
* @param mixed $value Value to normalize.
*
* @return array<string, mixed>
*/
private static function arrayValue( $value ): array {
return is_array( $value ) ? $value : array();
}
}
- Step 4: Run the test to verify it passes
Run: composer test -- --filter ContentPackageTest
Expected: PASS.
- Step 5: Commit
git add src/Package/ContentPackage.php tests/Unit/Package/ContentPackageTest.php
git commit -m "feat: add content package value object"
Task 2: Package Validation Result and Schema Validator
Files:
-
Create:
tests/Unit/Package/PackageValidatorTest.php -
Create:
src/Package/PackageValidationResult.php -
Create:
src/Package/PackageValidator.php -
Step 1: Write failing validator tests
Create tests/Unit/Package/PackageValidatorTest.php:
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageValidator;
class PackageValidatorTest extends TestCase {
public function test_it_accepts_valid_packages(): void {
$result = ( new PackageValidator() )->validate( $this->validPackage() );
self::assertTrue( $result->isValid() );
self::assertSame( array(), $result->errors() );
}
public function test_it_rejects_missing_required_fields(): void {
$package = $this->validPackage();
unset( $package['records'] );
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'records is required.' ), $result->errors() );
}
public function test_it_rejects_unsupported_schema_versions(): void {
$package = $this->validPackage();
$package['schema_version'] = '2.0';
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'schema_version must be 1.0.' ), $result->errors() );
}
public function test_it_rejects_missing_record_buckets(): void {
$package = $this->validPackage();
unset( $package['records']['media'] );
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'records.media is required and must be an array.' ), $result->errors() );
}
public function test_it_rejects_manifest_counts_that_do_not_match_records(): void {
$package = $this->validPackage();
$package['manifest']['posts'] = 2;
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'manifest.posts must match records.posts count.' ), $result->errors() );
}
/**
* @return array<string, mixed>
*/
private function validPackage(): array {
return array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 1,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => array(
'posts' => array( array( 'id' => 123, 'type' => 'post' ) ),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
),
'checksums' => array(
'records' => 'sha256:abc123',
),
);
}
}
- Step 2: Run the test to verify it fails
Run: composer test -- --filter PackageValidatorTest
Expected: FAIL with class WPContentSync\Package\PackageValidator not found.
- Step 3: Implement validation result
Create src/Package/PackageValidationResult.php:
<?php
/**
* Package validation result.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageValidationResult {
/** @var array<int, string> */
private array $errors;
/**
* @param array<int, string> $errors Validation errors.
*/
private function __construct( array $errors ) {
$this->errors = array_values( $errors );
}
public static function valid(): self {
return new self( array() );
}
/**
* @param array<int, string> $errors Validation errors.
*/
public static function invalid( array $errors ): self {
return new self( $errors );
}
public function isValid(): bool {
return array() === $this->errors;
}
/**
* @return array<int, string>
*/
public function errors(): array {
return $this->errors;
}
}
- Step 4: Implement schema validator
Create src/Package/PackageValidator.php:
<?php
/**
* Versioned package schema validator.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageValidator {
private const RECORD_BUCKETS = array(
'posts',
'terms',
'media',
'custom_post_types',
);
/**
* @param array<string, mixed> $data Decoded package data.
*/
public function validate( array $data ): PackageValidationResult {
$errors = array();
foreach ( array( 'schema_version', 'generated_at', 'source', 'destination', 'manifest', 'records', 'checksums' ) as $field ) {
if ( ! array_key_exists( $field, $data ) ) {
$errors[] = $field . ' is required.';
}
}
if ( isset( $data['schema_version'] ) && ContentPackage::SCHEMA_VERSION !== $data['schema_version'] ) {
$errors[] = 'schema_version must be ' . ContentPackage::SCHEMA_VERSION . '.';
}
if ( isset( $data['source'] ) && ! is_array( $data['source'] ) ) {
$errors[] = 'source must be an object.';
}
if ( isset( $data['destination'] ) && ! is_array( $data['destination'] ) ) {
$errors[] = 'destination must be an object.';
}
if ( isset( $data['manifest'] ) && ! is_array( $data['manifest'] ) ) {
$errors[] = 'manifest must be an object.';
}
if ( isset( $data['records'] ) && ! is_array( $data['records'] ) ) {
$errors[] = 'records must be an object.';
}
if ( isset( $data['checksums'] ) && ! is_array( $data['checksums'] ) ) {
$errors[] = 'checksums must be an object.';
}
if ( isset( $data['manifest'], $data['records'] ) && is_array( $data['manifest'] ) && is_array( $data['records'] ) ) {
$errors = array_merge( $errors, $this->validateRecordBuckets( $data['manifest'], $data['records'] ) );
}
return array() === $errors ? PackageValidationResult::valid() : PackageValidationResult::invalid( $errors );
}
/**
* @param array<string, mixed> $manifest Package manifest.
* @param array<string, mixed> $records Package records.
*
* @return array<int, string>
*/
private function validateRecordBuckets( array $manifest, array $records ): array {
$errors = array();
foreach ( self::RECORD_BUCKETS as $bucket ) {
if ( ! isset( $records[ $bucket ] ) || ! is_array( $records[ $bucket ] ) ) {
$errors[] = 'records.' . $bucket . ' is required and must be an array.';
continue;
}
if ( ! isset( $manifest[ $bucket ] ) || ! is_int( $manifest[ $bucket ] ) ) {
$errors[] = 'manifest.' . $bucket . ' is required and must be an integer.';
continue;
}
if ( count( $records[ $bucket ] ) !== $manifest[ $bucket ] ) {
$errors[] = 'manifest.' . $bucket . ' must match records.' . $bucket . ' count.';
}
}
return $errors;
}
}
- Step 5: Run the test to verify it passes
Run: composer test -- --filter PackageValidatorTest
Expected: PASS.
- Step 6: Commit
git add src/Package/PackageValidationResult.php src/Package/PackageValidator.php tests/Unit/Package/PackageValidatorTest.php
git commit -m "feat: add content package schema validator"
Task 3: Deterministic Package Checksums
Files:
-
Create:
tests/Unit/Package/PackageChecksumTest.php -
Create:
src/Package/PackageChecksum.php -
Modify:
src/Package/PackageValidator.php -
Modify:
tests/Unit/Package/PackageValidatorTest.php -
Step 1: Write failing checksum tests
Create tests/Unit/Package/PackageChecksumTest.php:
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
class PackageChecksumTest extends TestCase {
public function test_it_creates_stable_record_checksums(): void {
$records = array(
'posts' => array( array( 'title' => 'Example', 'id' => 123 ) ),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
self::assertSame(
PackageChecksum::records( $records ),
PackageChecksum::records( $records )
);
self::assertStringStartsWith( 'sha256:', PackageChecksum::records( $records ) );
}
public function test_it_verifies_record_checksums(): void {
$records = array(
'posts' => array( array( 'id' => 123 ) ),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
$checksum = PackageChecksum::records( $records );
self::assertTrue( PackageChecksum::verifyRecords( $records, $checksum ) );
self::assertFalse( PackageChecksum::verifyRecords( $records, 'sha256:not-real' ) );
}
}
- Step 2: Add failing validator checksum coverage
Add this test to tests/Unit/Package/PackageValidatorTest.php:
public function test_it_rejects_invalid_record_checksums(): void {
$package = $this->validPackage();
$package['checksums']['records'] = 'sha256:wrong';
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'checksums.records does not match records payload.' ), $result->errors() );
}
Update the validPackage() checksum in the same file:
'checksums' => array(
'records' => \WPContentSync\Package\PackageChecksum::records(
array(
'posts' => array( array( 'id' => 123, 'type' => 'post' ) ),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
)
),
),
- Step 3: Run tests to verify they fail
Run: composer test -- --filter "PackageChecksumTest|PackageValidatorTest"
Expected: FAIL with class WPContentSync\Package\PackageChecksum not found.
- Step 4: Implement checksum helper
Create src/Package/PackageChecksum.php:
<?php
/**
* Package checksum utility.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageChecksum {
/**
* @param array<string, mixed> $records Package records.
*/
public static function records( array $records ): string {
return 'sha256:' . hash( 'sha256', self::canonicalJson( $records ) );
}
/**
* @param array<string, mixed> $records Package records.
* @param string $checksum Expected checksum.
*/
public static function verifyRecords( array $records, string $checksum ): bool {
return hash_equals( self::records( $records ), $checksum );
}
/**
* @param mixed $value Value to encode.
*/
private static function canonicalJson( $value ): string {
$normalized = self::sortKeys( $value );
$json = wp_json_encode( $normalized );
if ( false === $json ) {
throw new \RuntimeException( 'Unable to encode package records for checksum.' );
}
return $json;
}
/**
* @param mixed $value Value to normalize.
*
* @return mixed
*/
private static function sortKeys( $value ) {
if ( ! is_array( $value ) ) {
return $value;
}
if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
ksort( $value );
}
foreach ( $value as $key => $child ) {
$value[ $key ] = self::sortKeys( $child );
}
return $value;
}
}
- Step 5: Add checksum validation
Modify src/Package/PackageValidator.php inside validate() after the record bucket validation block:
if (
isset( $data['records'], $data['checksums']['records'] )
&& is_array( $data['records'] )
&& is_string( $data['checksums']['records'] )
&& ! PackageChecksum::verifyRecords( $data['records'], $data['checksums']['records'] )
) {
$errors[] = 'checksums.records does not match records payload.';
}
- Step 6: Run tests to verify they pass
Run: composer test -- --filter "PackageChecksumTest|PackageValidatorTest"
Expected: PASS.
- Step 7: Commit
git add src/Package/PackageChecksum.php src/Package/PackageValidator.php tests/Unit/Package/PackageChecksumTest.php tests/Unit/Package/PackageValidatorTest.php
git commit -m "feat: add package checksum validation"
Task 4: JSON File Transport
Files:
-
Create:
tests/Unit/Transport/JsonFileTransportTest.php -
Create:
src/Transport/FileTransportInterface.php -
Create:
src/Transport/JsonFileTransport.php -
Step 1: Write failing transport tests
Create tests/Unit/Transport/JsonFileTransportTest.php:
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\JsonFileTransport;
class JsonFileTransportTest extends TestCase {
public function test_it_exports_pretty_json_packages(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$json = $transport->export( $this->package() );
self::assertStringContainsString( "\n", $json );
self::assertStringContainsString( '"schema_version": "1.0"', $json );
}
public function test_it_imports_valid_json_packages(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$package = $transport->import( $transport->export( $this->package() ) );
self::assertSame( '1.0', $package->schemaVersion() );
self::assertSame( 'https://example.test', $package->source()['site_url'] );
}
public function test_it_rejects_invalid_json(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'The selected file is not valid JSON.' );
$transport->import( '{"schema_version":' );
}
public function test_it_rejects_schema_errors(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'records is required.' );
$transport->import( '{"schema_version":"1.0"}' );
}
private function package(): ContentPackage {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 0,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => $records,
'checksums' => array(
'records' => PackageChecksum::records( $records ),
),
)
);
}
}
- Step 2: Run tests to verify they fail
Run: composer test -- --filter JsonFileTransportTest
Expected: FAIL with class WPContentSync\Transport\JsonFileTransport not found.
- Step 3: Implement file transport interface
Create src/Transport/FileTransportInterface.php:
<?php
/**
* File package transport boundary.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
interface FileTransportInterface {
public function export( ContentPackage $package ): string;
public function import( string $contents ): ContentPackage;
}
- Step 4: Implement JSON transport
Create src/Transport/JsonFileTransport.php:
<?php
/**
* JSON file transport implementation.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
final class JsonFileTransport implements FileTransportInterface {
private PackageValidator $validator;
public function __construct( PackageValidator $validator ) {
$this->validator = $validator;
}
public function export( ContentPackage $package ): string {
$json = wp_json_encode( $package->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( false === $json ) {
throw new \RuntimeException( 'Unable to encode content package JSON.' );
}
return $json;
}
public function import( string $contents ): ContentPackage {
$decoded = json_decode( $contents, true );
if ( ! is_array( $decoded ) ) {
throw new \InvalidArgumentException( 'The selected file is not valid JSON.' );
}
$result = $this->validator->validate( $decoded );
if ( ! $result->isValid() ) {
throw new \InvalidArgumentException( implode( ' ', $result->errors() ) );
}
return ContentPackage::fromArray( $decoded );
}
}
- Step 5: Run tests to verify they pass
Run: composer test -- --filter JsonFileTransportTest
Expected: PASS.
- Step 6: Commit
git add src/Transport/FileTransportInterface.php src/Transport/JsonFileTransport.php tests/Unit/Transport/JsonFileTransportTest.php
git commit -m "feat: add json file transport"
Task 5: Admin File Import Guard
Files:
-
Modify:
tests/bootstrap.php -
Create:
tests/Unit/Admin/FileImportControllerTest.php -
Create:
src/Admin/FileImportController.php -
Step 1: Add WordPress test stubs
Add these stubs to tests/bootstrap.php if they do not already exist:
if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $capability ): bool {
return $GLOBALS['wpcs_current_user_can'][ $capability ] ?? true;
}
}
if ( ! function_exists( 'check_admin_referer' ) ) {
function check_admin_referer( string $action, string $query_arg = '_wpnonce' ): bool {
return $GLOBALS['wpcs_nonce_valid'][ $action ][ $query_arg ] ?? true;
}
}
if ( ! function_exists( 'wp_safe_redirect' ) ) {
function wp_safe_redirect( string $location ): bool {
$GLOBALS['wpcs_redirect_location'] = $location;
return true;
}
}
if ( ! function_exists( 'admin_url' ) ) {
function admin_url( string $path = '' ): string {
return 'https://example.test/wp-admin/' . ltrim( $path, '/' );
}
}
if ( ! function_exists( 'add_query_arg' ) ) {
function add_query_arg( array $args, string $url ): string {
return $url . ( false === strpos( $url, '?' ) ? '?' : '&' ) . http_build_query( $args );
}
}
- Step 2: Write failing admin guard tests
Create tests/Unit/Admin/FileImportControllerTest.php:
<?php
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\FileImportController;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\JsonFileTransport;
class FileImportControllerTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_nonce_valid'], $GLOBALS['wpcs_redirect_location'] );
$_FILES = array();
parent::tearDown();
}
public function test_it_rejects_users_without_manage_options(): void {
$GLOBALS['wpcs_current_user_can']['manage_options'] = false;
$controller = $this->controller();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'You do not have permission to import content packages.' );
$controller->handleImport();
}
public function test_it_rejects_invalid_nonces(): void {
$GLOBALS['wpcs_nonce_valid']['wpcs_import_package']['wpcs_import_package_nonce'] = false;
$controller = $this->controller();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'The import request could not be verified.' );
$controller->handleImport();
}
public function test_it_rejects_missing_uploads(): void {
$controller = $this->controller();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'Choose a package JSON file before importing.' );
$controller->handleImport();
}
public function test_it_imports_valid_uploaded_packages_without_mutating_content(): void {
$file = tempnam( sys_get_temp_dir(), 'wpcs-package-' );
file_put_contents( $file, $this->validJson() );
$_FILES['wpcs_package_file'] = array(
'tmp_name' => $file,
'error' => UPLOAD_ERR_OK,
);
$this->controller()->handleImport();
self::assertStringContainsString( 'wpcs_imported=1', $GLOBALS['wpcs_redirect_location'] );
}
private function controller(): FileImportController {
return new FileImportController(
new JsonFileTransport( new PackageValidator() ),
new class() implements LoggerInterface {
/**
* @param array<string, mixed> $context Context.
*/
public function error( string $message, array $context = array() ): void {}
/**
* @param array<string, mixed> $context Context.
*/
public function warning( string $message, array $context = array() ): void {}
/**
* @param array<string, mixed> $context Context.
*/
public function info( string $message, array $context = array() ): void {}
/**
* @param array<string, mixed> $context Context.
*/
public function debug( string $message, array $context = array() ): void {}
}
);
}
private function validJson(): string {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return wp_json_encode(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ),
'destination' => array( 'site_url' => 'https://staging.example.test', 'name' => 'Staging' ),
'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ),
'records' => $records,
'checksums' => array( 'records' => PackageChecksum::records( $records ) ),
)
);
}
}
- Step 3: Run tests to verify they fail
Run: composer test -- --filter FileImportControllerTest
Expected: FAIL with class WPContentSync\Admin\FileImportController not found.
- Step 4: Implement admin import guard
Create src/Admin/FileImportController.php:
<?php
/**
* Admin file import controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Transport\FileTransportInterface;
final class FileImportController {
private FileTransportInterface $transport;
private LoggerInterface $logger;
public function __construct( FileTransportInterface $transport, LoggerInterface $logger ) {
$this->transport = $transport;
$this->logger = $logger;
}
public function register(): void {
add_action( 'admin_post_wpcs_import_package', array( $this, 'handleImport' ) );
}
public function handleImport(): void {
if ( ! current_user_can( 'manage_options' ) ) {
throw new \RuntimeException( 'You do not have permission to import content packages.' );
}
if ( ! check_admin_referer( 'wpcs_import_package', 'wpcs_import_package_nonce' ) ) {
throw new \RuntimeException( 'The import request could not be verified.' );
}
if ( ! isset( $_FILES['wpcs_package_file']['tmp_name'], $_FILES['wpcs_package_file']['error'] ) ) {
throw new \RuntimeException( 'Choose a package JSON file before importing.' );
}
if ( UPLOAD_ERR_OK !== $_FILES['wpcs_package_file']['error'] ) {
throw new \RuntimeException( 'The package file could not be uploaded.' );
}
$contents = file_get_contents( (string) $_FILES['wpcs_package_file']['tmp_name'] );
if ( false === $contents ) {
throw new \RuntimeException( 'The package file could not be read.' );
}
$package = $this->transport->import( $contents );
$this->logger->info(
'Validated imported content package.',
array(
'schema_version' => $package->schemaVersion(),
'manifest' => $package->manifest(),
)
);
wp_safe_redirect(
add_query_arg(
array( 'wpcs_imported' => '1' ),
admin_url( 'admin.php?page=wp-content-sync' )
)
);
}
}
- Step 5: Run tests to verify they pass
Run: composer test -- --filter FileImportControllerTest
Expected: PASS.
- Step 6: Commit
git add src/Admin/FileImportController.php tests/Unit/Admin/FileImportControllerTest.php tests/bootstrap.php
git commit -m "feat: guard admin package imports"
Task 6: Service Wiring and Admin Form Shell
Files:
-
Modify:
src/Plugin.php -
Modify:
src/Admin/AdminPage.php -
Modify:
templates/admin/dashboard.php -
Modify:
tests/Unit/PluginTest.php -
Step 1: Extend plugin service test
Add this assertion block to tests/Unit/PluginTest.php:
self::assertInstanceOf(
\WPContentSync\Transport\FileTransportInterface::class,
$container->get( \WPContentSync\Transport\FileTransportInterface::class )
);
self::assertInstanceOf(
\WPContentSync\Admin\FileImportController::class,
$container->get( \WPContentSync\Admin\FileImportController::class )
);
- Step 2: Run the test to verify it fails
Run: composer test -- --filter PluginTest
Expected: FAIL with service WPContentSync\Transport\FileTransportInterface not registered.
- Step 3: Wire services in
src/Plugin.php
Add these imports:
use WPContentSync\Admin\FileImportController;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\FileTransportInterface;
use WPContentSync\Transport\JsonFileTransport;
Register these factories before AdminPage::class:
$container->factory(
PackageValidator::class,
static function (): PackageValidator {
return new PackageValidator();
}
);
$container->factory(
FileTransportInterface::class,
static function () use ( $container ): FileTransportInterface {
return new JsonFileTransport(
$container->get( PackageValidator::class )
);
}
);
$container->factory(
FileImportController::class,
static function () use ( $container ): FileImportController {
return new FileImportController(
$container->get( FileTransportInterface::class ),
$container->get( LoggerInterface::class )
);
}
);
- Step 4: Register controller from
src/Admin/AdminPage.phporsrc/Plugin.php
Prefer registering from src/Plugin.php next to the admin page:
/** @var FileImportController $file_import_controller */
$file_import_controller = $this->container->get( FileImportController::class );
$file_import_controller->register();
- Step 5: Add import form shell
Add this form to templates/admin/dashboard.php after the current defaults table:
<h2><?php echo esc_html__( 'File Package Import', 'wp-content-sync' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data">
<input type="hidden" name="action" value="wpcs_import_package" />
<?php wp_nonce_field( 'wpcs_import_package', 'wpcs_import_package_nonce' ); ?>
<p>
<label for="wpcs-package-file"><?php echo esc_html__( 'Package JSON file', 'wp-content-sync' ); ?></label>
<input id="wpcs-package-file" type="file" name="wpcs_package_file" accept="application/json,.json" />
</p>
<?php submit_button( __( 'Validate Package', 'wp-content-sync' ), 'secondary' ); ?>
</form>
- Step 6: Add missing bootstrap stubs for form rendering
Add these stubs to tests/bootstrap.php if they do not already exist:
if ( ! function_exists( 'wp_nonce_field' ) ) {
function wp_nonce_field( string $action, string $name ): void {
echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $action ) . '" />';
}
}
if ( ! function_exists( 'submit_button' ) ) {
function submit_button( string $text, string $type = 'primary' ): void {
echo '<button class="button button-' . esc_attr( $type ) . '" type="submit">' . esc_html( $text ) . '</button>';
}
}
- Step 7: Run tests to verify they pass
Run: composer test -- --filter PluginTest
Expected: PASS.
- Step 8: Commit
git add src/Plugin.php src/Admin/AdminPage.php templates/admin/dashboard.php tests/Unit/PluginTest.php tests/bootstrap.php
git commit -m "feat: wire file transport services"
Task 7: Full File Transport Verification
Files:
-
Verify all files created or modified in Tasks 1-6.
-
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 existing foundation and URL transformer tests plus file transport tests.
- Step 5: Run a JSON round-trip smoke test
Run:
php -r "require 'tests/bootstrap.php'; `$records=array('posts'=>array(),'terms'=>array(),'media'=>array(),'custom_post_types'=>array()); `$package=WPContentSync\Package\ContentPackage::fromArray(array('schema_version'=>'1.0','generated_at'=>'2026-04-26T20:30:00+00:00','source'=>array('site_url'=>'https://example.test','name'=>'Example'),'destination'=>array('site_url'=>'https://staging.example.test','name'=>'Staging'),'manifest'=>array('posts'=>0,'terms'=>0,'media'=>0,'custom_post_types'=>0),'records'=>`$records,'checksums'=>array('records'=>WPContentSync\Package\PackageChecksum::records(`$records)))); `$transport=new WPContentSync\Transport\JsonFileTransport(new WPContentSync\Package\PackageValidator()); echo `$transport->import(`$transport->export(`$package))->source()['site_url'], PHP_EOL;"
Expected output:
https://example.test
- Step 6: Manual WordPress smoke test
In http://basic-wp.test/wp-admin, verify:
-
The WP Content Sync admin page still loads.
-
The File Package Import form is visible.
-
Uploading invalid JSON returns a controlled error or redirects with an actionable failure message.
-
Uploading a valid package redirects back with
wpcs_imported=1. -
No posts, terms, media, or custom post type records are created by this phase.
-
Step 7: Commit verification notes if docs changed
If manual smoke notes are added to a project doc, commit them:
git add docs
git commit -m "docs: add file transport smoke notes"
Spec Coverage
- File transfer fallback is covered by
JsonFileTransport. - Versioned JSON packages are covered by
ContentPackage. - Manifest, content record buckets, taxonomy buckets, media buckets, custom post type buckets, and checksums are covered by the schema and validator.
- Schema validation before mutation is covered by
PackageValidatorandFileImportController. - Invalid file handling is covered by transport and admin controller tests.
- Capability and nonce checks are covered by
FileImportController.
Deferred Work
- Extracting real WordPress posts, pages, taxonomies, media, and custom post types into the record buckets remains in Phase 5.
- Applying validated package records to the destination database remains in Phase 5.
- REST send/receive behavior remains in Phase 4.
- Rich admin progress display and operation history remain in Phase 6.
Placeholder Scan
- No unspecified implementation markers are intentionally included.
- Every code-creating step names exact files and includes concrete code.
- Every verification step includes exact commands and expected outcomes.