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

1372 lines
39 KiB
Markdown

# 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:
```json
{
"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
<?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
<?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**
```bash
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
<?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
<?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
<?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**
```bash
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
<?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`:
```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:
```php
'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
<?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:
```php
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**
```bash
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
<?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
<?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
<?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**
```bash
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:
```php
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
<?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
<?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**
```bash
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`:
```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:
```php
use WPContentSync\Admin\FileImportController;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\FileTransportInterface;
use WPContentSync\Transport\JsonFileTransport;
```
Register these factories before `AdminPage::class`:
```php
$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:
```php
/** @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:
```php
<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:
```php
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**
```bash
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:
```powershell
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:
```text
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:
```bash
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.