916 lines
26 KiB
Markdown
916 lines
26 KiB
Markdown
# 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.
|