Files
WP-Content-Sync/docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md
2026-04-26 20:03:02 -05:00

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.php as the immutable package value object.
  • Create: src/Package/PackageValidationResult.php as a small result object for schema errors.
  • Create: src/Package/PackageValidator.php to validate versioned package arrays before object hydration.
  • Create: src/Package/PackageChecksum.php to calculate and verify deterministic SHA-256 checksums.
  • Create: src/Transport/FileTransportInterface.php for export/import boundaries.
  • Create: src/Transport/JsonFileTransport.php to encode packages and parse uploaded JSON text.
  • Create: src/Admin/FileImportController.php to guard admin file imports with capability, nonce, upload checks, and validation.
  • Modify: src/Plugin.php to register package, transport, and file import services.
  • Modify: src/Admin/AdminPage.php to register the file import controller.
  • Modify: templates/admin/dashboard.php to add an import form shell.
  • Modify: tests/bootstrap.php to 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.php or src/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 PackageValidator and FileImportController.
  • 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.