Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 592e6e7403 | |||
| 6d11934fcc | |||
| c66501d0e5 | |||
| 7a30bbf1de | |||
| 5c0b22f4c4 | |||
| b176a37457 | |||
| 90cb78b712 | |||
| 52543aab2d | |||
| 8c3773f040 | |||
| e3d48f2383 | |||
| 3c7ad655c0 | |||
| 428c64a46a | |||
| e082f9c275 | |||
| cce40907d5 | |||
| 90b56e13bb | |||
| 76b614e9e3 | |||
| a9f719c408 | |||
| 2202804b15 | |||
| 35b9f29f41 | |||
| 49d3f5792c | |||
| 82efe41d90 |
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.
|
||||
@@ -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' )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user