From 82efe41d90162da2fda7dca87a92fa0929d2c63f Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:03:02 -0500 Subject: [PATCH 01/30] docs: add file transport implementation plan --- ...6-wordpress-content-sync-file-transport.md | 1371 +++++++++++++++++ ...ess-content-sync-implementation-roadmap.md | 2 +- 2 files changed, 1372 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md new file mode 100644 index 0000000..b464b95 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md @@ -0,0 +1,1371 @@ +# 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 + '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 + */ + private array $data; + + /** + * @param array $data Package data. + */ + private function __construct( array $data ) { + $this->data = $data; + } + + /** + * @param array $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 + */ + public function source(): array { + return $this->data['source']; + } + + /** + * @return array + */ + public function destination(): array { + return $this->data['destination']; + } + + /** + * @return array + */ + public function manifest(): array { + return $this->data['manifest']; + } + + /** + * @return array + */ + public function records(): array { + return $this->data['records']; + } + + /** + * @return array + */ + public function checksums(): array { + return $this->data['checksums']; + } + + /** + * @return array + */ + public function toArray(): array { + return $this->data; + } + + /** + * @param mixed $value Value to normalize. + * + * @return array + */ + 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 +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 + */ + 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 + */ + private array $errors; + + /** + * @param array $errors Validation errors. + */ + private function __construct( array $errors ) { + $this->errors = array_values( $errors ); + } + + public static function valid(): self { + return new self( array() ); + } + + /** + * @param array $errors Validation errors. + */ + public static function invalid( array $errors ): self { + return new self( $errors ); + } + + public function isValid(): bool { + return array() === $this->errors; + } + + /** + * @return array + */ + public function errors(): array { + return $this->errors; + } +} +``` + +- [ ] **Step 4: Implement schema validator** + +Create `src/Package/PackageValidator.php`: + +```php + $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 $manifest Package manifest. + * @param array $records Package records. + * + * @return array + */ + 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 + 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 + $records Package records. + */ + public static function records( array $records ): string { + return 'sha256:' . hash( 'sha256', self::canonicalJson( $records ) ); + } + + /** + * @param array $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 +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 +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 +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 $context Context. + */ + public function error( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function warning( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function info( string $message, array $context = array() ): void {} + + /** + * @param array $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 +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 +

+
+ + +

+ + +

+ +
+``` + +- [ ] **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 ''; + } +} + +if ( ! function_exists( 'submit_button' ) ) { + function submit_button( string $text, string $type = 'primary' ): void { + echo ''; + } +} +``` + +- [ ] **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. diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md index b826f73..15fdfbf 100644 --- a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md @@ -37,7 +37,7 @@ Adds domain mapping, URL replacement in post content, URL replacement inside ser ## Phase 3: Content Package Schema and File Transport -**Plan to create after Phase 2:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md` +**Plan:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-file-transport.md` Defines the sync package schema and implements export/import through JSON files for posts, pages, taxonomies, media metadata, and custom post type records. -- 2.52.0 From 49d3f5792c9b53e57b14ea46bb8020b77df140ee Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:05:59 -0500 Subject: [PATCH 02/30] feat: add content package value object --- src/Package/ContentPackage.php | 98 +++++++++++++++++++++++ tests/Unit/Package/ContentPackageTest.php | 54 +++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/Package/ContentPackage.php create mode 100644 tests/Unit/Package/ContentPackageTest.php diff --git a/src/Package/ContentPackage.php b/src/Package/ContentPackage.php new file mode 100644 index 0000000..e2c28e2 --- /dev/null +++ b/src/Package/ContentPackage.php @@ -0,0 +1,98 @@ + */ + private array $data; + + /** + * @param array $data Package data. + */ + private function __construct( array $data ) { + $this->data = $data; + } + + /** + * @param array $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 + */ + public function source(): array { + return $this->data['source']; + } + + /** + * @return array + */ + public function destination(): array { + return $this->data['destination']; + } + + /** + * @return array + */ + public function manifest(): array { + return $this->data['manifest']; + } + + /** + * @return array + */ + public function records(): array { + return $this->data['records']; + } + + /** + * @return array + */ + public function checksums(): array { + return $this->data['checksums']; + } + + /** + * @return array + */ + public function toArray(): array { + return $this->data; + } + + /** + * @param mixed $value Value to normalize. + * + * @return array + */ + private static function arrayValue( $value ): array { + return is_array( $value ) ? $value : array(); + } +} diff --git a/tests/Unit/Package/ContentPackageTest.php b/tests/Unit/Package/ContentPackageTest.php new file mode 100644 index 0000000..dba721c --- /dev/null +++ b/tests/Unit/Package/ContentPackageTest.php @@ -0,0 +1,54 @@ + '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() ); + } +} -- 2.52.0 From 35b9f29f413ba67eb34dfc3ab1f4aa8c4e417681 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:07:36 -0500 Subject: [PATCH 03/30] feat: add content package schema validator --- src/Package/PackageValidationResult.php | 42 ++++++++++ src/Package/PackageValidator.php | 88 +++++++++++++++++++ tests/Unit/Package/PackageValidatorTest.php | 93 +++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/Package/PackageValidationResult.php create mode 100644 src/Package/PackageValidator.php create mode 100644 tests/Unit/Package/PackageValidatorTest.php diff --git a/src/Package/PackageValidationResult.php b/src/Package/PackageValidationResult.php new file mode 100644 index 0000000..30dfc0d --- /dev/null +++ b/src/Package/PackageValidationResult.php @@ -0,0 +1,42 @@ + */ + private array $errors; + + /** + * @param array $errors Validation errors. + */ + private function __construct( array $errors ) { + $this->errors = array_values( $errors ); + } + + public static function valid(): self { + return new self( array() ); + } + + /** + * @param array $errors Validation errors. + */ + public static function invalid( array $errors ): self { + return new self( $errors ); + } + + public function isValid(): bool { + return array() === $this->errors; + } + + /** + * @return array + */ + public function errors(): array { + return $this->errors; + } +} diff --git a/src/Package/PackageValidator.php b/src/Package/PackageValidator.php new file mode 100644 index 0000000..5760b8e --- /dev/null +++ b/src/Package/PackageValidator.php @@ -0,0 +1,88 @@ + $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 $manifest Package manifest. + * @param array $records Package records. + * + * @return array + */ + 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; + } +} diff --git a/tests/Unit/Package/PackageValidatorTest.php b/tests/Unit/Package/PackageValidatorTest.php new file mode 100644 index 0000000..f3b0ee9 --- /dev/null +++ b/tests/Unit/Package/PackageValidatorTest.php @@ -0,0 +1,93 @@ +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 + */ + 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', + ), + ); + } +} -- 2.52.0 From 2202804b1575b318969902b29c44b6d35221b4cf Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:11:50 -0500 Subject: [PATCH 04/30] feat: add package checksum validation --- src/Package/PackageChecksum.php | 60 +++++++++++++++++ src/Package/PackageValidator.php | 15 ++++- tests/Unit/Package/PackageChecksumTest.php | 72 +++++++++++++++++++++ tests/Unit/Package/PackageValidatorTest.php | 25 ++++++- 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/Package/PackageChecksum.php create mode 100644 tests/Unit/Package/PackageChecksumTest.php diff --git a/src/Package/PackageChecksum.php b/src/Package/PackageChecksum.php new file mode 100644 index 0000000..1108d13 --- /dev/null +++ b/src/Package/PackageChecksum.php @@ -0,0 +1,60 @@ + $records Package records. + */ + public static function records( array $records ): string { + return 'sha256:' . hash( 'sha256', self::canonicalJson( $records ) ); + } + + /** + * @param array $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; + } +} diff --git a/src/Package/PackageValidator.php b/src/Package/PackageValidator.php index 5760b8e..06d426b 100644 --- a/src/Package/PackageValidator.php +++ b/src/Package/PackageValidator.php @@ -51,8 +51,21 @@ final class PackageValidator { $errors[] = 'checksums must be an object.'; } + $record_bucket_errors = array(); + if ( isset( $data['manifest'], $data['records'] ) && is_array( $data['manifest'] ) && is_array( $data['records'] ) ) { - $errors = array_merge( $errors, $this->validateRecordBuckets( $data['manifest'], $data['records'] ) ); + $record_bucket_errors = $this->validateRecordBuckets( $data['manifest'], $data['records'] ); + $errors = array_merge( $errors, $record_bucket_errors ); + } + + if ( + array() === $record_bucket_errors + && 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.'; } return array() === $errors ? PackageValidationResult::valid() : PackageValidationResult::invalid( $errors ); diff --git a/tests/Unit/Package/PackageChecksumTest.php b/tests/Unit/Package/PackageChecksumTest.php new file mode 100644 index 0000000..867cc95 --- /dev/null +++ b/tests/Unit/Package/PackageChecksumTest.php @@ -0,0 +1,72 @@ + 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_canonicalizes_associative_key_order(): void { + $records = array( + 'posts' => array( + array( + 'title' => 'Example', + 'id' => 123, + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + $reordered = array( + 'media' => array(), + 'custom_post_types' => array(), + 'terms' => array(), + 'posts' => array( + array( + 'id' => 123, + 'title' => 'Example', + ), + ), + ); + + self::assertSame( PackageChecksum::records( $records ), PackageChecksum::records( $reordered ) ); + } + + 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' ) ); + } +} diff --git a/tests/Unit/Package/PackageValidatorTest.php b/tests/Unit/Package/PackageValidatorTest.php index f3b0ee9..bc6aae3 100644 --- a/tests/Unit/Package/PackageValidatorTest.php +++ b/tests/Unit/Package/PackageValidatorTest.php @@ -3,6 +3,7 @@ namespace WPContentSync\Tests\Unit\Package; use PHPUnit\Framework\TestCase; +use WPContentSync\Package\PackageChecksum; use WPContentSync\Package\PackageValidator; class PackageValidatorTest extends TestCase { @@ -53,6 +54,16 @@ class PackageValidatorTest extends TestCase { self::assertSame( array( 'manifest.posts must match records.posts count.' ), $result->errors() ); } + 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() ); + } + /** * @return array */ @@ -86,7 +97,19 @@ class PackageValidatorTest extends TestCase { 'custom_post_types' => array(), ), 'checksums' => array( - 'records' => 'sha256:abc123', + 'records' => PackageChecksum::records( + array( + 'posts' => array( + array( + 'id' => 123, + 'type' => 'post', + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ) + ), ), ); } -- 2.52.0 From a9f719c4089e34d3a0f7c5e845960256c0baf578 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:26:06 -0500 Subject: [PATCH 05/30] feat: add json file transport --- src/Transport/FileTransportInterface.php | 16 ++++ src/Transport/JsonFileTransport.php | 45 +++++++++++ .../Unit/Transport/JsonFileTransportTest.php | 79 +++++++++++++++++++ tests/bootstrap.php | 5 +- 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/Transport/FileTransportInterface.php create mode 100644 src/Transport/JsonFileTransport.php create mode 100644 tests/Unit/Transport/JsonFileTransportTest.php diff --git a/src/Transport/FileTransportInterface.php b/src/Transport/FileTransportInterface.php new file mode 100644 index 0000000..025bcda --- /dev/null +++ b/src/Transport/FileTransportInterface.php @@ -0,0 +1,16 @@ +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 ); + } +} diff --git a/tests/Unit/Transport/JsonFileTransportTest.php b/tests/Unit/Transport/JsonFileTransportTest.php new file mode 100644 index 0000000..db551bd --- /dev/null +++ b/tests/Unit/Transport/JsonFileTransportTest.php @@ -0,0 +1,79 @@ +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 ), + ), + ) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 586b930..3479c6e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -143,11 +143,12 @@ if ( ! function_exists( 'wp_json_encode' ) ) { * Minimal JSON encoder for unit tests. * * @param mixed $value Value to encode. + * @param int $flags JSON encoding flags. * @return string|false */ - function wp_json_encode( $value ) { + function wp_json_encode( $value, $flags = 0 ) { // phpcs:ignore -- Test stub for WordPress' wp_json_encode(). - return json_encode( $value ); + return json_encode( $value, $flags ); } } -- 2.52.0 From 76b614e9e3e5eca861afe13f7a716a43546f9d32 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:32:04 -0500 Subject: [PATCH 06/30] feat: guard admin package imports --- src/Admin/FileImportController.php | 69 ++++++++ tests/Unit/Admin/FileImportControllerTest.php | 165 ++++++++++++++++++ tests/bootstrap.php | 70 +++++++- 3 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/Admin/FileImportController.php create mode 100644 tests/Unit/Admin/FileImportControllerTest.php diff --git a/src/Admin/FileImportController.php b/src/Admin/FileImportController.php new file mode 100644 index 0000000..5f36908 --- /dev/null +++ b/src/Admin/FileImportController.php @@ -0,0 +1,69 @@ +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 !== (int) $_FILES['wpcs_package_file']['error'] ) { + throw new \RuntimeException( 'The package file could not be uploaded.' ); + } + + $uploaded_file = sanitize_text_field( (string) $_FILES['wpcs_package_file']['tmp_name'] ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a validated local upload temp file. + $contents = file_get_contents( $uploaded_file ); + + 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' ) + ) + ); + } +} diff --git a/tests/Unit/Admin/FileImportControllerTest.php b/tests/Unit/Admin/FileImportControllerTest.php new file mode 100644 index 0000000..f1e962e --- /dev/null +++ b/tests/Unit/Admin/FileImportControllerTest.php @@ -0,0 +1,165 @@ + */ + private array $temporary_files = array(); + + protected function tearDown(): void { + foreach ( $this->temporary_files as $file ) { + if ( is_file( $file ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removing a PHPUnit temp file. + unlink( $file ); + } + } + + 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_rejects_failed_uploads(): void { + $_FILES['wpcs_package_file'] = array( + 'tmp_name' => '', + 'error' => UPLOAD_ERR_INI_SIZE, + ); + $controller = $this->controller(); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'The package file could not be uploaded.' ); + + $controller->handleImport(); + } + + public function test_it_imports_valid_uploaded_packages_without_mutating_content(): void { + $file = $this->createTemporaryPackageFile( $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 $context Context. + */ + public function error( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function warning( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function info( string $message, array $context = array() ): void {} + + /** + * @param array $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(), + ); + + $json = 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 ), + ), + ) + ); + + if ( false === $json ) { + throw new \RuntimeException( 'Unable to create package JSON fixture.' ); + } + + return $json; + } + + private function createTemporaryPackageFile( string $contents ): string { + $file = tempnam( sys_get_temp_dir(), 'wpcs-package-' ); + + if ( false === $file ) { + throw new \RuntimeException( 'Unable to create temporary package file.' ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Creating a PHPUnit temp fixture. + file_put_contents( $file, $contents ); + $this->temporary_files[] = $file; + + return $file; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3479c6e..1b9282d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -47,6 +47,22 @@ if ( ! function_exists( 'wp_strip_all_tags' ) ) { } } +if ( ! function_exists( 'wp_unslash' ) ) { + /** + * Minimal slashes remover for unit tests. + * + * @param mixed $value Value to unslash. + * @return mixed + */ + function wp_unslash( $value ) { + if ( is_array( $value ) ) { + return array_map( 'wp_unslash', $value ); + } + + return is_string( $value ) ? stripslashes( $value ) : $value; + } +} + if ( ! function_exists( 'esc_html' ) ) { /** * Minimal HTML escaper for unit tests. @@ -323,7 +339,59 @@ if ( ! function_exists( 'current_user_can' ) ) { * @return bool */ function current_user_can( $capability ) { - return 'manage_options' === $capability; + return $GLOBALS['wpcs_current_user_can'][ $capability ] ?? 'manage_options' === $capability; + } +} + +if ( ! function_exists( 'check_admin_referer' ) ) { + /** + * Minimal nonce checker for unit tests. + * + * @param string $action Nonce action. + * @param string $query_arg Nonce request field. + * @return bool + */ + function check_admin_referer( $action, $query_arg = '_wpnonce' ) { + return $GLOBALS['wpcs_nonce_valid'][ $action ][ $query_arg ] ?? true; + } +} + +if ( ! function_exists( 'wp_safe_redirect' ) ) { + /** + * Minimal safe redirect helper for unit tests. + * + * @param string $location Redirect location. + * @return bool + */ + function wp_safe_redirect( $location ) { + $GLOBALS['wpcs_redirect_location'] = $location; + + return true; + } +} + +if ( ! function_exists( 'admin_url' ) ) { + /** + * Minimal admin URL helper for unit tests. + * + * @param string $path Admin path. + * @return string + */ + function admin_url( $path = '' ) { + return 'https://example.test/wp-admin/' . ltrim( $path, '/' ); + } +} + +if ( ! function_exists( 'add_query_arg' ) ) { + /** + * Minimal query arg helper for unit tests. + * + * @param array $args Query args. + * @param string $url URL. + * @return string + */ + function add_query_arg( array $args, $url ) { + return $url . ( false === strpos( $url, '?' ) ? '?' : '&' ) . http_build_query( $args ); } } -- 2.52.0 From 90b56e13bb3313951063af87e41315e99f9aefc0 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:37:35 -0500 Subject: [PATCH 07/30] feat: wire file transport services --- src/Plugin.php | 34 ++++++++++++++++++++++++++++++++++ templates/admin/dashboard.php | 11 +++++++++++ tests/Unit/PluginTest.php | 15 +++++++++++++++ tests/bootstrap.php | 26 ++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/src/Plugin.php b/src/Plugin.php index 4b1838a..3d1f504 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,9 +8,13 @@ namespace WPContentSync; use WPContentSync\Admin\AdminPage; +use WPContentSync\Admin\FileImportController; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Logging\OptionLogger; +use WPContentSync\Package\PackageValidator; use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Transport\FileTransportInterface; +use WPContentSync\Transport\JsonFileTransport; use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\UrlTransformer; @@ -54,6 +58,32 @@ final class Plugin { } ); + $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 ) + ); + } + ); + $container->factory( AdminPage::class, static function () use ( $container ): AdminPage { @@ -71,6 +101,10 @@ final class Plugin { /** @var AdminPage $admin_page */ $admin_page = $this->container->get( AdminPage::class ); + /** @var FileImportController $file_import_controller */ + $file_import_controller = $this->container->get( FileImportController::class ); + $admin_page->register(); + $file_import_controller->register(); } } diff --git a/templates/admin/dashboard.php b/templates/admin/dashboard.php index 98dfb66..e066fa6 100644 --- a/templates/admin/dashboard.php +++ b/templates/admin/dashboard.php @@ -55,4 +55,15 @@ if ( ! defined( 'ABSPATH' ) ) { + +

+
+ + +

+ + +

