21 Commits

Author SHA1 Message Date
Keith Solomon 592e6e7403 feat: import taxonomy term records 2026-04-29 20:32:56 -05:00
Keith Solomon 6d11934fcc feat: import post content records 2026-04-28 18:22:01 -05:00
Keith Solomon c66501d0e5 feat: normalize content records 2026-04-28 18:13:44 -05:00
Keith Solomon 7a30bbf1de test: add wordpress content stubs 2026-04-28 18:09:42 -05:00
Keith Solomon 5c0b22f4c4 feat: add content handler registry 2026-04-28 13:55:09 -05:00
Keith Solomon b176a37457 feat: add sync context and operation state 2026-04-28 13:51:38 -05:00
Keith Solomon 90cb78b712 feat: add sync result value object 2026-04-28 13:42:32 -05:00
Keith Solomon 52543aab2d docs: add sync engine implementation plan 2026-04-28 13:37:31 -05:00
Keith Solomon 8c3773f040 feat: wire rest transport services 2026-04-28 13:04:39 -05:00
Keith Solomon e3d48f2383 feat: add rest package endpoints 2026-04-28 13:00:31 -05:00
Keith Solomon 3c7ad655c0 feat: add rest transport client 2026-04-28 12:38:59 -05:00
Keith Solomon 428c64a46a feat: add rest transport exception 2026-04-28 06:24:02 -05:00
Keith Solomon e082f9c275 docs: add rest transport implementation plan 2026-04-28 06:21:09 -05:00
Keith Solomon cce40907d5 fix: handle invalid package uploads 2026-04-26 20:45:18 -05:00
Keith Solomon 90b56e13bb feat: wire file transport services 2026-04-26 20:37:35 -05:00
Keith Solomon 76b614e9e3 feat: guard admin package imports 2026-04-26 20:32:04 -05:00
Keith Solomon a9f719c408 feat: add json file transport 2026-04-26 20:26:06 -05:00
Keith Solomon 2202804b15 feat: add package checksum validation 2026-04-26 20:11:50 -05:00
Keith Solomon 35b9f29f41 feat: add content package schema validator 2026-04-26 20:07:36 -05:00
Keith Solomon 49d3f5792c feat: add content package value object 2026-04-26 20:05:59 -05:00
Keith Solomon 82efe41d90 docs: add file transport implementation plan 2026-04-26 20:03:02 -05:00
45 changed files with 7534 additions and 6 deletions
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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.
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Transport\RestTransportException;
class RestTransportExceptionTest extends TestCase {
public function test_it_exposes_transport_failure_context(): void {
$exception = RestTransportException::connectionFailed(
'Connection timed out.',
array( 'url' => '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
<?php
/**
* Typed REST transport failure.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
final class RestTransportException extends \RuntimeException {
/** @var array<string, mixed> */
private array $context;
private string $failure_code;
/**
* @param array<string, mixed> $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<string, mixed> $context Failure context.
*/
public static function connectionFailed( string $message, array $context = array() ): self {
return new self( 'connection_failed', $message, $context );
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function authenticationFailed( string $message, array $context = array() ): self {
return new self( 'authentication_failed', $message, $context );
}
/**
* @param array<string, mixed> $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<string, mixed>
*/
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
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Transport\RestTransportException;
class RestTransportClientTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_http_response'], $GLOBALS['wpcs_last_http_request'] );
parent::tearDown();
}
public function test_it_tests_connections_with_application_password_auth(): void {
$client = new RestTransportClient();
self::assertTrue( $client->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
<?php
/**
* REST package transport client.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
final class RestTransportClient {
public function testConnection( string $base_url, string $username, string $application_password ): bool {
$response = wp_remote_get(
$this->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<string, mixed>
*/
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<string, mixed> $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
<?php
namespace WPContentSync\Tests\Unit\Rest;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Rest\RestPackageController;
class RestPackageControllerTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_rest_routes'], $GLOBALS['wpcs_current_user_can'] );
parent::tearDown();
}
public function test_it_registers_status_and_package_routes(): void {
$controller = new RestPackageController( new PackageValidator() );
$controller->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<string, mixed>
*/
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
<?php
/**
* REST package receive/status controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Rest;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
final class RestPackageController {
private PackageValidator $validator;
public function __construct( PackageValidator $validator ) {
$this->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<string, mixed>
*/
public function status(): array {
return array(
'ok' => true,
'plugin' => 'wp-content-sync',
'version' => WPCS_VERSION,
);
}
/**
* @param array<string, mixed> $request Request data.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
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.
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* Admin file import controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Transport\FileTransportInterface;
final class FileImportController {
private FileTransportInterface $transport;
private LoggerInterface $logger;
public function __construct( FileTransportInterface $transport, LoggerInterface $logger ) {
$this->transport = $transport;
$this->logger = $logger;
}
public function register(): void {
add_action( 'admin_post_wpcs_import_package', array( $this, 'handleImport' ) );
}
public function handleImport(): void {
if ( ! current_user_can( 'manage_options' ) ) {
throw new \RuntimeException( 'You do not have permission to import content packages.' );
}
if ( ! check_admin_referer( 'wpcs_import_package', 'wpcs_import_package_nonce' ) ) {
throw new \RuntimeException( 'The import request could not be verified.' );
}
if ( ! isset( $_FILES['wpcs_package_file']['tmp_name'], $_FILES['wpcs_package_file']['error'] ) ) {
throw new \RuntimeException( 'Choose a package JSON file before importing.' );
}
if ( UPLOAD_ERR_OK !== (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.' );
}
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.',
array(
'schema_version' => $package->schemaVersion(),
'manifest' => $package->manifest(),
)
);
$this->redirectToDashboard(
array(
'wpcs_imported' => '1',
)
);
}
/**
* @param array<string, string> $args Redirect query args.
*/
private function redirectToDashboard( array $args ): void {
wp_safe_redirect(
add_query_arg(
$args,
admin_url( 'admin.php?page=wp-content-sync' )
)
);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/**
* Content package handler boundary.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
interface ContentHandlerInterface {
public function bucket(): string;
/**
* @param array<int, array<string, mixed>> $records Package records for this handler bucket.
*/
public function importRecords( array $records, SyncContext $context ): SyncResult;
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* Ordered content handler registry.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
final class ContentHandlerRegistry {
private const PACKAGE_ORDER = array(
'custom_post_types',
'terms',
'posts',
'media',
);
/** @var array<string, ContentHandlerInterface> */
private array $handlers = array();
/**
* @param array<int, ContentHandlerInterface> $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<int, ContentHandlerInterface>
*/
public function ordered(): array {
$ordered = array();
foreach ( self::PACKAGE_ORDER as $bucket ) {
if ( isset( $this->handlers[ $bucket ] ) ) {
$ordered[] = $this->handlers[ $bucket ];
}
}
return $ordered;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* Typed content import failure.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
final class ContentImportException extends \RuntimeException {
private string $bucket;
/** @var array<string, mixed> */
private array $record;
/**
* @param array<string, mixed> $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<string, mixed>
*/
public function record(): array {
return $this->record;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* Normalizes package content records for handlers.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
final class ContentRecordNormalizer {
/**
* @param array<string, mixed> $record Raw post record.
* @return array<string, mixed>
*/
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<string, mixed> $record Raw term record.
* @return array<string, mixed>
*/
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<string, mixed> $record Raw media record.
* @return array<string, mixed>
*/
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<string, mixed>
*/
private function arrayValue( $value ): array {
return is_array( $value ) ? $value : array();
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* Imports post content records.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;
use WPContentSync\Url\UrlTransformer;
final class PostContentHandler implements ContentHandlerInterface {
private ContentRecordNormalizer $normalizer;
private UrlTransformer $url_transformer;
private MetadataUrlTransformer $metadata_transformer;
private LoggerInterface $logger;
public function __construct(
ContentRecordNormalizer $normalizer,
UrlTransformer $url_transformer,
MetadataUrlTransformer $metadata_transformer,
LoggerInterface $logger
) {
$this->normalizer = $normalizer;
$this->url_transformer = $url_transformer;
$this->metadata_transformer = $metadata_transformer;
$this->logger = $logger;
}
public function bucket(): string {
return 'posts';
}
/**
* @param array<int, array<string, mixed>> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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 );
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* Imports taxonomy term records.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;
use WPContentSync\Url\UrlTransformer;
final class TermContentHandler implements ContentHandlerInterface {
private ContentRecordNormalizer $normalizer;
private UrlTransformer $url_transformer;
private MetadataUrlTransformer $metadata_transformer;
private LoggerInterface $logger;
public function __construct(
ContentRecordNormalizer $normalizer,
UrlTransformer $url_transformer,
MetadataUrlTransformer $metadata_transformer,
LoggerInterface $logger
) {
$this->normalizer = $normalizer;
$this->url_transformer = $url_transformer;
$this->metadata_transformer = $metadata_transformer;
$this->logger = $logger;
}
public function bucket(): string {
return 'terms';
}
/**
* @param array<int, array<string, mixed>> $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<string, mixed> $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<string, mixed> $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<string, int>|\WP_Error $result Term save result.
* @param array<string, mixed> $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<string, mixed> $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 );
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Immutable sync package value object.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class ContentPackage {
public const SCHEMA_VERSION = '1.0';
/** @var array<string, mixed> */
private array $data;
/**
* @param array<string, mixed> $data Package data.
*/
private function __construct( array $data ) {
$this->data = $data;
}
/**
* @param array<string, mixed> $data Package data.
*/
public static function fromArray( array $data ): self {
return new self(
array(
'schema_version' => (string) ( $data['schema_version'] ?? self::SCHEMA_VERSION ),
'generated_at' => (string) ( $data['generated_at'] ?? '' ),
'source' => self::arrayValue( $data['source'] ?? array() ),
'destination' => self::arrayValue( $data['destination'] ?? array() ),
'manifest' => self::arrayValue( $data['manifest'] ?? array() ),
'records' => self::arrayValue( $data['records'] ?? array() ),
'checksums' => self::arrayValue( $data['checksums'] ?? array() ),
)
);
}
public function schemaVersion(): string {
return $this->data['schema_version'];
}
public function generatedAt(): string {
return $this->data['generated_at'];
}
/**
* @return array<string, mixed>
*/
public function source(): array {
return $this->data['source'];
}
/**
* @return array<string, mixed>
*/
public function destination(): array {
return $this->data['destination'];
}
/**
* @return array<string, mixed>
*/
public function manifest(): array {
return $this->data['manifest'];
}
/**
* @return array<string, mixed>
*/
public function records(): array {
return $this->data['records'];
}
/**
* @return array<string, mixed>
*/
public function checksums(): array {
return $this->data['checksums'];
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return $this->data;
}
/**
* @param mixed $value Value to normalize.
*
* @return array<string, mixed>
*/
private static function arrayValue( $value ): array {
return is_array( $value ) ? $value : array();
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* Package checksum utility.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageChecksum {
/**
* @param array<string, mixed> $records Package records.
*/
public static function records( array $records ): string {
return 'sha256:' . hash( 'sha256', self::canonicalJson( $records ) );
}
/**
* @param array<string, mixed> $records Package records.
* @param string $checksum Expected checksum.
*/
public static function verifyRecords( array $records, string $checksum ): bool {
return hash_equals( self::records( $records ), $checksum );
}
/**
* @param mixed $value Value to encode.
*/
private static function canonicalJson( $value ): string {
$normalized = self::sortKeys( $value );
$json = wp_json_encode( $normalized );
if ( false === $json ) {
throw new \RuntimeException( 'Unable to encode package records for checksum.' );
}
return $json;
}
/**
* @param mixed $value Value to normalize.
*
* @return mixed
*/
private static function sortKeys( $value ) {
if ( ! is_array( $value ) ) {
return $value;
}
if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
ksort( $value );
}
foreach ( $value as $key => $child ) {
$value[ $key ] = self::sortKeys( $child );
}
return $value;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Package validation result.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageValidationResult {
/** @var array<int, string> */
private array $errors;
/**
* @param array<int, string> $errors Validation errors.
*/
private function __construct( array $errors ) {
$this->errors = array_values( $errors );
}
public static function valid(): self {
return new self( array() );
}
/**
* @param array<int, string> $errors Validation errors.
*/
public static function invalid( array $errors ): self {
return new self( $errors );
}
public function isValid(): bool {
return array() === $this->errors;
}
/**
* @return array<int, string>
*/
public function errors(): array {
return $this->errors;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
/**
* Versioned package schema validator.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageValidator {
private const RECORD_BUCKETS = array(
'posts',
'terms',
'media',
'custom_post_types',
);
/**
* @param array<string, mixed> $data Decoded package data.
*/
public function validate( array $data ): PackageValidationResult {
$errors = array();
foreach ( array( 'schema_version', 'generated_at', 'source', 'destination', 'manifest', 'records', 'checksums' ) as $field ) {
if ( ! array_key_exists( $field, $data ) ) {
$errors[] = $field . ' is required.';
}
}
if ( isset( $data['schema_version'] ) && ContentPackage::SCHEMA_VERSION !== $data['schema_version'] ) {
$errors[] = 'schema_version must be ' . ContentPackage::SCHEMA_VERSION . '.';
}
if ( isset( $data['source'] ) && ! is_array( $data['source'] ) ) {
$errors[] = 'source must be an object.';
}
if ( isset( $data['destination'] ) && ! is_array( $data['destination'] ) ) {
$errors[] = 'destination must be an object.';
}
if ( isset( $data['manifest'] ) && ! is_array( $data['manifest'] ) ) {
$errors[] = 'manifest must be an object.';
}
if ( isset( $data['records'] ) && ! is_array( $data['records'] ) ) {
$errors[] = 'records must be an object.';
}
if ( isset( $data['checksums'] ) && ! is_array( $data['checksums'] ) ) {
$errors[] = 'checksums must be an object.';
}
$record_bucket_errors = array();
if ( isset( $data['manifest'], $data['records'] ) && is_array( $data['manifest'] ) && is_array( $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 );
}
/**
* @param array<string, mixed> $manifest Package manifest.
* @param array<string, mixed> $records Package records.
*
* @return array<int, string>
*/
private function validateRecordBuckets( array $manifest, array $records ): array {
$errors = array();
foreach ( self::RECORD_BUCKETS as $bucket ) {
if ( ! isset( $records[ $bucket ] ) || ! is_array( $records[ $bucket ] ) ) {
$errors[] = 'records.' . $bucket . ' is required and must be an array.';
continue;
}
if ( ! isset( $manifest[ $bucket ] ) || ! is_int( $manifest[ $bucket ] ) ) {
$errors[] = 'manifest.' . $bucket . ' is required and must be an integer.';
continue;
}
if ( count( $records[ $bucket ] ) !== $manifest[ $bucket ] ) {
$errors[] = 'manifest.' . $bucket . ' must match records.' . $bucket . ' count.';
}
}
return $errors;
}
}
+56
View File
@@ -8,9 +8,15 @@
namespace WPContentSync;
use WPContentSync\Admin\AdminPage;
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;
@@ -54,6 +60,48 @@ 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(
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 {
@@ -71,6 +119,14 @@ 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 );
/** @var RestPackageController $rest_package_controller */
$rest_package_controller = $this->container->get( RestPackageController::class );
$admin_page->register();
$file_import_controller->register();
$rest_package_controller->register();
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* REST package receive/status controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Rest;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
final class RestPackageController {
private PackageValidator $validator;
public function __construct( PackageValidator $validator ) {
$this->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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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();
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* Immutable sync operation context.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncContext {
private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );
private string $direction;
private string $operation_id;
private string $source_url;
private string $destination_url;
private string $conflict_strategy;
/** @var array<string, string> */
private array $url_mappings;
/**
* @param array<string, string> $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<string, mixed> $source Source site metadata.
* @param array<string, mixed> $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<string, string>
*/
public function urlMappings(): array {
return $this->url_mappings;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* Immutable sync operation progress state.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncOperationState {
private string $operation_id;
private string $status;
private string $current_bucket;
private int $processed;
private int $total;
private function __construct( string $operation_id, string $status, string $current_bucket, int $processed, int $total ) {
$this->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<string, mixed> $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<string, mixed>
*/
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,
);
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* Immutable sync operation result.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncResult {
private bool $successful;
private int $created;
private int $updated;
private int $skipped;
private int $conflicts;
/** @var array<int, string> */
private array $errors;
/**
* @param array<int, string> $errors Error messages.
*/
private function __construct( bool $successful, int $created, int $updated, int $skipped, int $conflicts, array $errors ) {
$this->successful = $successful;
$this->created = max( 0, $created );
$this->updated = max( 0, $updated );
$this->skipped = max( 0, $skipped );
$this->conflicts = max( 0, $conflicts );
$this->errors = array_values( array_map( 'strval', $errors ) );
}
/**
* @param array<string, int> $counts Result counts.
*/
public static function success( array $counts = array() ): self {
return new self(
true,
(int) ( $counts['created'] ?? 0 ),
(int) ( $counts['updated'] ?? 0 ),
(int) ( $counts['skipped'] ?? 0 ),
(int) ( $counts['conflicts'] ?? 0 ),
array()
);
}
/**
* @param array<int, string> $errors Error messages.
*/
public static function failure( array $errors ): self {
return new self( false, 0, 0, 0, 0, $errors );
}
/**
* @param array<int, self> $results Results to merge.
*/
public static function merge( array $results ): self {
$successful = true;
$created = 0;
$updated = 0;
$skipped = 0;
$conflicts = 0;
$errors = array();
foreach ( $results as $result ) {
$successful = $successful && $result->isSuccessful();
$created += $result->created();
$updated += $result->updated();
$skipped += $result->skipped();
$conflicts += $result->conflicts();
$errors = array_merge( $errors, $result->errors() );
}
return new self( $successful, $created, $updated, $skipped, $conflicts, $errors );
}
public function isSuccessful(): bool {
return $this->successful;
}
public function created(): int {
return $this->created;
}
public function updated(): int {
return $this->updated;
}
public function skipped(): int {
return $this->skipped;
}
public function conflicts(): int {
return $this->conflicts;
}
/**
* @return array<int, string>
*/
public function errors(): array {
return $this->errors;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return array(
'successful' => $this->successful,
'created' => $this->created,
'updated' => $this->updated,
'skipped' => $this->skipped,
'conflicts' => $this->conflicts,
'errors' => $this->errors,
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Sync operation state persistence.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncStateRepository {
private const DEFAULT_EXPIRATION = 86400;
public function save( SyncOperationState $state ): void {
set_transient( $this->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;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* File package transport boundary.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
interface FileTransportInterface {
public function export( ContentPackage $package ): string;
public function import( string $contents ): ContentPackage;
}
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* JSON file transport implementation.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
final class JsonFileTransport implements FileTransportInterface {
private PackageValidator $validator;
public function __construct( PackageValidator $validator ) {
$this->validator = $validator;
}
public function export( ContentPackage $package ): string {
$json = wp_json_encode( $package->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( false === $json ) {
throw new \RuntimeException( 'Unable to encode content package JSON.' );
}
return $json;
}
public function import( string $contents ): ContentPackage {
$decoded = json_decode( $contents, true );
if ( ! is_array( $decoded ) ) {
throw new \InvalidArgumentException( 'The selected file is not valid JSON.' );
}
$result = $this->validator->validate( $decoded );
if ( ! $result->isValid() ) {
throw new \InvalidArgumentException( implode( ' ', $result->errors() ) );
}
return ContentPackage::fromArray( $decoded );
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* REST package transport client.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
final class RestTransportClient {
public function testConnection( string $base_url, string $username, string $application_password ): bool {
$response = wp_remote_get(
$this->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<string, mixed>
*/
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<string, mixed> $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.';
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* Typed REST transport failure.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
final class RestTransportException extends \RuntimeException {
/** @var array<string, mixed> */
private array $context;
private string $failure_code;
/**
* @param array<string, mixed> $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<string, mixed> $context Failure context.
*/
public static function connectionFailed( string $message, array $context = array() ): self {
return new self( 'connection_failed', $message, $context );
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function authenticationFailed( string $message, array $context = array() ): self {
return new self( 'authentication_failed', $message, $context );
}
/**
* @param array<string, mixed> $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<string, mixed>
*/
public function context(): array {
return $this->context;
}
}
+25
View File
@@ -26,6 +26,20 @@ if ( ! defined( 'ABSPATH' ) ) {
</p>
</div>
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Displays redirect status only. ?>
<?php if ( isset( $_GET['wpcs_import_error'] ) ) : ?>
<div class="notice notice-error">
<p><?php echo esc_html( sanitize_text_field( wp_unslash( $_GET['wpcs_import_error'] ) ) ); ?></p>
</div>
<?php endif; ?>
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Displays redirect status only. ?>
<?php if ( isset( $_GET['wpcs_imported'] ) ) : ?>
<div class="notice notice-success">
<p><?php echo esc_html__( 'The package JSON file was validated successfully.', 'wp-content-sync' ); ?></p>
</div>
<?php endif; ?>
<h2><?php echo esc_html__( 'Current Defaults', 'wp-content-sync' ); ?></h2>
<table class="widefat striped">
<tbody>
@@ -55,4 +69,15 @@ if ( ! defined( 'ABSPATH' ) ) {
</tr>
</tbody>
</table>
<h2><?php echo esc_html__( 'File Package Import', 'wp-content-sync' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data">
<input type="hidden" name="action" value="wpcs_import_package" />
<?php wp_nonce_field( 'wpcs_import_package', 'wpcs_import_package_nonce' ); ?>
<p>
<label for="wpcs-package-file"><?php echo esc_html__( 'Package JSON file', 'wp-content-sync' ); ?></label>
<input id="wpcs-package-file" type="file" name="wpcs_package_file" accept="application/json,.json" />
</p>
<?php submit_button( __( 'Validate Package', 'wp-content-sync' ), 'secondary' ); ?>
</form>
</div>
@@ -0,0 +1,41 @@
<?php
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Settings\Settings;
class DashboardTemplateTest extends TestCase {
protected function tearDown(): void {
$_GET = array();
parent::tearDown();
}
public function test_it_renders_import_error_notices(): void {
$_GET['wpcs_import_error'] = 'The selected file is not valid JSON.';
$output = $this->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();
}
}
@@ -0,0 +1,179 @@
<?php
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\FileImportController;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\JsonFileTransport;
class FileImportControllerTest extends TestCase {
/** @var array<int, string> */
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'] );
}
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() ),
new class() implements LoggerInterface {
/**
* @param array<string, mixed> $context Context.
*/
public function error( string $message, array $context = array() ): void {}
/**
* @param array<string, mixed> $context Context.
*/
public function warning( string $message, array $context = array() ): void {}
/**
* @param array<string, mixed> $context Context.
*/
public function info( string $message, array $context = array() ): void {}
/**
* @param array<string, mixed> $context Context.
*/
public function debug( string $message, array $context = array() ): void {}
}
);
}
private function validJson(): string {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
$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;
}
}
@@ -0,0 +1,54 @@
<?php
/**
* Tests for content handler registry.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentHandlerInterface;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
class ContentHandlerRegistryTest extends TestCase {
public function test_it_returns_handlers_in_package_order(): void {
$posts = $this->handler( 'posts' );
$terms = $this->handler( 'terms' );
$media = $this->handler( 'media' );
$registry = new ContentHandlerRegistry( array( $media, $posts, $terms ) );
self::assertSame( array( $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<int, array<string, mixed>> $records Package records.
*/
public function importRecords( array $records, SyncContext $context ): SyncResult {
return SyncResult::success( array( 'skipped' => count( $records ) ) );
}
};
}
}
@@ -0,0 +1,120 @@
<?php
/**
* Tests for content record normalization.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentRecordNormalizer;
class ContentRecordNormalizerTest extends TestCase {
public function test_it_normalizes_post_records(): void {
$normalizer = new ContentRecordNormalizer();
$record = $normalizer->post(
array(
'id' => '42',
'post_type' => '<b>post</b>',
'post_title' => "Hello\nWorld",
'post_content' => '<p>Keep HTML</p>',
'post_excerpt' => '<em>Excerpt</em>',
'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' => '<p>Keep HTML</p>',
'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' => '<b>category</b>',
'name' => "News\nUpdates",
'slug' => 'news-updates',
'description' => '<p>Keep description HTML</p>',
'parent' => '2',
'meta' => array(
'landing_url' => 'https://source.test/news',
),
)
);
self::assertSame(
array(
'id' => 9,
'taxonomy' => 'category',
'name' => 'News Updates',
'slug' => 'news-updates',
'description' => '<p>Keep description HTML</p>',
'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
);
}
}
@@ -0,0 +1,218 @@
<?php
/**
* Tests for post content imports.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\PostContentHandler;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer;
class PostContentHandlerTest extends TestCase {
/** @var array<int, array<string, mixed>> */
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' => '<a href="https://source.test/page">Page</a>',
'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( '<a href="https://destination.test/page">Page</a>', 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<string, mixed> $overrides Record overrides.
* @return array<string, mixed>
*/
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<int, array<string, mixed>> */
private array $logs;
/**
* @param array<int, array<string, mixed>> $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<string, mixed> $context Context.
*/
private function record( string $level, string $message, array $context ): void {
$this->logs[] = array(
'level' => $level,
'message' => $message,
'context' => $context,
);
}
};
}
}
@@ -0,0 +1,209 @@
<?php
/**
* Tests for term content imports.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\TermContentHandler;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer;
class TermContentHandlerTest extends TestCase {
/** @var array<int, array<string, mixed>> */
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' => '<a href="https://source.test/news">News</a>',
'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( '<a href="https://destination.test/news">News</a>', $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<string, mixed> $overrides Record overrides.
* @return array<string, mixed>
*/
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<int, array<string, mixed>> */
private array $logs;
/**
* @param array<int, array<string, mixed>> $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<string, mixed> $context Context.
*/
private function record( string $level, string $message, array $context ): void {
$this->logs[] = array(
'level' => $level,
'message' => $message,
'context' => $context,
);
}
};
}
}
@@ -0,0 +1,132 @@
<?php
/**
* Tests for WordPress content stubs.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
class WordPressContentStubTest extends TestCase {
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_test_posts'],
$GLOBALS['wpcs_test_next_post_id'],
$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'],
$GLOBALS['wpcs_test_post_filter'],
$GLOBALS['wpcs_test_force_delete']
);
parent::tearDown();
}
public function test_post_stubs_insert_update_and_read_posts(): void {
$post_id = wp_insert_post(
array(
'post_title' => 'Hello',
'post_type' => 'post',
),
true
);
wp_update_post(
array(
'ID' => $post_id,
'post_title' => 'Updated',
),
true
);
self::assertSame( 'Updated', get_post( $post_id )['post_title'] );
}
public function test_meta_stubs_replace_values(): void {
update_post_meta( 10, '_source_url', 'https://source.test/page' );
update_post_meta( 10, '_source_url', 'https://destination.test/page' );
self::assertSame( array( 'https://destination.test/page' ), get_post_meta( 10, '_source_url', 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']
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
class ContentPackageTest extends TestCase {
public function test_it_normalizes_package_arrays(): void {
$package = ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 1,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => array(
'posts' => array(
array(
'id' => 123,
'type' => 'post',
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
),
'checksums' => array(
'records' => 'sha256:abc123',
),
)
);
self::assertSame( '1.0', $package->schemaVersion() );
self::assertSame( '2026-04-26T20:30:00+00:00', $package->generatedAt() );
self::assertSame( 'https://example.test', $package->source()['site_url'] );
self::assertSame( 'https://staging.example.test', $package->destination()['site_url'] );
self::assertSame( 1, $package->manifest()['posts'] );
self::assertSame( 123, $package->records()['posts'][0]['id'] );
self::assertSame( 'sha256:abc123', $package->checksums()['records'] );
self::assertSame( $package->toArray(), ContentPackage::fromArray( $package->toArray() )->toArray() );
}
}
@@ -0,0 +1,72 @@
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
class PackageChecksumTest extends TestCase {
public function test_it_creates_stable_record_checksums(): void {
$records = array(
'posts' => array(
array(
'title' => 'Example',
'id' => 123,
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
self::assertSame(
PackageChecksum::records( $records ),
PackageChecksum::records( $records )
);
self::assertStringStartsWith( 'sha256:', PackageChecksum::records( $records ) );
}
public function test_it_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' ) );
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
class PackageValidatorTest extends TestCase {
public function test_it_accepts_valid_packages(): void {
$result = ( new PackageValidator() )->validate( $this->validPackage() );
self::assertTrue( $result->isValid() );
self::assertSame( array(), $result->errors() );
}
public function test_it_rejects_missing_required_fields(): void {
$package = $this->validPackage();
unset( $package['records'] );
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'records is required.' ), $result->errors() );
}
public function test_it_rejects_unsupported_schema_versions(): void {
$package = $this->validPackage();
$package['schema_version'] = '2.0';
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'schema_version must be 1.0.' ), $result->errors() );
}
public function test_it_rejects_missing_record_buckets(): void {
$package = $this->validPackage();
unset( $package['records']['media'] );
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'records.media is required and must be an array.' ), $result->errors() );
}
public function test_it_rejects_manifest_counts_that_do_not_match_records(): void {
$package = $this->validPackage();
$package['manifest']['posts'] = 2;
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'manifest.posts must match records.posts count.' ), $result->errors() );
}
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<string, mixed>
*/
private function validPackage(): array {
return array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 1,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => array(
'posts' => array(
array(
'id' => 123,
'type' => 'post',
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
),
'checksums' => array(
'records' => PackageChecksum::records(
array(
'posts' => array(
array(
'id' => 123,
'type' => 'post',
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
)
),
),
);
}
}
+45
View File
@@ -3,12 +3,22 @@
namespace WPContentSync\Tests\Unit;
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() );
@@ -21,6 +31,41 @@ 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 )
);
}
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' );
@@ -0,0 +1,169 @@
<?php
/**
* Tests for REST package receive/status controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Rest;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
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'] );
parent::tearDown();
}
public function test_it_hooks_route_registration_to_rest_api_init(): void {
$controller = new RestPackageController( new PackageValidator() );
$controller->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<string, mixed> */
private array $params;
/**
* @param array<string, mixed> $params Request params.
*/
public function __construct( array $params ) {
$this->params = $params;
}
/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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 ),
),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Tests for sync operation context.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncContext;
class SyncContextTest extends TestCase {
public function test_it_builds_import_context_from_package_sites(): void {
$context = SyncContext::forImport(
array( 'site_url' => 'https://source.test' ),
array( 'site_url' => 'https://destination.test' ),
'last_write_wins',
'operation-1'
);
self::assertSame( 'import', $context->direction() );
self::assertSame( 'operation-1', $context->operationId() );
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
self::assertSame( 'https://source.test', $context->sourceUrl() );
self::assertSame( 'https://destination.test', $context->destinationUrl() );
self::assertSame(
array( 'https://source.test' => 'https://destination.test' ),
$context->urlMappings()
);
}
public function test_it_falls_back_to_last_write_wins_for_invalid_strategy(): void {
$context = SyncContext::forImport( array(), array(), 'surprise', 'operation-2' );
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* Tests for sync result summaries.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncResult;
class SyncResultTest extends TestCase {
public function test_it_tracks_successful_counts(): void {
$result = SyncResult::success(
array(
'created' => 2,
'updated' => 3,
'skipped' => 1,
'conflicts' => 1,
)
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 2, $result->created() );
self::assertSame( 3, $result->updated() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( array(), $result->errors() );
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() );
}
}
@@ -0,0 +1,46 @@
<?php
/**
* Tests for sync state persistence.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncOperationState;
use WPContentSync\Sync\SyncStateRepository;
class SyncStateRepositoryTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_test_transients'], $GLOBALS['wpcs_test_transient_expiration'] );
parent::tearDown();
}
public function test_it_saves_and_reads_operation_state(): void {
$repository = new SyncStateRepository();
$state = SyncOperationState::running( 'operation-1', 'posts', 2, 10 );
$repository->save( $state );
$loaded = $repository->get( 'operation-1' );
self::assertInstanceOf( SyncOperationState::class, $loaded );
self::assertSame( 'operation-1', $loaded->operationId() );
self::assertSame( 'posts', $loaded->currentBucket() );
self::assertSame( 2, $loaded->processed() );
self::assertSame( 10, $loaded->total() );
self::assertSame( 'running', $loaded->status() );
}
public function test_it_deletes_operation_state(): void {
$repository = new SyncStateRepository();
$repository->save( SyncOperationState::completed( 'operation-1', 10, 10 ) );
$repository->delete( 'operation-1' );
self::assertNull( $repository->get( 'operation-1' ) );
self::assertArrayNotHasKey( 'wpcs_sync_state_operation-1', $GLOBALS['wpcs_test_transient_expiration'] );
}
}
@@ -0,0 +1,79 @@
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\JsonFileTransport;
class JsonFileTransportTest extends TestCase {
public function test_it_exports_pretty_json_packages(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$json = $transport->export( $this->package() );
self::assertStringContainsString( "\n", $json );
self::assertStringContainsString( '"schema_version": "1.0"', $json );
}
public function test_it_imports_valid_json_packages(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$package = $transport->import( $transport->export( $this->package() ) );
self::assertSame( '1.0', $package->schemaVersion() );
self::assertSame( 'https://example.test', $package->source()['site_url'] );
}
public function test_it_rejects_invalid_json(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'The selected file is not valid JSON.' );
$transport->import( '{"schema_version":' );
}
public function test_it_rejects_schema_errors(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'records is required.' );
$transport->import( '{"schema_version":"1.0"}' );
}
private function package(): ContentPackage {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 0,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => $records,
'checksums' => array(
'records' => PackageChecksum::records( $records ),
),
)
);
}
}
@@ -0,0 +1,99 @@
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Transport\RestTransportException;
class RestTransportClientTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_http_response'], $GLOBALS['wpcs_last_http_request'] );
parent::tearDown();
}
public function test_it_tests_connections_with_application_password_auth(): void {
$client = new RestTransportClient();
self::assertTrue( $client->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 ),
),
)
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Transport\RestTransportException;
class RestTransportExceptionTest extends TestCase {
public function test_it_exposes_transport_failure_context(): void {
$exception = RestTransportException::connectionFailed(
'Connection timed out.',
array( 'url' => '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() );
}
}
+726 -3
View File
@@ -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.
@@ -35,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.
@@ -47,6 +75,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.
@@ -143,11 +187,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 );
}
}
@@ -190,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;
}
@@ -322,7 +397,655 @@ 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( 'wp_remote_get' ) ) {
/**
* Minimal HTTP GET helper for unit tests.
*
* @param string $url Request URL.
* @param array<string, mixed> $args Request arguments.
* @return array<string, mixed>|\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<string, mixed> $args Request arguments.
* @return array<string, mixed>|\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<string, mixed> $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<string, mixed> $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( 'register_rest_route' ) ) {
/**
* Minimal REST route registrar for unit tests.
*
* @param string $rest_namespace REST namespace.
* @param string $route REST route.
* @param array<string, mixed> $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( 'wp_insert_post' ) ) {
/**
* Minimal post inserter for unit tests.
*
* @param array<string, mixed> $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 ( 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 {
$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<string, mixed> $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<string, mixed>|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<string, mixed> $args Query args.
* @return array<int, object>
*/
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();
foreach ( $values as $value ) {
if ( (string) $args['meta_value'] === (string) $value ) {
return true;
}
}
return false;
}
);
}
return array_values( array_map( static fn( array $post ): object => (object) $post, $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<string, mixed> $args Term args.
* @return array<string, int>|\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<string, mixed> $args Term args.
* @return array<string, int>|\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_terms' ) ) {
/**
* Minimal terms query for unit tests.
*
* @param array<string, mixed> $args Query args.
* @return array<int, object>
*/
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.
*
* @param string $field Field name.
* @param mixed $value Field value.
* @param string $taxonomy Taxonomy.
* @return array<string, mixed>|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 (object) $term;
}
}
return false;
}
}
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.
*
* @param int $object_id Object ID.
* @param string|array<mixed> $terms Terms.
* @param string $taxonomy Taxonomy.
* @return array<int, mixed>
*/
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<string, mixed> $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.
*
* @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<string, string> $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 );
}
}
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 '<input type="hidden" name="' . esc_attr( $name ) . '" value="' . esc_attr( $action ) . '" />';
}
}
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 '<button class="button button-' . esc_attr( $type ) . '" type="submit">' . esc_html( $text ) . '</button>';
}
}