+ +
diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 9c5a1e5..f7dec7b 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -3,8 +3,10 @@ namespace WPContentSync\Tests\Unit; use PHPUnit\Framework\TestCase; +use WPContentSync\Admin\FileImportController; use WPContentSync\Container; use WPContentSync\Plugin; +use WPContentSync\Transport\FileTransportInterface; use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\UrlTransformer; @@ -21,6 +23,19 @@ class PluginTest extends TestCase { self::assertSame( $metadata_transformer, $container->get( MetadataUrlTransformer::class ) ); } + public function test_it_registers_file_transport_services(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( + FileTransportInterface::class, + $container->get( FileTransportInterface::class ) + ); + self::assertInstanceOf( + FileImportController::class, + $container->get( FileImportController::class ) + ); + } + private function getPluginContainer( Plugin $plugin ): Container { $reflection = new \ReflectionClass( $plugin ); $property = $reflection->getProperty( 'container' ); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1b9282d..77520e0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -395,6 +395,32 @@ if ( ! function_exists( 'add_query_arg' ) ) { } } +if ( ! function_exists( 'wp_nonce_field' ) ) { + /** + * Minimal nonce field renderer for unit tests. + * + * @param string $action Nonce action. + * @param string $name Field name. + * @return void + */ + function wp_nonce_field( $action, $name ) { + echo ''; + } +} + +if ( ! function_exists( 'submit_button' ) ) { + /** + * Minimal submit button renderer for unit tests. + * + * @param string $text Button text. + * @param string $type Button type. + * @return void + */ + function submit_button( $text, $type = 'primary' ) { + echo ''; + } +} + if ( ! function_exists( 'wp_die' ) ) { /** * Minimal WordPress die handler for unit tests. -- 2.52.0 From cce40907d5dfdee04520d5e94f771c2de3af12a1 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:45:18 -0500 Subject: [PATCH 08/30] fix: handle invalid package uploads --- src/Admin/FileImportController.php | 31 +++++++++++++- templates/admin/dashboard.php | 14 +++++++ tests/Unit/Admin/DashboardTemplateTest.php | 41 +++++++++++++++++++ tests/Unit/Admin/FileImportControllerTest.php | 14 +++++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Admin/DashboardTemplateTest.php diff --git a/src/Admin/FileImportController.php b/src/Admin/FileImportController.php index 5f36908..bd93a24 100644 --- a/src/Admin/FileImportController.php +++ b/src/Admin/FileImportController.php @@ -49,7 +49,23 @@ final class FileImportController { throw new \RuntimeException( 'The package file could not be read.' ); } - $package = $this->transport->import( $contents ); + try { + $package = $this->transport->import( $contents ); + } catch ( \InvalidArgumentException $exception ) { + $this->logger->warning( + 'Rejected imported content package.', + array( + 'error' => $exception->getMessage(), + ) + ); + + $this->redirectToDashboard( + array( + 'wpcs_import_error' => $exception->getMessage(), + ) + ); + return; + } $this->logger->info( 'Validated imported content package.', @@ -59,9 +75,20 @@ final class FileImportController { ) ); + $this->redirectToDashboard( + array( + 'wpcs_imported' => '1', + ) + ); + } + + /** + * @param array $args Redirect query args. + */ + private function redirectToDashboard( array $args ): void { wp_safe_redirect( add_query_arg( - array( 'wpcs_imported' => '1' ), + $args, admin_url( 'admin.php?page=wp-content-sync' ) ) ); diff --git a/templates/admin/dashboard.php b/templates/admin/dashboard.php index e066fa6..c59eddd 100644 --- a/templates/admin/dashboard.php +++ b/templates/admin/dashboard.php @@ -26,6 +26,20 @@ if ( ! defined( 'ABSPATH' ) ) {

+ + +
+

+
+ + + + +
+

+
+ +

diff --git a/tests/Unit/Admin/DashboardTemplateTest.php b/tests/Unit/Admin/DashboardTemplateTest.php new file mode 100644 index 0000000..8681013 --- /dev/null +++ b/tests/Unit/Admin/DashboardTemplateTest.php @@ -0,0 +1,41 @@ +renderDashboard(); + + self::assertStringContainsString( 'notice-error', $output ); + self::assertStringContainsString( 'The selected file is not valid JSON.', $output ); + } + + public function test_it_renders_import_success_notices(): void { + $_GET['wpcs_imported'] = '1'; + + $output = $this->renderDashboard(); + + self::assertStringContainsString( 'notice-success', $output ); + self::assertStringContainsString( 'The package JSON file was validated successfully.', $output ); + } + + private function renderDashboard(): string { + $settings = Settings::fromArray( array() ); + + ob_start(); + include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php'; + + return (string) ob_get_clean(); + } +} diff --git a/tests/Unit/Admin/FileImportControllerTest.php b/tests/Unit/Admin/FileImportControllerTest.php index f1e962e..8e7898c 100644 --- a/tests/Unit/Admin/FileImportControllerTest.php +++ b/tests/Unit/Admin/FileImportControllerTest.php @@ -82,6 +82,20 @@ class FileImportControllerTest extends TestCase { self::assertStringContainsString( 'wpcs_imported=1', $GLOBALS['wpcs_redirect_location'] ); } + public function test_it_redirects_with_error_for_invalid_uploaded_packages(): void { + $file = $this->createTemporaryPackageFile( '{"schema_version":' ); + + $_FILES['wpcs_package_file'] = array( + 'tmp_name' => $file, + 'error' => UPLOAD_ERR_OK, + ); + + $this->controller()->handleImport(); + + self::assertStringContainsString( 'wpcs_import_error=', $GLOBALS['wpcs_redirect_location'] ); + self::assertStringContainsString( 'not+valid+JSON', $GLOBALS['wpcs_redirect_location'] ); + } + private function controller(): FileImportController { return new FileImportController( new JsonFileTransport( new PackageValidator() ), -- 2.52.0 From e082f9c275e1ff84e78bddc94d807856d67017b2 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 06:21:09 -0500 Subject: [PATCH 09/30] docs: add rest transport implementation plan --- ...ess-content-sync-implementation-roadmap.md | 2 +- ...8-wordpress-content-sync-rest-transport.md | 915 ++++++++++++++++++ 2 files changed, 916 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md index 15fdfbf..fec1174 100644 --- a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md @@ -49,7 +49,7 @@ Defines the sync package schema and implements export/import through JSON files ## Phase 4: REST Transport -**Plan to create after Phase 3:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-rest-transport.md` +**Plan:** `docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md` Adds authenticated REST endpoints and REST client support using WordPress application passwords. diff --git a/docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md new file mode 100644 index 0000000..1e34ca4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md @@ -0,0 +1,915 @@ +# WordPress Content Sync REST 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:** Add authenticated REST package receive/status endpoints and a REST client that can test connections and send validated content packages using WordPress application passwords. + +**Architecture:** This phase adds a REST controller under `src/Rest/` and a REST client under `src/Transport/`. The REST controller validates request shape, permissions, and package schema, then returns a typed response without mutating WordPress content. The REST client serializes the existing `ContentPackage`, sends it with Basic application-password authentication, and converts network/API failures into typed transport errors so later orchestration can fall back to file transport. + +**Tech Stack:** PHP 7.4, WordPress REST API, WordPress HTTP API, application passwords, PHPUnit, PHPStan, PHPCS/WPCS. + +--- + +## File Structure + +- Create: `src/Transport/RestTransportException.php` for typed REST failure details. +- Create: `src/Transport/RestTransportClient.php` for connection tests and package sends. +- Create: `src/Rest/RestPackageController.php` for `/wp-content-sync/v1/status` and `/wp-content-sync/v1/package` endpoints. +- Modify: `src/Plugin.php` to register REST services. +- Modify: `tests/bootstrap.php` to add REST and HTTP API stubs. +- Test: `tests/Unit/Transport/RestTransportExceptionTest.php` +- Test: `tests/Unit/Transport/RestTransportClientTest.php` +- Test: `tests/Unit/Rest/RestPackageControllerTest.php` +- Test: `tests/Unit/PluginTest.php` + +--- + +## REST Contract + +### Status Endpoint + +Route: `GET /wp-json/wp-content-sync/v1/status` + +Successful response: + +```json +{ + "ok": true, + "plugin": "wp-content-sync", + "version": "0.1.0" +} +``` + +### Package Receive Endpoint + +Route: `POST /wp-json/wp-content-sync/v1/package` + +Request JSON: + +```json +{ + "package": { + "schema_version": "1.0", + "generated_at": "2026-04-28T12:00:00+00:00", + "source": { + "site_url": "https://example.test", + "name": "Example Production" + }, + "destination": { + "site_url": "https://staging.example.test", + "name": "Example Staging" + }, + "manifest": { + "posts": 0, + "terms": 0, + "media": 0, + "custom_post_types": 0 + }, + "records": { + "posts": [], + "terms": [], + "media": [], + "custom_post_types": [] + }, + "checksums": { + "records": "sha256:..." + } + } +} +``` + +Successful response: + +```json +{ + "accepted": true, + "schema_version": "1.0", + "manifest": { + "posts": 0, + "terms": 0, + "media": 0, + "custom_post_types": 0 + } +} +``` + +Error response for invalid package: + +```json +{ + "accepted": false, + "errors": [ + "records is required." + ] +} +``` + +--- + +## Task 1: REST Transport Exception + +**Files:** +- Create: `tests/Unit/Transport/RestTransportExceptionTest.php` +- Create: `src/Transport/RestTransportException.php` + +- [ ] **Step 1: Write the failing exception test** + +Create `tests/Unit/Transport/RestTransportExceptionTest.php`: + +```php + 'https://example.test/wp-json/wp-content-sync/v1/status' ) + ); + + self::assertSame( 'connection_failed', $exception->failureCode() ); + self::assertSame( 'Connection timed out.', $exception->getMessage() ); + self::assertSame( array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' ), $exception->context() ); + } + + public function test_it_exposes_authentication_failures(): void { + $exception = RestTransportException::authenticationFailed( 'REST authentication failed.' ); + + self::assertSame( 'authentication_failed', $exception->failureCode() ); + self::assertSame( 'REST authentication failed.', $exception->getMessage() ); + self::assertSame( array(), $exception->context() ); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `composer test -- --filter RestTransportExceptionTest` + +Expected: FAIL with class `WPContentSync\Transport\RestTransportException` not found. + +- [ ] **Step 3: Implement the exception** + +Create `src/Transport/RestTransportException.php`: + +```php + */ + private array $context; + + private string $failure_code; + + /** + * @param array $context Failure context. + */ + private function __construct( string $failure_code, string $message, array $context = array() ) { + parent::__construct( $message ); + + $this->failure_code = $failure_code; + $this->context = $context; + } + + /** + * @param array $context Failure context. + */ + public static function connectionFailed( string $message, array $context = array() ): self { + return new self( 'connection_failed', $message, $context ); + } + + /** + * @param array $context Failure context. + */ + public static function authenticationFailed( string $message, array $context = array() ): self { + return new self( 'authentication_failed', $message, $context ); + } + + /** + * @param array $context Failure context. + */ + public static function remoteRejected( string $message, array $context = array() ): self { + return new self( 'remote_rejected', $message, $context ); + } + + public function failureCode(): string { + return $this->failure_code; + } + + /** + * @return array + */ + public function context(): array { + return $this->context; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `composer test -- --filter RestTransportExceptionTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Transport/RestTransportException.php tests/Unit/Transport/RestTransportExceptionTest.php +git commit -m "feat: add rest transport exception" +``` + +--- + +## Task 2: REST Transport Client + +**Files:** +- Create: `tests/Unit/Transport/RestTransportClientTest.php` +- Create: `src/Transport/RestTransportClient.php` +- Modify: `tests/bootstrap.php` + +- [ ] **Step 1: Add HTTP API test stubs** + +Add these stubs to `tests/bootstrap.php` if they do not already exist: + +```php +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private string $message; + + public function __construct( string $code, string $message ) { + $this->message = $message; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + +if ( ! function_exists( 'wp_remote_get' ) ) { + function wp_remote_get( string $url, array $args = array() ) { + $GLOBALS['wpcs_last_http_request'] = array( 'method' => 'GET', 'url' => $url, 'args' => $args ); + return $GLOBALS['wpcs_http_response'] ?? array( 'response' => array( 'code' => 200 ), 'body' => '{"ok":true}' ); + } +} + +if ( ! function_exists( 'wp_remote_post' ) ) { + function wp_remote_post( string $url, array $args = array() ) { + $GLOBALS['wpcs_last_http_request'] = array( 'method' => 'POST', 'url' => $url, 'args' => $args ); + return $GLOBALS['wpcs_http_response'] ?? array( 'response' => array( 'code' => 200 ), 'body' => '{"accepted":true}' ); + } +} + +if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + function wp_remote_retrieve_response_code( array $response ): int { + return (int) ( $response['response']['code'] ?? 0 ); + } +} + +if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + function wp_remote_retrieve_body( array $response ): string { + return (string) ( $response['body'] ?? '' ); + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $value ): bool { + return $value instanceof WP_Error; + } +} +``` + +- [ ] **Step 2: Write failing client tests** + +Create `tests/Unit/Transport/RestTransportClientTest.php`: + +```php +testConnection( 'https://destination.test', 'codex', 'app-pass' ) ); + + self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] ); + self::assertSame( 'Basic ' . base64_encode( 'codex:app-pass' ), $GLOBALS['wpcs_last_http_request']['args']['headers']['Authorization'] ); + } + + public function test_it_sends_packages_to_receive_endpoint(): void { + $client = new RestTransportClient(); + + self::assertTrue( $client->sendPackage( 'https://destination.test/', 'codex', 'app-pass', $this->package() ) ); + + self::assertSame( 'POST', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/package', $GLOBALS['wpcs_last_http_request']['url'] ); + self::assertStringContainsString( '"package"', $GLOBALS['wpcs_last_http_request']['args']['body'] ); + self::assertSame( 'application/json', $GLOBALS['wpcs_last_http_request']['args']['headers']['Content-Type'] ); + } + + public function test_it_throws_authentication_failures_for_unauthorized_status(): void { + $GLOBALS['wpcs_http_response'] = array( 'response' => array( 'code' => 401 ), 'body' => '{"message":"Unauthorized"}' ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'REST authentication failed.' ); + + $client->testConnection( 'https://destination.test', 'codex', 'bad-pass' ); + } + + public function test_it_throws_remote_rejected_for_invalid_package_response(): void { + $GLOBALS['wpcs_http_response'] = array( 'response' => array( 'code' => 400 ), 'body' => '{"message":"Invalid package"}' ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'Invalid package' ); + + $client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() ); + } + + 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-28T12:00:00+00:00', + 'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ), + 'destination' => array( 'site_url' => 'https://destination.test', 'name' => 'Destination' ), + '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 RestTransportClientTest` + +Expected: FAIL with class `WPContentSync\Transport\RestTransportClient` not found. + +- [ ] **Step 4: Implement REST client** + +Create `src/Transport/RestTransportClient.php`: + +```php +endpoint( $base_url, 'status' ), + $this->requestArgs( $username, $application_password ) + ); + + $this->assertSuccessfulResponse( $response, 200 ); + + return true; + } + + public function sendPackage( string $base_url, string $username, string $application_password, ContentPackage $package ): bool { + $body = wp_json_encode( array( 'package' => $package->toArray() ) ); + + if ( false === $body ) { + throw RestTransportException::remoteRejected( 'Unable to encode REST package payload.' ); + } + + $args = $this->requestArgs( $username, $application_password ); + $args['body'] = $body; + $args['headers']['Content-Type'] = 'application/json'; + + $response = wp_remote_post( $this->endpoint( $base_url, 'package' ), $args ); + + $this->assertSuccessfulResponse( $response, 200 ); + + return true; + } + + private function endpoint( string $base_url, string $route ): string { + return rtrim( $base_url, '/' ) . '/wp-json/wp-content-sync/v1/' . ltrim( $route, '/' ); + } + + /** + * @return array + */ + private function requestArgs( string $username, string $application_password ): array { + return array( + 'timeout' => 15, + 'headers' => array( + 'Authorization' => 'Basic ' . base64_encode( $username . ':' . $application_password ), + ), + ); + } + + /** + * @param mixed $response HTTP response. + */ + private function assertSuccessfulResponse( $response, int $expected_code ): void { + if ( is_wp_error( $response ) ) { + throw RestTransportException::connectionFailed( $response->get_error_message() ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + + if ( 401 === $status_code || 403 === $status_code ) { + throw RestTransportException::authenticationFailed( 'REST authentication failed.' ); + } + + if ( $expected_code !== $status_code ) { + throw RestTransportException::remoteRejected( $this->responseMessage( $response ) ); + } + } + + /** + * @param array $response HTTP response. + */ + private function responseMessage( array $response ): string { + $body = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $body, true ); + + if ( is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) { + return $decoded['message']; + } + + return 'REST transport request failed.'; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `composer test -- --filter RestTransportClientTest` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/Transport/RestTransportClient.php src/Transport/RestTransportException.php tests/Unit/Transport/RestTransportClientTest.php tests/bootstrap.php +git commit -m "feat: add rest transport client" +``` + +--- + +## Task 3: REST Package Controller + +**Files:** +- Create: `tests/Unit/Rest/RestPackageControllerTest.php` +- Create: `src/Rest/RestPackageController.php` +- Modify: `tests/bootstrap.php` + +- [ ] **Step 1: Add REST API test stubs** + +Add these stubs to `tests/bootstrap.php` if they do not already exist: + +```php +if ( ! function_exists( 'register_rest_route' ) ) { + function register_rest_route( string $namespace, string $route, array $args ): bool { + $GLOBALS['wpcs_rest_routes'][ $namespace . $route ] = $args; + return true; + } +} + +if ( ! function_exists( 'rest_ensure_response' ) ) { + function rest_ensure_response( $response ) { + return $response; + } +} +``` + +- [ ] **Step 2: Write failing controller tests** + +Create `tests/Unit/Rest/RestPackageControllerTest.php`: + +```php +register(); + + self::assertArrayHasKey( 'wp-content-sync/v1/status', $GLOBALS['wpcs_rest_routes'] ); + self::assertArrayHasKey( 'wp-content-sync/v1/package', $GLOBALS['wpcs_rest_routes'] ); + } + + public function test_it_requires_manage_options_permission(): void { + $GLOBALS['wpcs_current_user_can']['manage_options'] = false; + $controller = new RestPackageController( new PackageValidator() ); + + self::assertFalse( $controller->canReceivePackage() ); + } + + public function test_it_returns_status_payload(): void { + $controller = new RestPackageController( new PackageValidator() ); + + self::assertSame( + array( + 'ok' => true, + 'plugin' => 'wp-content-sync', + 'version' => WPCS_VERSION, + ), + $controller->status() + ); + } + + public function test_it_accepts_valid_packages(): void { + $controller = new RestPackageController( new PackageValidator() ); + + self::assertSame( + array( + 'accepted' => true, + 'schema_version' => '1.0', + 'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ), + ), + $controller->receivePackage( array( 'package' => $this->validPackage() ) ) + ); + } + + public function test_it_rejects_invalid_package_shapes(): void { + $controller = new RestPackageController( new PackageValidator() ); + + self::assertSame( + array( + 'accepted' => false, + 'errors' => array( 'package is required and must be an object.' ), + ), + $controller->receivePackage( array() ) + ); + } + + /** + * @return array + */ + private function validPackage(): array { + $records = array( + 'posts' => array(), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + + return array( + 'schema_version' => '1.0', + 'generated_at' => '2026-04-28T12:00:00+00:00', + 'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ), + 'destination' => array( 'site_url' => 'https://destination.test', 'name' => 'Destination' ), + '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 RestPackageControllerTest` + +Expected: FAIL with class `WPContentSync\Rest\RestPackageController` not found. + +- [ ] **Step 4: Implement REST controller** + +Create `src/Rest/RestPackageController.php`: + +```php +validator = $validator; + } + + public function register(): void { + register_rest_route( + 'wp-content-sync/v1', + '/status', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'status' ), + 'permission_callback' => array( $this, 'canReceivePackage' ), + ) + ); + + register_rest_route( + 'wp-content-sync/v1', + '/package', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'receivePackage' ), + 'permission_callback' => array( $this, 'canReceivePackage' ), + ) + ); + } + + public function canReceivePackage(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * @return array + */ + public function status(): array { + return array( + 'ok' => true, + 'plugin' => 'wp-content-sync', + 'version' => WPCS_VERSION, + ); + } + + /** + * @param array $request Request data. + * + * @return array + */ + public function receivePackage( $request ): array { + $data = $this->requestData( $request ); + + if ( ! isset( $data['package'] ) || ! is_array( $data['package'] ) ) { + return array( + 'accepted' => false, + 'errors' => array( 'package is required and must be an object.' ), + ); + } + + $result = $this->validator->validate( $data['package'] ); + + if ( ! $result->isValid() ) { + return array( + 'accepted' => false, + 'errors' => $result->errors(), + ); + } + + $package = ContentPackage::fromArray( $data['package'] ); + + return array( + 'accepted' => true, + 'schema_version' => $package->schemaVersion(), + 'manifest' => $package->manifest(), + ); + } + + /** + * @param mixed $request REST request or test payload. + * + * @return array + */ + private function requestData( $request ): array { + if ( is_array( $request ) ) { + return $request; + } + + if ( is_object( $request ) && method_exists( $request, 'get_json_params' ) ) { + $params = $request->get_json_params(); + + return is_array( $params ) ? $params : array(); + } + + return array(); + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `composer test -- --filter RestPackageControllerTest` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/Rest/RestPackageController.php tests/Unit/Rest/RestPackageControllerTest.php tests/bootstrap.php +git commit -m "feat: add rest package endpoints" +``` + +--- + +## Task 4: Service Wiring + +**Files:** +- Modify: `src/Plugin.php` +- Modify: `tests/Unit/PluginTest.php` + +- [ ] **Step 1: Extend plugin wiring tests** + +Add this test to `tests/Unit/PluginTest.php`: + +```php + public function test_it_registers_rest_transport_services(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( + \WPContentSync\Transport\RestTransportClient::class, + $container->get( \WPContentSync\Transport\RestTransportClient::class ) + ); + self::assertInstanceOf( + \WPContentSync\Rest\RestPackageController::class, + $container->get( \WPContentSync\Rest\RestPackageController::class ) + ); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `composer test -- --filter PluginTest` + +Expected: FAIL with service `WPContentSync\Transport\RestTransportClient` not registered. + +- [ ] **Step 3: Wire services in `src/Plugin.php`** + +Add imports: + +```php +use WPContentSync\Rest\RestPackageController; +use WPContentSync\Transport\RestTransportClient; +``` + +Register factories before `AdminPage::class`: + +```php + $container->factory( + RestTransportClient::class, + static function (): RestTransportClient { + return new RestTransportClient(); + } + ); + + $container->factory( + RestPackageController::class, + static function () use ( $container ): RestPackageController { + return new RestPackageController( + $container->get( PackageValidator::class ) + ); + } + ); +``` + +Register the REST controller in `register()`: + +```php + /** @var RestPackageController $rest_package_controller */ + $rest_package_controller = $this->container->get( RestPackageController::class ); + + $rest_package_controller->register(); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `composer test -- --filter PluginTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Plugin.php tests/Unit/PluginTest.php +git commit -m "feat: wire rest transport services" +``` + +--- + +## Task 5: Full REST Transport Verification + +**Files:** +- Verify all files created or modified in Tasks 1-4. + +- [ ] **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, URL, file transport, and REST transport tests. + +- [ ] **Step 5: Run REST client smoke test** + +Run: + +```powershell +php -r "require 'tests/bootstrap.php'; `$client=new WPContentSync\Transport\RestTransportClient(); var_export(`$client->testConnection('https://destination.test','codex','app-pass')); echo PHP_EOL;" +``` + +Expected output: + +```text +true +``` + +- [ ] **Step 6: Manual WordPress smoke test** + +In `http://basic-wp.test/wp-admin`, verify: + +- The plugin still activates and the WP Content Sync admin page still loads. +- `GET http://basic-wp.test/wp-json/wp-content-sync/v1/status` requires authentication. +- Authenticated status requests return `ok: true`, plugin name, and version. +- Invalid package POST requests return `accepted: false` and validation errors. +- Valid package POST requests return `accepted: true` without creating posts, terms, media, or custom post type records. + +--- + +## Spec Coverage + +- REST API transport is covered by `RestTransportClient`. +- Application password authentication is covered by Basic auth header tests. +- Destination receive/status endpoints are covered by `RestPackageController`. +- Permission validation is covered by `canReceivePackage()` tests and route registration. +- Request shape and package schema validation are covered by receive endpoint tests. +- Typed REST failures for connection, authentication, and remote rejection are covered by `RestTransportException` and client tests. +- File fallback decision remains available to later sync orchestration through `RestTransportException::failureCode()`. + +## Deferred Work + +- Automatic retry/backoff remains in Phase 5 sync orchestration. +- Choosing REST versus file transport remains in Phase 5 sync orchestration. +- Storing and rotating application passwords remains in Phase 6 admin hardening. +- Applying accepted package records to WordPress content remains in Phase 5 content handlers. + +## 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. -- 2.52.0 From 428c64a46ac9c3b5cf6ce01a902279a6cfb5fe44 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 06:24:02 -0500 Subject: [PATCH 10/30] feat: add rest transport exception --- src/Transport/RestTransportException.php | 57 +++++++++++++++++++ .../Transport/RestTransportExceptionTest.php | 27 +++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/Transport/RestTransportException.php create mode 100644 tests/Unit/Transport/RestTransportExceptionTest.php diff --git a/src/Transport/RestTransportException.php b/src/Transport/RestTransportException.php new file mode 100644 index 0000000..461659b --- /dev/null +++ b/src/Transport/RestTransportException.php @@ -0,0 +1,57 @@ + */ + private array $context; + + private string $failure_code; + + /** + * @param array $context Failure context. + */ + private function __construct( string $failure_code, string $message, array $context = array() ) { + parent::__construct( $message ); + + $this->failure_code = $failure_code; + $this->context = $context; + } + + /** + * @param array $context Failure context. + */ + public static function connectionFailed( string $message, array $context = array() ): self { + return new self( 'connection_failed', $message, $context ); + } + + /** + * @param array $context Failure context. + */ + public static function authenticationFailed( string $message, array $context = array() ): self { + return new self( 'authentication_failed', $message, $context ); + } + + /** + * @param array $context Failure context. + */ + public static function remoteRejected( string $message, array $context = array() ): self { + return new self( 'remote_rejected', $message, $context ); + } + + public function failureCode(): string { + return $this->failure_code; + } + + /** + * @return array + */ + public function context(): array { + return $this->context; + } +} diff --git a/tests/Unit/Transport/RestTransportExceptionTest.php b/tests/Unit/Transport/RestTransportExceptionTest.php new file mode 100644 index 0000000..25fdba9 --- /dev/null +++ b/tests/Unit/Transport/RestTransportExceptionTest.php @@ -0,0 +1,27 @@ + 'https://example.test/wp-json/wp-content-sync/v1/status' ) + ); + + self::assertSame( 'connection_failed', $exception->failureCode() ); + self::assertSame( 'Connection timed out.', $exception->getMessage() ); + self::assertSame( array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' ), $exception->context() ); + } + + public function test_it_exposes_authentication_failures(): void { + $exception = RestTransportException::authenticationFailed( 'REST authentication failed.' ); + + self::assertSame( 'authentication_failed', $exception->failureCode() ); + self::assertSame( 'REST authentication failed.', $exception->getMessage() ); + self::assertSame( array(), $exception->context() ); + } +} -- 2.52.0 From 3c7ad655c054e6a6b46e4ce3752bae94120328ee Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 12:38:59 -0500 Subject: [PATCH 11/30] feat: add rest transport client --- src/Transport/RestTransportClient.php | 91 +++++++++++++++++ .../Transport/RestTransportClientTest.php | 99 +++++++++++++++++++ tests/bootstrap.php | 96 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 src/Transport/RestTransportClient.php create mode 100644 tests/Unit/Transport/RestTransportClientTest.php diff --git a/src/Transport/RestTransportClient.php b/src/Transport/RestTransportClient.php new file mode 100644 index 0000000..150a25b --- /dev/null +++ b/src/Transport/RestTransportClient.php @@ -0,0 +1,91 @@ +endpoint( $base_url, 'status' ), + $this->requestArgs( $username, $application_password ) + ); + + $this->assertSuccessfulResponse( $response, 200 ); + + return true; + } + + public function sendPackage( string $base_url, string $username, string $application_password, ContentPackage $package ): bool { + $body = wp_json_encode( array( 'package' => $package->toArray() ) ); + + if ( false === $body ) { + throw RestTransportException::remoteRejected( 'Unable to encode REST package payload.' ); + } + + $args = $this->requestArgs( $username, $application_password ); + $args['body'] = $body; + $args['headers']['Content-Type'] = 'application/json'; + + $response = wp_remote_post( $this->endpoint( $base_url, 'package' ), $args ); + + $this->assertSuccessfulResponse( $response, 200 ); + + return true; + } + + private function endpoint( string $base_url, string $route ): string { + return rtrim( $base_url, '/' ) . '/wp-json/wp-content-sync/v1/' . ltrim( $route, '/' ); + } + + /** + * @return array + */ + private function requestArgs( string $username, string $application_password ): array { + return array( + 'timeout' => 15, + 'headers' => array( + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Basic auth requires base64-encoded username:application-password credentials. + 'Authorization' => 'Basic ' . base64_encode( $username . ':' . $application_password ), + ), + ); + } + + /** + * @param mixed $response HTTP response. + */ + private function assertSuccessfulResponse( $response, int $expected_code ): void { + if ( is_wp_error( $response ) ) { + throw RestTransportException::connectionFailed( $response->get_error_message() ); + } + + $status_code = wp_remote_retrieve_response_code( $response ); + + if ( 401 === $status_code || 403 === $status_code ) { + throw RestTransportException::authenticationFailed( 'REST authentication failed.' ); + } + + if ( $expected_code !== $status_code ) { + throw RestTransportException::remoteRejected( $this->responseMessage( $response ) ); + } + } + + /** + * @param array $response HTTP response. + */ + private function responseMessage( array $response ): string { + $body = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $body, true ); + + if ( is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) { + return $decoded['message']; + } + + return 'REST transport request failed.'; + } +} diff --git a/tests/Unit/Transport/RestTransportClientTest.php b/tests/Unit/Transport/RestTransportClientTest.php new file mode 100644 index 0000000..af11023 --- /dev/null +++ b/tests/Unit/Transport/RestTransportClientTest.php @@ -0,0 +1,99 @@ +testConnection( 'https://destination.test', 'codex', 'app-pass' ) ); + + self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Expected Basic auth value for application-password requests. + self::assertSame( 'Basic ' . base64_encode( 'codex:app-pass' ), $GLOBALS['wpcs_last_http_request']['args']['headers']['Authorization'] ); + } + + public function test_it_sends_packages_to_receive_endpoint(): void { + $client = new RestTransportClient(); + + self::assertTrue( $client->sendPackage( 'https://destination.test/', 'codex', 'app-pass', $this->package() ) ); + + self::assertSame( 'POST', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/package', $GLOBALS['wpcs_last_http_request']['url'] ); + self::assertStringContainsString( '"package"', $GLOBALS['wpcs_last_http_request']['args']['body'] ); + self::assertSame( 'application/json', $GLOBALS['wpcs_last_http_request']['args']['headers']['Content-Type'] ); + } + + public function test_it_throws_authentication_failures_for_unauthorized_status(): void { + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 401 ), + 'body' => '{"message":"Unauthorized"}', + ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'REST authentication failed.' ); + + $client->testConnection( 'https://destination.test', 'codex', 'bad-pass' ); + } + + public function test_it_throws_remote_rejected_for_invalid_package_response(): void { + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 400 ), + 'body' => '{"message":"Invalid package"}', + ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'Invalid package' ); + + $client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() ); + } + + 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-28T12:00:00+00:00', + 'source' => array( + 'site_url' => 'https://example.test', + 'name' => 'Example', + ), + 'destination' => array( + 'site_url' => 'https://destination.test', + 'name' => 'Destination', + ), + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'records' => $records, + 'checksums' => array( + 'records' => PackageChecksum::records( $records ), + ), + ) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 77520e0..7c30eea 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,6 +5,8 @@ * @package WPContentSync */ +// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- WordPress class and function stubs share this test bootstrap. + require_once dirname( __DIR__ ) . '/vendor/autoload.php'; if ( ! defined( 'ABSPATH' ) ) { @@ -23,6 +25,20 @@ if ( ! defined( 'WPCS_VERSION' ) ) { define( 'WPCS_VERSION', '0.1.0' ); } +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private string $message; + + public function __construct( string $code, string $message ) { + $this->message = $message; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + if ( ! function_exists( 'sanitize_text_field' ) ) { /** * Minimal WordPress-compatible text sanitizer for unit tests. @@ -370,6 +386,86 @@ if ( ! function_exists( 'wp_safe_redirect' ) ) { } } +if ( ! function_exists( 'wp_remote_get' ) ) { + /** + * Minimal HTTP GET helper for unit tests. + * + * @param string $url Request URL. + * @param array $args Request arguments. + * @return array|\WP_Error + */ + function wp_remote_get( $url, array $args = array() ) { + $GLOBALS['wpcs_last_http_request'] = array( + 'method' => 'GET', + 'url' => $url, + 'args' => $args, + ); + + return $GLOBALS['wpcs_http_response'] ?? array( + 'response' => array( 'code' => 200 ), + 'body' => '{"ok":true}', + ); + } +} + +if ( ! function_exists( 'wp_remote_post' ) ) { + /** + * Minimal HTTP POST helper for unit tests. + * + * @param string $url Request URL. + * @param array $args Request arguments. + * @return array|\WP_Error + */ + function wp_remote_post( $url, array $args = array() ) { + $GLOBALS['wpcs_last_http_request'] = array( + 'method' => 'POST', + 'url' => $url, + 'args' => $args, + ); + + return $GLOBALS['wpcs_http_response'] ?? array( + 'response' => array( 'code' => 200 ), + 'body' => '{"accepted":true}', + ); + } +} + +if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + /** + * Minimal response code helper for unit tests. + * + * @param array $response HTTP response. + * @return int + */ + function wp_remote_retrieve_response_code( array $response ) { + return (int) ( $response['response']['code'] ?? 0 ); + } +} + +if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + /** + * Minimal response body helper for unit tests. + * + * @param array $response HTTP response. + * @return string + */ + function wp_remote_retrieve_body( array $response ) { + return (string) ( $response['body'] ?? '' ); + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + /** + * Minimal WP_Error checker for unit tests. + * + * @param mixed $value Value to check. + * @return bool + */ + function is_wp_error( $value ) { + return $value instanceof WP_Error; + } +} + if ( ! function_exists( 'admin_url' ) ) { /** * Minimal admin URL helper for unit tests. -- 2.52.0 From e3d48f238316708503fd8165b20ac4faf01c7ab6 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 13:00:31 -0500 Subject: [PATCH 12/30] feat: add rest package endpoints --- src/Rest/RestPackageController.php | 110 ++++++++++++ tests/Unit/Rest/RestPackageControllerTest.php | 163 ++++++++++++++++++ tests/bootstrap.php | 28 +++ 3 files changed, 301 insertions(+) create mode 100644 src/Rest/RestPackageController.php create mode 100644 tests/Unit/Rest/RestPackageControllerTest.php diff --git a/src/Rest/RestPackageController.php b/src/Rest/RestPackageController.php new file mode 100644 index 0000000..4e4dcd4 --- /dev/null +++ b/src/Rest/RestPackageController.php @@ -0,0 +1,110 @@ +validator = $validator; + } + + public function register(): void { + add_action( 'rest_api_init', array( $this, 'registerRoutes' ) ); + } + + public function registerRoutes(): void { + register_rest_route( + 'wp-content-sync/v1', + '/status', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'status' ), + 'permission_callback' => array( $this, 'canReceivePackage' ), + ) + ); + + register_rest_route( + 'wp-content-sync/v1', + '/package', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'receivePackage' ), + 'permission_callback' => array( $this, 'canReceivePackage' ), + ) + ); + } + + public function canReceivePackage(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * @return array + */ + public function status(): array { + return array( + 'ok' => true, + 'plugin' => 'wp-content-sync', + 'version' => WPCS_VERSION, + ); + } + + /** + * @param mixed $request REST request or decoded request array. + * @return array + */ + public function receivePackage( $request ): array { + $data = $this->requestData( $request ); + + if ( ! isset( $data['package'] ) || ! is_array( $data['package'] ) ) { + return array( + 'accepted' => false, + 'errors' => array( 'package is required and must be an object.' ), + ); + } + + $result = $this->validator->validate( $data['package'] ); + + if ( ! $result->isValid() ) { + return array( + 'accepted' => false, + 'errors' => $result->errors(), + ); + } + + $package = ContentPackage::fromArray( $data['package'] ); + + return array( + 'accepted' => true, + 'schema_version' => $package->schemaVersion(), + 'manifest' => $package->manifest(), + ); + } + + /** + * @param mixed $request REST request or decoded request array. + * @return array + */ + private function requestData( $request ): array { + if ( is_array( $request ) ) { + return $request; + } + + if ( is_object( $request ) && method_exists( $request, 'get_json_params' ) ) { + $params = $request->get_json_params(); + + return is_array( $params ) ? $params : array(); + } + + return array(); + } +} diff --git a/tests/Unit/Rest/RestPackageControllerTest.php b/tests/Unit/Rest/RestPackageControllerTest.php new file mode 100644 index 0000000..646b6aa --- /dev/null +++ b/tests/Unit/Rest/RestPackageControllerTest.php @@ -0,0 +1,163 @@ +register(); + + self::assertSame( + array( $controller, 'registerRoutes' ), + $GLOBALS['wpcs_test_actions']['rest_api_init'][0] + ); + } + + public function test_it_registers_status_and_package_routes(): void { + $controller = new RestPackageController( new PackageValidator() ); + $controller->registerRoutes(); + + self::assertArrayHasKey( 'wp-content-sync/v1/status', $GLOBALS['wpcs_rest_routes'] ); + self::assertArrayHasKey( 'wp-content-sync/v1/package', $GLOBALS['wpcs_rest_routes'] ); + } + + public function test_it_requires_manage_options_permission(): void { + $GLOBALS['wpcs_current_user_can']['manage_options'] = false; + $controller = new RestPackageController( new PackageValidator() ); + + self::assertFalse( $controller->canReceivePackage() ); + } + + public function test_it_returns_status_payload(): void { + $controller = new RestPackageController( new PackageValidator() ); + + self::assertSame( + array( + 'ok' => true, + 'plugin' => 'wp-content-sync', + 'version' => WPCS_VERSION, + ), + $controller->status() + ); + } + + public function test_it_accepts_valid_packages(): void { + $controller = new RestPackageController( new PackageValidator() ); + + self::assertSame( + $this->acceptedResponse(), + $controller->receivePackage( + array( + 'package' => $this->validPackage(), + ) + ) + ); + } + + public function test_it_accepts_rest_request_like_objects(): void { + $controller = new RestPackageController( new PackageValidator() ); + $request = new class( + array( + 'package' => $this->validPackage(), + ) + ) { + /** @var array */ + private array $params; + + /** + * @param array $params Request params. + */ + public function __construct( array $params ) { + $this->params = $params; + } + + /** + * @return array + */ + public function get_json_params(): array { + return $this->params; + } + }; + + self::assertSame( $this->acceptedResponse(), $controller->receivePackage( $request ) ); + } + + public function test_it_rejects_invalid_package_shapes(): void { + $controller = new RestPackageController( new PackageValidator() ); + + self::assertSame( + array( + 'accepted' => false, + 'errors' => array( 'package is required and must be an object.' ), + ), + $controller->receivePackage( array() ) + ); + } + + /** + * @return array + */ + private function acceptedResponse(): array { + return array( + 'accepted' => true, + 'schema_version' => '1.0', + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + ); + } + + /** + * @return array + */ + private function validPackage(): array { + $records = array( + 'posts' => array(), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + + return array( + 'schema_version' => '1.0', + 'generated_at' => '2026-04-28T12:00:00+00:00', + 'source' => array( + 'site_url' => 'https://example.test', + 'name' => 'Example', + ), + 'destination' => array( + 'site_url' => 'https://destination.test', + 'name' => 'Destination', + ), + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'records' => $records, + 'checksums' => array( + 'records' => PackageChecksum::records( $records ), + ), + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7c30eea..d6903a4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -466,6 +466,34 @@ if ( ! function_exists( 'is_wp_error' ) ) { } } +if ( ! function_exists( 'register_rest_route' ) ) { + /** + * Minimal REST route registrar for unit tests. + * + * @param string $rest_namespace REST namespace. + * @param string $route REST route. + * @param array $args Route arguments. + * @return bool + */ + function register_rest_route( $rest_namespace, $route, array $args ) { + $GLOBALS['wpcs_rest_routes'][ $rest_namespace . $route ] = $args; + + return true; + } +} + +if ( ! function_exists( 'rest_ensure_response' ) ) { + /** + * Minimal REST response wrapper for unit tests. + * + * @param mixed $response Response value. + * @return mixed + */ + function rest_ensure_response( $response ) { + return $response; + } +} + if ( ! function_exists( 'admin_url' ) ) { /** * Minimal admin URL helper for unit tests. -- 2.52.0 From 8c3773f040c36b25f6cf1870058ed447218b2c25 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 13:04:39 -0500 Subject: [PATCH 13/30] feat: wire rest transport services --- src/Plugin.php | 22 ++++++++++++++ tests/Unit/PluginTest.php | 30 +++++++++++++++++++ tests/Unit/Rest/RestPackageControllerTest.php | 6 ++++ 3 files changed, 58 insertions(+) diff --git a/src/Plugin.php b/src/Plugin.php index 3d1f504..7681ad3 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,9 +12,11 @@ use WPContentSync\Admin\FileImportController; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Logging\OptionLogger; use WPContentSync\Package\PackageValidator; +use WPContentSync\Rest\RestPackageController; use WPContentSync\Settings\SettingsRepository; use WPContentSync\Transport\FileTransportInterface; use WPContentSync\Transport\JsonFileTransport; +use WPContentSync\Transport\RestTransportClient; use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\UrlTransformer; @@ -84,6 +86,22 @@ final class Plugin { } ); + $container->factory( + RestTransportClient::class, + static function (): RestTransportClient { + return new RestTransportClient(); + } + ); + + $container->factory( + RestPackageController::class, + static function () use ( $container ): RestPackageController { + return new RestPackageController( + $container->get( PackageValidator::class ) + ); + } + ); + $container->factory( AdminPage::class, static function () use ( $container ): AdminPage { @@ -104,7 +122,11 @@ final class Plugin { /** @var FileImportController $file_import_controller */ $file_import_controller = $this->container->get( FileImportController::class ); + /** @var RestPackageController $rest_package_controller */ + $rest_package_controller = $this->container->get( RestPackageController::class ); + $admin_page->register(); $file_import_controller->register(); + $rest_package_controller->register(); } } diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index f7dec7b..a597111 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -6,11 +6,19 @@ use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; use WPContentSync\Container; use WPContentSync\Plugin; +use WPContentSync\Rest\RestPackageController; use WPContentSync\Transport\FileTransportInterface; +use WPContentSync\Transport\RestTransportClient; use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\UrlTransformer; class PluginTest extends TestCase { + protected function tearDown(): void { + unset( $GLOBALS['wpcs_test_actions'] ); + + parent::tearDown(); + } + public function test_it_registers_url_transformation_services(): void { $container = $this->getPluginContainer( Plugin::create() ); @@ -36,6 +44,28 @@ class PluginTest extends TestCase { ); } + public function test_it_registers_rest_transport_services(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( + RestTransportClient::class, + $container->get( RestTransportClient::class ) + ); + self::assertInstanceOf( + RestPackageController::class, + $container->get( RestPackageController::class ) + ); + } + + public function test_it_hooks_rest_package_controller_on_register(): void { + unset( $GLOBALS['wpcs_test_actions'] ); + + $plugin = Plugin::create(); + $plugin->register(); + + self::assertArrayHasKey( 'rest_api_init', $GLOBALS['wpcs_test_actions'] ); + } + private function getPluginContainer( Plugin $plugin ): Container { $reflection = new \ReflectionClass( $plugin ); $property = $reflection->getProperty( 'container' ); diff --git a/tests/Unit/Rest/RestPackageControllerTest.php b/tests/Unit/Rest/RestPackageControllerTest.php index 646b6aa..d7683c7 100644 --- a/tests/Unit/Rest/RestPackageControllerTest.php +++ b/tests/Unit/Rest/RestPackageControllerTest.php @@ -13,6 +13,12 @@ use WPContentSync\Package\PackageValidator; use WPContentSync\Rest\RestPackageController; class RestPackageControllerTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + + unset( $GLOBALS['wpcs_rest_routes'], $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_test_actions'] ); + } + protected function tearDown(): void { unset( $GLOBALS['wpcs_rest_routes'], $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_test_actions'] ); -- 2.52.0 From 52543aab2dd2afc423ee967c3fb6d296ef3ee085 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 13:37:31 -0500 Subject: [PATCH 14/30] docs: add sync engine implementation plan --- ...ess-content-sync-implementation-roadmap.md | 2 +- ...-wordpress-content-sync-engine-handlers.md | 1011 +++++++++++++++++ 2 files changed, 1012 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md index fec1174..21e9865 100644 --- a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md @@ -61,7 +61,7 @@ Adds authenticated REST endpoints and REST client support using WordPress applic ## Phase 5: Sync Engine and Content Handlers -**Plan to create after Phase 4:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-engine-handlers.md` +**Plan:** `docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md` Implements orchestration, content extraction/import handlers, conflict detection, retries, progress state, and operation logs. diff --git a/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md new file mode 100644 index 0000000..08d82b0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md @@ -0,0 +1,1011 @@ +# Sync Engine and Content Handlers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first real content mutation path so validated packages can be imported through deterministic content handlers with progress, logging, URL rewriting, and conflict-aware results. + +**Architecture:** Phase 5 starts import-first: REST/file transports continue producing `ContentPackage` objects, and a new `SyncEngine` applies those packages through ordered handlers. Export orchestration, retry/backoff, and media sideloading remain later slices so the first mutation path stays reviewable and safe. + +**Tech Stack:** PHP 7.4, WordPress APIs, PHPUnit, PHPStan, PHPCS/WPCS, existing package/transport/url/logging services. + +--- + +## File Structure + +- `src/Sync/SyncResult.php`: immutable result object with success, created, updated, skipped, conflict, and error counts. +- `src/Sync/SyncContext.php`: immutable context for direction, operation ID, source/destination URLs, URL mappings, and conflict strategy. +- `src/Sync/SyncOperationState.php`: immutable progress snapshot for current operation status and bucket offsets. +- `src/Sync/SyncStateRepository.php`: option/transient-backed state persistence for resumable imports. +- `src/Sync/SyncEngine.php`: validates package records and calls content handlers in deterministic order. +- `src/Content/ContentHandlerInterface.php`: shared handler boundary for package bucket imports. +- `src/Content/ContentHandlerRegistry.php`: ordered, keyed content handler collection. +- `src/Content/ContentImportException.php`: typed content import failure with handler/bucket context. +- `src/Content/ContentRecordNormalizer.php`: sanitizes package records into predictable post/term/media shapes. +- `src/Content/PostContentHandler.php`: imports posts, pages, and custom post type records using last-write-wins or manual-review behavior. +- `src/Content/TermContentHandler.php`: imports taxonomy terms and term metadata. +- `src/Content/MediaContentHandler.php`: imports attachment records and attachment metadata without sideloading remote files in this slice. +- `src/Admin/FileImportController.php`: applies validated packages with `SyncEngine`. +- `src/Rest/RestPackageController.php`: applies validated REST packages with `SyncEngine`. +- `src/Plugin.php`: registers sync/content services. +- `tests/bootstrap.php`: adds WordPress post, meta, term, attachment, and transient stubs. + +--- + +## Task 1: Sync Result Value Object + +**Files:** +- Create: `src/Sync/SyncResult.php` +- Create: `tests/Unit/Sync/SyncResultTest.php` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Unit/Sync/SyncResultTest.php`: + +```php + 2, + 'updated' => 3, + 'skipped' => 1, + 'conflicts' => 1, + ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 2, $result->created() ); + self::assertSame( 3, $result->updated() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( array(), $result->errors() ); + } + + public function test_it_tracks_failed_results(): void { + $result = SyncResult::failure( array( 'posts import failed.' ) ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'posts import failed.' ), $result->errors() ); + } + + public function test_it_merges_multiple_results(): void { + $result = SyncResult::merge( + array( + SyncResult::success( array( 'created' => 1 ) ), + SyncResult::success( array( 'updated' => 2, 'skipped' => 1 ) ), + SyncResult::failure( array( 'terms import failed.' ) ), + ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 2, $result->updated() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( array( 'terms import failed.' ), $result->errors() ); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `composer test -- --filter SyncResultTest` + +Expected: FAIL with class `WPContentSync\Sync\SyncResult` not found. + +- [ ] **Step 3: Implement `SyncResult`** + +Create `src/Sync/SyncResult.php`: + +```php + */ + private array $errors; + + /** + * @param array $errors Error messages. + */ + private function __construct( bool $successful, int $created, int $updated, int $skipped, int $conflicts, array $errors ) { + $this->successful = $successful; + $this->created = max( 0, $created ); + $this->updated = max( 0, $updated ); + $this->skipped = max( 0, $skipped ); + $this->conflicts = max( 0, $conflicts ); + $this->errors = array_values( array_map( 'strval', $errors ) ); + } + + /** + * @param array $counts Result counts. + */ + public static function success( array $counts = array() ): self { + return new self( + true, + (int) ( $counts['created'] ?? 0 ), + (int) ( $counts['updated'] ?? 0 ), + (int) ( $counts['skipped'] ?? 0 ), + (int) ( $counts['conflicts'] ?? 0 ), + array() + ); + } + + /** + * @param array $errors Error messages. + */ + public static function failure( array $errors ): self { + return new self( false, 0, 0, 0, 0, $errors ); + } + + /** + * @param array $results Results to merge. + */ + public static function merge( array $results ): self { + $successful = true; + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $errors = array(); + + foreach ( $results as $result ) { + $successful = $successful && $result->isSuccessful(); + $created += $result->created(); + $updated += $result->updated(); + $skipped += $result->skipped(); + $conflicts += $result->conflicts(); + $errors = array_merge( $errors, $result->errors() ); + } + + return new self( $successful, $created, $updated, $skipped, $conflicts, $errors ); + } + + public function isSuccessful(): bool { + return $this->successful; + } + + public function created(): int { + return $this->created; + } + + public function updated(): int { + return $this->updated; + } + + public function skipped(): int { + return $this->skipped; + } + + public function conflicts(): int { + return $this->conflicts; + } + + /** + * @return array + */ + public function errors(): array { + return $this->errors; + } + + /** + * @return array + */ + public function toArray(): array { + return array( + 'successful' => $this->successful, + 'created' => $this->created, + 'updated' => $this->updated, + 'skipped' => $this->skipped, + 'conflicts' => $this->conflicts, + 'errors' => $this->errors, + ); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `composer test -- --filter SyncResultTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Sync/SyncResult.php tests/Unit/Sync/SyncResultTest.php +git commit -m "feat: add sync result value object" +``` + +--- + +## Task 2: Sync Context and Operation State + +**Files:** +- Create: `src/Sync/SyncContext.php` +- Create: `src/Sync/SyncOperationState.php` +- Create: `src/Sync/SyncStateRepository.php` +- Modify: `tests/bootstrap.php` +- Create: `tests/Unit/Sync/SyncContextTest.php` +- Create: `tests/Unit/Sync/SyncStateRepositoryTest.php` + +- [ ] **Step 1: Add transient test stubs** + +Add to `tests/bootstrap.php` near the existing `delete_transient()` stub: + +```php +if ( ! function_exists( 'get_transient' ) ) { + /** + * Minimal WordPress transient reader for unit tests. + * + * @param string $name Transient name. + * @return mixed + */ + function get_transient( $name ) { + return $GLOBALS['wpcs_test_transients'][ $name ] ?? false; + } +} + +if ( ! function_exists( 'set_transient' ) ) { + /** + * Minimal WordPress transient writer for unit tests. + * + * @param string $name Transient name. + * @param mixed $value Transient value. + * @param int $expiration Expiration in seconds. + * @return bool + */ + function set_transient( $name, $value, $expiration = 0 ) { + $GLOBALS['wpcs_test_transients'][ $name ] = $value; + $GLOBALS['wpcs_test_transient_expiration'][ $name ] = $expiration; + + return true; + } +} +``` + +- [ ] **Step 2: Write failing context/state tests** + +Create `tests/Unit/Sync/SyncContextTest.php`: + +```php + 'https://source.test' ), + array( 'site_url' => 'https://destination.test' ), + 'last_write_wins', + 'operation-1' + ); + + self::assertSame( 'import', $context->direction() ); + self::assertSame( 'operation-1', $context->operationId() ); + self::assertSame( 'last_write_wins', $context->conflictStrategy() ); + self::assertSame( 'https://source.test', $context->sourceUrl() ); + self::assertSame( 'https://destination.test', $context->destinationUrl() ); + self::assertSame( + array( 'https://source.test' => 'https://destination.test' ), + $context->urlMappings() + ); + } + + public function test_it_falls_back_to_last_write_wins_for_invalid_strategy(): void { + $context = SyncContext::forImport( array(), array(), 'surprise', 'operation-2' ); + + self::assertSame( 'last_write_wins', $context->conflictStrategy() ); + } +} +``` + +Create `tests/Unit/Sync/SyncStateRepositoryTest.php`: + +```php +save( $state ); + + $loaded = $repository->get( 'operation-1' ); + + self::assertInstanceOf( SyncOperationState::class, $loaded ); + self::assertSame( 'operation-1', $loaded->operationId() ); + self::assertSame( 'posts', $loaded->currentBucket() ); + self::assertSame( 2, $loaded->processed() ); + self::assertSame( 10, $loaded->total() ); + self::assertSame( 'running', $loaded->status() ); + } + + public function test_it_deletes_operation_state(): void { + $repository = new SyncStateRepository(); + $repository->save( SyncOperationState::completed( 'operation-1', 10, 10 ) ); + + $repository->delete( 'operation-1' ); + + self::assertNull( $repository->get( 'operation-1' ) ); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `composer test -- --filter "SyncContextTest|SyncStateRepositoryTest"` + +Expected: FAIL with missing `SyncContext`, `SyncOperationState`, and `SyncStateRepository`. + +- [ ] **Step 4: Implement context and state classes** + +Create `src/Sync/SyncContext.php` with `forImport()`, getters, URL mapping normalization, and conflict strategy fallback. Create `src/Sync/SyncOperationState.php` with static `running()` and `completed()` constructors plus `fromArray()` / `toArray()`. Create `src/Sync/SyncStateRepository.php` that persists state with transient key `wpcs_sync_state_` and expiration `DAY_IN_SECONDS` if defined or `86400` otherwise. + +The repository implementation must use this exact key method: + +```php +private function key( string $operation_id ): string { + return 'wpcs_sync_state_' . sanitize_key( $operation_id ); +} +``` + +If `sanitize_key()` is not yet stubbed, add this to `tests/bootstrap.php`: + +```php +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( $key ) { + return strtolower( preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $key ) ); + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `composer test -- --filter "SyncContextTest|SyncStateRepositoryTest"` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/Sync/SyncContext.php src/Sync/SyncOperationState.php src/Sync/SyncStateRepository.php tests/Unit/Sync/SyncContextTest.php tests/Unit/Sync/SyncStateRepositoryTest.php tests/bootstrap.php +git commit -m "feat: add sync context and operation state" +``` + +--- + +## Task 3: Content Handler Boundary and Registry + +**Files:** +- Create: `src/Content/ContentHandlerInterface.php` +- Create: `src/Content/ContentHandlerRegistry.php` +- Create: `src/Content/ContentImportException.php` +- Create: `tests/Unit/Content/ContentHandlerRegistryTest.php` + +- [ ] **Step 1: Write failing registry tests** + +Create `tests/Unit/Content/ContentHandlerRegistryTest.php`: + +```php +handler( 'posts' ); + $terms = $this->handler( 'terms' ); + $media = $this->handler( 'media' ); + + $registry = new ContentHandlerRegistry( array( $media, $posts, $terms ) ); + + self::assertSame( array( $posts, $terms, $media ), $registry->ordered() ); + } + + public function test_it_rejects_duplicate_buckets(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Handler bucket "posts" is already registered.' ); + + new ContentHandlerRegistry( array( $this->handler( 'posts' ), $this->handler( 'posts' ) ) ); + } + + private function handler( string $bucket ): ContentHandlerInterface { + return new class( $bucket ) implements ContentHandlerInterface { + private string $bucket; + + public function __construct( string $bucket ) { + $this->bucket = $bucket; + } + + public function bucket(): string { + return $this->bucket; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + return SyncResult::success( array( 'skipped' => count( $records ) ) ); + } + }; + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `composer test -- --filter ContentHandlerRegistryTest` + +Expected: FAIL with missing content handler classes. + +- [ ] **Step 3: Implement interface, registry, and exception** + +Create `src/Content/ContentHandlerInterface.php`: + +```php +> $records Package records for this handler bucket. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult; +} +``` + +Create `ContentHandlerRegistry` with package order `custom_post_types`, `terms`, `posts`, `media`; then return only registered handlers that exist in that order. Create `ContentImportException` with `bucket()` and `record()` accessors. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `composer test -- --filter ContentHandlerRegistryTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Content/ContentHandlerInterface.php src/Content/ContentHandlerRegistry.php src/Content/ContentImportException.php tests/Unit/Content/ContentHandlerRegistryTest.php +git commit -m "feat: add content handler registry" +``` + +--- + +## Task 4: WordPress Content Test Stubs + +**Files:** +- Modify: `tests/bootstrap.php` +- Create: `tests/Unit/Content/WordPressContentStubTest.php` + +- [ ] **Step 1: Write failing stub tests** + +Create `tests/Unit/Content/WordPressContentStubTest.php`: + +```php + 'Hello', 'post_type' => 'post' ), true ); + + wp_update_post( array( 'ID' => $post_id, 'post_title' => 'Updated' ), true ); + + self::assertSame( 'Updated', get_post( $post_id )['post_title'] ); + } + + public function test_meta_stubs_replace_values(): void { + update_post_meta( 10, '_source_url', 'https://source.test/page' ); + update_post_meta( 10, '_source_url', 'https://destination.test/page' ); + + self::assertSame( array( 'https://destination.test/page' ), get_post_meta( 10, '_source_url' ) ); + } + + public function test_term_stubs_insert_update_and_read_terms(): void { + $result = wp_insert_term( 'News', 'category', array( 'slug' => 'news' ) ); + + wp_update_term( $result['term_id'], 'category', array( 'name' => 'Latest News' ) ); + + $term = get_term_by( 'slug', 'news', 'category' ); + self::assertSame( 'Latest News', $term['name'] ); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `composer test -- --filter WordPressContentStubTest` + +Expected: FAIL with missing WordPress content functions. + +- [ ] **Step 3: Add stubs** + +Add deterministic stubs to `tests/bootstrap.php` for: + +- `wp_insert_post( array $postarr, $wp_error = false )` +- `wp_update_post( array $postarr, $wp_error = false )` +- `get_post( $post = null, $output = OBJECT, $filter = 'raw' )` +- `get_posts( array $args = array() )` +- `update_post_meta( $post_id, $meta_key, $meta_value )` +- `get_post_meta( $post_id, $key = '', $single = false )` +- `delete_post_meta( $post_id, $meta_key )` +- `wp_insert_term( $term, $taxonomy, array $args = array() )` +- `wp_update_term( $term_id, $taxonomy, array $args = array() )` +- `get_term_by( $field, $value, $taxonomy )` +- `wp_set_object_terms( $object_id, $terms, $taxonomy )` +- `wp_insert_attachment( array $args, $file = false, $parent_post_id = 0, $wp_error = false )` +- `wp_update_attachment_metadata( $attachment_id, $data )` +- `wp_get_attachment_metadata( $attachment_id )` + +Use `$GLOBALS['wpcs_test_posts']`, `$GLOBALS['wpcs_test_next_post_id']`, `$GLOBALS['wpcs_test_post_meta']`, `$GLOBALS['wpcs_test_terms']`, and `$GLOBALS['wpcs_test_next_term_id']`. Return `WP_Error` only when `$wp_error` is true and required fields are missing. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `composer test -- --filter WordPressContentStubTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/bootstrap.php tests/Unit/Content/WordPressContentStubTest.php +git commit -m "test: add wordpress content stubs" +``` + +--- + +## Task 5: Content Record Normalizer + +**Files:** +- Create: `src/Content/ContentRecordNormalizer.php` +- Create: `tests/Unit/Content/ContentRecordNormalizerTest.php` + +- [ ] **Step 1: Write failing normalizer tests** + +Create tests that assert: + +- Post records normalize `id`, `post_type`, `post_title`, `post_content`, `post_excerpt`, `post_status`, `post_name`, `post_parent`, `menu_order`, and `meta`. +- Term records normalize `id`, `taxonomy`, `name`, `slug`, `description`, `parent`, and `meta`. +- Media records normalize `id`, `post_title`, `post_mime_type`, `source_url`, `metadata`, and `meta`. + +Run: `composer test -- --filter ContentRecordNormalizerTest` + +Expected: FAIL with missing class. + +- [ ] **Step 2: Implement the normalizer** + +Implement methods: + +```php +public function post( array $record ): array; +public function term( array $record ): array; +public function media( array $record ): array; +``` + +Rules: + +- Use `sanitize_text_field()` for scalar text fields. +- Use `esc_url_raw()` for URL fields. +- Cast IDs, parents, and menu order to integers. +- Preserve `post_content`, metadata values, and serialized structures for `MetadataUrlTransformer`. +- Normalize `meta` to `array`. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `composer test -- --filter ContentRecordNormalizerTest` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/Content/ContentRecordNormalizer.php tests/Unit/Content/ContentRecordNormalizerTest.php +git commit -m "feat: normalize content records" +``` + +--- + +## Task 6: Post Content Handler + +**Files:** +- Create: `src/Content/PostContentHandler.php` +- Create: `tests/Unit/Content/PostContentHandlerTest.php` + +- [ ] **Step 1: Write failing handler tests** + +Create tests for: + +- Creating a new post when no existing `_wpcs_source_id` meta matches. +- Updating an existing post when `_wpcs_source_id` meta matches and conflict strategy is `last_write_wins`. +- Skipping an existing post and recording a conflict when conflict strategy is `manual_review`. +- Rewriting post content and meta URLs from source to destination. + +Use `SyncContext::forImport()` with source `https://source.test` and destination `https://destination.test`. + +Run: `composer test -- --filter PostContentHandlerTest` + +Expected: FAIL with missing `PostContentHandler`. + +- [ ] **Step 2: Implement the handler** + +Constructor dependencies: + +```php +public function __construct( + ContentRecordNormalizer $normalizer, + UrlTransformer $url_transformer, + MetadataUrlTransformer $metadata_transformer, + LoggerInterface $logger +) {} +``` + +Behavior: + +- `bucket()` returns `posts`. +- Match existing posts by `_wpcs_source_id`. +- Create/update with `wp_insert_post()` and `wp_update_post()`. +- Store `_wpcs_source_id` and `_wpcs_source_site` meta. +- Transform `post_content` and `post_excerpt` with `UrlTransformer`. +- Transform each meta value with `MetadataUrlTransformer`. +- Log conflicts with `warning()` and successful imports with `info()`. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `composer test -- --filter PostContentHandlerTest` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/Content/PostContentHandler.php tests/Unit/Content/PostContentHandlerTest.php +git commit -m "feat: import post content records" +``` + +--- + +## Task 7: Term Content Handler + +**Files:** +- Create: `src/Content/TermContentHandler.php` +- Create: `tests/Unit/Content/TermContentHandlerTest.php` + +- [ ] **Step 1: Write failing term tests** + +Create tests for: + +- Creating a new term by taxonomy/slug. +- Updating an existing term under `last_write_wins`. +- Skipping and logging conflict under `manual_review`. +- Transforming term description and term meta URL values. + +Run: `composer test -- --filter TermContentHandlerTest` + +Expected: FAIL with missing `TermContentHandler`. + +- [ ] **Step 2: Implement the handler** + +Constructor dependencies mirror `PostContentHandler`. Behavior: + +- `bucket()` returns `terms`. +- Match terms by `_wpcs_source_id` term meta if available, otherwise taxonomy/slug. +- Use `wp_insert_term()` and `wp_update_term()`. +- Store `_wpcs_source_id` and `_wpcs_source_site` term meta if term meta stubs are added; otherwise store source tracking in `$GLOBALS['wpcs_test_terms']` for tests and use WordPress term meta in production. +- Return `SyncResult` counts. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `composer test -- --filter TermContentHandlerTest` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/Content/TermContentHandler.php tests/Unit/Content/TermContentHandlerTest.php tests/bootstrap.php +git commit -m "feat: import taxonomy term records" +``` + +--- + +## Task 8: Media Content Handler + +**Files:** +- Create: `src/Content/MediaContentHandler.php` +- Create: `tests/Unit/Content/MediaContentHandlerTest.php` + +- [ ] **Step 1: Write failing media tests** + +Create tests for: + +- Creating an attachment record without downloading files. +- Updating attachment metadata under `last_write_wins`. +- Transforming `source_url`, attachment metadata, and attachment meta. +- Skipping conflicts under `manual_review`. + +Run: `composer test -- --filter MediaContentHandlerTest` + +Expected: FAIL with missing `MediaContentHandler`. + +- [ ] **Step 2: Implement the handler** + +Behavior: + +- `bucket()` returns `media`. +- Use `wp_insert_attachment()` for new attachment records. +- Use `wp_update_post()` for existing attachment records. +- Use `wp_update_attachment_metadata()` for metadata. +- Store `_wpcs_source_id`, `_wpcs_source_site`, and `_wpcs_source_url` meta. +- Do not sideload binary media files in this task; log `warning()` when a record has `source_url` but no local file. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `composer test -- --filter MediaContentHandlerTest` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/Content/MediaContentHandler.php tests/Unit/Content/MediaContentHandlerTest.php +git commit -m "feat: import media metadata records" +``` + +--- + +## Task 9: Sync Engine Import Orchestration + +**Files:** +- Create: `src/Sync/SyncEngine.php` +- Create: `tests/Unit/Sync/SyncEngineTest.php` + +- [ ] **Step 1: Write failing engine tests** + +Create tests for: + +- Calling registered handlers in registry order with records from the matching package bucket. +- Merging handler results into one `SyncResult`. +- Saving running/completed state through `SyncStateRepository`. +- Logging operation start and completion. +- Returning failure when a handler throws `ContentImportException`. + +Run: `composer test -- --filter SyncEngineTest` + +Expected: FAIL with missing `SyncEngine`. + +- [ ] **Step 2: Implement `SyncEngine`** + +Constructor dependencies: + +```php +public function __construct( + ContentHandlerRegistry $handlers, + SyncStateRepository $state_repository, + SettingsRepository $settings_repository, + LoggerInterface $logger +) {} +``` + +Method: + +```php +public function importPackage( ContentPackage $package ): SyncResult; +``` + +Behavior: + +- Build `SyncContext::forImport()` from package source/destination and current settings conflict strategy. +- Generate an operation ID with `uniqid( 'wpcs_', true )`. +- Calculate total record count across manifest buckets. +- Save `running` state before each bucket and `completed` state at the end. +- Catch `ContentImportException`, log `error()`, save failed state, and merge a failure result. +- Never mutate records outside handlers. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `composer test -- --filter SyncEngineTest` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/Sync/SyncEngine.php tests/Unit/Sync/SyncEngineTest.php +git commit -m "feat: orchestrate package imports" +``` + +--- + +## Task 10: Wire Engine Into File and REST Imports + +**Files:** +- Modify: `src/Admin/FileImportController.php` +- Modify: `src/Rest/RestPackageController.php` +- Modify: `src/Plugin.php` +- Modify: `tests/Unit/Admin/FileImportControllerTest.php` +- Modify: `tests/Unit/Rest/RestPackageControllerTest.php` +- Modify: `tests/Unit/PluginTest.php` + +- [ ] **Step 1: Write failing wiring tests** + +Update file import tests so a valid uploaded package calls `SyncEngine::importPackage()` and redirects with `wpcs_imported=1` only when the result is successful. + +Update REST controller tests so a valid package returns: + +```php +array( + 'accepted' => true, + 'schema_version' => '1.0', + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'import' => array( + 'successful' => true, + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'conflicts' => 0, + 'errors' => array(), + ), +) +``` + +Update `PluginTest` to assert `SyncEngine`, `ContentHandlerRegistry`, and the three handlers are registered. + +Run: `composer test -- --filter "FileImportControllerTest|RestPackageControllerTest|PluginTest"` + +Expected: FAIL because controllers and container do not accept/use `SyncEngine`. + +- [ ] **Step 2: Wire production services** + +Update constructors: + +- `FileImportController( FileTransportInterface $transport, LoggerInterface $logger, SyncEngine $sync_engine )` +- `RestPackageController( PackageValidator $validator, SyncEngine $sync_engine )` + +Update successful import paths: + +- File import logs `Imported content package.` with `SyncResult::toArray()`. +- REST receive includes `import => $result->toArray()`. +- If `SyncResult::isSuccessful()` is false, file import redirects with `wpcs_import_error`, and REST returns `accepted => false` with result errors. + +Update `Plugin::create()` factories for normalizer, handlers, registry, state repository, and engine. + +- [ ] **Step 3: Run focused tests** + +Run: `composer test -- --filter "FileImportControllerTest|RestPackageControllerTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/Admin/FileImportController.php src/Rest/RestPackageController.php src/Plugin.php tests/Unit/Admin/FileImportControllerTest.php tests/Unit/Rest/RestPackageControllerTest.php tests/Unit/PluginTest.php +git commit -m "feat: apply packages during imports" +``` + +--- + +## Task 11: Full Phase 5 Verification + +**Files:** +- Verify all files created or modified in Tasks 1-10. + +- [ ] **Step 1: Run Composer validation** + +Run: `composer validate --strict` + +Expected: PASS with `./composer.json is valid`. + +- [ ] **Step 2: Run PHPCS** + +Run: `composer lint` + +Expected: PASS with no PHPCS errors. + +- [ ] **Step 3: Run PHPStan** + +Run: `composer stan` + +Expected: PASS with `[OK] No errors`. + +- [ ] **Step 4: Run PHPUnit** + +Run: `composer test` + +Expected: PASS with all foundation, URL, file transport, REST transport, sync, and content handler tests. + +- [ ] **Step 5: Live WordPress smoke** + +Copy runtime files into the Herd test plugin and verify: + +- Plugin still activates. +- Admin page still loads. +- Invalid file import still redirects with an actionable dashboard error. +- Valid empty package import redirects with success and logs a completed import. +- `GET /wp-json/wp-content-sync/v1/status` still requires authentication. +- Authenticated valid REST package POST returns `accepted: true` with an `import` result. +- Authenticated invalid REST package POST returns `accepted: false` with validation errors. + +- [ ] **Step 6: Commit verification docs only if files changed** + +If documentation or smoke notes are updated: + +```bash +git add docs/superpowers/plans/2026-04-28-wordpress-content-sync-engine-handlers.md +git commit -m "docs: update sync engine verification notes" +``` + +--- + +## Spec Coverage + +- Sync manager orchestration is covered by `SyncEngine`. +- Content handlers are covered for posts/pages/custom post type post records, terms, and media metadata. +- URL transformation is covered in post/term/media fields and metadata through existing URL transformer services. +- Conflict behavior is covered by `last_write_wins` and `manual_review` handler tests. +- Progress state is covered by `SyncOperationState` and `SyncStateRepository`. +- REST/file mutation paths are covered when controllers call `SyncEngine`. +- Partial failure logging is covered by `ContentImportException` handling and `SyncResult` errors. +- Retry/backoff, binary media sideloading, export orchestration, and background queues are intentionally deferred to later Phase 5 slices after the first safe import mutation path lands. + +## Placeholder Scan + +- No unresolved implementation markers are intentionally included. +- Each task names exact files, test commands, expected failures, implementation responsibilities, and commit messages. +- Deferred work is explicitly named as out of scope rather than left ambiguous. -- 2.52.0 From 90cb78b7126f6eefc01dc08600573f4007047fad Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 13:42:32 -0500 Subject: [PATCH 15/30] feat: add sync result value object --- src/Sync/SyncResult.php | 116 +++++++++++++++++++++++++++++ tests/Unit/Sync/SyncResultTest.php | 72 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/Sync/SyncResult.php create mode 100644 tests/Unit/Sync/SyncResultTest.php diff --git a/src/Sync/SyncResult.php b/src/Sync/SyncResult.php new file mode 100644 index 0000000..01d9790 --- /dev/null +++ b/src/Sync/SyncResult.php @@ -0,0 +1,116 @@ + */ + private array $errors; + + /** + * @param array $errors Error messages. + */ + private function __construct( bool $successful, int $created, int $updated, int $skipped, int $conflicts, array $errors ) { + $this->successful = $successful; + $this->created = max( 0, $created ); + $this->updated = max( 0, $updated ); + $this->skipped = max( 0, $skipped ); + $this->conflicts = max( 0, $conflicts ); + $this->errors = array_values( array_map( 'strval', $errors ) ); + } + + /** + * @param array $counts Result counts. + */ + public static function success( array $counts = array() ): self { + return new self( + true, + (int) ( $counts['created'] ?? 0 ), + (int) ( $counts['updated'] ?? 0 ), + (int) ( $counts['skipped'] ?? 0 ), + (int) ( $counts['conflicts'] ?? 0 ), + array() + ); + } + + /** + * @param array $errors Error messages. + */ + public static function failure( array $errors ): self { + return new self( false, 0, 0, 0, 0, $errors ); + } + + /** + * @param array $results Results to merge. + */ + public static function merge( array $results ): self { + $successful = true; + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $errors = array(); + + foreach ( $results as $result ) { + $successful = $successful && $result->isSuccessful(); + $created += $result->created(); + $updated += $result->updated(); + $skipped += $result->skipped(); + $conflicts += $result->conflicts(); + $errors = array_merge( $errors, $result->errors() ); + } + + return new self( $successful, $created, $updated, $skipped, $conflicts, $errors ); + } + + public function isSuccessful(): bool { + return $this->successful; + } + + public function created(): int { + return $this->created; + } + + public function updated(): int { + return $this->updated; + } + + public function skipped(): int { + return $this->skipped; + } + + public function conflicts(): int { + return $this->conflicts; + } + + /** + * @return array + */ + public function errors(): array { + return $this->errors; + } + + /** + * @return array + */ + public function toArray(): array { + return array( + 'successful' => $this->successful, + 'created' => $this->created, + 'updated' => $this->updated, + 'skipped' => $this->skipped, + 'conflicts' => $this->conflicts, + 'errors' => $this->errors, + ); + } +} diff --git a/tests/Unit/Sync/SyncResultTest.php b/tests/Unit/Sync/SyncResultTest.php new file mode 100644 index 0000000..48c3511 --- /dev/null +++ b/tests/Unit/Sync/SyncResultTest.php @@ -0,0 +1,72 @@ + 2, + 'updated' => 3, + 'skipped' => 1, + 'conflicts' => 1, + ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 2, $result->created() ); + self::assertSame( 3, $result->updated() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( array(), $result->errors() ); + self::assertSame( + array( + 'successful' => true, + 'created' => 2, + 'updated' => 3, + 'skipped' => 1, + 'conflicts' => 1, + 'errors' => array(), + ), + $result->toArray() + ); + } + + public function test_it_tracks_failed_results(): void { + $result = SyncResult::failure( array( 'posts import failed.' ) ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'posts import failed.' ), $result->errors() ); + } + + public function test_it_merges_multiple_results(): void { + $result = SyncResult::merge( + array( + SyncResult::success( array( 'created' => 1 ) ), + SyncResult::success( + array( + 'updated' => 2, + 'skipped' => 1, + 'conflicts' => 1, + ) + ), + SyncResult::failure( array( 'terms import failed.' ) ), + ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 2, $result->updated() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( array( 'terms import failed.' ), $result->errors() ); + } +} -- 2.52.0 From b176a374579594e5c0776b8a7185894d094b9baa Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 13:51:38 -0500 Subject: [PATCH 16/30] feat: add sync context and operation state --- src/Sync/SyncContext.php | 92 +++++++++++++++++++++ src/Sync/SyncOperationState.php | 78 +++++++++++++++++ src/Sync/SyncStateRepository.php | 38 +++++++++ tests/Unit/Sync/SyncContextTest.php | 38 +++++++++ tests/Unit/Sync/SyncStateRepositoryTest.php | 46 +++++++++++ tests/bootstrap.php | 42 ++++++++++ 6 files changed, 334 insertions(+) create mode 100644 src/Sync/SyncContext.php create mode 100644 src/Sync/SyncOperationState.php create mode 100644 src/Sync/SyncStateRepository.php create mode 100644 tests/Unit/Sync/SyncContextTest.php create mode 100644 tests/Unit/Sync/SyncStateRepositoryTest.php diff --git a/src/Sync/SyncContext.php b/src/Sync/SyncContext.php new file mode 100644 index 0000000..7352e6f --- /dev/null +++ b/src/Sync/SyncContext.php @@ -0,0 +1,92 @@ + */ + private array $url_mappings; + + /** + * @param array $url_mappings URL mappings. + */ + private function __construct( + string $direction, + string $operation_id, + string $source_url, + string $destination_url, + string $conflict_strategy, + array $url_mappings + ) { + $this->direction = $direction; + $this->operation_id = $operation_id; + $this->source_url = $source_url; + $this->destination_url = $destination_url; + $this->conflict_strategy = in_array( $conflict_strategy, self::CONFLICT_STRATEGIES, true ) ? $conflict_strategy : 'last_write_wins'; + $this->url_mappings = $url_mappings; + } + + /** + * @param array $source Source site metadata. + * @param array $destination Destination site metadata. + * @param string $conflict_strategy Conflict strategy. + * @param string $operation_id Operation ID. + */ + public static function forImport( array $source, array $destination, string $conflict_strategy, string $operation_id ): self { + $source_url = esc_url_raw( (string) ( $source['site_url'] ?? '' ) ); + $destination_url = esc_url_raw( (string) ( $destination['site_url'] ?? '' ) ); + $url_mappings = array(); + + if ( '' !== $source_url && '' !== $destination_url ) { + $url_mappings[ $source_url ] = $destination_url; + } + + return new self( + 'import', + sanitize_key( $operation_id ), + $source_url, + $destination_url, + $conflict_strategy, + $url_mappings + ); + } + + public function direction(): string { + return $this->direction; + } + + public function operationId(): string { + return $this->operation_id; + } + + public function sourceUrl(): string { + return $this->source_url; + } + + public function destinationUrl(): string { + return $this->destination_url; + } + + public function conflictStrategy(): string { + return $this->conflict_strategy; + } + + /** + * @return array + */ + public function urlMappings(): array { + return $this->url_mappings; + } +} diff --git a/src/Sync/SyncOperationState.php b/src/Sync/SyncOperationState.php new file mode 100644 index 0000000..b941876 --- /dev/null +++ b/src/Sync/SyncOperationState.php @@ -0,0 +1,78 @@ +operation_id = sanitize_key( $operation_id ); + $this->status = sanitize_key( $status ); + $this->current_bucket = sanitize_key( $current_bucket ); + $this->processed = max( 0, $processed ); + $this->total = max( 0, $total ); + } + + public static function running( string $operation_id, string $current_bucket, int $processed, int $total ): self { + return new self( $operation_id, 'running', $current_bucket, $processed, $total ); + } + + public static function completed( string $operation_id, int $processed, int $total ): self { + return new self( $operation_id, 'completed', '', $processed, $total ); + } + + /** + * @param array $data State data. + */ + public static function fromArray( array $data ): self { + return new self( + (string) ( $data['operation_id'] ?? '' ), + (string) ( $data['status'] ?? '' ), + (string) ( $data['current_bucket'] ?? '' ), + (int) ( $data['processed'] ?? 0 ), + (int) ( $data['total'] ?? 0 ) + ); + } + + public function operationId(): string { + return $this->operation_id; + } + + public function status(): string { + return $this->status; + } + + public function currentBucket(): string { + return $this->current_bucket; + } + + public function processed(): int { + return $this->processed; + } + + public function total(): int { + return $this->total; + } + + /** + * @return array + */ + public function toArray(): array { + return array( + 'operation_id' => $this->operation_id, + 'status' => $this->status, + 'current_bucket' => $this->current_bucket, + 'processed' => $this->processed, + 'total' => $this->total, + ); + } +} diff --git a/src/Sync/SyncStateRepository.php b/src/Sync/SyncStateRepository.php new file mode 100644 index 0000000..495ea1d --- /dev/null +++ b/src/Sync/SyncStateRepository.php @@ -0,0 +1,38 @@ +key( $state->operationId() ), $state->toArray(), $this->expiration() ); + } + + public function get( string $operation_id ): ?SyncOperationState { + $value = get_transient( $this->key( $operation_id ) ); + + if ( ! is_array( $value ) ) { + return null; + } + + return SyncOperationState::fromArray( $value ); + } + + public function delete( string $operation_id ): void { + delete_transient( $this->key( $operation_id ) ); + } + + private function key( string $operation_id ): string { + return 'wpcs_sync_state_' . sanitize_key( $operation_id ); + } + + private function expiration(): int { + return defined( 'DAY_IN_SECONDS' ) ? (int) DAY_IN_SECONDS : self::DEFAULT_EXPIRATION; + } +} diff --git a/tests/Unit/Sync/SyncContextTest.php b/tests/Unit/Sync/SyncContextTest.php new file mode 100644 index 0000000..cc2a6fd --- /dev/null +++ b/tests/Unit/Sync/SyncContextTest.php @@ -0,0 +1,38 @@ + 'https://source.test' ), + array( 'site_url' => 'https://destination.test' ), + 'last_write_wins', + 'operation-1' + ); + + self::assertSame( 'import', $context->direction() ); + self::assertSame( 'operation-1', $context->operationId() ); + self::assertSame( 'last_write_wins', $context->conflictStrategy() ); + self::assertSame( 'https://source.test', $context->sourceUrl() ); + self::assertSame( 'https://destination.test', $context->destinationUrl() ); + self::assertSame( + array( 'https://source.test' => 'https://destination.test' ), + $context->urlMappings() + ); + } + + public function test_it_falls_back_to_last_write_wins_for_invalid_strategy(): void { + $context = SyncContext::forImport( array(), array(), 'surprise', 'operation-2' ); + + self::assertSame( 'last_write_wins', $context->conflictStrategy() ); + } +} diff --git a/tests/Unit/Sync/SyncStateRepositoryTest.php b/tests/Unit/Sync/SyncStateRepositoryTest.php new file mode 100644 index 0000000..4be7c04 --- /dev/null +++ b/tests/Unit/Sync/SyncStateRepositoryTest.php @@ -0,0 +1,46 @@ +save( $state ); + + $loaded = $repository->get( 'operation-1' ); + + self::assertInstanceOf( SyncOperationState::class, $loaded ); + self::assertSame( 'operation-1', $loaded->operationId() ); + self::assertSame( 'posts', $loaded->currentBucket() ); + self::assertSame( 2, $loaded->processed() ); + self::assertSame( 10, $loaded->total() ); + self::assertSame( 'running', $loaded->status() ); + } + + public function test_it_deletes_operation_state(): void { + $repository = new SyncStateRepository(); + $repository->save( SyncOperationState::completed( 'operation-1', 10, 10 ) ); + + $repository->delete( 'operation-1' ); + + self::assertNull( $repository->get( 'operation-1' ) ); + self::assertArrayNotHasKey( 'wpcs_sync_state_operation-1', $GLOBALS['wpcs_test_transient_expiration'] ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d6903a4..648ecf6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -51,6 +51,18 @@ if ( ! function_exists( 'sanitize_text_field' ) ) { } } +if ( ! function_exists( 'sanitize_key' ) ) { + /** + * Minimal WordPress-compatible key sanitizer for unit tests. + * + * @param mixed $key Key to sanitize. + * @return string + */ + function sanitize_key( $key ) { + return strtolower( preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $key ) ); + } +} + if ( ! function_exists( 'wp_strip_all_tags' ) ) { /** * Minimal tag stripper for unit tests. @@ -223,6 +235,36 @@ if ( ! function_exists( 'delete_transient' ) ) { */ function delete_transient( $name ) { unset( $GLOBALS['wpcs_test_transients'][ $name ] ); + unset( $GLOBALS['wpcs_test_transient_expiration'][ $name ] ); + + return true; + } +} + +if ( ! function_exists( 'get_transient' ) ) { + /** + * Minimal WordPress transient reader for unit tests. + * + * @param string $name Transient name. + * @return mixed + */ + function get_transient( $name ) { + return $GLOBALS['wpcs_test_transients'][ $name ] ?? false; + } +} + +if ( ! function_exists( 'set_transient' ) ) { + /** + * Minimal WordPress transient writer for unit tests. + * + * @param string $name Transient name. + * @param mixed $value Transient value. + * @param int $expiration Expiration in seconds. + * @return bool + */ + function set_transient( $name, $value, $expiration = 0 ) { + $GLOBALS['wpcs_test_transients'][ $name ] = $value; + $GLOBALS['wpcs_test_transient_expiration'][ $name ] = $expiration; return true; } -- 2.52.0 From 5c0b22f4c4dc9b43159b22c4166e953d6205e41b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 13:55:09 -0500 Subject: [PATCH 17/30] feat: add content handler registry --- src/Content/ContentHandlerInterface.php | 20 +++++++ src/Content/ContentHandlerRegistry.php | 54 +++++++++++++++++++ src/Content/ContentImportException.php | 36 +++++++++++++ .../Content/ContentHandlerRegistryTest.php | 54 +++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 src/Content/ContentHandlerInterface.php create mode 100644 src/Content/ContentHandlerRegistry.php create mode 100644 src/Content/ContentImportException.php create mode 100644 tests/Unit/Content/ContentHandlerRegistryTest.php diff --git a/src/Content/ContentHandlerInterface.php b/src/Content/ContentHandlerInterface.php new file mode 100644 index 0000000..8350588 --- /dev/null +++ b/src/Content/ContentHandlerInterface.php @@ -0,0 +1,20 @@ +> $records Package records for this handler bucket. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult; +} diff --git a/src/Content/ContentHandlerRegistry.php b/src/Content/ContentHandlerRegistry.php new file mode 100644 index 0000000..7b3a90f --- /dev/null +++ b/src/Content/ContentHandlerRegistry.php @@ -0,0 +1,54 @@ + */ + private array $handlers = array(); + + /** + * @param array $handlers Content handlers. + */ + public function __construct( array $handlers ) { + foreach ( $handlers as $handler ) { + $this->register( $handler ); + } + } + + private function register( ContentHandlerInterface $handler ): void { + $bucket = $handler->bucket(); + + if ( isset( $this->handlers[ $bucket ] ) ) { + throw new \InvalidArgumentException( sprintf( 'Handler bucket "%s" is already registered.', $bucket ) ); + } + + $this->handlers[ $bucket ] = $handler; + } + + /** + * @return array + */ + public function ordered(): array { + $ordered = array(); + + foreach ( self::PACKAGE_ORDER as $bucket ) { + if ( isset( $this->handlers[ $bucket ] ) ) { + $ordered[] = $this->handlers[ $bucket ]; + } + } + + return $ordered; + } +} diff --git a/src/Content/ContentImportException.php b/src/Content/ContentImportException.php new file mode 100644 index 0000000..76ab60e --- /dev/null +++ b/src/Content/ContentImportException.php @@ -0,0 +1,36 @@ + */ + private array $record; + + /** + * @param array $record Content record that failed. + */ + public function __construct( string $bucket, array $record, string $message, ?\Throwable $previous = null ) { + parent::__construct( $message, 0, $previous ); + + $this->bucket = $bucket; + $this->record = $record; + } + + public function bucket(): string { + return $this->bucket; + } + + /** + * @return array + */ + public function record(): array { + return $this->record; + } +} diff --git a/tests/Unit/Content/ContentHandlerRegistryTest.php b/tests/Unit/Content/ContentHandlerRegistryTest.php new file mode 100644 index 0000000..488b9b9 --- /dev/null +++ b/tests/Unit/Content/ContentHandlerRegistryTest.php @@ -0,0 +1,54 @@ +handler( 'posts' ); + $terms = $this->handler( 'terms' ); + $media = $this->handler( 'media' ); + + $registry = new ContentHandlerRegistry( array( $media, $posts, $terms ) ); + + self::assertSame( array( $terms, $posts, $media ), $registry->ordered() ); + } + + public function test_it_rejects_duplicate_buckets(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'Handler bucket "posts" is already registered.' ); + + new ContentHandlerRegistry( array( $this->handler( 'posts' ), $this->handler( 'posts' ) ) ); + } + + private function handler( string $bucket ): ContentHandlerInterface { + return new class( $bucket ) implements ContentHandlerInterface { + private string $bucket; + + public function __construct( string $bucket ) { + $this->bucket = $bucket; + } + + public function bucket(): string { + return $this->bucket; + } + + /** + * @param array> $records Package records. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult { + return SyncResult::success( array( 'skipped' => count( $records ) ) ); + } + }; + } +} -- 2.52.0 From 7a30bbf1de7246e8d91ff5fd054544ae4093f80d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 18:09:42 -0500 Subject: [PATCH 18/30] test: add wordpress content stubs --- .../Unit/Content/WordPressContentStubTest.php | 131 +++++++ tests/bootstrap.php | 352 ++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 tests/Unit/Content/WordPressContentStubTest.php diff --git a/tests/Unit/Content/WordPressContentStubTest.php b/tests/Unit/Content/WordPressContentStubTest.php new file mode 100644 index 0000000..13a00f8 --- /dev/null +++ b/tests/Unit/Content/WordPressContentStubTest.php @@ -0,0 +1,131 @@ + 'Hello', + 'post_type' => 'post', + ), + true + ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated', + ), + true + ); + + self::assertSame( 'Updated', get_post( $post_id )['post_title'] ); + } + + public function test_meta_stubs_replace_values(): void { + update_post_meta( 10, '_source_url', 'https://source.test/page' ); + update_post_meta( 10, '_source_url', 'https://destination.test/page' ); + + self::assertSame( array( 'https://destination.test/page' ), get_post_meta( 10, '_source_url', false ) ); + self::assertSame( 'https://destination.test/page', get_post_meta( 10, '_source_url', true ) ); + } + + public function test_term_stubs_insert_update_and_read_terms(): void { + $result = wp_insert_term( 'News', 'category', array( 'slug' => 'news' ) ); + + wp_update_term( $result['term_id'], 'category', array( 'name' => 'Latest News' ) ); + + $term = get_term_by( 'slug', 'news', 'category' ); + self::assertSame( 'Latest News', $term['name'] ); + } + + public function test_attachment_stubs_store_metadata(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 44, + true + ); + + wp_update_attachment_metadata( + $attachment_id, + array( + 'width' => 1200, + ) + ); + + self::assertSame( 44, get_post( $attachment_id )['post_parent'] ); + self::assertSame( array( 'width' => 1200 ), wp_get_attachment_metadata( $attachment_id ) ); + } + + public function test_query_delete_and_object_term_stubs(): void { + $first_post_id = wp_insert_post( + array( + 'post_title' => 'First', + 'post_type' => 'post', + ), + true + ); + $second_post_id = wp_insert_post( + array( + 'post_title' => 'Second', + 'post_type' => 'page', + ), + true + ); + + update_post_meta( $first_post_id, '_wpcs_source_id', 10 ); + update_post_meta( $second_post_id, '_wpcs_source_id', 20 ); + wp_set_object_terms( $first_post_id, array( 'news', 'updates' ), 'category' ); + delete_post_meta( $second_post_id, '_wpcs_source_id' ); + wp_delete_post( $second_post_id, true ); + + $posts = get_posts( + array( + 'post_type' => 'post', + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Verifies the unit-test get_posts meta query stub. + 'meta_key' => '_wpcs_source_id', + 'meta_value' => 10, + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + self::assertCount( 1, $posts ); + self::assertSame( $first_post_id, $posts[0]['ID'] ); + self::assertSame( array(), get_post_meta( $second_post_id, '_wpcs_source_id', false ) ); + self::assertNull( get_post( $second_post_id ) ); + self::assertTrue( $GLOBALS['wpcs_test_force_delete'][ $second_post_id ] ); + self::assertSame( + array( 'news', 'updates' ), + $GLOBALS['wpcs_test_object_terms'][ $first_post_id ]['category'] + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 648ecf6..110d6d4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -536,6 +536,358 @@ if ( ! function_exists( 'rest_ensure_response' ) ) { } } +if ( ! function_exists( 'wp_insert_post' ) ) { + /** + * Minimal post inserter for unit tests. + * + * @param array $postarr Post data. + * @param bool $wp_error Whether to return WP_Error on failure. + * @return int|\WP_Error + */ + function wp_insert_post( array $postarr, $wp_error = false ) { + if ( isset( $postarr['ID'] ) && (int) $postarr['ID'] > 0 ) { + $post_id = (int) $postarr['ID']; + } else { + $post_id = (int) ( $GLOBALS['wpcs_test_next_post_id'] ?? 1 ); + $GLOBALS['wpcs_test_next_post_id'] = $post_id + 1; + } + + if ( $post_id <= 0 && $wp_error ) { + return new WP_Error( 'invalid_post_id', 'Post ID is invalid.' ); + } + + $GLOBALS['wpcs_test_posts'][ $post_id ] = array_merge( + array( + 'ID' => $post_id, + 'post_title' => '', + 'post_content' => '', + 'post_excerpt' => '', + 'post_status' => 'draft', + 'post_type' => 'post', + 'post_name' => '', + 'post_parent' => 0, + 'menu_order' => 0, + 'post_mime_type' => '', + ), + $postarr, + array( 'ID' => $post_id ) + ); + + return $post_id; + } +} + +if ( ! function_exists( 'wp_update_post' ) ) { + /** + * Minimal post updater for unit tests. + * + * @param array $postarr Post data. + * @param bool $wp_error Whether to return WP_Error on failure. + * @return int|\WP_Error + */ + function wp_update_post( array $postarr, $wp_error = false ) { + $post_id = (int) ( $postarr['ID'] ?? 0 ); + + if ( $post_id <= 0 || ! isset( $GLOBALS['wpcs_test_posts'][ $post_id ] ) ) { + return $wp_error ? new WP_Error( 'invalid_post_id', 'Post does not exist.' ) : 0; + } + + $GLOBALS['wpcs_test_posts'][ $post_id ] = array_merge( + $GLOBALS['wpcs_test_posts'][ $post_id ], + $postarr, + array( 'ID' => $post_id ) + ); + + return $post_id; + } +} + +if ( ! function_exists( 'get_post' ) ) { + /** + * Minimal post reader for unit tests. + * + * @param mixed $post Post ID. + * @param string $output Output format. + * @param string $filter Filter context. + * @return array|object|null + */ + function get_post( $post = null, $output = 'ARRAY_A', $filter = 'raw' ) { + $GLOBALS['wpcs_test_post_filter'] = $filter; + $post_id = (int) $post; + $data = $GLOBALS['wpcs_test_posts'][ $post_id ] ?? null; + + if ( null === $data ) { + return null; + } + + return 'OBJECT' === $output ? (object) $data : $data; + } +} + +if ( ! function_exists( 'get_posts' ) ) { + /** + * Minimal posts query for unit tests. + * + * @param array $args Query args. + * @return array> + */ + function get_posts( array $args = array() ) { + $posts = array_values( $GLOBALS['wpcs_test_posts'] ?? array() ); + + if ( isset( $args['post_type'] ) && 'any' !== $args['post_type'] ) { + $post_types = is_array( $args['post_type'] ) ? $args['post_type'] : array( $args['post_type'] ); + $posts = array_filter( + $posts, + static function ( array $post ) use ( $post_types ): bool { + return in_array( $post['post_type'] ?? '', $post_types, true ); + } + ); + } + + if ( isset( $args['meta_key'], $args['meta_value'] ) ) { + $posts = array_filter( + $posts, + static function ( array $post ) use ( $args ): bool { + $values = $GLOBALS['wpcs_test_post_meta'][ (int) $post['ID'] ][ (string) $args['meta_key'] ] ?? array(); + + return in_array( $args['meta_value'], $values, true ); + } + ); + } + + return array_values( $posts ); + } +} + +if ( ! function_exists( 'wp_delete_post' ) ) { + /** + * Minimal post deleter for unit tests. + * + * @param int $post_id Post ID. + * @param bool $force_delete Force delete flag. + * @return bool + */ + function wp_delete_post( $post_id, $force_delete = false ) { + $GLOBALS['wpcs_test_force_delete'][ (int) $post_id ] = (bool) $force_delete; + unset( $GLOBALS['wpcs_test_posts'][ (int) $post_id ] ); + unset( $GLOBALS['wpcs_test_post_meta'][ (int) $post_id ] ); + + return true; + } +} + +if ( ! function_exists( 'update_post_meta' ) ) { + /** + * Minimal post meta updater for unit tests. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return bool + */ + function update_post_meta( $post_id, $meta_key, $meta_value ) { + $GLOBALS['wpcs_test_post_meta'][ (int) $post_id ][ (string) $meta_key ] = array( $meta_value ); + + return true; + } +} + +if ( ! function_exists( 'get_post_meta' ) ) { + /** + * Minimal post meta reader for unit tests. + * + * @param int $post_id Post ID. + * @param string $key Meta key. + * @param bool $single Whether to return single value. + * @return mixed + */ + function get_post_meta( $post_id, $key = '', $single = false ) { + $meta = $GLOBALS['wpcs_test_post_meta'][ (int) $post_id ] ?? array(); + + if ( '' === $key ) { + return $meta; + } + + $values = $meta[ $key ] ?? array(); + + if ( $single ) { + return $values[0] ?? ''; + } + + return $values; + } +} + +if ( ! function_exists( 'delete_post_meta' ) ) { + /** + * Minimal post meta deleter for unit tests. + * + * @param int $post_id Post ID. + * @param string $meta_key Meta key. + * @return bool + */ + function delete_post_meta( $post_id, $meta_key ) { + unset( $GLOBALS['wpcs_test_post_meta'][ (int) $post_id ][ (string) $meta_key ] ); + + return true; + } +} + +if ( ! function_exists( 'wp_insert_term' ) ) { + /** + * Minimal term inserter for unit tests. + * + * @param string $term Term name. + * @param string $taxonomy Taxonomy. + * @param array $args Term args. + * @return array|\WP_Error + */ + function wp_insert_term( $term, $taxonomy, array $args = array() ) { + if ( '' === (string) $term || '' === (string) $taxonomy ) { + return new WP_Error( 'invalid_term', 'Term name and taxonomy are required.' ); + } + + $term_id = (int) ( $GLOBALS['wpcs_test_next_term_id'] ?? 1 ); + $GLOBALS['wpcs_test_next_term_id'] = $term_id + 1; + $slug = (string) ( $args['slug'] ?? sanitize_key( $term ) ); + + $GLOBALS['wpcs_test_terms'][ $term_id ] = array( + 'term_id' => $term_id, + 'term_taxonomy_id' => $term_id, + 'name' => (string) $term, + 'taxonomy' => (string) $taxonomy, + 'slug' => $slug, + 'description' => (string) ( $args['description'] ?? '' ), + 'parent' => (int) ( $args['parent'] ?? 0 ), + ); + + return array( + 'term_id' => $term_id, + 'term_taxonomy_id' => $term_id, + ); + } +} + +if ( ! function_exists( 'wp_update_term' ) ) { + /** + * Minimal term updater for unit tests. + * + * @param int $term_id Term ID. + * @param string $taxonomy Taxonomy. + * @param array $args Term args. + * @return array|\WP_Error + */ + function wp_update_term( $term_id, $taxonomy, array $args = array() ) { + $term_id = (int) $term_id; + + if ( ! isset( $GLOBALS['wpcs_test_terms'][ $term_id ] ) ) { + return new WP_Error( 'invalid_term_id', 'Term does not exist.' ); + } + + $GLOBALS['wpcs_test_terms'][ $term_id ] = array_merge( + $GLOBALS['wpcs_test_terms'][ $term_id ], + $args, + array( + 'term_id' => $term_id, + 'term_taxonomy_id' => $term_id, + 'taxonomy' => (string) $taxonomy, + ) + ); + + return array( + 'term_id' => $term_id, + 'term_taxonomy_id' => $term_id, + ); + } +} + +if ( ! function_exists( 'get_term_by' ) ) { + /** + * Minimal term reader for unit tests. + * + * @param string $field Field name. + * @param mixed $value Field value. + * @param string $taxonomy Taxonomy. + * @return array|false + */ + function get_term_by( $field, $value, $taxonomy ) { + foreach ( $GLOBALS['wpcs_test_terms'] ?? array() as $term ) { + if ( (string) ( $term['taxonomy'] ?? '' ) !== (string) $taxonomy ) { + continue; + } + + if ( isset( $term[ $field ] ) && (string) $value === (string) $term[ $field ] ) { + return $term; + } + } + + return false; + } +} + +if ( ! function_exists( 'wp_set_object_terms' ) ) { + /** + * Minimal object term relationship setter for unit tests. + * + * @param int $object_id Object ID. + * @param string|array $terms Terms. + * @param string $taxonomy Taxonomy. + * @return array + */ + function wp_set_object_terms( $object_id, $terms, $taxonomy ) { + $term_values = is_array( $terms ) ? array_values( $terms ) : array( $terms ); + $GLOBALS['wpcs_test_object_terms'][ (int) $object_id ][ (string) $taxonomy ] = $term_values; + + return $term_values; + } +} + +if ( ! function_exists( 'wp_insert_attachment' ) ) { + /** + * Minimal attachment inserter for unit tests. + * + * @param array $args Attachment args. + * @param mixed $file File path. + * @param int $parent_post_id Parent post ID. + * @param bool $wp_error Whether to return WP_Error on failure. + * @return int|\WP_Error + */ + function wp_insert_attachment( array $args, $file = false, $parent_post_id = 0, $wp_error = false ) { + $GLOBALS['wpcs_test_attachment_files'][] = $file; + $args['post_type'] = 'attachment'; + $args['post_parent'] = (int) $parent_post_id; + + return wp_insert_post( $args, $wp_error ); + } +} + +if ( ! function_exists( 'wp_update_attachment_metadata' ) ) { + /** + * Minimal attachment metadata updater for unit tests. + * + * @param int $attachment_id Attachment ID. + * @param mixed $data Metadata. + * @return bool + */ + function wp_update_attachment_metadata( $attachment_id, $data ) { + $GLOBALS['wpcs_test_attachment_metadata'][ (int) $attachment_id ] = $data; + + return true; + } +} + +if ( ! function_exists( 'wp_get_attachment_metadata' ) ) { + /** + * Minimal attachment metadata reader for unit tests. + * + * @param int $attachment_id Attachment ID. + * @return mixed + */ + function wp_get_attachment_metadata( $attachment_id ) { + return $GLOBALS['wpcs_test_attachment_metadata'][ (int) $attachment_id ] ?? false; + } +} + if ( ! function_exists( 'admin_url' ) ) { /** * Minimal admin URL helper for unit tests. -- 2.52.0 From c66501d0e5bf9b3df50a91dc320acdf34f83c4d6 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 18:13:44 -0500 Subject: [PATCH 19/30] feat: normalize content records --- src/Content/ContentRecordNormalizer.php | 68 ++++++++++ .../Content/ContentRecordNormalizerTest.php | 120 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/Content/ContentRecordNormalizer.php create mode 100644 tests/Unit/Content/ContentRecordNormalizerTest.php diff --git a/src/Content/ContentRecordNormalizer.php b/src/Content/ContentRecordNormalizer.php new file mode 100644 index 0000000..af40a33 --- /dev/null +++ b/src/Content/ContentRecordNormalizer.php @@ -0,0 +1,68 @@ + $record Raw post record. + * @return array + */ + public function post( array $record ): array { + return array( + 'id' => (int) ( $record['id'] ?? 0 ), + 'post_type' => sanitize_text_field( (string) ( $record['post_type'] ?? 'post' ) ), + 'post_title' => sanitize_text_field( (string) ( $record['post_title'] ?? '' ) ), + 'post_content' => (string) ( $record['post_content'] ?? '' ), + 'post_excerpt' => sanitize_text_field( (string) ( $record['post_excerpt'] ?? '' ) ), + 'post_status' => sanitize_text_field( (string) ( $record['post_status'] ?? 'draft' ) ), + 'post_name' => sanitize_text_field( (string) ( $record['post_name'] ?? '' ) ), + 'post_parent' => (int) ( $record['post_parent'] ?? 0 ), + 'menu_order' => (int) ( $record['menu_order'] ?? 0 ), + 'meta' => $this->arrayValue( $record['meta'] ?? array() ), + ); + } + + /** + * @param array $record Raw term record. + * @return array + */ + public function term( array $record ): array { + return array( + 'id' => (int) ( $record['id'] ?? 0 ), + 'taxonomy' => sanitize_text_field( (string) ( $record['taxonomy'] ?? '' ) ), + 'name' => sanitize_text_field( (string) ( $record['name'] ?? '' ) ), + 'slug' => sanitize_text_field( (string) ( $record['slug'] ?? '' ) ), + 'description' => (string) ( $record['description'] ?? '' ), + 'parent' => (int) ( $record['parent'] ?? 0 ), + 'meta' => $this->arrayValue( $record['meta'] ?? array() ), + ); + } + + /** + * @param array $record Raw media record. + * @return array + */ + public function media( array $record ): array { + return array( + 'id' => (int) ( $record['id'] ?? 0 ), + 'post_title' => sanitize_text_field( (string) ( $record['post_title'] ?? '' ) ), + 'post_mime_type' => sanitize_text_field( (string) ( $record['post_mime_type'] ?? '' ) ), + 'source_url' => esc_url_raw( (string) ( $record['source_url'] ?? '' ) ), + 'metadata' => $this->arrayValue( $record['metadata'] ?? array() ), + 'meta' => $this->arrayValue( $record['meta'] ?? array() ), + ); + } + + /** + * @param mixed $value Value to normalize. + * @return array + */ + private function arrayValue( $value ): array { + return is_array( $value ) ? $value : array(); + } +} diff --git a/tests/Unit/Content/ContentRecordNormalizerTest.php b/tests/Unit/Content/ContentRecordNormalizerTest.php new file mode 100644 index 0000000..d7e6004 --- /dev/null +++ b/tests/Unit/Content/ContentRecordNormalizerTest.php @@ -0,0 +1,120 @@ +post( + array( + 'id' => '42', + 'post_type' => 'post', + 'post_title' => "Hello\nWorld", + 'post_content' => '

Keep HTML

', + 'post_excerpt' => 'Excerpt', + 'post_status' => 'publish', + 'post_name' => 'hello-world', + 'post_parent' => '7', + 'menu_order' => '3', + 'meta' => array( + '_source_url' => 'https://source.test/page', + ), + ) + ); + + self::assertSame( + array( + 'id' => 42, + 'post_type' => 'post', + 'post_title' => 'Hello World', + 'post_content' => '

Keep HTML

', + 'post_excerpt' => 'Excerpt', + 'post_status' => 'publish', + 'post_name' => 'hello-world', + 'post_parent' => 7, + 'menu_order' => 3, + 'meta' => array( + '_source_url' => 'https://source.test/page', + ), + ), + $record + ); + } + + public function test_it_normalizes_term_records(): void { + $normalizer = new ContentRecordNormalizer(); + + $record = $normalizer->term( + array( + 'id' => '9', + 'taxonomy' => 'category', + 'name' => "News\nUpdates", + 'slug' => 'news-updates', + 'description' => '

Keep description HTML

', + 'parent' => '2', + 'meta' => array( + 'landing_url' => 'https://source.test/news', + ), + ) + ); + + self::assertSame( + array( + 'id' => 9, + 'taxonomy' => 'category', + 'name' => 'News Updates', + 'slug' => 'news-updates', + 'description' => '

Keep description HTML

', + 'parent' => 2, + 'meta' => array( + 'landing_url' => 'https://source.test/news', + ), + ), + $record + ); + } + + public function test_it_normalizes_media_records(): void { + $normalizer = new ContentRecordNormalizer(); + + $record = $normalizer->media( + array( + 'id' => '12', + 'post_title' => "Hero\nImage", + 'post_mime_type' => 'image/jpeg', + 'source_url' => 'https://source.test/uploads/hero.jpg', + 'metadata' => array( + 'width' => 1200, + ), + 'meta' => array( + '_wp_attachment_image_alt' => 'Hero', + ), + ) + ); + + self::assertSame( + array( + 'id' => 12, + 'post_title' => 'Hero Image', + 'post_mime_type' => 'image/jpeg', + 'source_url' => 'https://source.test/uploads/hero.jpg', + 'metadata' => array( + 'width' => 1200, + ), + 'meta' => array( + '_wp_attachment_image_alt' => 'Hero', + ), + ), + $record + ); + } +} -- 2.52.0 From 6d11934fcc85d8e64e1642a42989fa326543c9d0 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 28 Apr 2026 18:22:01 -0500 Subject: [PATCH 20/30] feat: import post content records --- src/Content/PostContentHandler.php | 215 +++++++++++++++++ tests/Unit/Content/PostContentHandlerTest.php | 218 ++++++++++++++++++ .../Unit/Content/WordPressContentStubTest.php | 2 +- tests/bootstrap.php | 16 +- 4 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 src/Content/PostContentHandler.php create mode 100644 tests/Unit/Content/PostContentHandlerTest.php diff --git a/src/Content/PostContentHandler.php b/src/Content/PostContentHandler.php new file mode 100644 index 0000000..31b23e0 --- /dev/null +++ b/src/Content/PostContentHandler.php @@ -0,0 +1,215 @@ +normalizer = $normalizer; + $this->url_transformer = $url_transformer; + $this->metadata_transformer = $metadata_transformer; + $this->logger = $logger; + } + + public function bucket(): string { + return 'posts'; + } + + /** + * @param array> $records Package records. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult { + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $mappings = $this->mappings( $context ); + $errors = array(); + + foreach ( $records as $record ) { + $normalized = $this->normalizer->post( $record ); + $existing = $this->findExistingPostId( (int) $normalized['id'] ); + + if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) { + ++$skipped; + ++$conflicts; + $this->logger->warning( + 'Skipped post import because manual review is required.', + array( + 'source_id' => $normalized['id'], + 'post_id' => $existing, + ) + ); + continue; + } + + try { + $post_id = $this->savePost( $normalized, $existing, $mappings ); + } catch ( ContentImportException $exception ) { + $errors[] = $exception->getMessage(); + $this->logger->error( + $exception->getMessage(), + array( + 'bucket' => $exception->bucket(), + 'record' => $exception->record(), + ) + ); + continue; + } + + if ( $existing > 0 ) { + ++$updated; + } else { + ++$created; + } + + $this->saveMeta( $post_id, $normalized, $context, $mappings ); + } + + if ( array() !== $errors ) { + return SyncResult::merge( + array( + SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ), + SyncResult::failure( $errors ), + ) + ); + } + + $this->logger->info( + 'Imported post content records.', + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + + return SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + } + + private function findExistingPostId( int $source_id ): int { + if ( $source_id <= 0 ) { + return 0; + } + + $posts = get_posts( + array( + 'post_type' => 'any', + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity. + 'meta_key' => '_wpcs_source_id', + 'meta_value' => (string) $source_id, + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + if ( array() === $posts ) { + return 0; + } + + return (int) $posts[0]->ID; + } + + /** + * @param array $record Normalized post record. + * @param int $existing Existing post ID. + */ + private function savePost( array $record, int $existing, UrlMappingCollection $mappings ): int { + $post_data = array( + 'post_type' => $record['post_type'], + 'post_title' => $record['post_title'], + 'post_content' => $this->url_transformer->transformString( (string) $record['post_content'], $mappings ), + 'post_excerpt' => $this->url_transformer->transformString( (string) $record['post_excerpt'], $mappings ), + 'post_status' => $record['post_status'], + 'post_name' => $record['post_name'], + 'post_parent' => $record['post_parent'], + 'menu_order' => $record['menu_order'], + ); + + if ( $existing > 0 ) { + $post_data['ID'] = $existing; + + return $this->postIdFromResult( wp_update_post( $post_data, true ), $record ); + } + + return $this->postIdFromResult( wp_insert_post( $post_data, true ), $record ); + } + + /** + * @param int|\WP_Error $result Post save result. + * @param array $record Normalized post record. + */ + private function postIdFromResult( $result, array $record ): int { + if ( is_wp_error( $result ) || (int) $result <= 0 ) { + throw new ContentImportException( + $this->bucket(), + $record, + sprintf( 'Post import failed for source ID %d.', (int) $record['id'] ) + ); + } + + return (int) $result; + } + + /** + * @param array $record Normalized post record. + */ + private function saveMeta( int $post_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void { + update_post_meta( $post_id, '_wpcs_source_id', (int) $record['id'] ); + update_post_meta( $post_id, '_wpcs_source_site', $context->sourceUrl() ); + + foreach ( $record['meta'] as $key => $value ) { + update_post_meta( + $post_id, + (string) $key, + $this->metadata_transformer->transformValue( $value, $mappings ) + ); + } + } + + private function mappings( SyncContext $context ): UrlMappingCollection { + $mappings = array(); + + foreach ( $context->urlMappings() as $source => $destination ) { + $mappings[] = new UrlMapping( $source, $destination ); + } + + return new UrlMappingCollection( $mappings ); + } +} diff --git a/tests/Unit/Content/PostContentHandlerTest.php b/tests/Unit/Content/PostContentHandlerTest.php new file mode 100644 index 0000000..b2d4bfa --- /dev/null +++ b/tests/Unit/Content/PostContentHandlerTest.php @@ -0,0 +1,218 @@ +> */ + private array $logs = array(); + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_posts'], + $GLOBALS['wpcs_test_next_post_id'], + $GLOBALS['wpcs_test_post_meta'] + ); + + $this->logs = array(); + + parent::tearDown(); + } + + public function test_it_creates_new_post_records(): void { + $result = $this->handler()->importRecords( + array( + $this->postRecord(), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'Imported Title', get_post( 1 )['post_title'] ); + self::assertSame( 42, get_post_meta( 1, '_wpcs_source_id', true ) ); + self::assertSame( 'https://source.test', get_post_meta( 1, '_wpcs_source_site', true ) ); + } + + public function test_it_updates_existing_posts_with_last_write_wins(): void { + $post_id = wp_insert_post( + array( + 'post_title' => 'Old Title', + 'post_type' => 'post', + ), + true + ); + update_post_meta( $post_id, '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->postRecord( array( 'post_title' => 'New Title' ) ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->updated() ); + self::assertSame( 'New Title', get_post( $post_id )['post_title'] ); + } + + public function test_it_skips_existing_posts_with_manual_review_conflict(): void { + $post_id = wp_insert_post( + array( + 'post_title' => 'Old Title', + 'post_type' => 'post', + ), + true + ); + update_post_meta( $post_id, '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->postRecord( array( 'post_title' => 'New Title' ) ), + ), + $this->context( 'manual_review' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( 'Old Title', get_post( $post_id )['post_title'] ); + self::assertSame( 'Skipped post import because manual review is required.', $this->logs[0]['message'] ); + } + + public function test_it_rewrites_post_content_excerpt_and_meta_urls(): void { + $result = $this->handler()->importRecords( + array( + $this->postRecord( + array( + 'post_content' => 'Page', + 'post_excerpt' => 'Read https://source.test/page', + 'meta' => array( + '_source_url' => 'https://source.test/page', + '_json_links' => '{"url":"https://source.test/page"}', + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 'Page', get_post( 1 )['post_content'] ); + self::assertSame( 'Read https://destination.test/page', get_post( 1 )['post_excerpt'] ); + self::assertSame( 'https://destination.test/page', get_post_meta( 1, '_source_url', true ) ); + self::assertSame( '{"url":"https:\/\/destination.test\/page"}', get_post_meta( 1, '_json_links', true ) ); + } + + public function test_it_returns_failure_when_wordpress_rejects_post_save(): void { + $result = $this->handler()->importRecords( + array( + $this->postRecord( + array( + 'id' => 0, + 'post_type' => '', + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Post import failed for source ID 0.' ), $result->errors() ); + self::assertSame( array(), get_post_meta( 0, '_wpcs_source_id', false ) ); + } + + + private function handler(): PostContentHandler { + return new PostContentHandler( + new ContentRecordNormalizer(), + new UrlTransformer(), + new MetadataUrlTransformer( new UrlTransformer() ), + $this->logger() + ); + } + + private function context( string $conflict_strategy ): SyncContext { + return SyncContext::forImport( + array( 'site_url' => 'https://source.test' ), + array( 'site_url' => 'https://destination.test' ), + $conflict_strategy, + 'operation-1' + ); + } + + /** + * @param array $overrides Record overrides. + * @return array + */ + private function postRecord( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 42, + 'post_type' => 'post', + 'post_title' => 'Imported Title', + 'post_content' => 'Imported content', + 'post_excerpt' => 'Imported excerpt', + 'post_status' => 'publish', + 'post_name' => 'imported-title', + 'post_parent' => 0, + 'menu_order' => 0, + 'meta' => array(), + ), + $overrides + ); + } + + private function logger(): LoggerInterface { + return new class( $this->logs ) implements LoggerInterface { + /** @var array> */ + private array $logs; + + /** + * @param array> $logs Logs. + */ + public function __construct( array &$logs ) { + $this->logs = &$logs; + } + + public function error( string $message, array $context = array() ): void { + $this->record( 'error', $message, $context ); + } + + public function warning( string $message, array $context = array() ): void { + $this->record( 'warning', $message, $context ); + } + + public function info( string $message, array $context = array() ): void { + $this->record( 'info', $message, $context ); + } + + public function debug( string $message, array $context = array() ): void { + $this->record( 'debug', $message, $context ); + } + + /** + * @param array $context Context. + */ + private function record( string $level, string $message, array $context ): void { + $this->logs[] = array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ); + } + }; + } +} diff --git a/tests/Unit/Content/WordPressContentStubTest.php b/tests/Unit/Content/WordPressContentStubTest.php index 13a00f8..d9b0eab 100644 --- a/tests/Unit/Content/WordPressContentStubTest.php +++ b/tests/Unit/Content/WordPressContentStubTest.php @@ -119,7 +119,7 @@ class WordPressContentStubTest extends TestCase { ); self::assertCount( 1, $posts ); - self::assertSame( $first_post_id, $posts[0]['ID'] ); + self::assertSame( $first_post_id, $posts[0]->ID ); self::assertSame( array(), get_post_meta( $second_post_id, '_wpcs_source_id', false ) ); self::assertNull( get_post( $second_post_id ) ); self::assertTrue( $GLOBALS['wpcs_test_force_delete'][ $second_post_id ] ); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 110d6d4..0d625cc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -545,6 +545,10 @@ if ( ! function_exists( 'wp_insert_post' ) ) { * @return int|\WP_Error */ function wp_insert_post( array $postarr, $wp_error = false ) { + if ( empty( $postarr['post_type'] ) ) { + return $wp_error ? new WP_Error( 'invalid_post_type', 'Post type is required.' ) : 0; + } + if ( isset( $postarr['ID'] ) && (int) $postarr['ID'] > 0 ) { $post_id = (int) $postarr['ID']; } else { @@ -629,7 +633,7 @@ if ( ! function_exists( 'get_posts' ) ) { * Minimal posts query for unit tests. * * @param array $args Query args. - * @return array> + * @return array */ function get_posts( array $args = array() ) { $posts = array_values( $GLOBALS['wpcs_test_posts'] ?? array() ); @@ -650,12 +654,18 @@ if ( ! function_exists( 'get_posts' ) ) { static function ( array $post ) use ( $args ): bool { $values = $GLOBALS['wpcs_test_post_meta'][ (int) $post['ID'] ][ (string) $args['meta_key'] ] ?? array(); - return in_array( $args['meta_value'], $values, true ); + foreach ( $values as $value ) { + if ( (string) $args['meta_value'] === (string) $value ) { + return true; + } + } + + return false; } ); } - return array_values( $posts ); + return array_values( array_map( static fn( array $post ): object => (object) $post, $posts ) ); } } -- 2.52.0 From 592e6e740314c048f0e9aa9246aa8ca5b9d06a4a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 29 Apr 2026 20:32:56 -0500 Subject: [PATCH 21/30] feat: import taxonomy term records --- src/Content/TermContentHandler.php | 224 ++++++++++++++++++ tests/Unit/Content/TermContentHandlerTest.php | 209 ++++++++++++++++ .../Unit/Content/WordPressContentStubTest.php | 3 +- tests/bootstrap.php | 102 +++++++- 4 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 src/Content/TermContentHandler.php create mode 100644 tests/Unit/Content/TermContentHandlerTest.php diff --git a/src/Content/TermContentHandler.php b/src/Content/TermContentHandler.php new file mode 100644 index 0000000..5aac700 --- /dev/null +++ b/src/Content/TermContentHandler.php @@ -0,0 +1,224 @@ +normalizer = $normalizer; + $this->url_transformer = $url_transformer; + $this->metadata_transformer = $metadata_transformer; + $this->logger = $logger; + } + + public function bucket(): string { + return 'terms'; + } + + /** + * @param array> $records Package records. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult { + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $errors = array(); + $mappings = $this->mappings( $context ); + + foreach ( $records as $record ) { + $normalized = $this->normalizer->term( $record ); + $existing = $this->findExistingTermId( $normalized ); + + if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) { + ++$skipped; + ++$conflicts; + $this->logger->warning( + 'Skipped term import because manual review is required.', + array( + 'source_id' => $normalized['id'], + 'term_id' => $existing, + 'taxonomy' => $normalized['taxonomy'], + ) + ); + continue; + } + + try { + $term_id = $this->saveTerm( $normalized, $existing, $mappings ); + } catch ( ContentImportException $exception ) { + $errors[] = $exception->getMessage(); + $this->logger->error( + $exception->getMessage(), + array( + 'bucket' => $exception->bucket(), + 'record' => $exception->record(), + ) + ); + continue; + } + + if ( $existing > 0 ) { + ++$updated; + } else { + ++$created; + } + + $this->saveMeta( $term_id, $normalized, $context, $mappings ); + } + + if ( array() !== $errors ) { + return SyncResult::merge( + array( + SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ), + SyncResult::failure( $errors ), + ) + ); + } + + $this->logger->info( + 'Imported taxonomy term records.', + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + + return SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + } + + /** + * @param array $record Normalized term record. + */ + private function findExistingTermId( array $record ): int { + $source_id = (int) $record['id']; + + if ( $source_id > 0 ) { + $terms = get_terms( + array( + 'taxonomy' => (string) $record['taxonomy'], + 'hide_empty' => false, + 'number' => 1, + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity. + 'meta_key' => '_wpcs_source_id', + 'meta_value' => (string) $source_id, + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + if ( ! is_wp_error( $terms ) && array() !== $terms ) { + return (int) $terms[0]->term_id; + } + } + + $term = get_term_by( 'slug', (string) $record['slug'], (string) $record['taxonomy'] ); + + return false === $term ? 0 : (int) $term->term_id; + } + + /** + * @param array $record Normalized term record. + * @param int $existing Existing term ID. + */ + private function saveTerm( array $record, int $existing, UrlMappingCollection $mappings ): int { + $args = array( + 'slug' => $record['slug'], + 'description' => $this->url_transformer->transformString( (string) $record['description'], $mappings ), + 'parent' => $record['parent'], + ); + + if ( $existing > 0 ) { + $args['name'] = $record['name']; + + return $this->termIdFromResult( + wp_update_term( $existing, (string) $record['taxonomy'], $args ), + $record + ); + } + + return $this->termIdFromResult( + wp_insert_term( (string) $record['name'], (string) $record['taxonomy'], $args ), + $record + ); + } + + /** + * @param array|\WP_Error $result Term save result. + * @param array $record Normalized term record. + */ + private function termIdFromResult( $result, array $record ): int { + if ( is_wp_error( $result ) || ! is_array( $result ) || (int) ( $result['term_id'] ?? 0 ) <= 0 ) { + throw new ContentImportException( + $this->bucket(), + $record, + sprintf( 'Term import failed for source ID %d.', (int) $record['id'] ) + ); + } + + return (int) $result['term_id']; + } + + /** + * @param array $record Normalized term record. + */ + private function saveMeta( int $term_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void { + update_term_meta( $term_id, '_wpcs_source_id', (int) $record['id'] ); + update_term_meta( $term_id, '_wpcs_source_site', $context->sourceUrl() ); + + foreach ( $record['meta'] as $key => $value ) { + update_term_meta( + $term_id, + (string) $key, + $this->metadata_transformer->transformValue( $value, $mappings ) + ); + } + } + + private function mappings( SyncContext $context ): UrlMappingCollection { + $mappings = array(); + + foreach ( $context->urlMappings() as $source => $destination ) { + $mappings[] = new UrlMapping( $source, $destination ); + } + + return new UrlMappingCollection( $mappings ); + } +} diff --git a/tests/Unit/Content/TermContentHandlerTest.php b/tests/Unit/Content/TermContentHandlerTest.php new file mode 100644 index 0000000..b0c5394 --- /dev/null +++ b/tests/Unit/Content/TermContentHandlerTest.php @@ -0,0 +1,209 @@ +> */ + private array $logs = array(); + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_terms'], + $GLOBALS['wpcs_test_next_term_id'], + $GLOBALS['wpcs_test_term_meta'] + ); + + $this->logs = array(); + + parent::tearDown(); + } + + public function test_it_creates_new_terms_by_taxonomy_and_slug(): void { + $result = $this->handler()->importRecords( + array( + $this->termRecord(), + ), + $this->context( 'last_write_wins' ) + ); + + $term = get_term_by( 'slug', 'news', 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'News', $term->name ); + self::assertSame( 42, get_term_meta( $term->term_id, '_wpcs_source_id', true ) ); + self::assertSame( 'https://source.test', get_term_meta( $term->term_id, '_wpcs_source_site', true ) ); + } + + public function test_it_updates_existing_terms_with_last_write_wins(): void { + $existing = wp_insert_term( 'Old News', 'category', array( 'slug' => 'news' ) ); + update_term_meta( $existing['term_id'], '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->termRecord( array( 'name' => 'Updated News' ) ), + ), + $this->context( 'last_write_wins' ) + ); + + $term = get_term_by( 'id', $existing['term_id'], 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->updated() ); + self::assertSame( 'Updated News', $term->name ); + } + + public function test_it_skips_existing_terms_with_manual_review_conflict(): void { + $existing = wp_insert_term( 'Old News', 'category', array( 'slug' => 'news' ) ); + update_term_meta( $existing['term_id'], '_wpcs_source_id', 42 ); + + $result = $this->handler()->importRecords( + array( + $this->termRecord( array( 'name' => 'Updated News' ) ), + ), + $this->context( 'manual_review' ) + ); + + $term = get_term_by( 'id', $existing['term_id'], 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( 'Old News', $term->name ); + self::assertSame( 'Skipped term import because manual review is required.', $this->logs[0]['message'] ); + } + + public function test_it_rewrites_term_description_and_meta_urls(): void { + $result = $this->handler()->importRecords( + array( + $this->termRecord( + array( + 'description' => 'News', + 'meta' => array( + 'landing_url' => 'https://source.test/news', + 'json_links' => '{"url":"https://source.test/news"}', + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + $term = get_term_by( 'slug', 'news', 'category' ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 'News', $term->description ); + self::assertSame( 'https://destination.test/news', get_term_meta( $term->term_id, 'landing_url', true ) ); + self::assertSame( '{"url":"https:\/\/destination.test\/news"}', get_term_meta( $term->term_id, 'json_links', true ) ); + } + + public function test_it_returns_failure_when_wordpress_rejects_term_save(): void { + $result = $this->handler()->importRecords( + array( + $this->termRecord( + array( + 'id' => 0, + 'taxonomy' => '', + 'name' => '', + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Term import failed for source ID 0.' ), $result->errors() ); + self::assertSame( array(), get_term_meta( 0, '_wpcs_source_id', false ) ); + } + + private function handler(): TermContentHandler { + return new TermContentHandler( + new ContentRecordNormalizer(), + new UrlTransformer(), + new MetadataUrlTransformer( new UrlTransformer() ), + $this->logger() + ); + } + + private function context( string $conflict_strategy ): SyncContext { + return SyncContext::forImport( + array( 'site_url' => 'https://source.test' ), + array( 'site_url' => 'https://destination.test' ), + $conflict_strategy, + 'operation-1' + ); + } + + /** + * @param array $overrides Record overrides. + * @return array + */ + private function termRecord( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 42, + 'taxonomy' => 'category', + 'name' => 'News', + 'slug' => 'news', + 'description' => 'News description', + 'parent' => 0, + 'meta' => array(), + ), + $overrides + ); + } + + private function logger(): LoggerInterface { + return new class( $this->logs ) implements LoggerInterface { + /** @var array> */ + private array $logs; + + /** + * @param array> $logs Logs. + */ + public function __construct( array &$logs ) { + $this->logs = &$logs; + } + + public function error( string $message, array $context = array() ): void { + $this->record( 'error', $message, $context ); + } + + public function warning( string $message, array $context = array() ): void { + $this->record( 'warning', $message, $context ); + } + + public function info( string $message, array $context = array() ): void { + $this->record( 'info', $message, $context ); + } + + public function debug( string $message, array $context = array() ): void { + $this->record( 'debug', $message, $context ); + } + + /** + * @param array $context Context. + */ + private function record( string $level, string $message, array $context ): void { + $this->logs[] = array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ); + } + }; + } +} diff --git a/tests/Unit/Content/WordPressContentStubTest.php b/tests/Unit/Content/WordPressContentStubTest.php index d9b0eab..9c9dc49 100644 --- a/tests/Unit/Content/WordPressContentStubTest.php +++ b/tests/Unit/Content/WordPressContentStubTest.php @@ -17,6 +17,7 @@ class WordPressContentStubTest extends TestCase { $GLOBALS['wpcs_test_post_meta'], $GLOBALS['wpcs_test_terms'], $GLOBALS['wpcs_test_next_term_id'], + $GLOBALS['wpcs_test_term_meta'], $GLOBALS['wpcs_test_object_terms'], $GLOBALS['wpcs_test_attachment_files'], $GLOBALS['wpcs_test_attachment_metadata'], @@ -61,7 +62,7 @@ class WordPressContentStubTest extends TestCase { wp_update_term( $result['term_id'], 'category', array( 'name' => 'Latest News' ) ); $term = get_term_by( 'slug', 'news', 'category' ); - self::assertSame( 'Latest News', $term['name'] ); + self::assertSame( 'Latest News', $term->name ); } public function test_attachment_stubs_store_metadata(): void { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0d625cc..523f2a4 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -811,6 +811,47 @@ if ( ! function_exists( 'wp_update_term' ) ) { } } +if ( ! function_exists( 'get_terms' ) ) { + /** + * Minimal terms query for unit tests. + * + * @param array $args Query args. + * @return array + */ + function get_terms( array $args = array() ) { + $terms = array_values( $GLOBALS['wpcs_test_terms'] ?? array() ); + + if ( isset( $args['taxonomy'] ) ) { + $taxonomies = is_array( $args['taxonomy'] ) ? $args['taxonomy'] : array( $args['taxonomy'] ); + $terms = array_filter( + $terms, + static function ( array $term ) use ( $taxonomies ): bool { + return in_array( $term['taxonomy'] ?? '', $taxonomies, true ); + } + ); + } + + if ( isset( $args['meta_key'], $args['meta_value'] ) ) { + $terms = array_filter( + $terms, + static function ( array $term ) use ( $args ): bool { + $values = $GLOBALS['wpcs_test_term_meta'][ (int) $term['term_id'] ][ (string) $args['meta_key'] ] ?? array(); + + foreach ( $values as $value ) { + if ( (string) $args['meta_value'] === (string) $value ) { + return true; + } + } + + return false; + } + ); + } + + return array_values( array_map( static fn( array $term ): object => (object) $term, $terms ) ); + } +} + if ( ! function_exists( 'get_term_by' ) ) { /** * Minimal term reader for unit tests. @@ -821,13 +862,15 @@ if ( ! function_exists( 'get_term_by' ) ) { * @return array|false */ function get_term_by( $field, $value, $taxonomy ) { + $field = 'id' === $field ? 'term_id' : $field; + foreach ( $GLOBALS['wpcs_test_terms'] ?? array() as $term ) { if ( (string) ( $term['taxonomy'] ?? '' ) !== (string) $taxonomy ) { continue; } if ( isset( $term[ $field ] ) && (string) $value === (string) $term[ $field ] ) { - return $term; + return (object) $term; } } @@ -835,6 +878,63 @@ if ( ! function_exists( 'get_term_by' ) ) { } } +if ( ! function_exists( 'update_term_meta' ) ) { + /** + * Minimal term meta updater for unit tests. + * + * @param int $term_id Term ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return bool + */ + function update_term_meta( $term_id, $meta_key, $meta_value ) { + $GLOBALS['wpcs_test_term_meta'][ (int) $term_id ][ (string) $meta_key ] = array( $meta_value ); + + return true; + } +} + +if ( ! function_exists( 'get_term_meta' ) ) { + /** + * Minimal term meta reader for unit tests. + * + * @param int $term_id Term ID. + * @param string $key Meta key. + * @param bool $single Whether to return single value. + * @return mixed + */ + function get_term_meta( $term_id, $key = '', $single = false ) { + $meta = $GLOBALS['wpcs_test_term_meta'][ (int) $term_id ] ?? array(); + + if ( '' === $key ) { + return $meta; + } + + $values = $meta[ $key ] ?? array(); + + if ( $single ) { + return $values[0] ?? ''; + } + + return $values; + } +} + +if ( ! function_exists( 'delete_term_meta' ) ) { + /** + * Minimal term meta deleter for unit tests. + * + * @param int $term_id Term ID. + * @param string $meta_key Meta key. + * @return bool + */ + function delete_term_meta( $term_id, $meta_key ) { + unset( $GLOBALS['wpcs_test_term_meta'][ (int) $term_id ][ (string) $meta_key ] ); + + return true; + } +} + if ( ! function_exists( 'wp_set_object_terms' ) ) { /** * Minimal object term relationship setter for unit tests. -- 2.52.0 From 425d42e4bb743a231624f3e13aa4058bf1d9a124 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Fri, 1 May 2026 14:57:10 -0500 Subject: [PATCH 22/30] feat: import media metadata records --- src/Content/MediaContentHandler.php | 238 +++++++++++++++ .../Unit/Content/MediaContentHandlerTest.php | 276 ++++++++++++++++++ tests/bootstrap.php | 4 + 3 files changed, 518 insertions(+) create mode 100644 src/Content/MediaContentHandler.php create mode 100644 tests/Unit/Content/MediaContentHandlerTest.php diff --git a/src/Content/MediaContentHandler.php b/src/Content/MediaContentHandler.php new file mode 100644 index 0000000..a8d846d --- /dev/null +++ b/src/Content/MediaContentHandler.php @@ -0,0 +1,238 @@ +normalizer = $normalizer; + $this->url_transformer = $url_transformer; + $this->metadata_transformer = $metadata_transformer; + $this->logger = $logger; + } + + public function bucket(): string { + return 'media'; + } + + /** + * @param array> $records Package records. + */ + public function importRecords( array $records, SyncContext $context ): SyncResult { + $created = 0; + $updated = 0; + $skipped = 0; + $conflicts = 0; + $errors = array(); + $mappings = $this->mappings( $context ); + + foreach ( $records as $record ) { + $normalized = $this->normalizer->media( $record ); + $existing = $this->findExistingAttachmentId( (int) $normalized['id'], $context->sourceUrl() ); + + if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) { + ++$skipped; + ++$conflicts; + $this->logger->warning( + 'Skipped media import because manual review is required.', + array( + 'source_id' => $normalized['id'], + 'attachment_id' => $existing, + ) + ); + continue; + } + + try { + $attachment_id = $this->saveAttachment( $normalized, $existing, $mappings ); + } catch ( ContentImportException $exception ) { + $errors[] = $exception->getMessage(); + $this->logger->error( + $exception->getMessage(), + array( + 'bucket' => $exception->bucket(), + 'record' => $exception->record(), + ) + ); + continue; + } + + if ( $existing > 0 ) { + ++$updated; + } else { + ++$created; + } + + $this->saveMetadata( $attachment_id, $normalized, $context, $mappings ); + } + + if ( array() !== $errors ) { + return SyncResult::merge( + array( + SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ), + SyncResult::failure( $errors ), + ) + ); + } + + $this->logger->info( + 'Imported media metadata records.', + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + + return SyncResult::success( + array( + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'conflicts' => $conflicts, + ) + ); + } + + private function findExistingAttachmentId( int $source_id, string $source_site ): int { + if ( $source_id <= 0 ) { + return 0; + } + + $attachments = get_posts( + array( + 'post_type' => 'attachment', + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity. + 'meta_key' => '_wpcs_source_id', + 'meta_value' => (string) $source_id, + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + if ( array() === $attachments ) { + return 0; + } + + foreach ( $attachments as $attachment ) { + $attachment_id = (int) $attachment->ID; + + if ( get_post_meta( $attachment_id, '_wpcs_source_site', true ) === $source_site ) { + return $attachment_id; + } + } + + return 0; + } + + /** + * @param array $record Normalized media record. + * @param int $existing Existing attachment ID. + */ + private function saveAttachment( array $record, int $existing, UrlMappingCollection $mappings ): int { + $attachment_data = array( + 'post_title' => $record['post_title'], + 'post_mime_type' => $record['post_mime_type'], + 'post_type' => 'attachment', + ); + + if ( '' !== $record['source_url'] ) { + $this->logger->warning( + 'Skipped media binary download; importing attachment metadata only.', + array( + 'source_id' => $record['id'], + 'source_url' => $this->url_transformer->transformString( (string) $record['source_url'], $mappings ), + ) + ); + } + + if ( $existing > 0 ) { + $attachment_data['ID'] = $existing; + + return $this->attachmentIdFromResult( wp_update_post( $attachment_data, true ), $record ); + } + + return $this->attachmentIdFromResult( wp_insert_attachment( $attachment_data, false, 0, true ), $record ); + } + + /** + * @param int|\WP_Error $result Attachment save result. + * @param array $record Normalized media record. + */ + private function attachmentIdFromResult( $result, array $record ): int { + if ( is_wp_error( $result ) || (int) $result <= 0 ) { + throw new ContentImportException( + $this->bucket(), + $record, + sprintf( 'Media import failed for source ID %d.', (int) $record['id'] ) + ); + } + + return (int) $result; + } + + /** + * @param array $record Normalized media record. + */ + private function saveMetadata( int $attachment_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void { + update_post_meta( $attachment_id, '_wpcs_source_id', (int) $record['id'] ); + update_post_meta( $attachment_id, '_wpcs_source_site', $context->sourceUrl() ); + update_post_meta( + $attachment_id, + '_wpcs_source_url', + $this->url_transformer->transformString( (string) $record['source_url'], $mappings ) + ); + + wp_update_attachment_metadata( + $attachment_id, + $this->metadata_transformer->transformValue( $record['metadata'], $mappings ) + ); + + foreach ( $record['meta'] as $key => $value ) { + update_post_meta( + $attachment_id, + (string) $key, + $this->metadata_transformer->transformValue( $value, $mappings ) + ); + } + } + + private function mappings( SyncContext $context ): UrlMappingCollection { + $mappings = array(); + + foreach ( $context->urlMappings() as $source => $destination ) { + $mappings[] = new UrlMapping( $source, $destination ); + } + + return new UrlMappingCollection( $mappings ); + } +} diff --git a/tests/Unit/Content/MediaContentHandlerTest.php b/tests/Unit/Content/MediaContentHandlerTest.php new file mode 100644 index 0000000..191ee54 --- /dev/null +++ b/tests/Unit/Content/MediaContentHandlerTest.php @@ -0,0 +1,276 @@ +> */ + private array $logs = array(); + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_posts'], + $GLOBALS['wpcs_test_next_post_id'], + $GLOBALS['wpcs_test_post_meta'], + $GLOBALS['wpcs_test_attachment_files'], + $GLOBALS['wpcs_test_attachment_metadata'] + ); + + $this->logs = array(); + + parent::tearDown(); + } + + public function test_it_creates_attachment_records_without_downloading_files(): void { + $result = $this->handler()->importRecords( + array( + $this->mediaRecord(), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'Imported Image', get_post( 1 )['post_title'] ); + self::assertSame( 'attachment', get_post( 1 )['post_type'] ); + self::assertSame( 'image/jpeg', get_post( 1 )['post_mime_type'] ); + self::assertSame( 42, get_post_meta( 1, '_wpcs_source_id', true ) ); + self::assertSame( 'https://source.test', get_post_meta( 1, '_wpcs_source_site', true ) ); + self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_wpcs_source_url', true ) ); + self::assertSame( array( false ), $GLOBALS['wpcs_test_attachment_files'] ); + self::assertSame( 'Skipped media binary download; importing attachment metadata only.', $this->logs[0]['message'] ); + } + + public function test_it_updates_attachment_metadata_with_last_write_wins(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Old Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 0, + true + ); + update_post_meta( $attachment_id, '_wpcs_source_id', 42 ); + update_post_meta( $attachment_id, '_wpcs_source_site', 'https://source.test' ); + + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( + array( + 'post_title' => 'Updated Image', + 'metadata' => array( + 'width' => 1200, + 'height' => 800, + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->updated() ); + self::assertSame( 'Updated Image', get_post( $attachment_id )['post_title'] ); + self::assertSame( + array( + 'width' => 1200, + 'height' => 800, + ), + wp_get_attachment_metadata( $attachment_id ) + ); + } + + public function test_it_does_not_match_existing_media_from_a_different_source_site(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Other Site Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 0, + true + ); + update_post_meta( $attachment_id, '_wpcs_source_id', 42 ); + update_post_meta( $attachment_id, '_wpcs_source_site', 'https://other-source.test' ); + + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( array( 'post_title' => 'Current Site Image' ) ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 'Other Site Image', get_post( $attachment_id )['post_title'] ); + self::assertSame( 'Current Site Image', get_post( 2 )['post_title'] ); + } + + public function test_it_rewrites_source_url_metadata_and_meta_urls(): void { + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( + array( + 'metadata' => array( + 'file' => 'https://source.test/uploads/image.jpg', + 'sizes' => array( + 'thumbnail' => array( + 'url' => 'https://source.test/uploads/image-150x150.jpg', + ), + ), + ), + 'meta' => array( + '_source_url' => 'https://source.test/uploads/image.jpg', + '_json_links' => '{"url":"https://source.test/uploads/image.jpg"}', + ), + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + $metadata = wp_get_attachment_metadata( 1 ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_wpcs_source_url', true ) ); + self::assertSame( 'https://destination.test/uploads/image.jpg', $metadata['file'] ); + self::assertSame( 'https://destination.test/uploads/image-150x150.jpg', $metadata['sizes']['thumbnail']['url'] ); + self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_source_url', true ) ); + self::assertSame( '{"url":"https:\/\/destination.test\/uploads\/image.jpg"}', get_post_meta( 1, '_json_links', true ) ); + } + + public function test_it_skips_existing_media_with_manual_review_conflict(): void { + $attachment_id = wp_insert_attachment( + array( + 'post_title' => 'Old Image', + 'post_mime_type' => 'image/jpeg', + ), + false, + 0, + true + ); + update_post_meta( $attachment_id, '_wpcs_source_id', 42 ); + update_post_meta( $attachment_id, '_wpcs_source_site', 'https://source.test' ); + + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( array( 'post_title' => 'Updated Image' ) ), + ), + $this->context( 'manual_review' ) + ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + self::assertSame( 'Old Image', get_post( $attachment_id )['post_title'] ); + self::assertSame( 'Skipped media import because manual review is required.', $this->logs[0]['message'] ); + } + + public function test_it_returns_failure_when_wordpress_rejects_attachment_save(): void { + $result = $this->handler()->importRecords( + array( + $this->mediaRecord( + array( + 'id' => 0, + 'post_mime_type' => '', + ) + ), + ), + $this->context( 'last_write_wins' ) + ); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Media import failed for source ID 0.' ), $result->errors() ); + self::assertSame( array(), get_post_meta( 0, '_wpcs_source_id', false ) ); + } + + private function handler(): MediaContentHandler { + return new MediaContentHandler( + new ContentRecordNormalizer(), + new UrlTransformer(), + new MetadataUrlTransformer( new UrlTransformer() ), + $this->logger() + ); + } + + private function context( string $conflict_strategy ): SyncContext { + return SyncContext::forImport( + array( 'site_url' => 'https://source.test' ), + array( 'site_url' => 'https://destination.test' ), + $conflict_strategy, + 'operation-1' + ); + } + + /** + * @param array $overrides Record overrides. + * @return array + */ + private function mediaRecord( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 42, + 'post_title' => 'Imported Image', + 'post_mime_type' => 'image/jpeg', + 'source_url' => 'https://source.test/uploads/image.jpg', + 'metadata' => array(), + 'meta' => array(), + ), + $overrides + ); + } + + private function logger(): LoggerInterface { + return new class( $this->logs ) implements LoggerInterface { + /** @var array> */ + private array $logs; + + /** + * @param array> $logs Logs. + */ + public function __construct( array &$logs ) { + $this->logs = &$logs; + } + + public function error( string $message, array $context = array() ): void { + $this->record( 'error', $message, $context ); + } + + public function warning( string $message, array $context = array() ): void { + $this->record( 'warning', $message, $context ); + } + + public function info( string $message, array $context = array() ): void { + $this->record( 'info', $message, $context ); + } + + public function debug( string $message, array $context = array() ): void { + $this->record( 'debug', $message, $context ); + } + + /** + * @param array $context Context. + */ + private function record( string $level, string $message, array $context ): void { + $this->logs[] = array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ); + } + }; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 523f2a4..eb077ca 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -963,6 +963,10 @@ if ( ! function_exists( 'wp_insert_attachment' ) ) { * @return int|\WP_Error */ function wp_insert_attachment( array $args, $file = false, $parent_post_id = 0, $wp_error = false ) { + if ( empty( $args['post_mime_type'] ) ) { + return $wp_error ? new WP_Error( 'invalid_attachment_mime_type', 'Attachment mime type is required.' ) : 0; + } + $GLOBALS['wpcs_test_attachment_files'][] = $file; $args['post_type'] = 'attachment'; $args['post_parent'] = (int) $parent_post_id; -- 2.52.0 From e9f4b17229c85b805421e4edb4703a22f5118d34 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Fri, 1 May 2026 15:12:22 -0500 Subject: [PATCH 23/30] feat: orchestrate package imports --- src/Sync/SyncEngine.php | 136 ++++++++++++++ tests/Unit/Sync/SyncEngineTest.php | 292 +++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 src/Sync/SyncEngine.php create mode 100644 tests/Unit/Sync/SyncEngineTest.php diff --git a/src/Sync/SyncEngine.php b/src/Sync/SyncEngine.php new file mode 100644 index 0000000..43449f8 --- /dev/null +++ b/src/Sync/SyncEngine.php @@ -0,0 +1,136 @@ +handlers = $handlers; + $this->state_repository = $state_repository; + $this->settings_repository = $settings_repository; + $this->logger = $logger; + } + + public function importPackage( ContentPackage $package ): SyncResult { + $operation_id = uniqid( 'wpcs_', true ); + $total = $this->totalRecords( $package ); + $processed = 0; + $results = array(); + $context = SyncContext::forImport( + $package->source(), + $package->destination(), + $this->settings_repository->get()->conflictStrategy(), + $operation_id + ); + + $this->logger->info( + 'Starting content package import.', + array( + 'operation_id' => $operation_id, + 'total' => $total, + ) + ); + + foreach ( $this->handlers->ordered() as $handler ) { + $bucket = $handler->bucket(); + $records = $this->recordsForBucket( $package, $bucket ); + + $this->state_repository->save( SyncOperationState::running( $operation_id, $bucket, $processed, $total ) ); + + try { + $results[] = $handler->importRecords( $records, $context ); + } catch ( ContentImportException $exception ) { + $this->state_repository->save( + SyncOperationState::fromArray( + array( + 'operation_id' => $operation_id, + 'status' => 'failed', + 'current_bucket' => $bucket, + 'processed' => $processed, + 'total' => $total, + ) + ) + ); + + $this->logger->error( + 'Content package import failed.', + array( + 'operation_id' => $operation_id, + 'bucket' => $exception->bucket(), + 'record' => $exception->record(), + 'error' => $exception->getMessage(), + ) + ); + + $results[] = SyncResult::failure( array( $exception->getMessage() ) ); + + return SyncResult::merge( $results ); + } + + $processed += count( $records ); + } + + $result = SyncResult::merge( $results ); + $this->state_repository->save( SyncOperationState::completed( $operation_id, $processed, $total ) ); + + $this->logger->info( + 'Completed content package import.', + array_merge( + array( 'operation_id' => $operation_id ), + $result->toArray() + ) + ); + + return $result; + } + + private function totalRecords( ContentPackage $package ): int { + $total = 0; + + foreach ( $package->manifest() as $count ) { + $total += max( 0, (int) $count ); + } + + return $total; + } + + /** + * @return array> + */ + private function recordsForBucket( ContentPackage $package, string $bucket ): array { + $records = $package->records()[ $bucket ] ?? array(); + + if ( ! is_array( $records ) ) { + return array(); + } + + return array_values( + array_filter( + $records, + static function ( $record ): bool { + return is_array( $record ); + } + ) + ); + } +} diff --git a/tests/Unit/Sync/SyncEngineTest.php b/tests/Unit/Sync/SyncEngineTest.php new file mode 100644 index 0000000..995724a --- /dev/null +++ b/tests/Unit/Sync/SyncEngineTest.php @@ -0,0 +1,292 @@ +> */ + private \ArrayObject $call_recorder; + + /** @var \ArrayObject> */ + private \ArrayObject $log_recorder; + + protected function setUp(): void { + $this->call_recorder = new \ArrayObject(); + $this->log_recorder = new \ArrayObject(); + + parent::setUp(); + } + + protected function tearDown(): void { + unset( + $GLOBALS['wpcs_test_options'], + $GLOBALS['wpcs_test_option_autoloads'], + $GLOBALS['wpcs_test_transients'], + $GLOBALS['wpcs_test_transient_expiration'] + ); + + parent::tearDown(); + } + + public function test_it_calls_handlers_in_registry_order_and_merges_results(): void { + $engine = $this->engine( + array( + $this->handler( 'media', SyncResult::success( array( 'created' => 1 ) ) ), + $this->handler( 'posts', SyncResult::success( array( 'updated' => 2 ) ) ), + $this->handler( + 'terms', + SyncResult::success( + array( + 'skipped' => 1, + 'conflicts' => 1, + ) + ) + ), + ) + ); + + $result = $engine->importPackage( $this->package() ); + + self::assertTrue( $result->isSuccessful() ); + self::assertSame( 1, $result->created() ); + self::assertSame( 2, $result->updated() ); + self::assertSame( 1, $result->skipped() ); + self::assertSame( 1, $result->conflicts() ); + $calls = $this->call_recorder->getArrayCopy(); + + self::assertSame( array( 'terms', 'posts', 'media' ), array_column( $calls, 'bucket' ) ); + self::assertSame( array( array( 'id' => 20 ) ), $calls[0]['records'] ); + self::assertSame( array( array( 'id' => 10 ) ), $calls[1]['records'] ); + self::assertSame( array( array( 'id' => 30 ) ), $calls[2]['records'] ); + self::assertSame( 'manual_review', $calls[0]['context']->conflictStrategy() ); + } + + public function test_it_saves_running_and_completed_state(): void { + $engine = $this->engine( + array( + $this->handler( 'posts', SyncResult::success( array( 'created' => 1 ) ) ), + $this->handler( 'media', SyncResult::success( array( 'created' => 1 ) ) ), + ) + ); + + $engine->importPackage( $this->package() ); + + $states = array_values( $GLOBALS['wpcs_test_transients'] ); + + self::assertCount( 1, $GLOBALS['wpcs_test_transients'] ); + self::assertSame( 'completed', $states[0]['status'] ); + self::assertSame( 2, $states[0]['processed'] ); + self::assertSame( 3, $states[0]['total'] ); + } + + public function test_it_logs_operation_start_and_completion(): void { + $engine = $this->engine( + array( + $this->handler( 'posts', SyncResult::success( array( 'created' => 1 ) ) ), + ) + ); + + $engine->importPackage( $this->package() ); + + $logs = $this->log_recorder->getArrayCopy(); + + self::assertSame( 'Starting content package import.', $logs[0]['message'] ); + self::assertSame( 'Completed content package import.', $logs[1]['message'] ); + self::assertSame( 1, $logs[1]['context']['created'] ); + } + + public function test_it_returns_failure_when_handler_throws_import_exception(): void { + $engine = $this->engine( + array( + $this->throwingHandler( 'posts' ), + $this->handler( 'media', SyncResult::success( array( 'created' => 1 ) ) ), + ) + ); + + $result = $engine->importPackage( $this->package() ); + $states = array_values( $GLOBALS['wpcs_test_transients'] ); + $logs = $this->log_recorder->getArrayCopy(); + + self::assertFalse( $result->isSuccessful() ); + self::assertSame( array( 'Posts failed.' ), $result->errors() ); + self::assertSame( 'failed', $states[0]['status'] ); + self::assertSame( 'Content package import failed.', $logs[1]['message'] ); + self::assertSame( 'posts', $logs[1]['context']['bucket'] ); + } + + /** + * @param array $handlers Handlers. + */ + private function engine( array $handlers ): SyncEngine { + update_option( + SettingsRepository::OPTION_NAME, + array( + 'conflict_strategy' => 'manual_review', + ), + false + ); + + return new SyncEngine( + new ContentHandlerRegistry( $handlers ), + new SyncStateRepository(), + new SettingsRepository(), + $this->logger() + ); + } + + private function package(): ContentPackage { + return ContentPackage::fromArray( + array( + 'source' => array( + 'site_url' => 'https://source.test', + ), + 'destination' => array( + 'site_url' => 'https://destination.test', + ), + 'manifest' => array( + 'posts' => 1, + 'terms' => 1, + 'media' => 1, + ), + 'records' => array( + 'posts' => array( + array( 'id' => 10 ), + ), + 'terms' => array( + array( 'id' => 20 ), + ), + 'media' => array( + array( 'id' => 30 ), + ), + ), + ) + ); + } + + private function handler( string $bucket, SyncResult $result ): ContentHandlerInterface { + return new class( $bucket, $result, $this->call_recorder ) implements ContentHandlerInterface { + private string $bucket; + private SyncResult $result; + + /** @var \ArrayObject> */ + private \ArrayObject $recorder; + + /** + * @param \ArrayObject> $recorder Call recorder. + */ + public function __construct( string $bucket, SyncResult $result, \ArrayObject $recorder ) { + $this->bucket = $bucket; + $this->result = $result; + $this->recorder = $recorder; + } + + public function bucket(): string { + return $this->bucket; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + $this->recorder->append( + array( + 'bucket' => $this->bucket, + 'records' => $records, + 'context' => $context, + ) + ); + + return $this->result; + } + }; + } + + private function throwingHandler( string $bucket ): ContentHandlerInterface { + return new class( $bucket, $this->call_recorder ) implements ContentHandlerInterface { + private string $bucket; + + /** @var \ArrayObject> */ + private \ArrayObject $recorder; + + /** + * @param \ArrayObject> $recorder Call recorder. + */ + public function __construct( string $bucket, \ArrayObject $recorder ) { + $this->bucket = $bucket; + $this->recorder = $recorder; + } + + public function bucket(): string { + return $this->bucket; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + $this->recorder->append( + array( + 'bucket' => $this->bucket, + 'records' => $records, + 'context' => $context, + ) + ); + + throw new ContentImportException( $this->bucket, array( 'id' => 10 ), 'Posts failed.' ); + } + }; + } + + private function logger(): LoggerInterface { + return new class( $this->log_recorder ) implements LoggerInterface { + /** @var \ArrayObject> */ + private \ArrayObject $recorder; + + /** + * @param \ArrayObject> $recorder Log recorder. + */ + public function __construct( \ArrayObject $recorder ) { + $this->recorder = $recorder; + } + + public function error( string $message, array $context = array() ): void { + $this->record( 'error', $message, $context ); + } + + public function warning( string $message, array $context = array() ): void { + $this->record( 'warning', $message, $context ); + } + + public function info( string $message, array $context = array() ): void { + $this->record( 'info', $message, $context ); + } + + public function debug( string $message, array $context = array() ): void { + $this->record( 'debug', $message, $context ); + } + + /** + * @param array $context Context. + */ + private function record( string $level, string $message, array $context ): void { + $this->recorder->append( + array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ) + ); + } + }; + } +} -- 2.52.0 From 825e89b55fb949b3a7e8945c8fd8515966f89097 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Fri, 1 May 2026 15:23:25 -0500 Subject: [PATCH 24/30] feat: apply packages during imports --- src/Admin/FileImportController.php | 33 +++-- src/Plugin.php | 88 +++++++++++- src/Rest/RestPackageController.php | 11 +- tests/Unit/Admin/FileImportControllerTest.php | 136 ++++++++++++++---- tests/Unit/PluginTest.php | 19 +++ tests/Unit/Rest/RestPackageControllerTest.php | 116 +++++++++++++-- 6 files changed, 355 insertions(+), 48 deletions(-) diff --git a/src/Admin/FileImportController.php b/src/Admin/FileImportController.php index bd93a24..e98be42 100644 --- a/src/Admin/FileImportController.php +++ b/src/Admin/FileImportController.php @@ -8,15 +8,18 @@ namespace WPContentSync\Admin; use WPContentSync\Logging\LoggerInterface; +use WPContentSync\Sync\SyncEngine; use WPContentSync\Transport\FileTransportInterface; final class FileImportController { private FileTransportInterface $transport; private LoggerInterface $logger; + private SyncEngine $sync_engine; - public function __construct( FileTransportInterface $transport, LoggerInterface $logger ) { - $this->transport = $transport; - $this->logger = $logger; + public function __construct( FileTransportInterface $transport, LoggerInterface $logger, SyncEngine $sync_engine ) { + $this->transport = $transport; + $this->logger = $logger; + $this->sync_engine = $sync_engine; } public function register(): void { @@ -67,13 +70,23 @@ final class FileImportController { return; } - $this->logger->info( - 'Validated imported content package.', - array( - 'schema_version' => $package->schemaVersion(), - 'manifest' => $package->manifest(), - ) - ); + $result = $this->sync_engine->importPackage( $package ); + + if ( ! $result->isSuccessful() ) { + $this->logger->error( + 'Imported content package failed.', + $result->toArray() + ); + + $this->redirectToDashboard( + array( + 'wpcs_import_error' => implode( ' ', $result->errors() ), + ) + ); + return; + } + + $this->logger->info( 'Imported content package.', $result->toArray() ); $this->redirectToDashboard( array( diff --git a/src/Plugin.php b/src/Plugin.php index 7681ad3..e5dfa05 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,11 +9,18 @@ namespace WPContentSync; use WPContentSync\Admin\AdminPage; use WPContentSync\Admin\FileImportController; +use WPContentSync\Content\ContentHandlerRegistry; +use WPContentSync\Content\ContentRecordNormalizer; +use WPContentSync\Content\MediaContentHandler; +use WPContentSync\Content\PostContentHandler; +use WPContentSync\Content\TermContentHandler; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Logging\OptionLogger; use WPContentSync\Package\PackageValidator; use WPContentSync\Rest\RestPackageController; use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncStateRepository; use WPContentSync\Transport\FileTransportInterface; use WPContentSync\Transport\JsonFileTransport; use WPContentSync\Transport\RestTransportClient; @@ -67,6 +74,81 @@ final class Plugin { } ); + $container->factory( + ContentRecordNormalizer::class, + static function (): ContentRecordNormalizer { + return new ContentRecordNormalizer(); + } + ); + + $container->factory( + PostContentHandler::class, + static function () use ( $container ): PostContentHandler { + return new PostContentHandler( + $container->get( ContentRecordNormalizer::class ), + $container->get( UrlTransformer::class ), + $container->get( MetadataUrlTransformer::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + + $container->factory( + TermContentHandler::class, + static function () use ( $container ): TermContentHandler { + return new TermContentHandler( + $container->get( ContentRecordNormalizer::class ), + $container->get( UrlTransformer::class ), + $container->get( MetadataUrlTransformer::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + + $container->factory( + MediaContentHandler::class, + static function () use ( $container ): MediaContentHandler { + return new MediaContentHandler( + $container->get( ContentRecordNormalizer::class ), + $container->get( UrlTransformer::class ), + $container->get( MetadataUrlTransformer::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + + $container->factory( + ContentHandlerRegistry::class, + static function () use ( $container ): ContentHandlerRegistry { + return new ContentHandlerRegistry( + array( + $container->get( PostContentHandler::class ), + $container->get( TermContentHandler::class ), + $container->get( MediaContentHandler::class ), + ) + ); + } + ); + + $container->factory( + SyncStateRepository::class, + static function (): SyncStateRepository { + return new SyncStateRepository(); + } + ); + + $container->factory( + SyncEngine::class, + static function () use ( $container ): SyncEngine { + return new SyncEngine( + $container->get( ContentHandlerRegistry::class ), + $container->get( SyncStateRepository::class ), + $container->get( SettingsRepository::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + $container->factory( FileTransportInterface::class, static function () use ( $container ): FileTransportInterface { @@ -81,7 +163,8 @@ final class Plugin { static function () use ( $container ): FileImportController { return new FileImportController( $container->get( FileTransportInterface::class ), - $container->get( LoggerInterface::class ) + $container->get( LoggerInterface::class ), + $container->get( SyncEngine::class ) ); } ); @@ -97,7 +180,8 @@ final class Plugin { RestPackageController::class, static function () use ( $container ): RestPackageController { return new RestPackageController( - $container->get( PackageValidator::class ) + $container->get( PackageValidator::class ), + $container->get( SyncEngine::class ) ); } ); diff --git a/src/Rest/RestPackageController.php b/src/Rest/RestPackageController.php index 4e4dcd4..6babc2a 100644 --- a/src/Rest/RestPackageController.php +++ b/src/Rest/RestPackageController.php @@ -9,12 +9,15 @@ namespace WPContentSync\Rest; use WPContentSync\Package\ContentPackage; use WPContentSync\Package\PackageValidator; +use WPContentSync\Sync\SyncEngine; final class RestPackageController { private PackageValidator $validator; + private SyncEngine $sync_engine; - public function __construct( PackageValidator $validator ) { - $this->validator = $validator; + public function __construct( PackageValidator $validator, SyncEngine $sync_engine ) { + $this->validator = $validator; + $this->sync_engine = $sync_engine; } public function register(): void { @@ -82,11 +85,13 @@ final class RestPackageController { } $package = ContentPackage::fromArray( $data['package'] ); + $import = $this->sync_engine->importPackage( $package ); return array( - 'accepted' => true, + 'accepted' => $import->isSuccessful(), 'schema_version' => $package->schemaVersion(), 'manifest' => $package->manifest(), + 'import' => $import->toArray(), ); } diff --git a/tests/Unit/Admin/FileImportControllerTest.php b/tests/Unit/Admin/FileImportControllerTest.php index 8e7898c..9b68767 100644 --- a/tests/Unit/Admin/FileImportControllerTest.php +++ b/tests/Unit/Admin/FileImportControllerTest.php @@ -4,15 +4,25 @@ namespace WPContentSync\Tests\Unit\Admin; use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; +use WPContentSync\Content\ContentHandlerInterface; +use WPContentSync\Content\ContentHandlerRegistry; use WPContentSync\Logging\LoggerInterface; use WPContentSync\Package\PackageChecksum; use WPContentSync\Package\PackageValidator; +use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Sync\SyncContext; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncResult; +use WPContentSync\Sync\SyncStateRepository; use WPContentSync\Transport\JsonFileTransport; class FileImportControllerTest extends TestCase { /** @var array */ private array $temporary_files = array(); + /** @var array> */ + private array $logs = array(); + protected function tearDown(): void { foreach ( $this->temporary_files as $file ) { if ( is_file( $file ) ) { @@ -21,8 +31,17 @@ class FileImportControllerTest extends TestCase { } } - unset( $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_nonce_valid'], $GLOBALS['wpcs_redirect_location'] ); - $_FILES = array(); + unset( + $GLOBALS['wpcs_current_user_can'], + $GLOBALS['wpcs_nonce_valid'], + $GLOBALS['wpcs_redirect_location'], + $GLOBALS['wpcs_test_options'], + $GLOBALS['wpcs_test_option_autoloads'], + $GLOBALS['wpcs_test_transients'], + $GLOBALS['wpcs_test_transient_expiration'] + ); + $_FILES = array(); + $this->logs = array(); parent::tearDown(); } @@ -69,7 +88,7 @@ class FileImportControllerTest extends TestCase { $controller->handleImport(); } - public function test_it_imports_valid_uploaded_packages_without_mutating_content(): void { + public function test_it_imports_valid_uploaded_packages_with_sync_engine(): void { $file = $this->createTemporaryPackageFile( $this->validJson() ); $_FILES['wpcs_package_file'] = array( @@ -80,6 +99,22 @@ class FileImportControllerTest extends TestCase { $this->controller()->handleImport(); self::assertStringContainsString( 'wpcs_imported=1', $GLOBALS['wpcs_redirect_location'] ); + self::assertSame( 'Imported content package.', $this->logs[2]['message'] ); + self::assertSame( 0, $this->logs[2]['context']['created'] ); + } + + public function test_it_redirects_with_error_when_sync_engine_import_fails(): void { + $file = $this->createTemporaryPackageFile( $this->validJson() ); + + $_FILES['wpcs_package_file'] = array( + 'tmp_name' => $file, + 'error' => UPLOAD_ERR_OK, + ); + + $this->controller( SyncResult::failure( array( 'Posts failed.' ) ) )->handleImport(); + + self::assertStringContainsString( 'wpcs_import_error=', $GLOBALS['wpcs_redirect_location'] ); + self::assertStringContainsString( 'Posts+failed.', $GLOBALS['wpcs_redirect_location'] ); } public function test_it_redirects_with_error_for_invalid_uploaded_packages(): void { @@ -96,33 +131,84 @@ class FileImportControllerTest extends TestCase { self::assertStringContainsString( 'not+valid+JSON', $GLOBALS['wpcs_redirect_location'] ); } - private function controller(): FileImportController { + private function controller( ?SyncResult $result = null ): FileImportController { + $logger = $this->logger(); + return new FileImportController( new JsonFileTransport( new PackageValidator() ), - new class() implements LoggerInterface { - /** - * @param array $context Context. - */ - public function error( string $message, array $context = array() ): void {} - - /** - * @param array $context Context. - */ - public function warning( string $message, array $context = array() ): void {} - - /** - * @param array $context Context. - */ - public function info( string $message, array $context = array() ): void {} - - /** - * @param array $context Context. - */ - public function debug( string $message, array $context = array() ): void {} - } + $logger, + $this->syncEngine( $result ?? SyncResult::success() ) ); } + private function syncEngine( SyncResult $result ): SyncEngine { + return new SyncEngine( + new ContentHandlerRegistry( array( $this->handler( $result ) ) ), + new SyncStateRepository(), + new SettingsRepository(), + $this->logger() + ); + } + + private function handler( SyncResult $result ): ContentHandlerInterface { + return new class( $result ) implements ContentHandlerInterface { + private SyncResult $result; + + public function __construct( SyncResult $result ) { + $this->result = $result; + } + + public function bucket(): string { + return 'posts'; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + return $this->result; + } + }; + } + + private function logger(): LoggerInterface { + return new class( $this->logs ) implements LoggerInterface { + /** @var array> */ + private array $logs; + + /** + * @param array> $logs Logs. + */ + public function __construct( array &$logs ) { + $this->logs = &$logs; + } + + public function error( string $message, array $context = array() ): void { + $this->record( 'error', $message, $context ); + } + + public function warning( string $message, array $context = array() ): void { + $this->record( 'warning', $message, $context ); + } + + public function info( string $message, array $context = array() ): void { + $this->record( 'info', $message, $context ); + } + + public function debug( string $message, array $context = array() ): void { + $this->record( 'debug', $message, $context ); + } + + /** + * @param array $context Context. + */ + private function record( string $level, string $message, array $context ): void { + $this->logs[] = array( + 'level' => $level, + 'message' => $message, + 'context' => $context, + ); + } + }; + } + private function validJson(): string { $records = array( 'posts' => array(), diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index a597111..5427d14 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -4,9 +4,16 @@ namespace WPContentSync\Tests\Unit; use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; +use WPContentSync\Content\ContentHandlerRegistry; +use WPContentSync\Content\ContentRecordNormalizer; +use WPContentSync\Content\MediaContentHandler; +use WPContentSync\Content\PostContentHandler; +use WPContentSync\Content\TermContentHandler; use WPContentSync\Container; use WPContentSync\Plugin; use WPContentSync\Rest\RestPackageController; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncStateRepository; use WPContentSync\Transport\FileTransportInterface; use WPContentSync\Transport\RestTransportClient; use WPContentSync\Url\MetadataUrlTransformer; @@ -57,6 +64,18 @@ class PluginTest extends TestCase { ); } + public function test_it_registers_sync_engine_and_content_handlers(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( ContentRecordNormalizer::class, $container->get( ContentRecordNormalizer::class ) ); + self::assertInstanceOf( PostContentHandler::class, $container->get( PostContentHandler::class ) ); + self::assertInstanceOf( TermContentHandler::class, $container->get( TermContentHandler::class ) ); + self::assertInstanceOf( MediaContentHandler::class, $container->get( MediaContentHandler::class ) ); + self::assertInstanceOf( ContentHandlerRegistry::class, $container->get( ContentHandlerRegistry::class ) ); + self::assertInstanceOf( SyncStateRepository::class, $container->get( SyncStateRepository::class ) ); + self::assertInstanceOf( SyncEngine::class, $container->get( SyncEngine::class ) ); + } + public function test_it_hooks_rest_package_controller_on_register(): void { unset( $GLOBALS['wpcs_test_actions'] ); diff --git a/tests/Unit/Rest/RestPackageControllerTest.php b/tests/Unit/Rest/RestPackageControllerTest.php index d7683c7..fa99da3 100644 --- a/tests/Unit/Rest/RestPackageControllerTest.php +++ b/tests/Unit/Rest/RestPackageControllerTest.php @@ -8,9 +8,17 @@ namespace WPContentSync\Tests\Unit\Rest; use PHPUnit\Framework\TestCase; +use WPContentSync\Content\ContentHandlerInterface; +use WPContentSync\Content\ContentHandlerRegistry; +use WPContentSync\Logging\LoggerInterface; use WPContentSync\Package\PackageChecksum; use WPContentSync\Package\PackageValidator; use WPContentSync\Rest\RestPackageController; +use WPContentSync\Settings\SettingsRepository; +use WPContentSync\Sync\SyncContext; +use WPContentSync\Sync\SyncEngine; +use WPContentSync\Sync\SyncResult; +use WPContentSync\Sync\SyncStateRepository; class RestPackageControllerTest extends TestCase { protected function setUp(): void { @@ -20,13 +28,21 @@ class RestPackageControllerTest extends TestCase { } protected function tearDown(): void { - unset( $GLOBALS['wpcs_rest_routes'], $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_test_actions'] ); + unset( + $GLOBALS['wpcs_rest_routes'], + $GLOBALS['wpcs_current_user_can'], + $GLOBALS['wpcs_test_actions'], + $GLOBALS['wpcs_test_options'], + $GLOBALS['wpcs_test_option_autoloads'], + $GLOBALS['wpcs_test_transients'], + $GLOBALS['wpcs_test_transient_expiration'] + ); parent::tearDown(); } public function test_it_hooks_route_registration_to_rest_api_init(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); $controller->register(); self::assertSame( @@ -36,7 +52,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_registers_status_and_package_routes(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); $controller->registerRoutes(); self::assertArrayHasKey( 'wp-content-sync/v1/status', $GLOBALS['wpcs_rest_routes'] ); @@ -45,13 +61,13 @@ class RestPackageControllerTest extends TestCase { public function test_it_requires_manage_options_permission(): void { $GLOBALS['wpcs_current_user_can']['manage_options'] = false; - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertFalse( $controller->canReceivePackage() ); } public function test_it_returns_status_payload(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertSame( array( @@ -64,7 +80,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_accepts_valid_packages(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertSame( $this->acceptedResponse(), @@ -77,7 +93,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_accepts_rest_request_like_objects(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); $request = new class( array( 'package' => $this->validPackage(), @@ -105,7 +121,7 @@ class RestPackageControllerTest extends TestCase { } public function test_it_rejects_invalid_package_shapes(): void { - $controller = new RestPackageController( new PackageValidator() ); + $controller = $this->controller(); self::assertSame( array( @@ -116,6 +132,36 @@ class RestPackageControllerTest extends TestCase { ); } + public function test_it_returns_rejected_response_when_sync_import_fails(): void { + $controller = $this->controller( SyncResult::failure( array( 'Posts failed.' ) ) ); + + self::assertSame( + array( + 'accepted' => false, + 'schema_version' => '1.0', + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'import' => array( + 'successful' => false, + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'conflicts' => 0, + 'errors' => array( 'Posts failed.' ), + ), + ), + $controller->receivePackage( + array( + 'package' => $this->validPackage(), + ) + ) + ); + } + /** * @return array */ @@ -129,9 +175,63 @@ class RestPackageControllerTest extends TestCase { 'media' => 0, 'custom_post_types' => 0, ), + 'import' => array( + 'successful' => true, + 'created' => 0, + 'updated' => 0, + 'skipped' => 0, + 'conflicts' => 0, + 'errors' => array(), + ), ); } + private function controller( ?SyncResult $result = null ): RestPackageController { + return new RestPackageController( + new PackageValidator(), + $this->syncEngine( $result ?? SyncResult::success() ) + ); + } + + private function syncEngine( SyncResult $result ): SyncEngine { + return new SyncEngine( + new ContentHandlerRegistry( array( $this->handler( $result ) ) ), + new SyncStateRepository(), + new SettingsRepository(), + $this->logger() + ); + } + + private function handler( SyncResult $result ): ContentHandlerInterface { + return new class( $result ) implements ContentHandlerInterface { + private SyncResult $result; + + public function __construct( SyncResult $result ) { + $this->result = $result; + } + + public function bucket(): string { + return 'posts'; + } + + public function importRecords( array $records, SyncContext $context ): SyncResult { + return $this->result; + } + }; + } + + private function logger(): LoggerInterface { + return new class() implements LoggerInterface { + public function error( string $message, array $context = array() ): void {} + + public function warning( string $message, array $context = array() ): void {} + + public function info( string $message, array $context = array() ): void {} + + public function debug( string $message, array $context = array() ): void {} + }; + } + /** * @return array */ -- 2.52.0 From a2327d05dd86f088b7aa73211b0ec4b415e24edd Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 6 May 2026 22:48:16 -0500 Subject: [PATCH 25/30] docs: add admin hardening implementation plan --- ...-wordpress-content-sync-admin-hardening.md | 849 ++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md new file mode 100644 index 0000000..4ed082c --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-admin-hardening.md @@ -0,0 +1,849 @@ +# Admin Workflow and Hardening 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:** Complete the plugin's usable admin workflow for configuring sync pairs, testing connections, importing/exporting package files, viewing operation results/logs, and running a final smoke checklist with hardened security and user-facing errors. + +**Architecture:** Phase 6 keeps the current import-first sync engine and adds admin workflows around it. Settings stay option-backed, state-changing admin actions use dedicated controllers with capability and nonce checks, and templates receive pre-sanitized view data while still escaping every output at render time. + +**Tech Stack:** PHP 7.4, WordPress admin APIs, WordPress HTTP/REST APIs, PHPUnit, PHPStan, PHPCS/WPCS, existing `SettingsRepository`, `RestTransportClient`, `JsonFileTransport`, `SyncEngine`, and `OptionLogger`. + +--- + +## File Structure + +- `src/Settings/Settings.php`: expand typed settings to include sync pair credentials, URL mappings, selected content types, default direction, and retention/debug controls. +- `src/Admin/AdminNotice.php`: value object for redirect-driven admin notices with `type`, `message`, and optional result context. +- `src/Admin/AdminNoticeRepository.php`: converts `$_GET` query flags into safe notices for templates. +- `src/Admin/SettingsController.php`: handles settings saves through `admin_post_wpcs_save_settings`. +- `src/Admin/ConnectionTestController.php`: handles connection diagnostics through `admin_post_wpcs_test_connection`. +- `src/Admin/FileExportController.php`: exports a valid empty package scaffold for a configured pair until full extraction is implemented. +- `src/Admin/LogController.php`: handles log clearing through `admin_post_wpcs_clear_logs`. +- `src/Admin/AdminPage.php`: registers the new controllers and passes settings/notices/logs to the dashboard template. +- `src/Logging/OptionLogger.php`: add safe read/clear helpers for operation history. +- `src/Transport/RestTransportClient.php`: treat REST `accepted: false` JSON responses as remote rejections. +- `templates/admin/dashboard.php`: replace the placeholder dashboard with forms for settings, diagnostics, file import/export, operation history, and smoke guidance. +- `tests/Unit/Admin/*Test.php`: unit coverage for notices, settings save, connection diagnostics, export, log clearing, and dashboard rendering. +- `tests/Unit/SettingsTest.php`: settings expansion and sanitization coverage. +- `tests/Unit/Transport/RestTransportClientTest.php`: REST rejected-body coverage. +- `docs/smoke/phase-6-admin-hardening.md`: manual smoke checklist and known local environment notes. + +--- + +## Task 1: Expand Settings for Admin Workflow + +**Files:** +- Modify: `src/Settings/Settings.php` +- Modify: `tests/Unit/SettingsTest.php` + +- [ ] **Step 1: Write failing settings tests** + +Add tests to `tests/Unit/SettingsTest.php`: + +```php +public function test_it_sanitizes_full_admin_workflow_settings(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Production to Staging', + 'source_url' => 'https://example.test/', + 'destination_url' => 'https://staging.example.test/', + 'username' => '', + 'application_password' => 'secret app password', + 'default_direction' => 'push', + 'content_types' => array( 'posts', 'terms', 'media', 'bad_type' ), + 'url_mappings' => array( + array( + 'source' => 'https://example.test', + 'destination' => 'https://staging.example.test', + ), + ), + ), + ), + 'log_retention' => '50', + 'debug_logging' => '1', + ) + ); + + $pairs = $settings->syncPairs(); + + self::assertSame( 'Production to Staging', $pairs[0]['name'] ); + self::assertSame( 'https://example.test/', $pairs[0]['source_url'] ); + self::assertSame( 'https://staging.example.test/', $pairs[0]['destination_url'] ); + self::assertSame( 'codex', $pairs[0]['username'] ); + self::assertSame( 'secret app password', $pairs[0]['application_password'] ); + self::assertSame( 'push', $pairs[0]['default_direction'] ); + self::assertSame( array( 'posts', 'terms', 'media' ), $pairs[0]['content_types'] ); + self::assertSame( + array( + array( + 'source' => 'https://example.test', + 'destination' => 'https://staging.example.test', + ), + ), + $pairs[0]['url_mappings'] + ); + self::assertSame( 50, $settings->logRetention() ); + self::assertTrue( $settings->debugLoggingEnabled() ); +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter SettingsTest` + +Expected: FAIL because `logRetention()`, `debugLoggingEnabled()`, and expanded sync pair keys do not exist. + +- [ ] **Step 3: Implement settings expansion** + +Update `Settings` to support: + +```php +private const DIRECTIONS = array( 'push', 'pull' ); +private const CONTENT_TYPES = array( 'posts', 'terms', 'media', 'custom_post_types' ); +private const MIN_LOGS = 10; +private const MAX_LOGS = 1000; + +public function logRetention(): int; +public function debugLoggingEnabled(): bool; +``` + +Update `sanitizeSyncPairs()` so each pair returns: + +```php +array( + 'name' => $name, + 'source_url' => $source_url, + 'destination_url' => $destination_url, + 'username' => $username, + 'application_password' => $application_password, + 'default_direction' => $direction, + 'content_types' => $content_types, + 'url_mappings' => $url_mappings, +) +``` + +Rules: +- Preserve legacy `name`, `source_url`, and `destination_url` behavior. +- Sanitize `username` with `sanitize_text_field()`. +- Sanitize `application_password` with `sanitize_text_field()` and never log it. +- Keep only allowed content types. +- Keep only URL mappings where both source and destination are non-empty URLs. +- Clamp log retention between `10` and `1000`. +- Default direction to `push`. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter SettingsTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Settings/Settings.php tests/Unit/SettingsTest.php +git commit -m "feat: expand admin sync settings" +``` + +--- + +## Task 2: Admin Notices + +**Files:** +- Create: `src/Admin/AdminNotice.php` +- Create: `src/Admin/AdminNoticeRepository.php` +- Create: `tests/Unit/Admin/AdminNoticeRepositoryTest.php` + +- [ ] **Step 1: Write failing notice tests** + +Create `tests/Unit/Admin/AdminNoticeRepositoryTest.php`: + +```php +current(); + + self::assertSame( 'success', $notices[0]->type() ); + self::assertSame( 'The package JSON file was imported successfully.', $notices[0]->message() ); + } + + public function test_it_sanitizes_error_notices(): void { + $_GET['wpcs_import_error'] = ''; + $notices = ( new AdminNoticeRepository() )->current(); + + self::assertSame( 'error', $notices[0]->type() ); + self::assertSame( 'Bad package', $notices[0]->message() ); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter AdminNoticeRepositoryTest` + +Expected: FAIL with missing classes. + +- [ ] **Step 3: Implement notices** + +Create `AdminNotice` with: + +```php +public function __construct( string $type, string $message ); +public function type(): string; +public function message(): string; +``` + +Allowed types: `success`, `warning`, `error`, `info`; fallback to `info`. + +Create `AdminNoticeRepository::current(): array` returning notices for: +- `wpcs_imported=1`: success, imported successfully. +- `wpcs_import_error`: sanitized error message. +- `wpcs_settings_saved=1`: success, settings saved. +- `wpcs_connection_ok=1`: success, REST connection succeeded. +- `wpcs_connection_error`: sanitized error message. +- `wpcs_logs_cleared=1`: success, logs cleared. +- `wpcs_export_error`: sanitized error message. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter AdminNoticeRepositoryTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/AdminNotice.php src/Admin/AdminNoticeRepository.php tests/Unit/Admin/AdminNoticeRepositoryTest.php +git commit -m "feat: add admin notices" +``` + +--- + +## Task 3: Settings Save Controller + +**Files:** +- Create: `src/Admin/SettingsController.php` +- Modify: `src/Plugin.php` +- Create: `tests/Unit/Admin/SettingsControllerTest.php` +- Modify: `tests/Unit/PluginTest.php` + +- [ ] **Step 1: Write failing controller tests** + +Create `tests/Unit/Admin/SettingsControllerTest.php`: + +```php + 'debug', + 'conflict_strategy' => 'manual_review', + ); + + ( new SettingsController( new SettingsRepository() ) )->handleSave(); + + self::assertSame( 'debug', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['logging_level'] ); + self::assertStringContainsString( 'wpcs_settings_saved=1', $GLOBALS['wpcs_redirect_location'] ); + } +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter SettingsControllerTest` + +Expected: FAIL with missing `SettingsController`. + +- [ ] **Step 3: Implement controller** + +Create `SettingsController`: + +```php +final class SettingsController { + private SettingsRepository $settings_repository; + + public function __construct( SettingsRepository $settings_repository ) {} + public function register(): void; + public function handleSave(): void; +} +``` + +Behavior: +- `register()` hooks `admin_post_wpcs_save_settings`. +- `handleSave()` requires `manage_options`. +- Verify nonce `wpcs_save_settings` / `wpcs_settings_nonce`. +- Read `$_POST['wpcs_settings']`, unslash with `wp_unslash()`, require array. +- Persist through `SettingsRepository::save( Settings::fromArray( $data ) )`. +- Redirect to dashboard with `wpcs_settings_saved=1`. + +Update `Plugin::create()` to register `SettingsController`. +Update `Plugin::register()` to call `SettingsController::register()`. +Update `PluginTest` to assert the service exists and the hook is registered. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "SettingsControllerTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/SettingsController.php src/Plugin.php tests/Unit/Admin/SettingsControllerTest.php tests/Unit/PluginTest.php +git commit -m "feat: save admin sync settings" +``` + +--- + +## Task 4: Dashboard Settings Form + +**Files:** +- Modify: `templates/admin/dashboard.php` +- Modify: `src/Admin/AdminPage.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing dashboard tests** + +Add tests: + +```php +public function test_it_renders_settings_form_with_nonce_and_escaped_pair_values(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'source_url' => 'https://example.test', + 'destination_url' => 'https://staging.example.test', + 'username' => 'codex', + ), + ), + ) + ); + + $output = $this->renderDashboard( $settings ); + + self::assertStringContainsString( 'action="https://example.test/wp-admin/admin-post.php"', $output ); + self::assertStringContainsString( 'name="action" value="wpcs_save_settings"', $output ); + self::assertStringContainsString( 'Staging', $output ); + self::assertStringNotContainsString( 'Staging', $output ); + self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][application_password]"', $output ); +} +``` + +Update `renderDashboard()` to accept optional `Settings $settings`. + +- [ ] **Step 2: Run test to verify failure** + +Run: `composer test -- --filter DashboardTemplateTest` + +Expected: FAIL because the dashboard does not render the settings form. + +- [ ] **Step 3: Implement dashboard form** + +Update `dashboard.php`: +- Replace the foundation notice with a "Configuration" section. +- Render a form posting to `admin-post.php`. +- Include `action=wpcs_save_settings`. +- Include nonce `wp_nonce_field( 'wpcs_save_settings', 'wpcs_settings_nonce' )`. +- Render existing sync pairs; if none exist, render one blank pair row. +- Fields per pair: name, source URL, destination URL, username, application password, default direction, content types, URL mappings. +- Never render saved application password values back into the password field; render placeholder text only. +- Escape every attribute with `esc_attr()` and every text node with `esc_html()`. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter DashboardTemplateTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add templates/admin/dashboard.php src/Admin/AdminPage.php tests/Unit/Admin/DashboardTemplateTest.php +git commit -m "feat: render admin settings workflow" +``` + +--- + +## Task 5: Connection Diagnostics + +**Files:** +- Create: `src/Admin/ConnectionTestController.php` +- Modify: `src/Plugin.php` +- Create: `tests/Unit/Admin/ConnectionTestControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` +- Modify: `templates/admin/dashboard.php` + +- [ ] **Step 1: Write failing diagnostics tests** + +Create `ConnectionTestControllerTest` that injects `RestTransportClient`, posts `pair_index=0`, and verifies: +- capability and nonce are required. +- success redirects with `wpcs_connection_ok=1`. +- failures redirect with `wpcs_connection_error`. +- application password is never placed in redirect query args. + +Use settings fixture: + +```php +update_option( + SettingsRepository::OPTION_NAME, + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'destination_url' => 'https://destination.test', + 'username' => 'codex', + 'application_password' => 'app-pass', + ), + ), + ), + false +); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter ConnectionTestControllerTest` + +Expected: FAIL with missing controller. + +- [ ] **Step 3: Implement diagnostics controller** + +Create `ConnectionTestController`: +- Hook `admin_post_wpcs_test_connection`. +- Require `manage_options`. +- Verify nonce `wpcs_test_connection` / `wpcs_connection_nonce`. +- Read `pair_index` as integer. +- Load selected pair from settings. +- Call `RestTransportClient::testConnection( destination_url, username, application_password )`. +- On success redirect with `wpcs_connection_ok=1`. +- On `RestTransportException` redirect with sanitized `wpcs_connection_error`. +- Log success/failure without credentials. + +Update `Plugin` to register the controller. +Update dashboard to add a "Test REST Connection" button per pair with nonce. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "ConnectionTestControllerTest|DashboardTemplateTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/ConnectionTestController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/Admin/ConnectionTestControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php +git commit -m "feat: add connection diagnostics" +``` + +--- + +## Task 6: REST Transport Rejected Body Handling + +**Files:** +- Modify: `src/Transport/RestTransportClient.php` +- Modify: `tests/Unit/Transport/RestTransportClientTest.php` + +- [ ] **Step 1: Write failing REST client test** + +Add: + +```php +public function test_it_throws_when_receive_endpoint_returns_accepted_false(): void { + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 200 ), + 'body' => '{"accepted":false,"import":{"errors":["Posts failed."]}}', + ); + $client = new RestTransportClient(); + + $this->expectException( RestTransportException::class ); + $this->expectExceptionMessage( 'Posts failed.' ); + + $client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() ); +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `composer test -- --filter RestTransportClientTest` + +Expected: FAIL because HTTP 200 currently counts as success. + +- [ ] **Step 3: Implement body inspection** + +Update `assertSuccessfulResponse()`: +- If status code is expected, decode body. +- If decoded body has `accepted === false`, throw `RestTransportException::remoteRejected()`. +- Prefer first string in `import.errors`, then first string in `errors`, then `message`, then fallback. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter RestTransportClientTest` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Transport/RestTransportClient.php tests/Unit/Transport/RestTransportClientTest.php +git commit -m "fix: reject failed rest imports" +``` + +--- + +## Task 7: Operation Logs and Clear Action + +**Files:** +- Modify: `src/Logging/OptionLogger.php` +- Create: `src/Admin/LogController.php` +- Modify: `src/Plugin.php` +- Modify: `templates/admin/dashboard.php` +- Modify: `tests/Unit/OptionLoggerTest.php` +- Create: `tests/Unit/Admin/LogControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing logger/controller tests** + +Add `OptionLoggerTest` coverage for: + +```php +$logger = new OptionLogger( 10 ); +$logger->info( 'Imported content package.' ); +self::assertCount( 1, $logger->entries() ); +$logger->clear(); +self::assertSame( array(), $logger->entries() ); +``` + +Create `LogControllerTest` verifying: +- capability and nonce are required. +- `handleClear()` clears `OptionLogger::OPTION_NAME`. +- redirect contains `wpcs_logs_cleared=1`. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter "OptionLoggerTest|LogControllerTest"` + +Expected: FAIL with missing methods/controller. + +- [ ] **Step 3: Implement logs** + +Update `OptionLogger`: + +```php +public function entries(): array; +public function clear(): void; +``` + +Create `LogController`: +- Hook `admin_post_wpcs_clear_logs`. +- Require `manage_options`. +- Verify nonce `wpcs_clear_logs` / `wpcs_logs_nonce`. +- Clear logs. +- Redirect with `wpcs_logs_cleared=1`. + +Update dashboard: +- Render a recent operation history table. +- Show timestamp, level, message, and redacted context summary. +- Add "Clear Logs" form with nonce. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "OptionLoggerTest|LogControllerTest|DashboardTemplateTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Logging/OptionLogger.php src/Admin/LogController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/OptionLoggerTest.php tests/Unit/Admin/LogControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php +git commit -m "feat: add operation log controls" +``` + +--- + +## Task 8: File Export Scaffold + +**Files:** +- Create: `src/Admin/FileExportController.php` +- Modify: `src/Plugin.php` +- Modify: `templates/admin/dashboard.php` +- Create: `tests/Unit/Admin/FileExportControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing export tests** + +Create `FileExportControllerTest` verifying: +- capability and nonce are required. +- a configured pair exports a valid JSON package with all four record buckets. +- response headers include `Content-Type: application/json`. +- no content mutation occurs during export. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter FileExportControllerTest` + +Expected: FAIL with missing controller. + +- [ ] **Step 3: Implement export scaffold** + +Create `FileExportController`: +- Hook `admin_post_wpcs_export_package`. +- Require `manage_options`. +- Verify nonce `wpcs_export_package` / `wpcs_export_nonce`. +- Read `pair_index`. +- Build an empty `ContentPackage` using the selected pair: + - `source.site_url` from pair source URL. + - `destination.site_url` from pair destination URL. + - manifest counts all zero. + - records buckets all empty. + - checksum from `PackageChecksum::records()`. +- Export with `JsonFileTransport::export()`. +- Send JSON download headers. + +Update dashboard with an "Export Empty Package" form and helper copy stating full extraction will be added in a later slice. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "FileExportControllerTest|DashboardTemplateTest|PluginTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/FileExportController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/Admin/FileExportControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php +git commit -m "feat: add package export scaffold" +``` + +--- + +## Task 9: Import Result UI Hardening + +**Files:** +- Modify: `src/Admin/FileImportController.php` +- Modify: `templates/admin/dashboard.php` +- Modify: `tests/Unit/Admin/FileImportControllerTest.php` +- Modify: `tests/Unit/Admin/DashboardTemplateTest.php` + +- [ ] **Step 1: Write failing result tests** + +Add file import test asserting success redirects include counts: + +```php +self::assertStringContainsString( 'wpcs_created=0', $GLOBALS['wpcs_redirect_location'] ); +self::assertStringContainsString( 'wpcs_updated=0', $GLOBALS['wpcs_redirect_location'] ); +self::assertStringContainsString( 'wpcs_skipped=0', $GLOBALS['wpcs_redirect_location'] ); +self::assertStringContainsString( 'wpcs_conflicts=0', $GLOBALS['wpcs_redirect_location'] ); +``` + +Add dashboard test asserting the success notice includes created/updated/skipped/conflict counts from sanitized query args. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `composer test -- --filter "FileImportControllerTest|DashboardTemplateTest"` + +Expected: FAIL because result counts are not in redirects or UI. + +- [ ] **Step 3: Implement result count redirects** + +Update `FileImportController` success redirect: + +```php +array( + 'wpcs_imported' => '1', + 'wpcs_created' => (string) $result->created(), + 'wpcs_updated' => (string) $result->updated(), + 'wpcs_skipped' => (string) $result->skipped(), + 'wpcs_conflicts' => (string) $result->conflicts(), +) +``` + +Update dashboard to display counts with `absint()` and escaped labels. + +- [ ] **Step 4: Run focused tests** + +Run: `composer test -- --filter "FileImportControllerTest|DashboardTemplateTest"` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Admin/FileImportController.php templates/admin/dashboard.php tests/Unit/Admin/FileImportControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php +git commit -m "feat: show import result summaries" +``` + +--- + +## Task 10: Smoke Checklist Documentation + +**Files:** +- Create: `docs/smoke/phase-6-admin-hardening.md` + +- [ ] **Step 1: Create smoke checklist** + +Create `docs/smoke/phase-6-admin-hardening.md` with: + +```markdown +# Phase 6 Admin Hardening Smoke Checklist + +## Environment + +- WordPress site URL: +- Plugin branch/commit: +- PHP version: +- WordPress version: + +## Checks + +- [ ] Plugin activates without fatal errors. +- [ ] Tools -> Content Sync loads for an administrator. +- [ ] Non-administrators cannot access the dashboard. +- [ ] Settings save rejects missing/invalid nonce. +- [ ] Settings save persists sync pair name, URLs, username, content types, direction, and URL mappings. +- [ ] Saved application password is not rendered back into the password field. +- [ ] REST status endpoint rejects unauthenticated HTTP requests. +- [ ] REST status endpoint accepts authenticated requests when the server passes `Authorization` to PHP. +- [ ] Connection test succeeds with a valid application password. +- [ ] Connection test shows an actionable error for invalid credentials. +- [ ] Invalid package file import redirects with an error notice. +- [ ] Valid empty package import redirects with success and result counts. +- [ ] REST package POST accepts a valid package and includes import counts. +- [ ] REST package POST rejects invalid package data. +- [ ] Operation log table shows recent events with redacted credential-like fields. +- [ ] Clear logs requires nonce and removes log entries. +- [ ] Export scaffold downloads valid JSON. + +## Local Notes + +- On Herd/nginx, direct HTTP Basic auth may require forwarding the `Authorization` header. If HTTP application-password smoke returns 401 while internal REST dispatch passes, verify server auth header configuration before treating it as a plugin failure. +``` + +- [ ] **Step 2: Commit docs** + +```bash +git add docs/smoke/phase-6-admin-hardening.md +git commit -m "docs: add admin hardening smoke checklist" +``` + +--- + +## Task 11: Full Phase 6 Verification + +**Files:** +- Verify all files changed in Tasks 1-10. + +- [ ] **Step 1: Run Composer validation** + +Run: `composer validate --strict` + +Expected: PASS with `./composer.json is valid`. + +- [ ] **Step 2: Run PHPCS** + +Run: `composer lint` + +Expected: PASS with no PHPCS errors or warnings. + +- [ ] **Step 3: Run PHPStan** + +Run: `vendor\bin\phpstan analyse --memory-limit=1G` + +Expected: PASS with `[OK] No errors`. + +- [ ] **Step 4: Run PHPUnit** + +Run: `composer test` + +Expected: PASS with all unit tests passing. + +- [ ] **Step 5: Copy runtime files to Herd test plugin** + +Run: + +```powershell +Copy-Item -Path src,templates,wp-content-sync.php,composer.json -Destination 'C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\WP-Content-Sync' -Recurse -Force +``` + +Expected: command exits 0. + +- [ ] **Step 6: Run manual smoke checklist** + +Use `docs/smoke/phase-6-admin-hardening.md`. + +Required checks before completion: +- Plugin active. +- Admin dashboard loads after login. +- Settings save works and rejects invalid nonce. +- Connection diagnostics show success/failure notices. +- Invalid file import redirects with error. +- Valid empty file import redirects with success counts. +- REST unauthenticated status returns 401. +- REST valid and invalid package behavior is verified by HTTP application password if server passes Basic auth, otherwise by internal REST dispatch plus documented server caveat. +- Logs render and clear. +- Export scaffold downloads valid JSON. + +- [ ] **Step 7: Commit verification notes if changed** + +If smoke notes are updated with environment results: + +```bash +git add docs/smoke/phase-6-admin-hardening.md +git commit -m "docs: record phase 6 smoke results" +``` + +--- + +## Spec Coverage + +- Sync pair configuration is covered by Tasks 1, 3, and 4. +- Credentials and authentication setup are covered by Tasks 1, 4, and 5, with password fields not rendered back to the browser. +- URL mapping configuration is covered by Task 1 and Task 4. +- Content type selection and default sync direction are covered by Task 1 and Task 4. +- Connection diagnostics are covered by Task 5. +- Import screens and user-facing import errors are covered by Tasks 2, 4, and 9. +- Export screen is covered by Task 8 as a valid package scaffold until full extraction is implemented. +- Operation history and debug/log controls are covered by Task 7. +- REST failure hardening is covered by Task 6. +- Nonces/capabilities for state-changing admin actions are covered by Tasks 3, 5, 7, 8, and existing file import tests. +- Final smoke/integration checklist is covered by Tasks 10 and 11. + +## Deferred Work + +- Full content extraction for non-empty package exports remains a later slice because Phase 5 built import orchestration and handlers, not source extraction orchestration. +- Background queues, progress polling, and cancelable long-running operations remain a later scalability slice. +- HTTP Basic authentication pass-through is a server configuration concern; plugin REST behavior remains covered by controller tests and internal REST dispatch smoke. + +## Placeholder Scan + +- No unresolved placeholder markers are intentionally included. +- Each task names exact files, expected failing tests, implementation behavior, verification commands, and commit messages. +- Deferred work is explicitly scoped with rationale and is not required for Phase 6 exit criteria in this implementation slice. -- 2.52.0 From 27919ff11f87708ae1370f934ecb7bf6ebdc9a0e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 7 May 2026 06:06:31 -0500 Subject: [PATCH 26/30] feat: expand admin sync settings --- src/Settings/Settings.php | 113 +++++++++++++++++++++++++++++++++--- tests/Unit/SettingsTest.php | 60 ++++++++++++++++++- 2 files changed, 161 insertions(+), 12 deletions(-) diff --git a/src/Settings/Settings.php b/src/Settings/Settings.php index 846bda0..534929a 100644 --- a/src/Settings/Settings.php +++ b/src/Settings/Settings.php @@ -5,29 +5,39 @@ namespace WPContentSync\Settings; final class Settings { private const LOGGING_LEVELS = array( 'error', 'warning', 'info', 'debug' ); private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' ); + private const DIRECTIONS = array( 'push', 'pull' ); + private const CONTENT_TYPES = array( 'posts', 'terms', 'media', 'custom_post_types' ); + private const MIN_LOGS = 10; + private const MAX_LOGS = 1000; /** - * @var array + * @var array, url_mappings: array}> */ private array $sync_pairs; private string $logging_level; private bool $automatic_url_replacement; private string $conflict_strategy; + private int $log_retention; + private bool $debug_logging; /** - * @param array $sync_pairs Sync pairs. + * @param array, url_mappings: array}> $sync_pairs Sync pairs. */ private function __construct( array $sync_pairs, string $logging_level, bool $automatic_url_replacement, - string $conflict_strategy + string $conflict_strategy, + int $log_retention, + bool $debug_logging ) { $this->sync_pairs = $sync_pairs; $this->logging_level = $logging_level; $this->automatic_url_replacement = $automatic_url_replacement; $this->conflict_strategy = $conflict_strategy; + $this->log_retention = $log_retention; + $this->debug_logging = $debug_logging; } /** @@ -54,12 +64,16 @@ final class Settings { self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ), $logging_level, $automatic_url_replacement, - $conflict_strategy + $conflict_strategy, + self::sanitizeLogRetention( $data['log_retention'] ?? 200 ), + array_key_exists( 'debug_logging', $data ) + ? self::sanitizeBoolean( $data['debug_logging'] ) + : false ); } /** - * @return array + * @return array, url_mappings: array}> */ public function syncPairs(): array { return $this->sync_pairs; @@ -77,6 +91,14 @@ final class Settings { return $this->conflict_strategy; } + public function logRetention(): int { + return $this->log_retention; + } + + public function debugLoggingEnabled(): bool { + return $this->debug_logging; + } + /** * @return array */ @@ -86,6 +108,8 @@ final class Settings { 'logging_level' => $this->logging_level, 'automatic_url_replacement' => $this->automatic_url_replacement, 'conflict_strategy' => $this->conflict_strategy, + 'log_retention' => $this->log_retention, + 'debug_logging' => $this->debug_logging, ); } @@ -114,7 +138,7 @@ final class Settings { /** * @param mixed $pairs Raw sync pairs. - * @return array + * @return array, url_mappings: array}> */ private static function sanitizeSyncPairs( $pairs ): array { if ( ! is_array( $pairs ) ) { @@ -131,15 +155,86 @@ final class Settings { $name = sanitize_text_field( (string) ( $pair['name'] ?? '' ) ); $source_url = esc_url_raw( (string) ( $pair['source_url'] ?? '' ) ); $destination_url = esc_url_raw( (string) ( $pair['destination_url'] ?? '' ) ); + $username = sanitize_text_field( (string) ( $pair['username'] ?? '' ) ); + $password = sanitize_text_field( (string) ( $pair['application_password'] ?? '' ) ); + $direction = self::sanitizeChoice( $pair['default_direction'] ?? 'push', self::DIRECTIONS, 'push' ); + $content_types = self::sanitizeContentTypes( $pair['content_types'] ?? self::CONTENT_TYPES ); + $url_mappings = self::sanitizeUrlMappings( $pair['url_mappings'] ?? array() ); if ( '' === $name || '' === $source_url || '' === $destination_url ) { continue; } $sanitized[] = array( - 'name' => $name, - 'source_url' => $source_url, - 'destination_url' => $destination_url, + 'name' => $name, + 'source_url' => $source_url, + 'destination_url' => $destination_url, + 'username' => $username, + 'application_password' => $password, + 'default_direction' => $direction, + 'content_types' => $content_types, + 'url_mappings' => $url_mappings, + ); + } + + return $sanitized; + } + + /** + * @param mixed $value Raw log retention. + */ + private static function sanitizeLogRetention( $value ): int { + return min( self::MAX_LOGS, max( self::MIN_LOGS, (int) $value ) ); + } + + /** + * @param mixed $content_types Raw content type list. + * @return array + */ + private static function sanitizeContentTypes( $content_types ): array { + if ( ! is_array( $content_types ) ) { + return self::CONTENT_TYPES; + } + + $sanitized = array(); + + foreach ( $content_types as $content_type ) { + $content_type = sanitize_text_field( (string) $content_type ); + + if ( in_array( $content_type, self::CONTENT_TYPES, true ) && ! in_array( $content_type, $sanitized, true ) ) { + $sanitized[] = $content_type; + } + } + + return $sanitized; + } + + /** + * @param mixed $mappings Raw URL mappings. + * @return array + */ + private static function sanitizeUrlMappings( $mappings ): array { + if ( ! is_array( $mappings ) ) { + return array(); + } + + $sanitized = array(); + + foreach ( $mappings as $mapping ) { + if ( ! is_array( $mapping ) ) { + continue; + } + + $source = esc_url_raw( (string) ( $mapping['source'] ?? '' ) ); + $destination = esc_url_raw( (string) ( $mapping['destination'] ?? '' ) ); + + if ( '' === $source || '' === $destination ) { + continue; + } + + $sanitized[] = array( + 'source' => $source, + 'destination' => $destination, ); } diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php index cd02848..40564be 100644 --- a/tests/Unit/SettingsTest.php +++ b/tests/Unit/SettingsTest.php @@ -57,6 +57,53 @@ class SettingsTest extends TestCase { self::assertTrue( $settings->automaticUrlReplacementEnabled() ); } + public function test_it_sanitizes_full_admin_workflow_settings(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Production to Staging', + 'source_url' => 'https://example.test/', + 'destination_url' => 'https://staging.example.test/', + 'username' => '', + 'application_password' => 'secret app password', + 'default_direction' => 'push', + 'content_types' => array( 'posts', 'terms', 'media', 'bad_type' ), + 'url_mappings' => array( + array( + 'source' => 'https://example.test', + 'destination' => 'https://staging.example.test', + ), + ), + ), + ), + 'log_retention' => '50', + 'debug_logging' => '1', + ) + ); + + $pairs = $settings->syncPairs(); + + self::assertSame( 'Production to Staging', $pairs[0]['name'] ); + self::assertSame( 'https://example.test/', $pairs[0]['source_url'] ); + self::assertSame( 'https://staging.example.test/', $pairs[0]['destination_url'] ); + self::assertSame( 'codex', $pairs[0]['username'] ); + self::assertSame( 'secret app password', $pairs[0]['application_password'] ); + self::assertSame( 'push', $pairs[0]['default_direction'] ); + self::assertSame( array( 'posts', 'terms', 'media' ), $pairs[0]['content_types'] ); + self::assertSame( + array( + array( + 'source' => 'https://example.test', + 'destination' => 'https://staging.example.test', + ), + ), + $pairs[0]['url_mappings'] + ); + self::assertSame( 50, $settings->logRetention() ); + self::assertTrue( $settings->debugLoggingEnabled() ); + } + public function test_it_serializes_to_array(): void { $settings = Settings::fromArray( array( @@ -74,14 +121,21 @@ class SettingsTest extends TestCase { array( 'sync_pairs' => array( array( - 'name' => 'Staging', - 'source_url' => 'https://example.test', - 'destination_url' => 'https://staging.example.test', + 'name' => 'Staging', + 'source_url' => 'https://example.test', + 'destination_url' => 'https://staging.example.test', + 'username' => '', + 'application_password' => '', + 'default_direction' => 'push', + 'content_types' => array( 'posts', 'terms', 'media', 'custom_post_types' ), + 'url_mappings' => array(), ), ), 'logging_level' => 'warning', 'automatic_url_replacement' => true, 'conflict_strategy' => 'last_write_wins', + 'log_retention' => 200, + 'debug_logging' => false, ), $settings->toArray() ); -- 2.52.0 From 9f945955d1014b0a41bfe01507279c003452abfe Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 7 May 2026 06:22:50 -0500 Subject: [PATCH 27/30] feat: add admin notices --- src/Admin/AdminNotice.php | 28 +++++++++ src/Admin/AdminNoticeRepository.php | 59 +++++++++++++++++++ .../Unit/Admin/AdminNoticeRepositoryTest.php | 51 ++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/Admin/AdminNotice.php create mode 100644 src/Admin/AdminNoticeRepository.php create mode 100644 tests/Unit/Admin/AdminNoticeRepositoryTest.php diff --git a/src/Admin/AdminNotice.php b/src/Admin/AdminNotice.php new file mode 100644 index 0000000..b6cd502 --- /dev/null +++ b/src/Admin/AdminNotice.php @@ -0,0 +1,28 @@ +type = in_array( $type, self::TYPES, true ) ? $type : 'info'; + $this->message = sanitize_text_field( $message ); + } + + public function type(): string { + return $this->type; + } + + public function message(): string { + return $this->message; + } +} diff --git a/src/Admin/AdminNoticeRepository.php b/src/Admin/AdminNoticeRepository.php new file mode 100644 index 0000000..8e38708 --- /dev/null +++ b/src/Admin/AdminNoticeRepository.php @@ -0,0 +1,59 @@ + + */ + public function current(): array { + $notices = array(); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_imported'] ) ) { + $notices[] = new AdminNotice( 'success', __( 'The package JSON file was imported successfully.', 'wp-content-sync' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_import_error'] ) ) { + $notices[] = new AdminNotice( 'error', $this->queryValue( 'wpcs_import_error' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_settings_saved'] ) ) { + $notices[] = new AdminNotice( 'success', __( 'Settings saved.', 'wp-content-sync' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_connection_ok'] ) ) { + $notices[] = new AdminNotice( 'success', __( 'REST connection succeeded.', 'wp-content-sync' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_logs_cleared'] ) ) { + $notices[] = new AdminNotice( 'success', __( 'Logs cleared.', 'wp-content-sync' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_connection_error'] ) ) { + $notices[] = new AdminNotice( 'error', $this->queryValue( 'wpcs_connection_error' ) ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + if ( isset( $_GET['wpcs_export_error'] ) ) { + $notices[] = new AdminNotice( 'error', $this->queryValue( 'wpcs_export_error' ) ); + } + + return $notices; + } + + private function queryValue( string $key ): string { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args. + return sanitize_text_field( wp_unslash( $_GET[ $key ] ?? '' ) ); + } +} diff --git a/tests/Unit/Admin/AdminNoticeRepositoryTest.php b/tests/Unit/Admin/AdminNoticeRepositoryTest.php new file mode 100644 index 0000000..1b428b8 --- /dev/null +++ b/tests/Unit/Admin/AdminNoticeRepositoryTest.php @@ -0,0 +1,51 @@ +current(); + + self::assertSame( 'success', $notices[0]->type() ); + self::assertSame( 'The package JSON file was imported successfully.', $notices[0]->message() ); + } + + public function test_it_sanitizes_error_notices(): void { + $_GET['wpcs_import_error'] = ''; + $notices = ( new AdminNoticeRepository() )->current(); + + self::assertSame( 'error', $notices[0]->type() ); + self::assertSame( 'Bad package', $notices[0]->message() ); + } + + public function test_it_builds_settings_connection_logs_and_export_notices(): void { + $_GET['wpcs_settings_saved'] = '1'; + $_GET['wpcs_connection_ok'] = '1'; + $_GET['wpcs_logs_cleared'] = '1'; + $_GET['wpcs_connection_error'] = 'REST authentication failed.'; + $_GET['wpcs_export_error'] = 'Export failed.'; + + $notices = ( new AdminNoticeRepository() )->current(); + + self::assertSame( 'Settings saved.', $notices[0]->message() ); + self::assertSame( 'REST connection succeeded.', $notices[1]->message() ); + self::assertSame( 'Logs cleared.', $notices[2]->message() ); + self::assertSame( 'REST authentication failed.', $notices[3]->message() ); + self::assertSame( 'Export failed.', $notices[4]->message() ); + } +} -- 2.52.0 From c84df8b5f283734793f4e21a5f6e850c910e592d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 7 May 2026 06:28:41 -0500 Subject: [PATCH 28/30] feat: save admin sync settings --- src/Admin/SettingsController.php | 46 ++++++++++++++ src/Plugin.php | 14 +++++ tests/Unit/Admin/SettingsControllerTest.php | 70 +++++++++++++++++++++ tests/Unit/PluginTest.php | 19 ++++++ 4 files changed, 149 insertions(+) create mode 100644 src/Admin/SettingsController.php create mode 100644 tests/Unit/Admin/SettingsControllerTest.php diff --git a/src/Admin/SettingsController.php b/src/Admin/SettingsController.php new file mode 100644 index 0000000..53ff75b --- /dev/null +++ b/src/Admin/SettingsController.php @@ -0,0 +1,46 @@ +settings_repository = $settings_repository; + } + + public function register(): void { + add_action( 'admin_post_wpcs_save_settings', array( $this, 'handleSave' ) ); + } + + public function handleSave(): void { + if ( ! current_user_can( 'manage_options' ) ) { + throw new \RuntimeException( 'You do not have permission to save WP Content Sync settings.' ); + } + + if ( ! check_admin_referer( 'wpcs_save_settings', 'wpcs_settings_nonce' ) ) { + throw new \RuntimeException( 'The settings save request could not be verified.' ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified above; Settings::fromArray sanitizes the full option payload. + $data = isset( $_POST['wpcs_settings'] ) ? wp_unslash( $_POST['wpcs_settings'] ) : array(); + $data = is_array( $data ) ? $data : array(); + + $this->settings_repository->save( Settings::fromArray( $data ) ); + + wp_safe_redirect( + add_query_arg( + array( 'wpcs_settings_saved' => '1' ), + admin_url( 'admin.php?page=wp-content-sync' ) + ) + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index e5dfa05..b37c33c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,6 +9,7 @@ namespace WPContentSync; use WPContentSync\Admin\AdminPage; use WPContentSync\Admin\FileImportController; +use WPContentSync\Admin\SettingsController; use WPContentSync\Content\ContentHandlerRegistry; use WPContentSync\Content\ContentRecordNormalizer; use WPContentSync\Content\MediaContentHandler; @@ -169,6 +170,15 @@ final class Plugin { } ); + $container->factory( + SettingsController::class, + static function () use ( $container ): SettingsController { + return new SettingsController( + $container->get( SettingsRepository::class ) + ); + } + ); + $container->factory( RestTransportClient::class, static function (): RestTransportClient { @@ -209,8 +219,12 @@ final class Plugin { /** @var RestPackageController $rest_package_controller */ $rest_package_controller = $this->container->get( RestPackageController::class ); + /** @var SettingsController $settings_controller */ + $settings_controller = $this->container->get( SettingsController::class ); + $admin_page->register(); $file_import_controller->register(); $rest_package_controller->register(); + $settings_controller->register(); } } diff --git a/tests/Unit/Admin/SettingsControllerTest.php b/tests/Unit/Admin/SettingsControllerTest.php new file mode 100644 index 0000000..dc354c7 --- /dev/null +++ b/tests/Unit/Admin/SettingsControllerTest.php @@ -0,0 +1,70 @@ + 'debug', + 'conflict_strategy' => 'manual_review', + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'source_url' => 'https://example.test', + 'destination_url' => 'https://staging.example.test', + ), + ), + ); + + ( new SettingsController( new SettingsRepository() ) )->handleSave(); + + self::assertSame( 'debug', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['logging_level'] ); + self::assertSame( 'manual_review', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['conflict_strategy'] ); + self::assertStringContainsString( 'wpcs_settings_saved=1', $GLOBALS['wpcs_redirect_location'] ); + } + + public function test_it_rejects_users_without_manage_options(): void { + $GLOBALS['wpcs_current_user_can']['manage_options'] = false; + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'You do not have permission to save WP Content Sync settings.' ); + + ( new SettingsController( new SettingsRepository() ) )->handleSave(); + } + + public function test_it_rejects_invalid_nonces(): void { + $GLOBALS['wpcs_nonce_valid']['wpcs_save_settings']['wpcs_settings_nonce'] = false; + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'The settings save request could not be verified.' ); + + ( new SettingsController( new SettingsRepository() ) )->handleSave(); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 5427d14..99db60a 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -4,6 +4,7 @@ namespace WPContentSync\Tests\Unit; use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; +use WPContentSync\Admin\SettingsController; use WPContentSync\Content\ContentHandlerRegistry; use WPContentSync\Content\ContentRecordNormalizer; use WPContentSync\Content\MediaContentHandler; @@ -51,6 +52,15 @@ class PluginTest extends TestCase { ); } + public function test_it_registers_settings_controller(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( + SettingsController::class, + $container->get( SettingsController::class ) + ); + } + public function test_it_registers_rest_transport_services(): void { $container = $this->getPluginContainer( Plugin::create() ); @@ -85,6 +95,15 @@ class PluginTest extends TestCase { self::assertArrayHasKey( 'rest_api_init', $GLOBALS['wpcs_test_actions'] ); } + public function test_it_hooks_settings_controller_on_register(): void { + unset( $GLOBALS['wpcs_test_actions'] ); + + $plugin = Plugin::create(); + $plugin->register(); + + self::assertArrayHasKey( 'admin_post_wpcs_save_settings', $GLOBALS['wpcs_test_actions'] ); + } + private function getPluginContainer( Plugin $plugin ): Container { $reflection = new \ReflectionClass( $plugin ); $property = $reflection->getProperty( 'container' ); -- 2.52.0 From 4d83bd4a483c9ee24e28c76188a1116ac9047507 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 7 May 2026 06:36:01 -0500 Subject: [PATCH 29/30] feat: render admin settings workflow --- templates/admin/dashboard.php | 180 ++++++++++++++++----- tests/Unit/Admin/DashboardTemplateTest.php | 29 +++- 2 files changed, 170 insertions(+), 39 deletions(-) diff --git a/templates/admin/dashboard.php b/templates/admin/dashboard.php index c59eddd..8c405ea 100644 --- a/templates/admin/dashboard.php +++ b/templates/admin/dashboard.php @@ -10,22 +10,33 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } + +$sync_pairs = $settings->syncPairs(); + +if ( array() === $sync_pairs ) { + $sync_pairs = array( + array( + 'name' => '', + 'source_url' => '', + 'destination_url' => '', + 'username' => '', + 'application_password' => '', + 'default_direction' => 'push', + 'content_types' => array( 'posts', 'terms', 'media', 'custom_post_types' ), + 'url_mappings' => array( + array( + 'source' => '', + 'destination' => '', + ), + ), + ), + ); +} ?>

-
-

- -

-
-
@@ -40,35 +51,130 @@ if ( ! defined( 'ABSPATH' ) ) {
-

-
- - - - - - - - - - - - - - - - - - -
syncPairs() ) ); ?>
loggingLevel() ); ?>
+

+
+ + + +

+ $pair ) : ?> +
+

automaticUrlReplacementEnabled() - ? __( 'Enabled', 'wp-content-sync' ) - : __( 'Disabled', 'wp-content-sync' ) + printf( + /* translators: %d: sync pair number. */ + esc_html__( 'Sync Pair %d', 'wp-content-sync' ), + (int) $index + 1 ); ?> -

conflictStrategy() ); ?>
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +

diff --git a/tests/Unit/Admin/DashboardTemplateTest.php b/tests/Unit/Admin/DashboardTemplateTest.php index 8681013..6acfc5e 100644 --- a/tests/Unit/Admin/DashboardTemplateTest.php +++ b/tests/Unit/Admin/DashboardTemplateTest.php @@ -30,8 +30,33 @@ class DashboardTemplateTest extends TestCase { self::assertStringContainsString( 'The package JSON file was validated successfully.', $output ); } - private function renderDashboard(): string { - $settings = Settings::fromArray( array() ); + public function test_it_renders_settings_form_with_nonce_and_escaped_pair_values(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'source_url' => 'https://example.test', + 'destination_url' => 'https://staging.example.test', + 'username' => 'codex', + ), + ), + ) + ); + + $output = $this->renderDashboard( $settings ); + + self::assertStringContainsString( 'action="https://example.test/wp-admin/admin-post.php"', $output ); + self::assertStringContainsString( 'name="action" value="wpcs_save_settings"', $output ); + self::assertStringContainsString( 'name="wpcs_settings_nonce"', $output ); + self::assertStringContainsString( 'Staging', $output ); + self::assertStringNotContainsString( 'Staging', $output ); + self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][application_password]"', $output ); + self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][url_mappings][0][source]"', $output ); + } + + private function renderDashboard( ?Settings $settings = null ): string { + $settings = $settings ?? Settings::fromArray( array() ); ob_start(); include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php'; -- 2.52.0 From 3f643d9e411eddd90f2f2a656740ac227425f590 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Thu, 7 May 2026 06:42:40 -0500 Subject: [PATCH 30/30] feat: add connection diagnostics --- src/Admin/ConnectionTestController.php | 100 +++++++++++++++++ src/Plugin.php | 16 +++ templates/admin/dashboard.php | 34 ++++++ .../Admin/ConnectionTestControllerTest.php | 106 ++++++++++++++++++ tests/Unit/Admin/DashboardTemplateTest.php | 21 ++++ tests/Unit/PluginTest.php | 19 ++++ 6 files changed, 296 insertions(+) create mode 100644 src/Admin/ConnectionTestController.php create mode 100644 tests/Unit/Admin/ConnectionTestControllerTest.php diff --git a/src/Admin/ConnectionTestController.php b/src/Admin/ConnectionTestController.php new file mode 100644 index 0000000..598da3c --- /dev/null +++ b/src/Admin/ConnectionTestController.php @@ -0,0 +1,100 @@ +settings_repository = $settings_repository; + $this->transport_client = $transport_client; + $this->logger = $logger; + } + + public function register(): void { + add_action( 'admin_post_wpcs_test_connection', array( $this, 'handleTest' ) ); + } + + public function handleTest(): void { + if ( ! current_user_can( 'manage_options' ) ) { + throw new \RuntimeException( 'You do not have permission to test WP Content Sync connections.' ); + } + + if ( ! check_admin_referer( 'wpcs_test_connection', 'wpcs_connection_nonce' ) ) { + throw new \RuntimeException( 'The connection test request could not be verified.' ); + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above. + $pair_index = isset( $_POST['pair_index'] ) ? max( 0, (int) sanitize_text_field( wp_unslash( $_POST['pair_index'] ) ) ) : 0; + $pairs = $this->settings_repository->get()->syncPairs(); + $pair = $pairs[ $pair_index ] ?? null; + + if ( ! is_array( $pair ) ) { + $this->redirectWithError( 'The selected sync pair could not be found.' ); + return; + } + + try { + $this->transport_client->testConnection( + (string) $pair['destination_url'], + (string) $pair['username'], + (string) $pair['application_password'] + ); + + $this->logger->info( + 'REST connection test succeeded.', + array( + 'pair_index' => $pair_index, + 'destination_url' => (string) $pair['destination_url'], + 'username' => (string) $pair['username'], + ) + ); + + wp_safe_redirect( + add_query_arg( + array( 'wpcs_connection_ok' => '1' ), + admin_url( 'admin.php?page=wp-content-sync' ) + ) + ); + } catch ( RestTransportException $exception ) { + $this->logger->warning( + 'REST connection test failed.', + array( + 'pair_index' => $pair_index, + 'destination_url' => (string) $pair['destination_url'], + 'username' => (string) $pair['username'], + 'failure_code' => $exception->failureCode(), + ) + ); + + $this->redirectWithError( $exception->getMessage() ); + } + } + + private function redirectWithError( string $message ): void { + wp_safe_redirect( + add_query_arg( + array( 'wpcs_connection_error' => sanitize_text_field( $message ) ), + admin_url( 'admin.php?page=wp-content-sync' ) + ) + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index b37c33c..243b7e4 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,6 +8,7 @@ namespace WPContentSync; use WPContentSync\Admin\AdminPage; +use WPContentSync\Admin\ConnectionTestController; use WPContentSync\Admin\FileImportController; use WPContentSync\Admin\SettingsController; use WPContentSync\Content\ContentHandlerRegistry; @@ -186,6 +187,17 @@ final class Plugin { } ); + $container->factory( + ConnectionTestController::class, + static function () use ( $container ): ConnectionTestController { + return new ConnectionTestController( + $container->get( SettingsRepository::class ), + $container->get( RestTransportClient::class ), + $container->get( LoggerInterface::class ) + ); + } + ); + $container->factory( RestPackageController::class, static function () use ( $container ): RestPackageController { @@ -222,9 +234,13 @@ final class Plugin { /** @var SettingsController $settings_controller */ $settings_controller = $this->container->get( SettingsController::class ); + /** @var ConnectionTestController $connection_test_controller */ + $connection_test_controller = $this->container->get( ConnectionTestController::class ); + $admin_page->register(); $file_import_controller->register(); $rest_package_controller->register(); $settings_controller->register(); + $connection_test_controller->register(); } } diff --git a/templates/admin/dashboard.php b/templates/admin/dashboard.php index 8c405ea..b93d458 100644 --- a/templates/admin/dashboard.php +++ b/templates/admin/dashboard.php @@ -51,6 +51,20 @@ if ( array() === $sync_pairs ) { + + +
+

+
+ + + + +
+

+
+ +

@@ -176,6 +190,26 @@ if ( array() === $sync_pairs ) { +

+

+ $pair ) : ?> +
+ + + +

+ +

+ +
+ +

diff --git a/tests/Unit/Admin/ConnectionTestControllerTest.php b/tests/Unit/Admin/ConnectionTestControllerTest.php new file mode 100644 index 0000000..1fe3b85 --- /dev/null +++ b/tests/Unit/Admin/ConnectionTestControllerTest.php @@ -0,0 +1,106 @@ +storePair(); + $GLOBALS['wpcs_current_user_can']['manage_options'] = true; + $GLOBALS['wpcs_nonce_valid']['wpcs_test_connection']['wpcs_connection_nonce'] = true; + $_POST['pair_index'] = '0'; + + $this->controller()->handleTest(); + + self::assertStringContainsString( 'wpcs_connection_ok=1', $GLOBALS['wpcs_redirect_location'] ); + self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] ); + self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] ); + } + + public function test_it_rejects_users_without_manage_options(): void { + $GLOBALS['wpcs_current_user_can']['manage_options'] = false; + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'You do not have permission to test WP Content Sync connections.' ); + + $this->controller()->handleTest(); + } + + public function test_it_rejects_invalid_nonces(): void { + $GLOBALS['wpcs_nonce_valid']['wpcs_test_connection']['wpcs_connection_nonce'] = false; + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'The connection test request could not be verified.' ); + + $this->controller()->handleTest(); + } + + public function test_it_redirects_failures_without_leaking_application_passwords(): void { + $this->storePair(); + $GLOBALS['wpcs_current_user_can']['manage_options'] = true; + $GLOBALS['wpcs_nonce_valid']['wpcs_test_connection']['wpcs_connection_nonce'] = true; + $GLOBALS['wpcs_http_response'] = array( + 'response' => array( 'code' => 401 ), + 'body' => '{"message":"Unauthorized"}', + ); + $_POST['pair_index'] = '0'; + + $this->controller()->handleTest(); + + self::assertStringContainsString( 'wpcs_connection_error=', $GLOBALS['wpcs_redirect_location'] ); + self::assertStringNotContainsString( 'app-pass', $GLOBALS['wpcs_redirect_location'] ); + self::assertStringNotContainsString( 'app-pass', wp_json_encode( $GLOBALS['wpcs_test_options'][ OptionLogger::OPTION_NAME ] ) ); + } + + private function controller(): ConnectionTestController { + return new ConnectionTestController( + new SettingsRepository(), + new RestTransportClient(), + new OptionLogger() + ); + } + + private function storePair(): void { + update_option( + SettingsRepository::OPTION_NAME, + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'source_url' => 'https://source.test', + 'destination_url' => 'https://destination.test', + 'username' => 'codex', + 'application_password' => 'app-pass', + ), + ), + ), + false + ); + } +} diff --git a/tests/Unit/Admin/DashboardTemplateTest.php b/tests/Unit/Admin/DashboardTemplateTest.php index 6acfc5e..eef6562 100644 --- a/tests/Unit/Admin/DashboardTemplateTest.php +++ b/tests/Unit/Admin/DashboardTemplateTest.php @@ -55,6 +55,27 @@ class DashboardTemplateTest extends TestCase { self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][url_mappings][0][source]"', $output ); } + public function test_it_renders_connection_diagnostics_for_each_pair(): void { + $settings = Settings::fromArray( + array( + 'sync_pairs' => array( + array( + 'name' => 'Staging', + 'destination_url' => 'https://staging.example.test', + 'username' => 'codex', + ), + ), + ) + ); + + $output = $this->renderDashboard( $settings ); + + self::assertStringContainsString( 'name="action" value="wpcs_test_connection"', $output ); + self::assertStringContainsString( 'name="wpcs_connection_nonce"', $output ); + self::assertStringContainsString( 'name="pair_index" value="0"', $output ); + self::assertStringContainsString( 'Test REST Connection', $output ); + } + private function renderDashboard( ?Settings $settings = null ): string { $settings = $settings ?? Settings::fromArray( array() ); diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 99db60a..1c0d509 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -4,6 +4,7 @@ namespace WPContentSync\Tests\Unit; use PHPUnit\Framework\TestCase; use WPContentSync\Admin\FileImportController; +use WPContentSync\Admin\ConnectionTestController; use WPContentSync\Admin\SettingsController; use WPContentSync\Content\ContentHandlerRegistry; use WPContentSync\Content\ContentRecordNormalizer; @@ -61,6 +62,15 @@ class PluginTest extends TestCase { ); } + public function test_it_registers_connection_test_controller(): void { + $container = $this->getPluginContainer( Plugin::create() ); + + self::assertInstanceOf( + ConnectionTestController::class, + $container->get( ConnectionTestController::class ) + ); + } + public function test_it_registers_rest_transport_services(): void { $container = $this->getPluginContainer( Plugin::create() ); @@ -104,6 +114,15 @@ class PluginTest extends TestCase { self::assertArrayHasKey( 'admin_post_wpcs_save_settings', $GLOBALS['wpcs_test_actions'] ); } + public function test_it_hooks_connection_test_controller_on_register(): void { + unset( $GLOBALS['wpcs_test_actions'] ); + + $plugin = Plugin::create(); + $plugin->register(); + + self::assertArrayHasKey( 'admin_post_wpcs_test_connection', $GLOBALS['wpcs_test_actions'] ); + } + private function getPluginContainer( Plugin $plugin ): Container { $reflection = new \ReflectionClass( $plugin ); $property = $reflection->getProperty( 'container' ); -- 2.52.0