add file transport implementation #2

Open
keith wants to merge 30 commits from feature/url-transformer-plan into main
59 changed files with 10547 additions and 54 deletions
@@ -0,0 +1,849 @@
# Admin Workflow and Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete the plugin's usable admin workflow for configuring sync pairs, testing connections, importing/exporting package files, viewing operation results/logs, and running a final smoke checklist with hardened security and user-facing errors.
**Architecture:** Phase 6 keeps the current import-first sync engine and adds admin workflows around it. Settings stay option-backed, state-changing admin actions use dedicated controllers with capability and nonce checks, and templates receive pre-sanitized view data while still escaping every output at render time.
**Tech Stack:** PHP 7.4, WordPress admin APIs, WordPress HTTP/REST APIs, PHPUnit, PHPStan, PHPCS/WPCS, existing `SettingsRepository`, `RestTransportClient`, `JsonFileTransport`, `SyncEngine`, and `OptionLogger`.
---
## File Structure
- `src/Settings/Settings.php`: expand typed settings to include sync pair credentials, URL mappings, selected content types, default direction, and retention/debug controls.
- `src/Admin/AdminNotice.php`: value object for redirect-driven admin notices with `type`, `message`, and optional result context.
- `src/Admin/AdminNoticeRepository.php`: converts `$_GET` query flags into safe notices for templates.
- `src/Admin/SettingsController.php`: handles settings saves through `admin_post_wpcs_save_settings`.
- `src/Admin/ConnectionTestController.php`: handles connection diagnostics through `admin_post_wpcs_test_connection`.
- `src/Admin/FileExportController.php`: exports a valid empty package scaffold for a configured pair until full extraction is implemented.
- `src/Admin/LogController.php`: handles log clearing through `admin_post_wpcs_clear_logs`.
- `src/Admin/AdminPage.php`: registers the new controllers and passes settings/notices/logs to the dashboard template.
- `src/Logging/OptionLogger.php`: add safe read/clear helpers for operation history.
- `src/Transport/RestTransportClient.php`: treat REST `accepted: false` JSON responses as remote rejections.
- `templates/admin/dashboard.php`: replace the placeholder dashboard with forms for settings, diagnostics, file import/export, operation history, and smoke guidance.
- `tests/Unit/Admin/*Test.php`: unit coverage for notices, settings save, connection diagnostics, export, log clearing, and dashboard rendering.
- `tests/Unit/SettingsTest.php`: settings expansion and sanitization coverage.
- `tests/Unit/Transport/RestTransportClientTest.php`: REST rejected-body coverage.
- `docs/smoke/phase-6-admin-hardening.md`: manual smoke checklist and known local environment notes.
---
## Task 1: Expand Settings for Admin Workflow
**Files:**
- Modify: `src/Settings/Settings.php`
- Modify: `tests/Unit/SettingsTest.php`
- [ ] **Step 1: Write failing settings tests**
Add tests to `tests/Unit/SettingsTest.php`:
```php
public function test_it_sanitizes_full_admin_workflow_settings(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => '<b>Production to Staging</b>',
'source_url' => 'https://example.test/',
'destination_url' => 'https://staging.example.test/',
'username' => '<script>codex</script>',
'application_password' => 'secret app password',
'default_direction' => 'push',
'content_types' => array( 'posts', 'terms', 'media', 'bad_type' ),
'url_mappings' => array(
array(
'source' => 'https://example.test',
'destination' => 'https://staging.example.test',
),
),
),
),
'log_retention' => '50',
'debug_logging' => '1',
)
);
$pairs = $settings->syncPairs();
self::assertSame( 'Production to Staging', $pairs[0]['name'] );
self::assertSame( 'https://example.test/', $pairs[0]['source_url'] );
self::assertSame( 'https://staging.example.test/', $pairs[0]['destination_url'] );
self::assertSame( 'codex', $pairs[0]['username'] );
self::assertSame( 'secret app password', $pairs[0]['application_password'] );
self::assertSame( 'push', $pairs[0]['default_direction'] );
self::assertSame( array( 'posts', 'terms', 'media' ), $pairs[0]['content_types'] );
self::assertSame(
array(
array(
'source' => 'https://example.test',
'destination' => 'https://staging.example.test',
),
),
$pairs[0]['url_mappings']
);
self::assertSame( 50, $settings->logRetention() );
self::assertTrue( $settings->debugLoggingEnabled() );
}
```
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter SettingsTest`
Expected: FAIL because `logRetention()`, `debugLoggingEnabled()`, and expanded sync pair keys do not exist.
- [ ] **Step 3: Implement settings expansion**
Update `Settings` to support:
```php
private const DIRECTIONS = array( 'push', 'pull' );
private const CONTENT_TYPES = array( 'posts', 'terms', 'media', 'custom_post_types' );
private const MIN_LOGS = 10;
private const MAX_LOGS = 1000;
public function logRetention(): int;
public function debugLoggingEnabled(): bool;
```
Update `sanitizeSyncPairs()` so each pair returns:
```php
array(
'name' => $name,
'source_url' => $source_url,
'destination_url' => $destination_url,
'username' => $username,
'application_password' => $application_password,
'default_direction' => $direction,
'content_types' => $content_types,
'url_mappings' => $url_mappings,
)
```
Rules:
- Preserve legacy `name`, `source_url`, and `destination_url` behavior.
- Sanitize `username` with `sanitize_text_field()`.
- Sanitize `application_password` with `sanitize_text_field()` and never log it.
- Keep only allowed content types.
- Keep only URL mappings where both source and destination are non-empty URLs.
- Clamp log retention between `10` and `1000`.
- Default direction to `push`.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter SettingsTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Settings/Settings.php tests/Unit/SettingsTest.php
git commit -m "feat: expand admin sync settings"
```
---
## Task 2: Admin Notices
**Files:**
- Create: `src/Admin/AdminNotice.php`
- Create: `src/Admin/AdminNoticeRepository.php`
- Create: `tests/Unit/Admin/AdminNoticeRepositoryTest.php`
- [ ] **Step 1: Write failing notice tests**
Create `tests/Unit/Admin/AdminNoticeRepositoryTest.php`:
```php
<?php
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\AdminNoticeRepository;
class AdminNoticeRepositoryTest extends TestCase {
protected function tearDown(): void {
$_GET = array();
parent::tearDown();
}
public function test_it_builds_import_success_notices(): void {
$_GET['wpcs_imported'] = '1';
$notices = ( new AdminNoticeRepository() )->current();
self::assertSame( 'success', $notices[0]->type() );
self::assertSame( 'The package JSON file was imported successfully.', $notices[0]->message() );
}
public function test_it_sanitizes_error_notices(): void {
$_GET['wpcs_import_error'] = '<script>Bad package</script>';
$notices = ( new AdminNoticeRepository() )->current();
self::assertSame( 'error', $notices[0]->type() );
self::assertSame( 'Bad package', $notices[0]->message() );
}
}
```
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter AdminNoticeRepositoryTest`
Expected: FAIL with missing classes.
- [ ] **Step 3: Implement notices**
Create `AdminNotice` with:
```php
public function __construct( string $type, string $message );
public function type(): string;
public function message(): string;
```
Allowed types: `success`, `warning`, `error`, `info`; fallback to `info`.
Create `AdminNoticeRepository::current(): array` returning notices for:
- `wpcs_imported=1`: success, imported successfully.
- `wpcs_import_error`: sanitized error message.
- `wpcs_settings_saved=1`: success, settings saved.
- `wpcs_connection_ok=1`: success, REST connection succeeded.
- `wpcs_connection_error`: sanitized error message.
- `wpcs_logs_cleared=1`: success, logs cleared.
- `wpcs_export_error`: sanitized error message.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter AdminNoticeRepositoryTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Admin/AdminNotice.php src/Admin/AdminNoticeRepository.php tests/Unit/Admin/AdminNoticeRepositoryTest.php
git commit -m "feat: add admin notices"
```
---
## Task 3: Settings Save Controller
**Files:**
- Create: `src/Admin/SettingsController.php`
- Modify: `src/Plugin.php`
- Create: `tests/Unit/Admin/SettingsControllerTest.php`
- Modify: `tests/Unit/PluginTest.php`
- [ ] **Step 1: Write failing controller tests**
Create `tests/Unit/Admin/SettingsControllerTest.php`:
```php
<?php
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\SettingsController;
use WPContentSync\Settings\SettingsRepository;
class SettingsControllerTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_nonce_valid'], $GLOBALS['wpcs_redirect_location'], $GLOBALS['wpcs_test_options'] );
$_POST = array();
parent::tearDown();
}
public function test_it_saves_settings_with_nonce_and_capability(): void {
$GLOBALS['wpcs_current_user_can']['manage_options'] = true;
$GLOBALS['wpcs_nonce_valid']['wpcs_save_settings']['wpcs_settings_nonce'] = true;
$_POST['wpcs_settings'] = array(
'logging_level' => 'debug',
'conflict_strategy' => 'manual_review',
);
( new SettingsController( new SettingsRepository() ) )->handleSave();
self::assertSame( 'debug', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['logging_level'] );
self::assertStringContainsString( 'wpcs_settings_saved=1', $GLOBALS['wpcs_redirect_location'] );
}
}
```
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter SettingsControllerTest`
Expected: FAIL with missing `SettingsController`.
- [ ] **Step 3: Implement controller**
Create `SettingsController`:
```php
final class SettingsController {
private SettingsRepository $settings_repository;
public function __construct( SettingsRepository $settings_repository ) {}
public function register(): void;
public function handleSave(): void;
}
```
Behavior:
- `register()` hooks `admin_post_wpcs_save_settings`.
- `handleSave()` requires `manage_options`.
- Verify nonce `wpcs_save_settings` / `wpcs_settings_nonce`.
- Read `$_POST['wpcs_settings']`, unslash with `wp_unslash()`, require array.
- Persist through `SettingsRepository::save( Settings::fromArray( $data ) )`.
- Redirect to dashboard with `wpcs_settings_saved=1`.
Update `Plugin::create()` to register `SettingsController`.
Update `Plugin::register()` to call `SettingsController::register()`.
Update `PluginTest` to assert the service exists and the hook is registered.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter "SettingsControllerTest|PluginTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Admin/SettingsController.php src/Plugin.php tests/Unit/Admin/SettingsControllerTest.php tests/Unit/PluginTest.php
git commit -m "feat: save admin sync settings"
```
---
## Task 4: Dashboard Settings Form
**Files:**
- Modify: `templates/admin/dashboard.php`
- Modify: `src/Admin/AdminPage.php`
- Modify: `tests/Unit/Admin/DashboardTemplateTest.php`
- [ ] **Step 1: Write failing dashboard tests**
Add tests:
```php
public function test_it_renders_settings_form_with_nonce_and_escaped_pair_values(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => '<b>Staging</b>',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
'username' => 'codex',
),
),
)
);
$output = $this->renderDashboard( $settings );
self::assertStringContainsString( 'action="https://example.test/wp-admin/admin-post.php"', $output );
self::assertStringContainsString( 'name="action" value="wpcs_save_settings"', $output );
self::assertStringContainsString( 'Staging', $output );
self::assertStringNotContainsString( '<b>Staging</b>', $output );
self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][application_password]"', $output );
}
```
Update `renderDashboard()` to accept optional `Settings $settings`.
- [ ] **Step 2: Run test to verify failure**
Run: `composer test -- --filter DashboardTemplateTest`
Expected: FAIL because the dashboard does not render the settings form.
- [ ] **Step 3: Implement dashboard form**
Update `dashboard.php`:
- Replace the foundation notice with a "Configuration" section.
- Render a form posting to `admin-post.php`.
- Include `action=wpcs_save_settings`.
- Include nonce `wp_nonce_field( 'wpcs_save_settings', 'wpcs_settings_nonce' )`.
- Render existing sync pairs; if none exist, render one blank pair row.
- Fields per pair: name, source URL, destination URL, username, application password, default direction, content types, URL mappings.
- Never render saved application password values back into the password field; render placeholder text only.
- Escape every attribute with `esc_attr()` and every text node with `esc_html()`.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter DashboardTemplateTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add templates/admin/dashboard.php src/Admin/AdminPage.php tests/Unit/Admin/DashboardTemplateTest.php
git commit -m "feat: render admin settings workflow"
```
---
## Task 5: Connection Diagnostics
**Files:**
- Create: `src/Admin/ConnectionTestController.php`
- Modify: `src/Plugin.php`
- Create: `tests/Unit/Admin/ConnectionTestControllerTest.php`
- Modify: `tests/Unit/Admin/DashboardTemplateTest.php`
- Modify: `templates/admin/dashboard.php`
- [ ] **Step 1: Write failing diagnostics tests**
Create `ConnectionTestControllerTest` that injects `RestTransportClient`, posts `pair_index=0`, and verifies:
- capability and nonce are required.
- success redirects with `wpcs_connection_ok=1`.
- failures redirect with `wpcs_connection_error`.
- application password is never placed in redirect query args.
Use settings fixture:
```php
update_option(
SettingsRepository::OPTION_NAME,
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'destination_url' => 'https://destination.test',
'username' => 'codex',
'application_password' => 'app-pass',
),
),
),
false
);
```
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter ConnectionTestControllerTest`
Expected: FAIL with missing controller.
- [ ] **Step 3: Implement diagnostics controller**
Create `ConnectionTestController`:
- Hook `admin_post_wpcs_test_connection`.
- Require `manage_options`.
- Verify nonce `wpcs_test_connection` / `wpcs_connection_nonce`.
- Read `pair_index` as integer.
- Load selected pair from settings.
- Call `RestTransportClient::testConnection( destination_url, username, application_password )`.
- On success redirect with `wpcs_connection_ok=1`.
- On `RestTransportException` redirect with sanitized `wpcs_connection_error`.
- Log success/failure without credentials.
Update `Plugin` to register the controller.
Update dashboard to add a "Test REST Connection" button per pair with nonce.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter "ConnectionTestControllerTest|DashboardTemplateTest|PluginTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Admin/ConnectionTestController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/Admin/ConnectionTestControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php
git commit -m "feat: add connection diagnostics"
```
---
## Task 6: REST Transport Rejected Body Handling
**Files:**
- Modify: `src/Transport/RestTransportClient.php`
- Modify: `tests/Unit/Transport/RestTransportClientTest.php`
- [ ] **Step 1: Write failing REST client test**
Add:
```php
public function test_it_throws_when_receive_endpoint_returns_accepted_false(): void {
$GLOBALS['wpcs_http_response'] = array(
'response' => array( 'code' => 200 ),
'body' => '{"accepted":false,"import":{"errors":["Posts failed."]}}',
);
$client = new RestTransportClient();
$this->expectException( RestTransportException::class );
$this->expectExceptionMessage( 'Posts failed.' );
$client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() );
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `composer test -- --filter RestTransportClientTest`
Expected: FAIL because HTTP 200 currently counts as success.
- [ ] **Step 3: Implement body inspection**
Update `assertSuccessfulResponse()`:
- If status code is expected, decode body.
- If decoded body has `accepted === false`, throw `RestTransportException::remoteRejected()`.
- Prefer first string in `import.errors`, then first string in `errors`, then `message`, then fallback.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter RestTransportClientTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Transport/RestTransportClient.php tests/Unit/Transport/RestTransportClientTest.php
git commit -m "fix: reject failed rest imports"
```
---
## Task 7: Operation Logs and Clear Action
**Files:**
- Modify: `src/Logging/OptionLogger.php`
- Create: `src/Admin/LogController.php`
- Modify: `src/Plugin.php`
- Modify: `templates/admin/dashboard.php`
- Modify: `tests/Unit/OptionLoggerTest.php`
- Create: `tests/Unit/Admin/LogControllerTest.php`
- Modify: `tests/Unit/Admin/DashboardTemplateTest.php`
- [ ] **Step 1: Write failing logger/controller tests**
Add `OptionLoggerTest` coverage for:
```php
$logger = new OptionLogger( 10 );
$logger->info( 'Imported content package.' );
self::assertCount( 1, $logger->entries() );
$logger->clear();
self::assertSame( array(), $logger->entries() );
```
Create `LogControllerTest` verifying:
- capability and nonce are required.
- `handleClear()` clears `OptionLogger::OPTION_NAME`.
- redirect contains `wpcs_logs_cleared=1`.
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter "OptionLoggerTest|LogControllerTest"`
Expected: FAIL with missing methods/controller.
- [ ] **Step 3: Implement logs**
Update `OptionLogger`:
```php
public function entries(): array;
public function clear(): void;
```
Create `LogController`:
- Hook `admin_post_wpcs_clear_logs`.
- Require `manage_options`.
- Verify nonce `wpcs_clear_logs` / `wpcs_logs_nonce`.
- Clear logs.
- Redirect with `wpcs_logs_cleared=1`.
Update dashboard:
- Render a recent operation history table.
- Show timestamp, level, message, and redacted context summary.
- Add "Clear Logs" form with nonce.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter "OptionLoggerTest|LogControllerTest|DashboardTemplateTest|PluginTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Logging/OptionLogger.php src/Admin/LogController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/OptionLoggerTest.php tests/Unit/Admin/LogControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php
git commit -m "feat: add operation log controls"
```
---
## Task 8: File Export Scaffold
**Files:**
- Create: `src/Admin/FileExportController.php`
- Modify: `src/Plugin.php`
- Modify: `templates/admin/dashboard.php`
- Create: `tests/Unit/Admin/FileExportControllerTest.php`
- Modify: `tests/Unit/Admin/DashboardTemplateTest.php`
- [ ] **Step 1: Write failing export tests**
Create `FileExportControllerTest` verifying:
- capability and nonce are required.
- a configured pair exports a valid JSON package with all four record buckets.
- response headers include `Content-Type: application/json`.
- no content mutation occurs during export.
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter FileExportControllerTest`
Expected: FAIL with missing controller.
- [ ] **Step 3: Implement export scaffold**
Create `FileExportController`:
- Hook `admin_post_wpcs_export_package`.
- Require `manage_options`.
- Verify nonce `wpcs_export_package` / `wpcs_export_nonce`.
- Read `pair_index`.
- Build an empty `ContentPackage` using the selected pair:
- `source.site_url` from pair source URL.
- `destination.site_url` from pair destination URL.
- manifest counts all zero.
- records buckets all empty.
- checksum from `PackageChecksum::records()`.
- Export with `JsonFileTransport::export()`.
- Send JSON download headers.
Update dashboard with an "Export Empty Package" form and helper copy stating full extraction will be added in a later slice.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter "FileExportControllerTest|DashboardTemplateTest|PluginTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Admin/FileExportController.php src/Plugin.php templates/admin/dashboard.php tests/Unit/Admin/FileExportControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php tests/Unit/PluginTest.php
git commit -m "feat: add package export scaffold"
```
---
## Task 9: Import Result UI Hardening
**Files:**
- Modify: `src/Admin/FileImportController.php`
- Modify: `templates/admin/dashboard.php`
- Modify: `tests/Unit/Admin/FileImportControllerTest.php`
- Modify: `tests/Unit/Admin/DashboardTemplateTest.php`
- [ ] **Step 1: Write failing result tests**
Add file import test asserting success redirects include counts:
```php
self::assertStringContainsString( 'wpcs_created=0', $GLOBALS['wpcs_redirect_location'] );
self::assertStringContainsString( 'wpcs_updated=0', $GLOBALS['wpcs_redirect_location'] );
self::assertStringContainsString( 'wpcs_skipped=0', $GLOBALS['wpcs_redirect_location'] );
self::assertStringContainsString( 'wpcs_conflicts=0', $GLOBALS['wpcs_redirect_location'] );
```
Add dashboard test asserting the success notice includes created/updated/skipped/conflict counts from sanitized query args.
- [ ] **Step 2: Run tests to verify failure**
Run: `composer test -- --filter "FileImportControllerTest|DashboardTemplateTest"`
Expected: FAIL because result counts are not in redirects or UI.
- [ ] **Step 3: Implement result count redirects**
Update `FileImportController` success redirect:
```php
array(
'wpcs_imported' => '1',
'wpcs_created' => (string) $result->created(),
'wpcs_updated' => (string) $result->updated(),
'wpcs_skipped' => (string) $result->skipped(),
'wpcs_conflicts' => (string) $result->conflicts(),
)
```
Update dashboard to display counts with `absint()` and escaped labels.
- [ ] **Step 4: Run focused tests**
Run: `composer test -- --filter "FileImportControllerTest|DashboardTemplateTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Admin/FileImportController.php templates/admin/dashboard.php tests/Unit/Admin/FileImportControllerTest.php tests/Unit/Admin/DashboardTemplateTest.php
git commit -m "feat: show import result summaries"
```
---
## Task 10: Smoke Checklist Documentation
**Files:**
- Create: `docs/smoke/phase-6-admin-hardening.md`
- [ ] **Step 1: Create smoke checklist**
Create `docs/smoke/phase-6-admin-hardening.md` with:
```markdown
# Phase 6 Admin Hardening Smoke Checklist
## Environment
- WordPress site URL:
- Plugin branch/commit:
- PHP version:
- WordPress version:
## Checks
- [ ] Plugin activates without fatal errors.
- [ ] Tools -> Content Sync loads for an administrator.
- [ ] Non-administrators cannot access the dashboard.
- [ ] Settings save rejects missing/invalid nonce.
- [ ] Settings save persists sync pair name, URLs, username, content types, direction, and URL mappings.
- [ ] Saved application password is not rendered back into the password field.
- [ ] REST status endpoint rejects unauthenticated HTTP requests.
- [ ] REST status endpoint accepts authenticated requests when the server passes `Authorization` to PHP.
- [ ] Connection test succeeds with a valid application password.
- [ ] Connection test shows an actionable error for invalid credentials.
- [ ] Invalid package file import redirects with an error notice.
- [ ] Valid empty package import redirects with success and result counts.
- [ ] REST package POST accepts a valid package and includes import counts.
- [ ] REST package POST rejects invalid package data.
- [ ] Operation log table shows recent events with redacted credential-like fields.
- [ ] Clear logs requires nonce and removes log entries.
- [ ] Export scaffold downloads valid JSON.
## Local Notes
- On Herd/nginx, direct HTTP Basic auth may require forwarding the `Authorization` header. If HTTP application-password smoke returns 401 while internal REST dispatch passes, verify server auth header configuration before treating it as a plugin failure.
```
- [ ] **Step 2: Commit docs**
```bash
git add docs/smoke/phase-6-admin-hardening.md
git commit -m "docs: add admin hardening smoke checklist"
```
---
## Task 11: Full Phase 6 Verification
**Files:**
- Verify all files changed in Tasks 1-10.
- [ ] **Step 1: Run Composer validation**
Run: `composer validate --strict`
Expected: PASS with `./composer.json is valid`.
- [ ] **Step 2: Run PHPCS**
Run: `composer lint`
Expected: PASS with no PHPCS errors or warnings.
- [ ] **Step 3: Run PHPStan**
Run: `vendor\bin\phpstan analyse --memory-limit=1G`
Expected: PASS with `[OK] No errors`.
- [ ] **Step 4: Run PHPUnit**
Run: `composer test`
Expected: PASS with all unit tests passing.
- [ ] **Step 5: Copy runtime files to Herd test plugin**
Run:
```powershell
Copy-Item -Path src,templates,wp-content-sync.php,composer.json -Destination 'C:\Users\ksolo\Herd\basic-wp\wp-content\plugins\WP-Content-Sync' -Recurse -Force
```
Expected: command exits 0.
- [ ] **Step 6: Run manual smoke checklist**
Use `docs/smoke/phase-6-admin-hardening.md`.
Required checks before completion:
- Plugin active.
- Admin dashboard loads after login.
- Settings save works and rejects invalid nonce.
- Connection diagnostics show success/failure notices.
- Invalid file import redirects with error.
- Valid empty file import redirects with success counts.
- REST unauthenticated status returns 401.
- REST valid and invalid package behavior is verified by HTTP application password if server passes Basic auth, otherwise by internal REST dispatch plus documented server caveat.
- Logs render and clear.
- Export scaffold downloads valid JSON.
- [ ] **Step 7: Commit verification notes if changed**
If smoke notes are updated with environment results:
```bash
git add docs/smoke/phase-6-admin-hardening.md
git commit -m "docs: record phase 6 smoke results"
```
---
## Spec Coverage
- Sync pair configuration is covered by Tasks 1, 3, and 4.
- Credentials and authentication setup are covered by Tasks 1, 4, and 5, with password fields not rendered back to the browser.
- URL mapping configuration is covered by Task 1 and Task 4.
- Content type selection and default sync direction are covered by Task 1 and Task 4.
- Connection diagnostics are covered by Task 5.
- Import screens and user-facing import errors are covered by Tasks 2, 4, and 9.
- Export screen is covered by Task 8 as a valid package scaffold until full extraction is implemented.
- Operation history and debug/log controls are covered by Task 7.
- REST failure hardening is covered by Task 6.
- Nonces/capabilities for state-changing admin actions are covered by Tasks 3, 5, 7, 8, and existing file import tests.
- Final smoke/integration checklist is covered by Tasks 10 and 11.
## Deferred Work
- Full content extraction for non-empty package exports remains a later slice because Phase 5 built import orchestration and handlers, not source extraction orchestration.
- Background queues, progress polling, and cancelable long-running operations remain a later scalability slice.
- HTTP Basic authentication pass-through is a server configuration concern; plugin REST behavior remains covered by controller tests and internal REST dispatch smoke.
## Placeholder Scan
- No unresolved placeholder markers are intentionally included.
- Each task names exact files, expected failing tests, implementation behavior, verification commands, and commit messages.
- Deferred work is explicitly scoped with rationale and is not required for Phase 6 exit criteria in this implementation slice.
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 ## 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. 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 ## 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. 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 ## 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. 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.
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Admin notice value object.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
final class AdminNotice {
private const TYPES = array( 'success', 'warning', 'error', 'info' );
private string $type;
private string $message;
public function __construct( string $type, string $message ) {
$this->type = in_array( $type, self::TYPES, true ) ? $type : 'info';
$this->message = sanitize_text_field( $message );
}
public function type(): string {
return $this->type;
}
public function message(): string {
return $this->message;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Builds admin notices from redirect query args.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
final class AdminNoticeRepository {
/**
* @return array<int, AdminNotice>
*/
public function current(): array {
$notices = array();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_imported'] ) ) {
$notices[] = new AdminNotice( 'success', __( 'The package JSON file was imported successfully.', 'wp-content-sync' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_import_error'] ) ) {
$notices[] = new AdminNotice( 'error', $this->queryValue( 'wpcs_import_error' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_settings_saved'] ) ) {
$notices[] = new AdminNotice( 'success', __( 'Settings saved.', 'wp-content-sync' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_connection_ok'] ) ) {
$notices[] = new AdminNotice( 'success', __( 'REST connection succeeded.', 'wp-content-sync' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_logs_cleared'] ) ) {
$notices[] = new AdminNotice( 'success', __( 'Logs cleared.', 'wp-content-sync' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_connection_error'] ) ) {
$notices[] = new AdminNotice( 'error', $this->queryValue( 'wpcs_connection_error' ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
if ( isset( $_GET['wpcs_export_error'] ) ) {
$notices[] = new AdminNotice( 'error', $this->queryValue( 'wpcs_export_error' ) );
}
return $notices;
}
private function queryValue( string $key ): string {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads redirect-only admin notice query args.
return sanitize_text_field( wp_unslash( $_GET[ $key ] ?? '' ) );
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* Handles admin REST connection diagnostics.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Settings\SettingsRepository;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Transport\RestTransportException;
final class ConnectionTestController {
private SettingsRepository $settings_repository;
private RestTransportClient $transport_client;
private LoggerInterface $logger;
public function __construct(
SettingsRepository $settings_repository,
RestTransportClient $transport_client,
LoggerInterface $logger
) {
$this->settings_repository = $settings_repository;
$this->transport_client = $transport_client;
$this->logger = $logger;
}
public function register(): void {
add_action( 'admin_post_wpcs_test_connection', array( $this, 'handleTest' ) );
}
public function handleTest(): void {
if ( ! current_user_can( 'manage_options' ) ) {
throw new \RuntimeException( 'You do not have permission to test WP Content Sync connections.' );
}
if ( ! check_admin_referer( 'wpcs_test_connection', 'wpcs_connection_nonce' ) ) {
throw new \RuntimeException( 'The connection test request could not be verified.' );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above.
$pair_index = isset( $_POST['pair_index'] ) ? max( 0, (int) sanitize_text_field( wp_unslash( $_POST['pair_index'] ) ) ) : 0;
$pairs = $this->settings_repository->get()->syncPairs();
$pair = $pairs[ $pair_index ] ?? null;
if ( ! is_array( $pair ) ) {
$this->redirectWithError( 'The selected sync pair could not be found.' );
return;
}
try {
$this->transport_client->testConnection(
(string) $pair['destination_url'],
(string) $pair['username'],
(string) $pair['application_password']
);
$this->logger->info(
'REST connection test succeeded.',
array(
'pair_index' => $pair_index,
'destination_url' => (string) $pair['destination_url'],
'username' => (string) $pair['username'],
)
);
wp_safe_redirect(
add_query_arg(
array( 'wpcs_connection_ok' => '1' ),
admin_url( 'admin.php?page=wp-content-sync' )
)
);
} catch ( RestTransportException $exception ) {
$this->logger->warning(
'REST connection test failed.',
array(
'pair_index' => $pair_index,
'destination_url' => (string) $pair['destination_url'],
'username' => (string) $pair['username'],
'failure_code' => $exception->failureCode(),
)
);
$this->redirectWithError( $exception->getMessage() );
}
}
private function redirectWithError( string $message ): void {
wp_safe_redirect(
add_query_arg(
array( 'wpcs_connection_error' => sanitize_text_field( $message ) ),
admin_url( 'admin.php?page=wp-content-sync' )
)
);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
/**
* Admin file import controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncEngine;
use WPContentSync\Transport\FileTransportInterface;
final class FileImportController {
private FileTransportInterface $transport;
private LoggerInterface $logger;
private SyncEngine $sync_engine;
public function __construct( FileTransportInterface $transport, LoggerInterface $logger, SyncEngine $sync_engine ) {
$this->transport = $transport;
$this->logger = $logger;
$this->sync_engine = $sync_engine;
}
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;
}
$result = $this->sync_engine->importPackage( $package );
if ( ! $result->isSuccessful() ) {
$this->logger->error(
'Imported content package failed.',
$result->toArray()
);
$this->redirectToDashboard(
array(
'wpcs_import_error' => implode( ' ', $result->errors() ),
)
);
return;
}
$this->logger->info( 'Imported content package.', $result->toArray() );
$this->redirectToDashboard(
array(
'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' )
)
);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Handles admin settings saves.
*
* @package WPContentSync
*/
namespace WPContentSync\Admin;
use WPContentSync\Settings\Settings;
use WPContentSync\Settings\SettingsRepository;
final class SettingsController {
private SettingsRepository $settings_repository;
public function __construct( SettingsRepository $settings_repository ) {
$this->settings_repository = $settings_repository;
}
public function register(): void {
add_action( 'admin_post_wpcs_save_settings', array( $this, 'handleSave' ) );
}
public function handleSave(): void {
if ( ! current_user_can( 'manage_options' ) ) {
throw new \RuntimeException( 'You do not have permission to save WP Content Sync settings.' );
}
if ( ! check_admin_referer( 'wpcs_save_settings', 'wpcs_settings_nonce' ) ) {
throw new \RuntimeException( 'The settings save request could not be verified.' );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified above; Settings::fromArray sanitizes the full option payload.
$data = isset( $_POST['wpcs_settings'] ) ? wp_unslash( $_POST['wpcs_settings'] ) : array();
$data = is_array( $data ) ? $data : array();
$this->settings_repository->save( Settings::fromArray( $data ) );
wp_safe_redirect(
add_query_arg(
array( 'wpcs_settings_saved' => '1' ),
admin_url( 'admin.php?page=wp-content-sync' )
)
);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/**
* Content package handler boundary.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
interface ContentHandlerInterface {
public function bucket(): string;
/**
* @param array<int, array<string, mixed>> $records Package records for this handler bucket.
*/
public function importRecords( array $records, SyncContext $context ): SyncResult;
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* Ordered content handler registry.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
final class ContentHandlerRegistry {
private const PACKAGE_ORDER = array(
'custom_post_types',
'terms',
'posts',
'media',
);
/** @var array<string, ContentHandlerInterface> */
private array $handlers = array();
/**
* @param array<int, ContentHandlerInterface> $handlers Content handlers.
*/
public function __construct( array $handlers ) {
foreach ( $handlers as $handler ) {
$this->register( $handler );
}
}
private function register( ContentHandlerInterface $handler ): void {
$bucket = $handler->bucket();
if ( isset( $this->handlers[ $bucket ] ) ) {
throw new \InvalidArgumentException( sprintf( 'Handler bucket "%s" is already registered.', $bucket ) );
}
$this->handlers[ $bucket ] = $handler;
}
/**
* @return array<int, ContentHandlerInterface>
*/
public function ordered(): array {
$ordered = array();
foreach ( self::PACKAGE_ORDER as $bucket ) {
if ( isset( $this->handlers[ $bucket ] ) ) {
$ordered[] = $this->handlers[ $bucket ];
}
}
return $ordered;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* Typed content import failure.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
final class ContentImportException extends \RuntimeException {
private string $bucket;
/** @var array<string, mixed> */
private array $record;
/**
* @param array<string, mixed> $record Content record that failed.
*/
public function __construct( string $bucket, array $record, string $message, ?\Throwable $previous = null ) {
parent::__construct( $message, 0, $previous );
$this->bucket = $bucket;
$this->record = $record;
}
public function bucket(): string {
return $this->bucket;
}
/**
* @return array<string, mixed>
*/
public function record(): array {
return $this->record;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* Normalizes package content records for handlers.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
final class ContentRecordNormalizer {
/**
* @param array<string, mixed> $record Raw post record.
* @return array<string, mixed>
*/
public function post( array $record ): array {
return array(
'id' => (int) ( $record['id'] ?? 0 ),
'post_type' => sanitize_text_field( (string) ( $record['post_type'] ?? 'post' ) ),
'post_title' => sanitize_text_field( (string) ( $record['post_title'] ?? '' ) ),
'post_content' => (string) ( $record['post_content'] ?? '' ),
'post_excerpt' => sanitize_text_field( (string) ( $record['post_excerpt'] ?? '' ) ),
'post_status' => sanitize_text_field( (string) ( $record['post_status'] ?? 'draft' ) ),
'post_name' => sanitize_text_field( (string) ( $record['post_name'] ?? '' ) ),
'post_parent' => (int) ( $record['post_parent'] ?? 0 ),
'menu_order' => (int) ( $record['menu_order'] ?? 0 ),
'meta' => $this->arrayValue( $record['meta'] ?? array() ),
);
}
/**
* @param array<string, mixed> $record Raw term record.
* @return array<string, mixed>
*/
public function term( array $record ): array {
return array(
'id' => (int) ( $record['id'] ?? 0 ),
'taxonomy' => sanitize_text_field( (string) ( $record['taxonomy'] ?? '' ) ),
'name' => sanitize_text_field( (string) ( $record['name'] ?? '' ) ),
'slug' => sanitize_text_field( (string) ( $record['slug'] ?? '' ) ),
'description' => (string) ( $record['description'] ?? '' ),
'parent' => (int) ( $record['parent'] ?? 0 ),
'meta' => $this->arrayValue( $record['meta'] ?? array() ),
);
}
/**
* @param array<string, mixed> $record Raw media record.
* @return array<string, mixed>
*/
public function media( array $record ): array {
return array(
'id' => (int) ( $record['id'] ?? 0 ),
'post_title' => sanitize_text_field( (string) ( $record['post_title'] ?? '' ) ),
'post_mime_type' => sanitize_text_field( (string) ( $record['post_mime_type'] ?? '' ) ),
'source_url' => esc_url_raw( (string) ( $record['source_url'] ?? '' ) ),
'metadata' => $this->arrayValue( $record['metadata'] ?? array() ),
'meta' => $this->arrayValue( $record['meta'] ?? array() ),
);
}
/**
* @param mixed $value Value to normalize.
* @return array<string, mixed>
*/
private function arrayValue( $value ): array {
return is_array( $value ) ? $value : array();
}
}
+238
View File
@@ -0,0 +1,238 @@
<?php
/**
* Imports media attachment metadata 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 MediaContentHandler 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 'media';
}
/**
* @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->media( $record );
$existing = $this->findExistingAttachmentId( (int) $normalized['id'], $context->sourceUrl() );
if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) {
++$skipped;
++$conflicts;
$this->logger->warning(
'Skipped media import because manual review is required.',
array(
'source_id' => $normalized['id'],
'attachment_id' => $existing,
)
);
continue;
}
try {
$attachment_id = $this->saveAttachment( $normalized, $existing, $mappings );
} catch ( ContentImportException $exception ) {
$errors[] = $exception->getMessage();
$this->logger->error(
$exception->getMessage(),
array(
'bucket' => $exception->bucket(),
'record' => $exception->record(),
)
);
continue;
}
if ( $existing > 0 ) {
++$updated;
} else {
++$created;
}
$this->saveMetadata( $attachment_id, $normalized, $context, $mappings );
}
if ( array() !== $errors ) {
return SyncResult::merge(
array(
SyncResult::success(
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
),
SyncResult::failure( $errors ),
)
);
}
$this->logger->info(
'Imported media metadata records.',
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
);
return SyncResult::success(
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
);
}
private function findExistingAttachmentId( int $source_id, string $source_site ): int {
if ( $source_id <= 0 ) {
return 0;
}
$attachments = get_posts(
array(
'post_type' => 'attachment',
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity.
'meta_key' => '_wpcs_source_id',
'meta_value' => (string) $source_id,
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
if ( array() === $attachments ) {
return 0;
}
foreach ( $attachments as $attachment ) {
$attachment_id = (int) $attachment->ID;
if ( get_post_meta( $attachment_id, '_wpcs_source_site', true ) === $source_site ) {
return $attachment_id;
}
}
return 0;
}
/**
* @param array<string, mixed> $record Normalized media record.
* @param int $existing Existing attachment ID.
*/
private function saveAttachment( array $record, int $existing, UrlMappingCollection $mappings ): int {
$attachment_data = array(
'post_title' => $record['post_title'],
'post_mime_type' => $record['post_mime_type'],
'post_type' => 'attachment',
);
if ( '' !== $record['source_url'] ) {
$this->logger->warning(
'Skipped media binary download; importing attachment metadata only.',
array(
'source_id' => $record['id'],
'source_url' => $this->url_transformer->transformString( (string) $record['source_url'], $mappings ),
)
);
}
if ( $existing > 0 ) {
$attachment_data['ID'] = $existing;
return $this->attachmentIdFromResult( wp_update_post( $attachment_data, true ), $record );
}
return $this->attachmentIdFromResult( wp_insert_attachment( $attachment_data, false, 0, true ), $record );
}
/**
* @param int|\WP_Error $result Attachment save result.
* @param array<string, mixed> $record Normalized media record.
*/
private function attachmentIdFromResult( $result, array $record ): int {
if ( is_wp_error( $result ) || (int) $result <= 0 ) {
throw new ContentImportException(
$this->bucket(),
$record,
sprintf( 'Media import failed for source ID %d.', (int) $record['id'] )
);
}
return (int) $result;
}
/**
* @param array<string, mixed> $record Normalized media record.
*/
private function saveMetadata( int $attachment_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void {
update_post_meta( $attachment_id, '_wpcs_source_id', (int) $record['id'] );
update_post_meta( $attachment_id, '_wpcs_source_site', $context->sourceUrl() );
update_post_meta(
$attachment_id,
'_wpcs_source_url',
$this->url_transformer->transformString( (string) $record['source_url'], $mappings )
);
wp_update_attachment_metadata(
$attachment_id,
$this->metadata_transformer->transformValue( $record['metadata'], $mappings )
);
foreach ( $record['meta'] as $key => $value ) {
update_post_meta(
$attachment_id,
(string) $key,
$this->metadata_transformer->transformValue( $value, $mappings )
);
}
}
private function mappings( SyncContext $context ): UrlMappingCollection {
$mappings = array();
foreach ( $context->urlMappings() as $source => $destination ) {
$mappings[] = new UrlMapping( $source, $destination );
}
return new UrlMappingCollection( $mappings );
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
/**
* Imports post content records.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;
use WPContentSync\Url\UrlTransformer;
final class PostContentHandler implements ContentHandlerInterface {
private ContentRecordNormalizer $normalizer;
private UrlTransformer $url_transformer;
private MetadataUrlTransformer $metadata_transformer;
private LoggerInterface $logger;
public function __construct(
ContentRecordNormalizer $normalizer,
UrlTransformer $url_transformer,
MetadataUrlTransformer $metadata_transformer,
LoggerInterface $logger
) {
$this->normalizer = $normalizer;
$this->url_transformer = $url_transformer;
$this->metadata_transformer = $metadata_transformer;
$this->logger = $logger;
}
public function bucket(): string {
return 'posts';
}
/**
* @param array<int, array<string, mixed>> $records Package records.
*/
public function importRecords( array $records, SyncContext $context ): SyncResult {
$created = 0;
$updated = 0;
$skipped = 0;
$conflicts = 0;
$mappings = $this->mappings( $context );
$errors = array();
foreach ( $records as $record ) {
$normalized = $this->normalizer->post( $record );
$existing = $this->findExistingPostId( (int) $normalized['id'] );
if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) {
++$skipped;
++$conflicts;
$this->logger->warning(
'Skipped post import because manual review is required.',
array(
'source_id' => $normalized['id'],
'post_id' => $existing,
)
);
continue;
}
try {
$post_id = $this->savePost( $normalized, $existing, $mappings );
} catch ( ContentImportException $exception ) {
$errors[] = $exception->getMessage();
$this->logger->error(
$exception->getMessage(),
array(
'bucket' => $exception->bucket(),
'record' => $exception->record(),
)
);
continue;
}
if ( $existing > 0 ) {
++$updated;
} else {
++$created;
}
$this->saveMeta( $post_id, $normalized, $context, $mappings );
}
if ( array() !== $errors ) {
return SyncResult::merge(
array(
SyncResult::success(
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
),
SyncResult::failure( $errors ),
)
);
}
$this->logger->info(
'Imported post content records.',
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
);
return SyncResult::success(
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
);
}
private function findExistingPostId( int $source_id ): int {
if ( $source_id <= 0 ) {
return 0;
}
$posts = get_posts(
array(
'post_type' => 'any',
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity.
'meta_key' => '_wpcs_source_id',
'meta_value' => (string) $source_id,
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
if ( array() === $posts ) {
return 0;
}
return (int) $posts[0]->ID;
}
/**
* @param array<string, mixed> $record Normalized post record.
* @param int $existing Existing post ID.
*/
private function savePost( array $record, int $existing, UrlMappingCollection $mappings ): int {
$post_data = array(
'post_type' => $record['post_type'],
'post_title' => $record['post_title'],
'post_content' => $this->url_transformer->transformString( (string) $record['post_content'], $mappings ),
'post_excerpt' => $this->url_transformer->transformString( (string) $record['post_excerpt'], $mappings ),
'post_status' => $record['post_status'],
'post_name' => $record['post_name'],
'post_parent' => $record['post_parent'],
'menu_order' => $record['menu_order'],
);
if ( $existing > 0 ) {
$post_data['ID'] = $existing;
return $this->postIdFromResult( wp_update_post( $post_data, true ), $record );
}
return $this->postIdFromResult( wp_insert_post( $post_data, true ), $record );
}
/**
* @param int|\WP_Error $result Post save result.
* @param array<string, mixed> $record Normalized post record.
*/
private function postIdFromResult( $result, array $record ): int {
if ( is_wp_error( $result ) || (int) $result <= 0 ) {
throw new ContentImportException(
$this->bucket(),
$record,
sprintf( 'Post import failed for source ID %d.', (int) $record['id'] )
);
}
return (int) $result;
}
/**
* @param array<string, mixed> $record Normalized post record.
*/
private function saveMeta( int $post_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void {
update_post_meta( $post_id, '_wpcs_source_id', (int) $record['id'] );
update_post_meta( $post_id, '_wpcs_source_site', $context->sourceUrl() );
foreach ( $record['meta'] as $key => $value ) {
update_post_meta(
$post_id,
(string) $key,
$this->metadata_transformer->transformValue( $value, $mappings )
);
}
}
private function mappings( SyncContext $context ): UrlMappingCollection {
$mappings = array();
foreach ( $context->urlMappings() as $source => $destination ) {
$mappings[] = new UrlMapping( $source, $destination );
}
return new UrlMappingCollection( $mappings );
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* Imports taxonomy term records.
*
* @package WPContentSync
*/
namespace WPContentSync\Content;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;
use WPContentSync\Url\UrlTransformer;
final class TermContentHandler implements ContentHandlerInterface {
private ContentRecordNormalizer $normalizer;
private UrlTransformer $url_transformer;
private MetadataUrlTransformer $metadata_transformer;
private LoggerInterface $logger;
public function __construct(
ContentRecordNormalizer $normalizer,
UrlTransformer $url_transformer,
MetadataUrlTransformer $metadata_transformer,
LoggerInterface $logger
) {
$this->normalizer = $normalizer;
$this->url_transformer = $url_transformer;
$this->metadata_transformer = $metadata_transformer;
$this->logger = $logger;
}
public function bucket(): string {
return 'terms';
}
/**
* @param array<int, array<string, mixed>> $records Package records.
*/
public function importRecords( array $records, SyncContext $context ): SyncResult {
$created = 0;
$updated = 0;
$skipped = 0;
$conflicts = 0;
$errors = array();
$mappings = $this->mappings( $context );
foreach ( $records as $record ) {
$normalized = $this->normalizer->term( $record );
$existing = $this->findExistingTermId( $normalized );
if ( $existing > 0 && 'manual_review' === $context->conflictStrategy() ) {
++$skipped;
++$conflicts;
$this->logger->warning(
'Skipped term import because manual review is required.',
array(
'source_id' => $normalized['id'],
'term_id' => $existing,
'taxonomy' => $normalized['taxonomy'],
)
);
continue;
}
try {
$term_id = $this->saveTerm( $normalized, $existing, $mappings );
} catch ( ContentImportException $exception ) {
$errors[] = $exception->getMessage();
$this->logger->error(
$exception->getMessage(),
array(
'bucket' => $exception->bucket(),
'record' => $exception->record(),
)
);
continue;
}
if ( $existing > 0 ) {
++$updated;
} else {
++$created;
}
$this->saveMeta( $term_id, $normalized, $context, $mappings );
}
if ( array() !== $errors ) {
return SyncResult::merge(
array(
SyncResult::success(
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
),
SyncResult::failure( $errors ),
)
);
}
$this->logger->info(
'Imported taxonomy term records.',
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
);
return SyncResult::success(
array(
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'conflicts' => $conflicts,
)
);
}
/**
* @param array<string, mixed> $record Normalized term record.
*/
private function findExistingTermId( array $record ): int {
$source_id = (int) $record['id'];
if ( $source_id > 0 ) {
$terms = get_terms(
array(
'taxonomy' => (string) $record['taxonomy'],
'hide_empty' => false,
'number' => 1,
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Source ID lookup is the handler's stable import identity.
'meta_key' => '_wpcs_source_id',
'meta_value' => (string) $source_id,
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
if ( ! is_wp_error( $terms ) && array() !== $terms ) {
return (int) $terms[0]->term_id;
}
}
$term = get_term_by( 'slug', (string) $record['slug'], (string) $record['taxonomy'] );
return false === $term ? 0 : (int) $term->term_id;
}
/**
* @param array<string, mixed> $record Normalized term record.
* @param int $existing Existing term ID.
*/
private function saveTerm( array $record, int $existing, UrlMappingCollection $mappings ): int {
$args = array(
'slug' => $record['slug'],
'description' => $this->url_transformer->transformString( (string) $record['description'], $mappings ),
'parent' => $record['parent'],
);
if ( $existing > 0 ) {
$args['name'] = $record['name'];
return $this->termIdFromResult(
wp_update_term( $existing, (string) $record['taxonomy'], $args ),
$record
);
}
return $this->termIdFromResult(
wp_insert_term( (string) $record['name'], (string) $record['taxonomy'], $args ),
$record
);
}
/**
* @param array<string, int>|\WP_Error $result Term save result.
* @param array<string, mixed> $record Normalized term record.
*/
private function termIdFromResult( $result, array $record ): int {
if ( is_wp_error( $result ) || ! is_array( $result ) || (int) ( $result['term_id'] ?? 0 ) <= 0 ) {
throw new ContentImportException(
$this->bucket(),
$record,
sprintf( 'Term import failed for source ID %d.', (int) $record['id'] )
);
}
return (int) $result['term_id'];
}
/**
* @param array<string, mixed> $record Normalized term record.
*/
private function saveMeta( int $term_id, array $record, SyncContext $context, UrlMappingCollection $mappings ): void {
update_term_meta( $term_id, '_wpcs_source_id', (int) $record['id'] );
update_term_meta( $term_id, '_wpcs_source_site', $context->sourceUrl() );
foreach ( $record['meta'] as $key => $value ) {
update_term_meta(
$term_id,
(string) $key,
$this->metadata_transformer->transformValue( $value, $mappings )
);
}
}
private function mappings( SyncContext $context ): UrlMappingCollection {
$mappings = array();
foreach ( $context->urlMappings() as $source => $destination ) {
$mappings[] = new UrlMapping( $source, $destination );
}
return new UrlMappingCollection( $mappings );
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Immutable sync package value object.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class ContentPackage {
public const SCHEMA_VERSION = '1.0';
/** @var array<string, mixed> */
private array $data;
/**
* @param array<string, mixed> $data Package data.
*/
private function __construct( array $data ) {
$this->data = $data;
}
/**
* @param array<string, mixed> $data Package data.
*/
public static function fromArray( array $data ): self {
return new self(
array(
'schema_version' => (string) ( $data['schema_version'] ?? self::SCHEMA_VERSION ),
'generated_at' => (string) ( $data['generated_at'] ?? '' ),
'source' => self::arrayValue( $data['source'] ?? array() ),
'destination' => self::arrayValue( $data['destination'] ?? array() ),
'manifest' => self::arrayValue( $data['manifest'] ?? array() ),
'records' => self::arrayValue( $data['records'] ?? array() ),
'checksums' => self::arrayValue( $data['checksums'] ?? array() ),
)
);
}
public function schemaVersion(): string {
return $this->data['schema_version'];
}
public function generatedAt(): string {
return $this->data['generated_at'];
}
/**
* @return array<string, mixed>
*/
public function source(): array {
return $this->data['source'];
}
/**
* @return array<string, mixed>
*/
public function destination(): array {
return $this->data['destination'];
}
/**
* @return array<string, mixed>
*/
public function manifest(): array {
return $this->data['manifest'];
}
/**
* @return array<string, mixed>
*/
public function records(): array {
return $this->data['records'];
}
/**
* @return array<string, mixed>
*/
public function checksums(): array {
return $this->data['checksums'];
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return $this->data;
}
/**
* @param mixed $value Value to normalize.
*
* @return array<string, mixed>
*/
private static function arrayValue( $value ): array {
return is_array( $value ) ? $value : array();
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* Package checksum utility.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageChecksum {
/**
* @param array<string, mixed> $records Package records.
*/
public static function records( array $records ): string {
return 'sha256:' . hash( 'sha256', self::canonicalJson( $records ) );
}
/**
* @param array<string, mixed> $records Package records.
* @param string $checksum Expected checksum.
*/
public static function verifyRecords( array $records, string $checksum ): bool {
return hash_equals( self::records( $records ), $checksum );
}
/**
* @param mixed $value Value to encode.
*/
private static function canonicalJson( $value ): string {
$normalized = self::sortKeys( $value );
$json = wp_json_encode( $normalized );
if ( false === $json ) {
throw new \RuntimeException( 'Unable to encode package records for checksum.' );
}
return $json;
}
/**
* @param mixed $value Value to normalize.
*
* @return mixed
*/
private static function sortKeys( $value ) {
if ( ! is_array( $value ) ) {
return $value;
}
if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
ksort( $value );
}
foreach ( $value as $key => $child ) {
$value[ $key ] = self::sortKeys( $child );
}
return $value;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Package validation result.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageValidationResult {
/** @var array<int, string> */
private array $errors;
/**
* @param array<int, string> $errors Validation errors.
*/
private function __construct( array $errors ) {
$this->errors = array_values( $errors );
}
public static function valid(): self {
return new self( array() );
}
/**
* @param array<int, string> $errors Validation errors.
*/
public static function invalid( array $errors ): self {
return new self( $errors );
}
public function isValid(): bool {
return array() === $this->errors;
}
/**
* @return array<int, string>
*/
public function errors(): array {
return $this->errors;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
/**
* Versioned package schema validator.
*
* @package WPContentSync
*/
namespace WPContentSync\Package;
final class PackageValidator {
private const RECORD_BUCKETS = array(
'posts',
'terms',
'media',
'custom_post_types',
);
/**
* @param array<string, mixed> $data Decoded package data.
*/
public function validate( array $data ): PackageValidationResult {
$errors = array();
foreach ( array( 'schema_version', 'generated_at', 'source', 'destination', 'manifest', 'records', 'checksums' ) as $field ) {
if ( ! array_key_exists( $field, $data ) ) {
$errors[] = $field . ' is required.';
}
}
if ( isset( $data['schema_version'] ) && ContentPackage::SCHEMA_VERSION !== $data['schema_version'] ) {
$errors[] = 'schema_version must be ' . ContentPackage::SCHEMA_VERSION . '.';
}
if ( isset( $data['source'] ) && ! is_array( $data['source'] ) ) {
$errors[] = 'source must be an object.';
}
if ( isset( $data['destination'] ) && ! is_array( $data['destination'] ) ) {
$errors[] = 'destination must be an object.';
}
if ( isset( $data['manifest'] ) && ! is_array( $data['manifest'] ) ) {
$errors[] = 'manifest must be an object.';
}
if ( isset( $data['records'] ) && ! is_array( $data['records'] ) ) {
$errors[] = 'records must be an object.';
}
if ( isset( $data['checksums'] ) && ! is_array( $data['checksums'] ) ) {
$errors[] = 'checksums must be an object.';
}
$record_bucket_errors = array();
if ( isset( $data['manifest'], $data['records'] ) && is_array( $data['manifest'] ) && is_array( $data['records'] ) ) {
$record_bucket_errors = $this->validateRecordBuckets( $data['manifest'], $data['records'] );
$errors = array_merge( $errors, $record_bucket_errors );
}
if (
array() === $record_bucket_errors
&& isset( $data['records'], $data['checksums']['records'] )
&& is_array( $data['records'] )
&& is_string( $data['checksums']['records'] )
&& ! PackageChecksum::verifyRecords( $data['records'], $data['checksums']['records'] )
) {
$errors[] = 'checksums.records does not match records payload.';
}
return array() === $errors ? PackageValidationResult::valid() : PackageValidationResult::invalid( $errors );
}
/**
* @param array<string, mixed> $manifest Package manifest.
* @param array<string, mixed> $records Package records.
*
* @return array<int, string>
*/
private function validateRecordBuckets( array $manifest, array $records ): array {
$errors = array();
foreach ( self::RECORD_BUCKETS as $bucket ) {
if ( ! isset( $records[ $bucket ] ) || ! is_array( $records[ $bucket ] ) ) {
$errors[] = 'records.' . $bucket . ' is required and must be an array.';
continue;
}
if ( ! isset( $manifest[ $bucket ] ) || ! is_int( $manifest[ $bucket ] ) ) {
$errors[] = 'manifest.' . $bucket . ' is required and must be an integer.';
continue;
}
if ( count( $records[ $bucket ] ) !== $manifest[ $bucket ] ) {
$errors[] = 'manifest.' . $bucket . ' must match records.' . $bucket . ' count.';
}
}
return $errors;
}
}
+170
View File
@@ -8,9 +8,24 @@
namespace WPContentSync; namespace WPContentSync;
use WPContentSync\Admin\AdminPage; use WPContentSync\Admin\AdminPage;
use WPContentSync\Admin\ConnectionTestController;
use WPContentSync\Admin\FileImportController;
use WPContentSync\Admin\SettingsController;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\MediaContentHandler;
use WPContentSync\Content\PostContentHandler;
use WPContentSync\Content\TermContentHandler;
use WPContentSync\Logging\LoggerInterface; use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Logging\OptionLogger; use WPContentSync\Logging\OptionLogger;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Rest\RestPackageController;
use WPContentSync\Settings\SettingsRepository; use WPContentSync\Settings\SettingsRepository;
use WPContentSync\Sync\SyncEngine;
use WPContentSync\Sync\SyncStateRepository;
use WPContentSync\Transport\FileTransportInterface;
use WPContentSync\Transport\JsonFileTransport;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer; use WPContentSync\Url\UrlTransformer;
@@ -54,6 +69,145 @@ final class Plugin {
} }
); );
$container->factory(
PackageValidator::class,
static function (): PackageValidator {
return new PackageValidator();
}
);
$container->factory(
ContentRecordNormalizer::class,
static function (): ContentRecordNormalizer {
return new ContentRecordNormalizer();
}
);
$container->factory(
PostContentHandler::class,
static function () use ( $container ): PostContentHandler {
return new PostContentHandler(
$container->get( ContentRecordNormalizer::class ),
$container->get( UrlTransformer::class ),
$container->get( MetadataUrlTransformer::class ),
$container->get( LoggerInterface::class )
);
}
);
$container->factory(
TermContentHandler::class,
static function () use ( $container ): TermContentHandler {
return new TermContentHandler(
$container->get( ContentRecordNormalizer::class ),
$container->get( UrlTransformer::class ),
$container->get( MetadataUrlTransformer::class ),
$container->get( LoggerInterface::class )
);
}
);
$container->factory(
MediaContentHandler::class,
static function () use ( $container ): MediaContentHandler {
return new MediaContentHandler(
$container->get( ContentRecordNormalizer::class ),
$container->get( UrlTransformer::class ),
$container->get( MetadataUrlTransformer::class ),
$container->get( LoggerInterface::class )
);
}
);
$container->factory(
ContentHandlerRegistry::class,
static function () use ( $container ): ContentHandlerRegistry {
return new ContentHandlerRegistry(
array(
$container->get( PostContentHandler::class ),
$container->get( TermContentHandler::class ),
$container->get( MediaContentHandler::class ),
)
);
}
);
$container->factory(
SyncStateRepository::class,
static function (): SyncStateRepository {
return new SyncStateRepository();
}
);
$container->factory(
SyncEngine::class,
static function () use ( $container ): SyncEngine {
return new SyncEngine(
$container->get( ContentHandlerRegistry::class ),
$container->get( SyncStateRepository::class ),
$container->get( SettingsRepository::class ),
$container->get( LoggerInterface::class )
);
}
);
$container->factory(
FileTransportInterface::class,
static function () use ( $container ): FileTransportInterface {
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->get( SyncEngine::class )
);
}
);
$container->factory(
SettingsController::class,
static function () use ( $container ): SettingsController {
return new SettingsController(
$container->get( SettingsRepository::class )
);
}
);
$container->factory(
RestTransportClient::class,
static function (): RestTransportClient {
return new RestTransportClient();
}
);
$container->factory(
ConnectionTestController::class,
static function () use ( $container ): ConnectionTestController {
return new ConnectionTestController(
$container->get( SettingsRepository::class ),
$container->get( RestTransportClient::class ),
$container->get( LoggerInterface::class )
);
}
);
$container->factory(
RestPackageController::class,
static function () use ( $container ): RestPackageController {
return new RestPackageController(
$container->get( PackageValidator::class ),
$container->get( SyncEngine::class )
);
}
);
$container->factory( $container->factory(
AdminPage::class, AdminPage::class,
static function () use ( $container ): AdminPage { static function () use ( $container ): AdminPage {
@@ -71,6 +225,22 @@ final class Plugin {
/** @var AdminPage $admin_page */ /** @var AdminPage $admin_page */
$admin_page = $this->container->get( AdminPage::class ); $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 );
/** @var SettingsController $settings_controller */
$settings_controller = $this->container->get( SettingsController::class );
/** @var ConnectionTestController $connection_test_controller */
$connection_test_controller = $this->container->get( ConnectionTestController::class );
$admin_page->register(); $admin_page->register();
$file_import_controller->register();
$rest_package_controller->register();
$settings_controller->register();
$connection_test_controller->register();
} }
} }
+115
View File
@@ -0,0 +1,115 @@
<?php
/**
* REST package receive/status controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Rest;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Sync\SyncEngine;
final class RestPackageController {
private PackageValidator $validator;
private SyncEngine $sync_engine;
public function __construct( PackageValidator $validator, SyncEngine $sync_engine ) {
$this->validator = $validator;
$this->sync_engine = $sync_engine;
}
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'] );
$import = $this->sync_engine->importPackage( $package );
return array(
'accepted' => $import->isSuccessful(),
'schema_version' => $package->schemaVersion(),
'manifest' => $package->manifest(),
'import' => $import->toArray(),
);
}
/**
* @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();
}
}
+104 -9
View File
@@ -5,29 +5,39 @@ namespace WPContentSync\Settings;
final class Settings { final class Settings {
private const LOGGING_LEVELS = array( 'error', 'warning', 'info', 'debug' ); private const LOGGING_LEVELS = array( 'error', 'warning', 'info', 'debug' );
private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' ); private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );
private const DIRECTIONS = array( 'push', 'pull' );
private const CONTENT_TYPES = array( 'posts', 'terms', 'media', 'custom_post_types' );
private const MIN_LOGS = 10;
private const MAX_LOGS = 1000;
/** /**
* @var array<int, array{name: string, source_url: string, destination_url: string}> * @var array<int, array{name: string, source_url: string, destination_url: string, username: string, application_password: string, default_direction: string, content_types: array<int, string>, url_mappings: array<int, array{source: string, destination: string}>}>
*/ */
private array $sync_pairs; private array $sync_pairs;
private string $logging_level; private string $logging_level;
private bool $automatic_url_replacement; private bool $automatic_url_replacement;
private string $conflict_strategy; private string $conflict_strategy;
private int $log_retention;
private bool $debug_logging;
/** /**
* @param array<int, array{name: string, source_url: string, destination_url: string}> $sync_pairs Sync pairs. * @param array<int, array{name: string, source_url: string, destination_url: string, username: string, application_password: string, default_direction: string, content_types: array<int, string>, url_mappings: array<int, array{source: string, destination: string}>}> $sync_pairs Sync pairs.
*/ */
private function __construct( private function __construct(
array $sync_pairs, array $sync_pairs,
string $logging_level, string $logging_level,
bool $automatic_url_replacement, bool $automatic_url_replacement,
string $conflict_strategy string $conflict_strategy,
int $log_retention,
bool $debug_logging
) { ) {
$this->sync_pairs = $sync_pairs; $this->sync_pairs = $sync_pairs;
$this->logging_level = $logging_level; $this->logging_level = $logging_level;
$this->automatic_url_replacement = $automatic_url_replacement; $this->automatic_url_replacement = $automatic_url_replacement;
$this->conflict_strategy = $conflict_strategy; $this->conflict_strategy = $conflict_strategy;
$this->log_retention = $log_retention;
$this->debug_logging = $debug_logging;
} }
/** /**
@@ -54,12 +64,16 @@ final class Settings {
self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ), self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ),
$logging_level, $logging_level,
$automatic_url_replacement, $automatic_url_replacement,
$conflict_strategy $conflict_strategy,
self::sanitizeLogRetention( $data['log_retention'] ?? 200 ),
array_key_exists( 'debug_logging', $data )
? self::sanitizeBoolean( $data['debug_logging'] )
: false
); );
} }
/** /**
* @return array<int, array{name: string, source_url: string, destination_url: string}> * @return array<int, array{name: string, source_url: string, destination_url: string, username: string, application_password: string, default_direction: string, content_types: array<int, string>, url_mappings: array<int, array{source: string, destination: string}>}>
*/ */
public function syncPairs(): array { public function syncPairs(): array {
return $this->sync_pairs; return $this->sync_pairs;
@@ -77,6 +91,14 @@ final class Settings {
return $this->conflict_strategy; return $this->conflict_strategy;
} }
public function logRetention(): int {
return $this->log_retention;
}
public function debugLoggingEnabled(): bool {
return $this->debug_logging;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -86,6 +108,8 @@ final class Settings {
'logging_level' => $this->logging_level, 'logging_level' => $this->logging_level,
'automatic_url_replacement' => $this->automatic_url_replacement, 'automatic_url_replacement' => $this->automatic_url_replacement,
'conflict_strategy' => $this->conflict_strategy, 'conflict_strategy' => $this->conflict_strategy,
'log_retention' => $this->log_retention,
'debug_logging' => $this->debug_logging,
); );
} }
@@ -114,7 +138,7 @@ final class Settings {
/** /**
* @param mixed $pairs Raw sync pairs. * @param mixed $pairs Raw sync pairs.
* @return array<int, array{name: string, source_url: string, destination_url: string}> * @return array<int, array{name: string, source_url: string, destination_url: string, username: string, application_password: string, default_direction: string, content_types: array<int, string>, url_mappings: array<int, array{source: string, destination: string}>}>
*/ */
private static function sanitizeSyncPairs( $pairs ): array { private static function sanitizeSyncPairs( $pairs ): array {
if ( ! is_array( $pairs ) ) { if ( ! is_array( $pairs ) ) {
@@ -131,15 +155,86 @@ final class Settings {
$name = sanitize_text_field( (string) ( $pair['name'] ?? '' ) ); $name = sanitize_text_field( (string) ( $pair['name'] ?? '' ) );
$source_url = esc_url_raw( (string) ( $pair['source_url'] ?? '' ) ); $source_url = esc_url_raw( (string) ( $pair['source_url'] ?? '' ) );
$destination_url = esc_url_raw( (string) ( $pair['destination_url'] ?? '' ) ); $destination_url = esc_url_raw( (string) ( $pair['destination_url'] ?? '' ) );
$username = sanitize_text_field( (string) ( $pair['username'] ?? '' ) );
$password = sanitize_text_field( (string) ( $pair['application_password'] ?? '' ) );
$direction = self::sanitizeChoice( $pair['default_direction'] ?? 'push', self::DIRECTIONS, 'push' );
$content_types = self::sanitizeContentTypes( $pair['content_types'] ?? self::CONTENT_TYPES );
$url_mappings = self::sanitizeUrlMappings( $pair['url_mappings'] ?? array() );
if ( '' === $name || '' === $source_url || '' === $destination_url ) { if ( '' === $name || '' === $source_url || '' === $destination_url ) {
continue; continue;
} }
$sanitized[] = array( $sanitized[] = array(
'name' => $name, 'name' => $name,
'source_url' => $source_url, 'source_url' => $source_url,
'destination_url' => $destination_url, 'destination_url' => $destination_url,
'username' => $username,
'application_password' => $password,
'default_direction' => $direction,
'content_types' => $content_types,
'url_mappings' => $url_mappings,
);
}
return $sanitized;
}
/**
* @param mixed $value Raw log retention.
*/
private static function sanitizeLogRetention( $value ): int {
return min( self::MAX_LOGS, max( self::MIN_LOGS, (int) $value ) );
}
/**
* @param mixed $content_types Raw content type list.
* @return array<int, string>
*/
private static function sanitizeContentTypes( $content_types ): array {
if ( ! is_array( $content_types ) ) {
return self::CONTENT_TYPES;
}
$sanitized = array();
foreach ( $content_types as $content_type ) {
$content_type = sanitize_text_field( (string) $content_type );
if ( in_array( $content_type, self::CONTENT_TYPES, true ) && ! in_array( $content_type, $sanitized, true ) ) {
$sanitized[] = $content_type;
}
}
return $sanitized;
}
/**
* @param mixed $mappings Raw URL mappings.
* @return array<int, array{source: string, destination: string}>
*/
private static function sanitizeUrlMappings( $mappings ): array {
if ( ! is_array( $mappings ) ) {
return array();
}
$sanitized = array();
foreach ( $mappings as $mapping ) {
if ( ! is_array( $mapping ) ) {
continue;
}
$source = esc_url_raw( (string) ( $mapping['source'] ?? '' ) );
$destination = esc_url_raw( (string) ( $mapping['destination'] ?? '' ) );
if ( '' === $source || '' === $destination ) {
continue;
}
$sanitized[] = array(
'source' => $source,
'destination' => $destination,
); );
} }
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* Immutable sync operation context.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncContext {
private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );
private string $direction;
private string $operation_id;
private string $source_url;
private string $destination_url;
private string $conflict_strategy;
/** @var array<string, string> */
private array $url_mappings;
/**
* @param array<string, string> $url_mappings URL mappings.
*/
private function __construct(
string $direction,
string $operation_id,
string $source_url,
string $destination_url,
string $conflict_strategy,
array $url_mappings
) {
$this->direction = $direction;
$this->operation_id = $operation_id;
$this->source_url = $source_url;
$this->destination_url = $destination_url;
$this->conflict_strategy = in_array( $conflict_strategy, self::CONFLICT_STRATEGIES, true ) ? $conflict_strategy : 'last_write_wins';
$this->url_mappings = $url_mappings;
}
/**
* @param array<string, mixed> $source Source site metadata.
* @param array<string, mixed> $destination Destination site metadata.
* @param string $conflict_strategy Conflict strategy.
* @param string $operation_id Operation ID.
*/
public static function forImport( array $source, array $destination, string $conflict_strategy, string $operation_id ): self {
$source_url = esc_url_raw( (string) ( $source['site_url'] ?? '' ) );
$destination_url = esc_url_raw( (string) ( $destination['site_url'] ?? '' ) );
$url_mappings = array();
if ( '' !== $source_url && '' !== $destination_url ) {
$url_mappings[ $source_url ] = $destination_url;
}
return new self(
'import',
sanitize_key( $operation_id ),
$source_url,
$destination_url,
$conflict_strategy,
$url_mappings
);
}
public function direction(): string {
return $this->direction;
}
public function operationId(): string {
return $this->operation_id;
}
public function sourceUrl(): string {
return $this->source_url;
}
public function destinationUrl(): string {
return $this->destination_url;
}
public function conflictStrategy(): string {
return $this->conflict_strategy;
}
/**
* @return array<string, string>
*/
public function urlMappings(): array {
return $this->url_mappings;
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Orchestrates content package imports.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Content\ContentImportException;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Settings\SettingsRepository;
final class SyncEngine {
private ContentHandlerRegistry $handlers;
private SyncStateRepository $state_repository;
private SettingsRepository $settings_repository;
private LoggerInterface $logger;
public function __construct(
ContentHandlerRegistry $handlers,
SyncStateRepository $state_repository,
SettingsRepository $settings_repository,
LoggerInterface $logger
) {
$this->handlers = $handlers;
$this->state_repository = $state_repository;
$this->settings_repository = $settings_repository;
$this->logger = $logger;
}
public function importPackage( ContentPackage $package ): SyncResult {
$operation_id = uniqid( 'wpcs_', true );
$total = $this->totalRecords( $package );
$processed = 0;
$results = array();
$context = SyncContext::forImport(
$package->source(),
$package->destination(),
$this->settings_repository->get()->conflictStrategy(),
$operation_id
);
$this->logger->info(
'Starting content package import.',
array(
'operation_id' => $operation_id,
'total' => $total,
)
);
foreach ( $this->handlers->ordered() as $handler ) {
$bucket = $handler->bucket();
$records = $this->recordsForBucket( $package, $bucket );
$this->state_repository->save( SyncOperationState::running( $operation_id, $bucket, $processed, $total ) );
try {
$results[] = $handler->importRecords( $records, $context );
} catch ( ContentImportException $exception ) {
$this->state_repository->save(
SyncOperationState::fromArray(
array(
'operation_id' => $operation_id,
'status' => 'failed',
'current_bucket' => $bucket,
'processed' => $processed,
'total' => $total,
)
)
);
$this->logger->error(
'Content package import failed.',
array(
'operation_id' => $operation_id,
'bucket' => $exception->bucket(),
'record' => $exception->record(),
'error' => $exception->getMessage(),
)
);
$results[] = SyncResult::failure( array( $exception->getMessage() ) );
return SyncResult::merge( $results );
}
$processed += count( $records );
}
$result = SyncResult::merge( $results );
$this->state_repository->save( SyncOperationState::completed( $operation_id, $processed, $total ) );
$this->logger->info(
'Completed content package import.',
array_merge(
array( 'operation_id' => $operation_id ),
$result->toArray()
)
);
return $result;
}
private function totalRecords( ContentPackage $package ): int {
$total = 0;
foreach ( $package->manifest() as $count ) {
$total += max( 0, (int) $count );
}
return $total;
}
/**
* @return array<int, array<string, mixed>>
*/
private function recordsForBucket( ContentPackage $package, string $bucket ): array {
$records = $package->records()[ $bucket ] ?? array();
if ( ! is_array( $records ) ) {
return array();
}
return array_values(
array_filter(
$records,
static function ( $record ): bool {
return is_array( $record );
}
)
);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
/**
* Immutable sync operation progress state.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncOperationState {
private string $operation_id;
private string $status;
private string $current_bucket;
private int $processed;
private int $total;
private function __construct( string $operation_id, string $status, string $current_bucket, int $processed, int $total ) {
$this->operation_id = sanitize_key( $operation_id );
$this->status = sanitize_key( $status );
$this->current_bucket = sanitize_key( $current_bucket );
$this->processed = max( 0, $processed );
$this->total = max( 0, $total );
}
public static function running( string $operation_id, string $current_bucket, int $processed, int $total ): self {
return new self( $operation_id, 'running', $current_bucket, $processed, $total );
}
public static function completed( string $operation_id, int $processed, int $total ): self {
return new self( $operation_id, 'completed', '', $processed, $total );
}
/**
* @param array<string, mixed> $data State data.
*/
public static function fromArray( array $data ): self {
return new self(
(string) ( $data['operation_id'] ?? '' ),
(string) ( $data['status'] ?? '' ),
(string) ( $data['current_bucket'] ?? '' ),
(int) ( $data['processed'] ?? 0 ),
(int) ( $data['total'] ?? 0 )
);
}
public function operationId(): string {
return $this->operation_id;
}
public function status(): string {
return $this->status;
}
public function currentBucket(): string {
return $this->current_bucket;
}
public function processed(): int {
return $this->processed;
}
public function total(): int {
return $this->total;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return array(
'operation_id' => $this->operation_id,
'status' => $this->status,
'current_bucket' => $this->current_bucket,
'processed' => $this->processed,
'total' => $this->total,
);
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
/**
* Immutable sync operation result.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncResult {
private bool $successful;
private int $created;
private int $updated;
private int $skipped;
private int $conflicts;
/** @var array<int, string> */
private array $errors;
/**
* @param array<int, string> $errors Error messages.
*/
private function __construct( bool $successful, int $created, int $updated, int $skipped, int $conflicts, array $errors ) {
$this->successful = $successful;
$this->created = max( 0, $created );
$this->updated = max( 0, $updated );
$this->skipped = max( 0, $skipped );
$this->conflicts = max( 0, $conflicts );
$this->errors = array_values( array_map( 'strval', $errors ) );
}
/**
* @param array<string, int> $counts Result counts.
*/
public static function success( array $counts = array() ): self {
return new self(
true,
(int) ( $counts['created'] ?? 0 ),
(int) ( $counts['updated'] ?? 0 ),
(int) ( $counts['skipped'] ?? 0 ),
(int) ( $counts['conflicts'] ?? 0 ),
array()
);
}
/**
* @param array<int, string> $errors Error messages.
*/
public static function failure( array $errors ): self {
return new self( false, 0, 0, 0, 0, $errors );
}
/**
* @param array<int, self> $results Results to merge.
*/
public static function merge( array $results ): self {
$successful = true;
$created = 0;
$updated = 0;
$skipped = 0;
$conflicts = 0;
$errors = array();
foreach ( $results as $result ) {
$successful = $successful && $result->isSuccessful();
$created += $result->created();
$updated += $result->updated();
$skipped += $result->skipped();
$conflicts += $result->conflicts();
$errors = array_merge( $errors, $result->errors() );
}
return new self( $successful, $created, $updated, $skipped, $conflicts, $errors );
}
public function isSuccessful(): bool {
return $this->successful;
}
public function created(): int {
return $this->created;
}
public function updated(): int {
return $this->updated;
}
public function skipped(): int {
return $this->skipped;
}
public function conflicts(): int {
return $this->conflicts;
}
/**
* @return array<int, string>
*/
public function errors(): array {
return $this->errors;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array {
return array(
'successful' => $this->successful,
'created' => $this->created,
'updated' => $this->updated,
'skipped' => $this->skipped,
'conflicts' => $this->conflicts,
'errors' => $this->errors,
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Sync operation state persistence.
*
* @package WPContentSync
*/
namespace WPContentSync\Sync;
final class SyncStateRepository {
private const DEFAULT_EXPIRATION = 86400;
public function save( SyncOperationState $state ): void {
set_transient( $this->key( $state->operationId() ), $state->toArray(), $this->expiration() );
}
public function get( string $operation_id ): ?SyncOperationState {
$value = get_transient( $this->key( $operation_id ) );
if ( ! is_array( $value ) ) {
return null;
}
return SyncOperationState::fromArray( $value );
}
public function delete( string $operation_id ): void {
delete_transient( $this->key( $operation_id ) );
}
private function key( string $operation_id ): string {
return 'wpcs_sync_state_' . sanitize_key( $operation_id );
}
private function expiration(): int {
return defined( 'DAY_IN_SECONDS' ) ? (int) DAY_IN_SECONDS : self::DEFAULT_EXPIRATION;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* File package transport boundary.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
interface FileTransportInterface {
public function export( ContentPackage $package ): string;
public function import( string $contents ): ContentPackage;
}
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* JSON file transport implementation.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageValidator;
final class JsonFileTransport implements FileTransportInterface {
private PackageValidator $validator;
public function __construct( PackageValidator $validator ) {
$this->validator = $validator;
}
public function export( ContentPackage $package ): string {
$json = wp_json_encode( $package->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( false === $json ) {
throw new \RuntimeException( 'Unable to encode content package JSON.' );
}
return $json;
}
public function import( string $contents ): ContentPackage {
$decoded = json_decode( $contents, true );
if ( ! is_array( $decoded ) ) {
throw new \InvalidArgumentException( 'The selected file is not valid JSON.' );
}
$result = $this->validator->validate( $decoded );
if ( ! $result->isValid() ) {
throw new \InvalidArgumentException( implode( ' ', $result->errors() ) );
}
return ContentPackage::fromArray( $decoded );
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* REST package transport client.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
use WPContentSync\Package\ContentPackage;
final class RestTransportClient {
public function testConnection( string $base_url, string $username, string $application_password ): bool {
$response = wp_remote_get(
$this->endpoint( $base_url, 'status' ),
$this->requestArgs( $username, $application_password )
);
$this->assertSuccessfulResponse( $response, 200 );
return true;
}
public function sendPackage( string $base_url, string $username, string $application_password, ContentPackage $package ): bool {
$body = wp_json_encode( array( 'package' => $package->toArray() ) );
if ( false === $body ) {
throw RestTransportException::remoteRejected( 'Unable to encode REST package payload.' );
}
$args = $this->requestArgs( $username, $application_password );
$args['body'] = $body;
$args['headers']['Content-Type'] = 'application/json';
$response = wp_remote_post( $this->endpoint( $base_url, 'package' ), $args );
$this->assertSuccessfulResponse( $response, 200 );
return true;
}
private function endpoint( string $base_url, string $route ): string {
return rtrim( $base_url, '/' ) . '/wp-json/wp-content-sync/v1/' . ltrim( $route, '/' );
}
/**
* @return array<string, mixed>
*/
private function requestArgs( string $username, string $application_password ): array {
return array(
'timeout' => 15,
'headers' => array(
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Basic auth requires base64-encoded username:application-password credentials.
'Authorization' => 'Basic ' . base64_encode( $username . ':' . $application_password ),
),
);
}
/**
* @param mixed $response HTTP response.
*/
private function assertSuccessfulResponse( $response, int $expected_code ): void {
if ( is_wp_error( $response ) ) {
throw RestTransportException::connectionFailed( $response->get_error_message() );
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 401 === $status_code || 403 === $status_code ) {
throw RestTransportException::authenticationFailed( 'REST authentication failed.' );
}
if ( $expected_code !== $status_code ) {
throw RestTransportException::remoteRejected( $this->responseMessage( $response ) );
}
}
/**
* @param array<string, mixed> $response HTTP response.
*/
private function responseMessage( array $response ): string {
$body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $body, true );
if ( is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) {
return $decoded['message'];
}
return 'REST transport request failed.';
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
/**
* Typed REST transport failure.
*
* @package WPContentSync
*/
namespace WPContentSync\Transport;
final class RestTransportException extends \RuntimeException {
/** @var array<string, mixed> */
private array $context;
private string $failure_code;
/**
* @param array<string, mixed> $context Failure context.
*/
private function __construct( string $failure_code, string $message, array $context = array() ) {
parent::__construct( $message );
$this->failure_code = $failure_code;
$this->context = $context;
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function connectionFailed( string $message, array $context = array() ): self {
return new self( 'connection_failed', $message, $context );
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function authenticationFailed( string $message, array $context = array() ): self {
return new self( 'authentication_failed', $message, $context );
}
/**
* @param array<string, mixed> $context Failure context.
*/
public static function remoteRejected( string $message, array $context = array() ): self {
return new self( 'remote_rejected', $message, $context );
}
public function failureCode(): string {
return $this->failure_code;
}
/**
* @return array<string, mixed>
*/
public function context(): array {
return $this->context;
}
}
+201 -36
View File
@@ -10,49 +10,214 @@
if ( ! defined( 'ABSPATH' ) ) { if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
$sync_pairs = $settings->syncPairs();
if ( array() === $sync_pairs ) {
$sync_pairs = array(
array(
'name' => '',
'source_url' => '',
'destination_url' => '',
'username' => '',
'application_password' => '',
'default_direction' => 'push',
'content_types' => array( 'posts', 'terms', 'media', 'custom_post_types' ),
'url_mappings' => array(
array(
'source' => '',
'destination' => '',
),
),
),
);
}
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php echo esc_html__( 'WP Content Sync', 'wp-content-sync' ); ?></h1> <h1><?php echo esc_html__( 'WP Content Sync', 'wp-content-sync' ); ?></h1>
<p><?php echo esc_html__( 'Configure sync pairs, test connections, and run push or pull operations from this screen.', 'wp-content-sync' ); ?></p> <p><?php echo esc_html__( 'Configure sync pairs, test connections, and run push or pull operations from this screen.', 'wp-content-sync' ); ?></p>
<div class="notice notice-info"> <?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Displays redirect status only. ?>
<p> <?php if ( isset( $_GET['wpcs_import_error'] ) ) : ?>
<?php <div class="notice notice-error">
echo esc_html__( <p><?php echo esc_html( sanitize_text_field( wp_unslash( $_GET['wpcs_import_error'] ) ) ); ?></p>
'The foundation is installed. Sync pair management, URL mappings, file transport, and REST transport will be added in upcoming implementation phases.', </div>
'wp-content-sync' <?php endif; ?>
);
?>
</p>
</div>
<h2><?php echo esc_html__( 'Current Defaults', 'wp-content-sync' ); ?></h2> <?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Displays redirect status only. ?>
<table class="widefat striped"> <?php if ( isset( $_GET['wpcs_imported'] ) ) : ?>
<tbody> <div class="notice notice-success">
<tr> <p><?php echo esc_html__( 'The package JSON file was validated successfully.', 'wp-content-sync' ); ?></p>
<th scope="row"><?php echo esc_html__( 'Configured Sync Pairs', 'wp-content-sync' ); ?></th> </div>
<td><?php echo esc_html( (string) count( $settings->syncPairs() ) ); ?></td> <?php endif; ?>
</tr>
<tr> <?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Displays redirect status only. ?>
<th scope="row"><?php echo esc_html__( 'Logging Level', 'wp-content-sync' ); ?></th> <?php if ( isset( $_GET['wpcs_connection_ok'] ) ) : ?>
<td><?php echo esc_html( $settings->loggingLevel() ); ?></td> <div class="notice notice-success">
</tr> <p><?php echo esc_html__( 'REST connection succeeded.', 'wp-content-sync' ); ?></p>
<tr> </div>
<th scope="row"><?php echo esc_html__( 'URL Replacement', 'wp-content-sync' ); ?></th> <?php endif; ?>
<td>
<?php // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Displays redirect status only. ?>
<?php if ( isset( $_GET['wpcs_connection_error'] ) ) : ?>
<div class="notice notice-error">
<p><?php echo esc_html( sanitize_text_field( wp_unslash( $_GET['wpcs_connection_error'] ) ) ); ?></p>
</div>
<?php endif; ?>
<h2><?php echo esc_html__( 'Configuration', 'wp-content-sync' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="wpcs_save_settings" />
<?php wp_nonce_field( 'wpcs_save_settings', 'wpcs_settings_nonce' ); ?>
<h3><?php echo esc_html__( 'Sync Pairs', 'wp-content-sync' ); ?></h3>
<?php foreach ( $sync_pairs as $index => $pair ) : ?>
<div class="card">
<h4>
<?php <?php
echo esc_html( printf(
$settings->automaticUrlReplacementEnabled() /* translators: %d: sync pair number. */
? __( 'Enabled', 'wp-content-sync' ) esc_html__( 'Sync Pair %d', 'wp-content-sync' ),
: __( 'Disabled', 'wp-content-sync' ) (int) $index + 1
); );
?> ?>
</td> </h4>
</tr> <table class="form-table" role="presentation">
<tr> <tbody>
<th scope="row"><?php echo esc_html__( 'Conflict Strategy', 'wp-content-sync' ); ?></th> <tr>
<td><?php echo esc_html( $settings->conflictStrategy() ); ?></td> <th scope="row"><label for="wpcs-pair-name-<?php echo esc_attr( (string) $index ); ?>"><?php echo esc_html__( 'Name', 'wp-content-sync' ); ?></label></th>
</tr> <td><input class="regular-text" id="wpcs-pair-name-<?php echo esc_attr( (string) $index ); ?>" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][name]" type="text" value="<?php echo esc_attr( $pair['name'] ); ?>" /></td>
</tbody> </tr>
</table> <tr>
<th scope="row"><label for="wpcs-source-url-<?php echo esc_attr( (string) $index ); ?>"><?php echo esc_html__( 'Source URL', 'wp-content-sync' ); ?></label></th>
<td><input class="regular-text code" id="wpcs-source-url-<?php echo esc_attr( (string) $index ); ?>" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][source_url]" type="url" value="<?php echo esc_attr( $pair['source_url'] ); ?>" /></td>
</tr>
<tr>
<th scope="row"><label for="wpcs-destination-url-<?php echo esc_attr( (string) $index ); ?>"><?php echo esc_html__( 'Destination URL', 'wp-content-sync' ); ?></label></th>
<td><input class="regular-text code" id="wpcs-destination-url-<?php echo esc_attr( (string) $index ); ?>" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][destination_url]" type="url" value="<?php echo esc_attr( $pair['destination_url'] ); ?>" /></td>
</tr>
<tr>
<th scope="row"><label for="wpcs-username-<?php echo esc_attr( (string) $index ); ?>"><?php echo esc_html__( 'REST Username', 'wp-content-sync' ); ?></label></th>
<td><input class="regular-text" id="wpcs-username-<?php echo esc_attr( (string) $index ); ?>" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][username]" type="text" value="<?php echo esc_attr( $pair['username'] ); ?>" /></td>
</tr>
<tr>
<th scope="row"><label for="wpcs-application-password-<?php echo esc_attr( (string) $index ); ?>"><?php echo esc_html__( 'Application Password', 'wp-content-sync' ); ?></label></th>
<td>
<input class="regular-text" id="wpcs-application-password-<?php echo esc_attr( (string) $index ); ?>" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][application_password]" type="password" value="" autocomplete="new-password" placeholder="<?php echo esc_attr( __( 'Enter a new application password to replace the saved value', 'wp-content-sync' ) ); ?>" />
<p class="description"><?php echo esc_html__( 'Saved application passwords are never rendered back to the browser.', 'wp-content-sync' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'Default Direction', 'wp-content-sync' ); ?></th>
<td>
<select name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][default_direction]">
<option value="push" <?php echo 'push' === $pair['default_direction'] ? 'selected="selected"' : ''; ?>><?php echo esc_html__( 'Push', 'wp-content-sync' ); ?></option>
<option value="pull" <?php echo 'pull' === $pair['default_direction'] ? 'selected="selected"' : ''; ?>><?php echo esc_html__( 'Pull', 'wp-content-sync' ); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'Content Types', 'wp-content-sync' ); ?></th>
<td>
<?php foreach ( array( 'posts', 'terms', 'media', 'custom_post_types' ) as $content_type ) : ?>
<label>
<input name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][content_types][]" type="checkbox" value="<?php echo esc_attr( $content_type ); ?>" <?php echo in_array( $content_type, $pair['content_types'], true ) ? 'checked="checked"' : ''; ?> />
<?php echo esc_html( ucwords( str_replace( '_', ' ', $content_type ) ) ); ?>
</label><br />
<?php endforeach; ?>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'URL Mapping', 'wp-content-sync' ); ?></th>
<td>
<?php $mapping = $pair['url_mappings'][0] ?? array( 'source' => '', 'destination' => '' ); ?>
<input class="regular-text code" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][url_mappings][0][source]" type="url" value="<?php echo esc_attr( $mapping['source'] ); ?>" placeholder="<?php echo esc_attr( __( 'Source URL', 'wp-content-sync' ) ); ?>" />
<input class="regular-text code" name="wpcs_settings[sync_pairs][<?php echo esc_attr( (string) $index ); ?>][url_mappings][0][destination]" type="url" value="<?php echo esc_attr( $mapping['destination'] ); ?>" placeholder="<?php echo esc_attr( __( 'Destination URL', 'wp-content-sync' ) ); ?>" />
</td>
</tr>
</tbody>
</table>
</div>
<?php endforeach; ?>
<h3><?php echo esc_html__( 'Defaults', 'wp-content-sync' ); ?></h3>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><?php echo esc_html__( 'Logging Level', 'wp-content-sync' ); ?></th>
<td>
<select name="wpcs_settings[logging_level]">
<?php foreach ( array( 'error', 'warning', 'info', 'debug' ) as $level ) : ?>
<option value="<?php echo esc_attr( $level ); ?>" <?php echo $level === $settings->loggingLevel() ? 'selected="selected"' : ''; ?>><?php echo esc_html( $level ); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'Conflict Strategy', 'wp-content-sync' ); ?></th>
<td>
<select name="wpcs_settings[conflict_strategy]">
<option value="last_write_wins" <?php echo 'last_write_wins' === $settings->conflictStrategy() ? 'selected="selected"' : ''; ?>><?php echo esc_html__( 'Last write wins', 'wp-content-sync' ); ?></option>
<option value="manual_review" <?php echo 'manual_review' === $settings->conflictStrategy() ? 'selected="selected"' : ''; ?>><?php echo esc_html__( 'Manual review', 'wp-content-sync' ); ?></option>
</select>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'URL Replacement', 'wp-content-sync' ); ?></th>
<td>
<label>
<input name="wpcs_settings[automatic_url_replacement]" type="checkbox" value="1" <?php echo $settings->automaticUrlReplacementEnabled() ? 'checked="checked"' : ''; ?> />
<?php echo esc_html__( 'Automatically replace mapped URLs during import.', 'wp-content-sync' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><label for="wpcs-log-retention"><?php echo esc_html__( 'Log Retention', 'wp-content-sync' ); ?></label></th>
<td><input id="wpcs-log-retention" name="wpcs_settings[log_retention]" type="number" min="10" max="1000" value="<?php echo esc_attr( (string) $settings->logRetention() ); ?>" /></td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__( 'Debug Logging', 'wp-content-sync' ); ?></th>
<td>
<label>
<input name="wpcs_settings[debug_logging]" type="checkbox" value="1" <?php echo $settings->debugLoggingEnabled() ? 'checked="checked"' : ''; ?> />
<?php echo esc_html__( 'Enable verbose debug logs while troubleshooting.', 'wp-content-sync' ); ?>
</label>
</td>
</tr>
</tbody>
</table>
<?php submit_button( __( 'Save Settings', 'wp-content-sync' ) ); ?>
</form>
<h2><?php echo esc_html__( 'Connection Diagnostics', 'wp-content-sync' ); ?></h2>
<p><?php echo esc_html__( 'Test whether each configured destination site accepts REST requests with the saved application password.', 'wp-content-sync' ); ?></p>
<?php foreach ( $sync_pairs as $index => $pair ) : ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="wpcs_test_connection" />
<input type="hidden" name="pair_index" value="<?php echo esc_attr( (string) $index ); ?>" />
<?php wp_nonce_field( 'wpcs_test_connection', 'wpcs_connection_nonce' ); ?>
<p>
<?php
printf(
/* translators: %s: sync pair name. */
esc_html__( 'Pair: %s', 'wp-content-sync' ),
esc_html( '' !== $pair['name'] ? $pair['name'] : __( 'Unnamed sync pair', 'wp-content-sync' ) )
);
?>
</p>
<?php submit_button( __( 'Test REST Connection', 'wp-content-sync' ), 'secondary' ); ?>
</form>
<?php endforeach; ?>
<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> </div>
@@ -0,0 +1,51 @@
<?php
/**
* Tests for redirect-driven admin notices.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\AdminNoticeRepository;
class AdminNoticeRepositoryTest extends TestCase {
protected function tearDown(): void {
$_GET = array();
parent::tearDown();
}
public function test_it_builds_import_success_notices(): void {
$_GET['wpcs_imported'] = '1';
$notices = ( new AdminNoticeRepository() )->current();
self::assertSame( 'success', $notices[0]->type() );
self::assertSame( 'The package JSON file was imported successfully.', $notices[0]->message() );
}
public function test_it_sanitizes_error_notices(): void {
$_GET['wpcs_import_error'] = '<script>Bad package</script>';
$notices = ( new AdminNoticeRepository() )->current();
self::assertSame( 'error', $notices[0]->type() );
self::assertSame( 'Bad package', $notices[0]->message() );
}
public function test_it_builds_settings_connection_logs_and_export_notices(): void {
$_GET['wpcs_settings_saved'] = '1';
$_GET['wpcs_connection_ok'] = '1';
$_GET['wpcs_logs_cleared'] = '1';
$_GET['wpcs_connection_error'] = 'REST authentication failed.';
$_GET['wpcs_export_error'] = 'Export failed.';
$notices = ( new AdminNoticeRepository() )->current();
self::assertSame( 'Settings saved.', $notices[0]->message() );
self::assertSame( 'REST connection succeeded.', $notices[1]->message() );
self::assertSame( 'Logs cleared.', $notices[2]->message() );
self::assertSame( 'REST authentication failed.', $notices[3]->message() );
self::assertSame( 'Export failed.', $notices[4]->message() );
}
}
@@ -0,0 +1,106 @@
<?php
/**
* Tests for admin REST connection diagnostics.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\ConnectionTestController;
use WPContentSync\Logging\OptionLogger;
use WPContentSync\Settings\SettingsRepository;
use WPContentSync\Transport\RestTransportClient;
class ConnectionTestControllerTest extends TestCase {
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_current_user_can'],
$GLOBALS['wpcs_nonce_valid'],
$GLOBALS['wpcs_redirect_location'],
$GLOBALS['wpcs_test_options'],
$GLOBALS['wpcs_test_option_autoloads'],
$GLOBALS['wpcs_http_response'],
$GLOBALS['wpcs_last_http_request']
);
$_POST = array();
parent::tearDown();
}
public function test_it_tests_a_configured_pair_with_nonce_and_capability(): void {
$this->storePair();
$GLOBALS['wpcs_current_user_can']['manage_options'] = true;
$GLOBALS['wpcs_nonce_valid']['wpcs_test_connection']['wpcs_connection_nonce'] = true;
$_POST['pair_index'] = '0';
$this->controller()->handleTest();
self::assertStringContainsString( 'wpcs_connection_ok=1', $GLOBALS['wpcs_redirect_location'] );
self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] );
self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] );
}
public function test_it_rejects_users_without_manage_options(): void {
$GLOBALS['wpcs_current_user_can']['manage_options'] = false;
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'You do not have permission to test WP Content Sync connections.' );
$this->controller()->handleTest();
}
public function test_it_rejects_invalid_nonces(): void {
$GLOBALS['wpcs_nonce_valid']['wpcs_test_connection']['wpcs_connection_nonce'] = false;
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'The connection test request could not be verified.' );
$this->controller()->handleTest();
}
public function test_it_redirects_failures_without_leaking_application_passwords(): void {
$this->storePair();
$GLOBALS['wpcs_current_user_can']['manage_options'] = true;
$GLOBALS['wpcs_nonce_valid']['wpcs_test_connection']['wpcs_connection_nonce'] = true;
$GLOBALS['wpcs_http_response'] = array(
'response' => array( 'code' => 401 ),
'body' => '{"message":"Unauthorized"}',
);
$_POST['pair_index'] = '0';
$this->controller()->handleTest();
self::assertStringContainsString( 'wpcs_connection_error=', $GLOBALS['wpcs_redirect_location'] );
self::assertStringNotContainsString( 'app-pass', $GLOBALS['wpcs_redirect_location'] );
self::assertStringNotContainsString( 'app-pass', wp_json_encode( $GLOBALS['wpcs_test_options'][ OptionLogger::OPTION_NAME ] ) );
}
private function controller(): ConnectionTestController {
return new ConnectionTestController(
new SettingsRepository(),
new RestTransportClient(),
new OptionLogger()
);
}
private function storePair(): void {
update_option(
SettingsRepository::OPTION_NAME,
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'source_url' => 'https://source.test',
'destination_url' => 'https://destination.test',
'username' => 'codex',
'application_password' => 'app-pass',
),
),
),
false
);
}
}
@@ -0,0 +1,87 @@
<?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 );
}
public function test_it_renders_settings_form_with_nonce_and_escaped_pair_values(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => '<b>Staging</b>',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
'username' => 'codex',
),
),
)
);
$output = $this->renderDashboard( $settings );
self::assertStringContainsString( 'action="https://example.test/wp-admin/admin-post.php"', $output );
self::assertStringContainsString( 'name="action" value="wpcs_save_settings"', $output );
self::assertStringContainsString( 'name="wpcs_settings_nonce"', $output );
self::assertStringContainsString( 'Staging', $output );
self::assertStringNotContainsString( '<b>Staging</b>', $output );
self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][application_password]"', $output );
self::assertStringContainsString( 'name="wpcs_settings[sync_pairs][0][url_mappings][0][source]"', $output );
}
public function test_it_renders_connection_diagnostics_for_each_pair(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'destination_url' => 'https://staging.example.test',
'username' => 'codex',
),
),
)
);
$output = $this->renderDashboard( $settings );
self::assertStringContainsString( 'name="action" value="wpcs_test_connection"', $output );
self::assertStringContainsString( 'name="wpcs_connection_nonce"', $output );
self::assertStringContainsString( 'name="pair_index" value="0"', $output );
self::assertStringContainsString( 'Test REST Connection', $output );
}
private function renderDashboard( ?Settings $settings = null ): string {
$settings = $settings ?? Settings::fromArray( array() );
ob_start();
include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php';
return (string) ob_get_clean();
}
}
@@ -0,0 +1,265 @@
<?php
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\FileImportController;
use WPContentSync\Content\ContentHandlerInterface;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Settings\SettingsRepository;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncEngine;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Sync\SyncStateRepository;
use WPContentSync\Transport\JsonFileTransport;
class FileImportControllerTest extends TestCase {
/** @var array<int, string> */
private array $temporary_files = array();
/** @var array<int, array<string, mixed>> */
private array $logs = 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'],
$GLOBALS['wpcs_test_options'],
$GLOBALS['wpcs_test_option_autoloads'],
$GLOBALS['wpcs_test_transients'],
$GLOBALS['wpcs_test_transient_expiration']
);
$_FILES = array();
$this->logs = array();
parent::tearDown();
}
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_with_sync_engine(): 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'] );
self::assertSame( 'Imported content package.', $this->logs[2]['message'] );
self::assertSame( 0, $this->logs[2]['context']['created'] );
}
public function test_it_redirects_with_error_when_sync_engine_import_fails(): void {
$file = $this->createTemporaryPackageFile( $this->validJson() );
$_FILES['wpcs_package_file'] = array(
'tmp_name' => $file,
'error' => UPLOAD_ERR_OK,
);
$this->controller( SyncResult::failure( array( 'Posts failed.' ) ) )->handleImport();
self::assertStringContainsString( 'wpcs_import_error=', $GLOBALS['wpcs_redirect_location'] );
self::assertStringContainsString( 'Posts+failed.', $GLOBALS['wpcs_redirect_location'] );
}
public function test_it_redirects_with_error_for_invalid_uploaded_packages(): void {
$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( ?SyncResult $result = null ): FileImportController {
$logger = $this->logger();
return new FileImportController(
new JsonFileTransport( new PackageValidator() ),
$logger,
$this->syncEngine( $result ?? SyncResult::success() )
);
}
private function syncEngine( SyncResult $result ): SyncEngine {
return new SyncEngine(
new ContentHandlerRegistry( array( $this->handler( $result ) ) ),
new SyncStateRepository(),
new SettingsRepository(),
$this->logger()
);
}
private function handler( SyncResult $result ): ContentHandlerInterface {
return new class( $result ) implements ContentHandlerInterface {
private SyncResult $result;
public function __construct( SyncResult $result ) {
$this->result = $result;
}
public function bucket(): string {
return 'posts';
}
public function importRecords( array $records, SyncContext $context ): SyncResult {
return $this->result;
}
};
}
private function logger(): LoggerInterface {
return new class( $this->logs ) implements LoggerInterface {
/** @var array<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,
);
}
};
}
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,70 @@
<?php
/**
* Tests for admin settings saves.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\SettingsController;
use WPContentSync\Settings\SettingsRepository;
class SettingsControllerTest extends TestCase {
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_current_user_can'],
$GLOBALS['wpcs_nonce_valid'],
$GLOBALS['wpcs_redirect_location'],
$GLOBALS['wpcs_test_options'],
$GLOBALS['wpcs_test_option_autoloads']
);
$_POST = array();
parent::tearDown();
}
public function test_it_saves_settings_with_nonce_and_capability(): void {
$GLOBALS['wpcs_current_user_can']['manage_options'] = true;
$GLOBALS['wpcs_nonce_valid']['wpcs_save_settings']['wpcs_settings_nonce'] = true;
$_POST['wpcs_settings'] = array(
'logging_level' => 'debug',
'conflict_strategy' => 'manual_review',
'sync_pairs' => array(
array(
'name' => 'Staging',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
),
),
);
( new SettingsController( new SettingsRepository() ) )->handleSave();
self::assertSame( 'debug', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['logging_level'] );
self::assertSame( 'manual_review', $GLOBALS['wpcs_test_options'][ SettingsRepository::OPTION_NAME ]['conflict_strategy'] );
self::assertStringContainsString( 'wpcs_settings_saved=1', $GLOBALS['wpcs_redirect_location'] );
}
public function test_it_rejects_users_without_manage_options(): void {
$GLOBALS['wpcs_current_user_can']['manage_options'] = false;
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'You do not have permission to save WP Content Sync settings.' );
( new SettingsController( new SettingsRepository() ) )->handleSave();
}
public function test_it_rejects_invalid_nonces(): void {
$GLOBALS['wpcs_nonce_valid']['wpcs_save_settings']['wpcs_settings_nonce'] = false;
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'The settings save request could not be verified.' );
( new SettingsController( new SettingsRepository() ) )->handleSave();
}
}
@@ -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,276 @@
<?php
/**
* Tests for media content imports.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\MediaContentHandler;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer;
class MediaContentHandlerTest 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'],
$GLOBALS['wpcs_test_attachment_files'],
$GLOBALS['wpcs_test_attachment_metadata']
);
$this->logs = array();
parent::tearDown();
}
public function test_it_creates_attachment_records_without_downloading_files(): void {
$result = $this->handler()->importRecords(
array(
$this->mediaRecord(),
),
$this->context( 'last_write_wins' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 'Imported Image', get_post( 1 )['post_title'] );
self::assertSame( 'attachment', get_post( 1 )['post_type'] );
self::assertSame( 'image/jpeg', get_post( 1 )['post_mime_type'] );
self::assertSame( 42, get_post_meta( 1, '_wpcs_source_id', true ) );
self::assertSame( 'https://source.test', get_post_meta( 1, '_wpcs_source_site', true ) );
self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_wpcs_source_url', true ) );
self::assertSame( array( false ), $GLOBALS['wpcs_test_attachment_files'] );
self::assertSame( 'Skipped media binary download; importing attachment metadata only.', $this->logs[0]['message'] );
}
public function test_it_updates_attachment_metadata_with_last_write_wins(): void {
$attachment_id = wp_insert_attachment(
array(
'post_title' => 'Old Image',
'post_mime_type' => 'image/jpeg',
),
false,
0,
true
);
update_post_meta( $attachment_id, '_wpcs_source_id', 42 );
update_post_meta( $attachment_id, '_wpcs_source_site', 'https://source.test' );
$result = $this->handler()->importRecords(
array(
$this->mediaRecord(
array(
'post_title' => 'Updated Image',
'metadata' => array(
'width' => 1200,
'height' => 800,
),
)
),
),
$this->context( 'last_write_wins' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->updated() );
self::assertSame( 'Updated Image', get_post( $attachment_id )['post_title'] );
self::assertSame(
array(
'width' => 1200,
'height' => 800,
),
wp_get_attachment_metadata( $attachment_id )
);
}
public function test_it_does_not_match_existing_media_from_a_different_source_site(): void {
$attachment_id = wp_insert_attachment(
array(
'post_title' => 'Other Site Image',
'post_mime_type' => 'image/jpeg',
),
false,
0,
true
);
update_post_meta( $attachment_id, '_wpcs_source_id', 42 );
update_post_meta( $attachment_id, '_wpcs_source_site', 'https://other-source.test' );
$result = $this->handler()->importRecords(
array(
$this->mediaRecord( array( 'post_title' => 'Current Site Image' ) ),
),
$this->context( 'last_write_wins' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 'Other Site Image', get_post( $attachment_id )['post_title'] );
self::assertSame( 'Current Site Image', get_post( 2 )['post_title'] );
}
public function test_it_rewrites_source_url_metadata_and_meta_urls(): void {
$result = $this->handler()->importRecords(
array(
$this->mediaRecord(
array(
'metadata' => array(
'file' => 'https://source.test/uploads/image.jpg',
'sizes' => array(
'thumbnail' => array(
'url' => 'https://source.test/uploads/image-150x150.jpg',
),
),
),
'meta' => array(
'_source_url' => 'https://source.test/uploads/image.jpg',
'_json_links' => '{"url":"https://source.test/uploads/image.jpg"}',
),
)
),
),
$this->context( 'last_write_wins' )
);
$metadata = wp_get_attachment_metadata( 1 );
self::assertTrue( $result->isSuccessful() );
self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_wpcs_source_url', true ) );
self::assertSame( 'https://destination.test/uploads/image.jpg', $metadata['file'] );
self::assertSame( 'https://destination.test/uploads/image-150x150.jpg', $metadata['sizes']['thumbnail']['url'] );
self::assertSame( 'https://destination.test/uploads/image.jpg', get_post_meta( 1, '_source_url', true ) );
self::assertSame( '{"url":"https:\/\/destination.test\/uploads\/image.jpg"}', get_post_meta( 1, '_json_links', true ) );
}
public function test_it_skips_existing_media_with_manual_review_conflict(): void {
$attachment_id = wp_insert_attachment(
array(
'post_title' => 'Old Image',
'post_mime_type' => 'image/jpeg',
),
false,
0,
true
);
update_post_meta( $attachment_id, '_wpcs_source_id', 42 );
update_post_meta( $attachment_id, '_wpcs_source_site', 'https://source.test' );
$result = $this->handler()->importRecords(
array(
$this->mediaRecord( array( 'post_title' => 'Updated Image' ) ),
),
$this->context( 'manual_review' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( 'Old Image', get_post( $attachment_id )['post_title'] );
self::assertSame( 'Skipped media import because manual review is required.', $this->logs[0]['message'] );
}
public function test_it_returns_failure_when_wordpress_rejects_attachment_save(): void {
$result = $this->handler()->importRecords(
array(
$this->mediaRecord(
array(
'id' => 0,
'post_mime_type' => '',
)
),
),
$this->context( 'last_write_wins' )
);
self::assertFalse( $result->isSuccessful() );
self::assertSame( array( 'Media import failed for source ID 0.' ), $result->errors() );
self::assertSame( array(), get_post_meta( 0, '_wpcs_source_id', false ) );
}
private function handler(): MediaContentHandler {
return new MediaContentHandler(
new ContentRecordNormalizer(),
new UrlTransformer(),
new MetadataUrlTransformer( new UrlTransformer() ),
$this->logger()
);
}
private function context( string $conflict_strategy ): SyncContext {
return SyncContext::forImport(
array( 'site_url' => 'https://source.test' ),
array( 'site_url' => 'https://destination.test' ),
$conflict_strategy,
'operation-1'
);
}
/**
* @param array<string, mixed> $overrides Record overrides.
* @return array<string, mixed>
*/
private function mediaRecord( array $overrides = array() ): array {
return array_merge(
array(
'id' => 42,
'post_title' => 'Imported Image',
'post_mime_type' => 'image/jpeg',
'source_url' => 'https://source.test/uploads/image.jpg',
'metadata' => array(),
'meta' => array(),
),
$overrides
);
}
private function logger(): LoggerInterface {
return new class( $this->logs ) implements LoggerInterface {
/** @var array<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,218 @@
<?php
/**
* Tests for post content imports.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\PostContentHandler;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer;
class PostContentHandlerTest extends TestCase {
/** @var array<int, array<string, mixed>> */
private array $logs = array();
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_test_posts'],
$GLOBALS['wpcs_test_next_post_id'],
$GLOBALS['wpcs_test_post_meta']
);
$this->logs = array();
parent::tearDown();
}
public function test_it_creates_new_post_records(): void {
$result = $this->handler()->importRecords(
array(
$this->postRecord(),
),
$this->context( 'last_write_wins' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 'Imported Title', get_post( 1 )['post_title'] );
self::assertSame( 42, get_post_meta( 1, '_wpcs_source_id', true ) );
self::assertSame( 'https://source.test', get_post_meta( 1, '_wpcs_source_site', true ) );
}
public function test_it_updates_existing_posts_with_last_write_wins(): void {
$post_id = wp_insert_post(
array(
'post_title' => 'Old Title',
'post_type' => 'post',
),
true
);
update_post_meta( $post_id, '_wpcs_source_id', 42 );
$result = $this->handler()->importRecords(
array(
$this->postRecord( array( 'post_title' => 'New Title' ) ),
),
$this->context( 'last_write_wins' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->updated() );
self::assertSame( 'New Title', get_post( $post_id )['post_title'] );
}
public function test_it_skips_existing_posts_with_manual_review_conflict(): void {
$post_id = wp_insert_post(
array(
'post_title' => 'Old Title',
'post_type' => 'post',
),
true
);
update_post_meta( $post_id, '_wpcs_source_id', 42 );
$result = $this->handler()->importRecords(
array(
$this->postRecord( array( 'post_title' => 'New Title' ) ),
),
$this->context( 'manual_review' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( 'Old Title', get_post( $post_id )['post_title'] );
self::assertSame( 'Skipped post import because manual review is required.', $this->logs[0]['message'] );
}
public function test_it_rewrites_post_content_excerpt_and_meta_urls(): void {
$result = $this->handler()->importRecords(
array(
$this->postRecord(
array(
'post_content' => '<a href="https://source.test/page">Page</a>',
'post_excerpt' => 'Read https://source.test/page',
'meta' => array(
'_source_url' => 'https://source.test/page',
'_json_links' => '{"url":"https://source.test/page"}',
),
)
),
),
$this->context( 'last_write_wins' )
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( '<a href="https://destination.test/page">Page</a>', get_post( 1 )['post_content'] );
self::assertSame( 'Read https://destination.test/page', get_post( 1 )['post_excerpt'] );
self::assertSame( 'https://destination.test/page', get_post_meta( 1, '_source_url', true ) );
self::assertSame( '{"url":"https:\/\/destination.test\/page"}', get_post_meta( 1, '_json_links', true ) );
}
public function test_it_returns_failure_when_wordpress_rejects_post_save(): void {
$result = $this->handler()->importRecords(
array(
$this->postRecord(
array(
'id' => 0,
'post_type' => '',
)
),
),
$this->context( 'last_write_wins' )
);
self::assertFalse( $result->isSuccessful() );
self::assertSame( array( 'Post import failed for source ID 0.' ), $result->errors() );
self::assertSame( array(), get_post_meta( 0, '_wpcs_source_id', false ) );
}
private function handler(): PostContentHandler {
return new PostContentHandler(
new ContentRecordNormalizer(),
new UrlTransformer(),
new MetadataUrlTransformer( new UrlTransformer() ),
$this->logger()
);
}
private function context( string $conflict_strategy ): SyncContext {
return SyncContext::forImport(
array( 'site_url' => 'https://source.test' ),
array( 'site_url' => 'https://destination.test' ),
$conflict_strategy,
'operation-1'
);
}
/**
* @param array<string, mixed> $overrides Record overrides.
* @return array<string, mixed>
*/
private function postRecord( array $overrides = array() ): array {
return array_merge(
array(
'id' => 42,
'post_type' => 'post',
'post_title' => 'Imported Title',
'post_content' => 'Imported content',
'post_excerpt' => 'Imported excerpt',
'post_status' => 'publish',
'post_name' => 'imported-title',
'post_parent' => 0,
'menu_order' => 0,
'meta' => array(),
),
$overrides
);
}
private function logger(): LoggerInterface {
return new class( $this->logs ) implements LoggerInterface {
/** @var array<int, array<string, mixed>> */
private array $logs;
/**
* @param array<int, array<string, mixed>> $logs Logs.
*/
public function __construct( array &$logs ) {
$this->logs = &$logs;
}
public function error( string $message, array $context = array() ): void {
$this->record( 'error', $message, $context );
}
public function warning( string $message, array $context = array() ): void {
$this->record( 'warning', $message, $context );
}
public function info( string $message, array $context = array() ): void {
$this->record( 'info', $message, $context );
}
public function debug( string $message, array $context = array() ): void {
$this->record( 'debug', $message, $context );
}
/**
* @param array<string, mixed> $context Context.
*/
private function record( string $level, string $message, array $context ): void {
$this->logs[] = array(
'level' => $level,
'message' => $message,
'context' => $context,
);
}
};
}
}
@@ -0,0 +1,209 @@
<?php
/**
* Tests for term content imports.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\TermContentHandler;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer;
class TermContentHandlerTest extends TestCase {
/** @var array<int, array<string, mixed>> */
private array $logs = array();
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_test_terms'],
$GLOBALS['wpcs_test_next_term_id'],
$GLOBALS['wpcs_test_term_meta']
);
$this->logs = array();
parent::tearDown();
}
public function test_it_creates_new_terms_by_taxonomy_and_slug(): void {
$result = $this->handler()->importRecords(
array(
$this->termRecord(),
),
$this->context( 'last_write_wins' )
);
$term = get_term_by( 'slug', 'news', 'category' );
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 'News', $term->name );
self::assertSame( 42, get_term_meta( $term->term_id, '_wpcs_source_id', true ) );
self::assertSame( 'https://source.test', get_term_meta( $term->term_id, '_wpcs_source_site', true ) );
}
public function test_it_updates_existing_terms_with_last_write_wins(): void {
$existing = wp_insert_term( 'Old News', 'category', array( 'slug' => 'news' ) );
update_term_meta( $existing['term_id'], '_wpcs_source_id', 42 );
$result = $this->handler()->importRecords(
array(
$this->termRecord( array( 'name' => 'Updated News' ) ),
),
$this->context( 'last_write_wins' )
);
$term = get_term_by( 'id', $existing['term_id'], 'category' );
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->updated() );
self::assertSame( 'Updated News', $term->name );
}
public function test_it_skips_existing_terms_with_manual_review_conflict(): void {
$existing = wp_insert_term( 'Old News', 'category', array( 'slug' => 'news' ) );
update_term_meta( $existing['term_id'], '_wpcs_source_id', 42 );
$result = $this->handler()->importRecords(
array(
$this->termRecord( array( 'name' => 'Updated News' ) ),
),
$this->context( 'manual_review' )
);
$term = get_term_by( 'id', $existing['term_id'], 'category' );
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( 'Old News', $term->name );
self::assertSame( 'Skipped term import because manual review is required.', $this->logs[0]['message'] );
}
public function test_it_rewrites_term_description_and_meta_urls(): void {
$result = $this->handler()->importRecords(
array(
$this->termRecord(
array(
'description' => '<a href="https://source.test/news">News</a>',
'meta' => array(
'landing_url' => 'https://source.test/news',
'json_links' => '{"url":"https://source.test/news"}',
),
)
),
),
$this->context( 'last_write_wins' )
);
$term = get_term_by( 'slug', 'news', 'category' );
self::assertTrue( $result->isSuccessful() );
self::assertSame( '<a href="https://destination.test/news">News</a>', $term->description );
self::assertSame( 'https://destination.test/news', get_term_meta( $term->term_id, 'landing_url', true ) );
self::assertSame( '{"url":"https:\/\/destination.test\/news"}', get_term_meta( $term->term_id, 'json_links', true ) );
}
public function test_it_returns_failure_when_wordpress_rejects_term_save(): void {
$result = $this->handler()->importRecords(
array(
$this->termRecord(
array(
'id' => 0,
'taxonomy' => '',
'name' => '',
)
),
),
$this->context( 'last_write_wins' )
);
self::assertFalse( $result->isSuccessful() );
self::assertSame( array( 'Term import failed for source ID 0.' ), $result->errors() );
self::assertSame( array(), get_term_meta( 0, '_wpcs_source_id', false ) );
}
private function handler(): TermContentHandler {
return new TermContentHandler(
new ContentRecordNormalizer(),
new UrlTransformer(),
new MetadataUrlTransformer( new UrlTransformer() ),
$this->logger()
);
}
private function context( string $conflict_strategy ): SyncContext {
return SyncContext::forImport(
array( 'site_url' => 'https://source.test' ),
array( 'site_url' => 'https://destination.test' ),
$conflict_strategy,
'operation-1'
);
}
/**
* @param array<string, mixed> $overrides Record overrides.
* @return array<string, mixed>
*/
private function termRecord( array $overrides = array() ): array {
return array_merge(
array(
'id' => 42,
'taxonomy' => 'category',
'name' => 'News',
'slug' => 'news',
'description' => 'News description',
'parent' => 0,
'meta' => array(),
),
$overrides
);
}
private function logger(): LoggerInterface {
return new class( $this->logs ) implements LoggerInterface {
/** @var array<int, array<string, mixed>> */
private array $logs;
/**
* @param array<int, array<string, mixed>> $logs Logs.
*/
public function __construct( array &$logs ) {
$this->logs = &$logs;
}
public function error( string $message, array $context = array() ): void {
$this->record( 'error', $message, $context );
}
public function warning( string $message, array $context = array() ): void {
$this->record( 'warning', $message, $context );
}
public function info( string $message, array $context = array() ): void {
$this->record( 'info', $message, $context );
}
public function debug( string $message, array $context = array() ): void {
$this->record( 'debug', $message, $context );
}
/**
* @param array<string, mixed> $context Context.
*/
private function record( string $level, string $message, array $context ): void {
$this->logs[] = array(
'level' => $level,
'message' => $message,
'context' => $context,
);
}
};
}
}
@@ -0,0 +1,132 @@
<?php
/**
* Tests for WordPress content stubs.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Content;
use PHPUnit\Framework\TestCase;
class WordPressContentStubTest extends TestCase {
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_test_posts'],
$GLOBALS['wpcs_test_next_post_id'],
$GLOBALS['wpcs_test_post_meta'],
$GLOBALS['wpcs_test_terms'],
$GLOBALS['wpcs_test_next_term_id'],
$GLOBALS['wpcs_test_term_meta'],
$GLOBALS['wpcs_test_object_terms'],
$GLOBALS['wpcs_test_attachment_files'],
$GLOBALS['wpcs_test_attachment_metadata'],
$GLOBALS['wpcs_test_post_filter'],
$GLOBALS['wpcs_test_force_delete']
);
parent::tearDown();
}
public function test_post_stubs_insert_update_and_read_posts(): void {
$post_id = wp_insert_post(
array(
'post_title' => 'Hello',
'post_type' => 'post',
),
true
);
wp_update_post(
array(
'ID' => $post_id,
'post_title' => 'Updated',
),
true
);
self::assertSame( 'Updated', get_post( $post_id )['post_title'] );
}
public function test_meta_stubs_replace_values(): void {
update_post_meta( 10, '_source_url', 'https://source.test/page' );
update_post_meta( 10, '_source_url', 'https://destination.test/page' );
self::assertSame( array( 'https://destination.test/page' ), get_post_meta( 10, '_source_url', false ) );
self::assertSame( 'https://destination.test/page', get_post_meta( 10, '_source_url', true ) );
}
public function test_term_stubs_insert_update_and_read_terms(): void {
$result = wp_insert_term( 'News', 'category', array( 'slug' => 'news' ) );
wp_update_term( $result['term_id'], 'category', array( 'name' => 'Latest News' ) );
$term = get_term_by( 'slug', 'news', 'category' );
self::assertSame( 'Latest News', $term->name );
}
public function test_attachment_stubs_store_metadata(): void {
$attachment_id = wp_insert_attachment(
array(
'post_title' => 'Image',
'post_mime_type' => 'image/jpeg',
),
false,
44,
true
);
wp_update_attachment_metadata(
$attachment_id,
array(
'width' => 1200,
)
);
self::assertSame( 44, get_post( $attachment_id )['post_parent'] );
self::assertSame( array( 'width' => 1200 ), wp_get_attachment_metadata( $attachment_id ) );
}
public function test_query_delete_and_object_term_stubs(): void {
$first_post_id = wp_insert_post(
array(
'post_title' => 'First',
'post_type' => 'post',
),
true
);
$second_post_id = wp_insert_post(
array(
'post_title' => 'Second',
'post_type' => 'page',
),
true
);
update_post_meta( $first_post_id, '_wpcs_source_id', 10 );
update_post_meta( $second_post_id, '_wpcs_source_id', 20 );
wp_set_object_terms( $first_post_id, array( 'news', 'updates' ), 'category' );
delete_post_meta( $second_post_id, '_wpcs_source_id' );
wp_delete_post( $second_post_id, true );
$posts = get_posts(
array(
'post_type' => 'post',
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Verifies the unit-test get_posts meta query stub.
'meta_key' => '_wpcs_source_id',
'meta_value' => 10,
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
self::assertCount( 1, $posts );
self::assertSame( $first_post_id, $posts[0]->ID );
self::assertSame( array(), get_post_meta( $second_post_id, '_wpcs_source_id', false ) );
self::assertNull( get_post( $second_post_id ) );
self::assertTrue( $GLOBALS['wpcs_test_force_delete'][ $second_post_id ] );
self::assertSame(
array( 'news', 'updates' ),
$GLOBALS['wpcs_test_object_terms'][ $first_post_id ]['category']
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
class ContentPackageTest extends TestCase {
public function test_it_normalizes_package_arrays(): void {
$package = ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 1,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => array(
'posts' => array(
array(
'id' => 123,
'type' => 'post',
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
),
'checksums' => array(
'records' => 'sha256:abc123',
),
)
);
self::assertSame( '1.0', $package->schemaVersion() );
self::assertSame( '2026-04-26T20:30:00+00:00', $package->generatedAt() );
self::assertSame( 'https://example.test', $package->source()['site_url'] );
self::assertSame( 'https://staging.example.test', $package->destination()['site_url'] );
self::assertSame( 1, $package->manifest()['posts'] );
self::assertSame( 123, $package->records()['posts'][0]['id'] );
self::assertSame( 'sha256:abc123', $package->checksums()['records'] );
self::assertSame( $package->toArray(), ContentPackage::fromArray( $package->toArray() )->toArray() );
}
}
@@ -0,0 +1,72 @@
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
class PackageChecksumTest extends TestCase {
public function test_it_creates_stable_record_checksums(): void {
$records = array(
'posts' => array(
array(
'title' => 'Example',
'id' => 123,
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
self::assertSame(
PackageChecksum::records( $records ),
PackageChecksum::records( $records )
);
self::assertStringStartsWith( 'sha256:', PackageChecksum::records( $records ) );
}
public function test_it_canonicalizes_associative_key_order(): void {
$records = array(
'posts' => array(
array(
'title' => 'Example',
'id' => 123,
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
$reordered = array(
'media' => array(),
'custom_post_types' => array(),
'terms' => array(),
'posts' => array(
array(
'id' => 123,
'title' => 'Example',
),
),
);
self::assertSame( PackageChecksum::records( $records ), PackageChecksum::records( $reordered ) );
}
public function test_it_verifies_record_checksums(): void {
$records = array(
'posts' => array(
array(
'id' => 123,
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
$checksum = PackageChecksum::records( $records );
self::assertTrue( PackageChecksum::verifyRecords( $records, $checksum ) );
self::assertFalse( PackageChecksum::verifyRecords( $records, 'sha256:not-real' ) );
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace WPContentSync\Tests\Unit\Package;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
class PackageValidatorTest extends TestCase {
public function test_it_accepts_valid_packages(): void {
$result = ( new PackageValidator() )->validate( $this->validPackage() );
self::assertTrue( $result->isValid() );
self::assertSame( array(), $result->errors() );
}
public function test_it_rejects_missing_required_fields(): void {
$package = $this->validPackage();
unset( $package['records'] );
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'records is required.' ), $result->errors() );
}
public function test_it_rejects_unsupported_schema_versions(): void {
$package = $this->validPackage();
$package['schema_version'] = '2.0';
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'schema_version must be 1.0.' ), $result->errors() );
}
public function test_it_rejects_missing_record_buckets(): void {
$package = $this->validPackage();
unset( $package['records']['media'] );
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'records.media is required and must be an array.' ), $result->errors() );
}
public function test_it_rejects_manifest_counts_that_do_not_match_records(): void {
$package = $this->validPackage();
$package['manifest']['posts'] = 2;
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'manifest.posts must match records.posts count.' ), $result->errors() );
}
public function test_it_rejects_invalid_record_checksums(): void {
$package = $this->validPackage();
$package['checksums']['records'] = 'sha256:wrong';
$result = ( new PackageValidator() )->validate( $package );
self::assertFalse( $result->isValid() );
self::assertSame( array( 'checksums.records does not match records payload.' ), $result->errors() );
}
/**
* @return array<string, mixed>
*/
private function validPackage(): array {
return array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 1,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => array(
'posts' => array(
array(
'id' => 123,
'type' => 'post',
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
),
'checksums' => array(
'records' => PackageChecksum::records(
array(
'posts' => array(
array(
'id' => 123,
'type' => 'post',
),
),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
)
),
),
);
}
}
+102
View File
@@ -3,12 +3,31 @@
namespace WPContentSync\Tests\Unit; namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use WPContentSync\Admin\FileImportController;
use WPContentSync\Admin\ConnectionTestController;
use WPContentSync\Admin\SettingsController;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Content\ContentRecordNormalizer;
use WPContentSync\Content\MediaContentHandler;
use WPContentSync\Content\PostContentHandler;
use WPContentSync\Content\TermContentHandler;
use WPContentSync\Container; use WPContentSync\Container;
use WPContentSync\Plugin; use WPContentSync\Plugin;
use WPContentSync\Rest\RestPackageController;
use WPContentSync\Sync\SyncEngine;
use WPContentSync\Sync\SyncStateRepository;
use WPContentSync\Transport\FileTransportInterface;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer; use WPContentSync\Url\UrlTransformer;
class PluginTest extends TestCase { class PluginTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_test_actions'] );
parent::tearDown();
}
public function test_it_registers_url_transformation_services(): void { public function test_it_registers_url_transformation_services(): void {
$container = $this->getPluginContainer( Plugin::create() ); $container = $this->getPluginContainer( Plugin::create() );
@@ -21,6 +40,89 @@ class PluginTest extends TestCase {
self::assertSame( $metadata_transformer, $container->get( MetadataUrlTransformer::class ) ); 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_settings_controller(): void {
$container = $this->getPluginContainer( Plugin::create() );
self::assertInstanceOf(
SettingsController::class,
$container->get( SettingsController::class )
);
}
public function test_it_registers_connection_test_controller(): void {
$container = $this->getPluginContainer( Plugin::create() );
self::assertInstanceOf(
ConnectionTestController::class,
$container->get( ConnectionTestController::class )
);
}
public function test_it_registers_rest_transport_services(): void {
$container = $this->getPluginContainer( Plugin::create() );
self::assertInstanceOf(
RestTransportClient::class,
$container->get( RestTransportClient::class )
);
self::assertInstanceOf(
RestPackageController::class,
$container->get( RestPackageController::class )
);
}
public function test_it_registers_sync_engine_and_content_handlers(): void {
$container = $this->getPluginContainer( Plugin::create() );
self::assertInstanceOf( ContentRecordNormalizer::class, $container->get( ContentRecordNormalizer::class ) );
self::assertInstanceOf( PostContentHandler::class, $container->get( PostContentHandler::class ) );
self::assertInstanceOf( TermContentHandler::class, $container->get( TermContentHandler::class ) );
self::assertInstanceOf( MediaContentHandler::class, $container->get( MediaContentHandler::class ) );
self::assertInstanceOf( ContentHandlerRegistry::class, $container->get( ContentHandlerRegistry::class ) );
self::assertInstanceOf( SyncStateRepository::class, $container->get( SyncStateRepository::class ) );
self::assertInstanceOf( SyncEngine::class, $container->get( SyncEngine::class ) );
}
public function test_it_hooks_rest_package_controller_on_register(): void {
unset( $GLOBALS['wpcs_test_actions'] );
$plugin = Plugin::create();
$plugin->register();
self::assertArrayHasKey( 'rest_api_init', $GLOBALS['wpcs_test_actions'] );
}
public function test_it_hooks_settings_controller_on_register(): void {
unset( $GLOBALS['wpcs_test_actions'] );
$plugin = Plugin::create();
$plugin->register();
self::assertArrayHasKey( 'admin_post_wpcs_save_settings', $GLOBALS['wpcs_test_actions'] );
}
public function test_it_hooks_connection_test_controller_on_register(): void {
unset( $GLOBALS['wpcs_test_actions'] );
$plugin = Plugin::create();
$plugin->register();
self::assertArrayHasKey( 'admin_post_wpcs_test_connection', $GLOBALS['wpcs_test_actions'] );
}
private function getPluginContainer( Plugin $plugin ): Container { private function getPluginContainer( Plugin $plugin ): Container {
$reflection = new \ReflectionClass( $plugin ); $reflection = new \ReflectionClass( $plugin );
$property = $reflection->getProperty( 'container' ); $property = $reflection->getProperty( 'container' );
@@ -0,0 +1,269 @@
<?php
/**
* Tests for REST package receive/status controller.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Rest;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentHandlerInterface;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Rest\RestPackageController;
use WPContentSync\Settings\SettingsRepository;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncEngine;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Sync\SyncStateRepository;
class RestPackageControllerTest extends TestCase {
protected function setUp(): void {
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'],
$GLOBALS['wpcs_test_options'],
$GLOBALS['wpcs_test_option_autoloads'],
$GLOBALS['wpcs_test_transients'],
$GLOBALS['wpcs_test_transient_expiration']
);
parent::tearDown();
}
public function test_it_hooks_route_registration_to_rest_api_init(): void {
$controller = $this->controller();
$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 = $this->controller();
$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 = $this->controller();
self::assertFalse( $controller->canReceivePackage() );
}
public function test_it_returns_status_payload(): void {
$controller = $this->controller();
self::assertSame(
array(
'ok' => true,
'plugin' => 'wp-content-sync',
'version' => WPCS_VERSION,
),
$controller->status()
);
}
public function test_it_accepts_valid_packages(): void {
$controller = $this->controller();
self::assertSame(
$this->acceptedResponse(),
$controller->receivePackage(
array(
'package' => $this->validPackage(),
)
)
);
}
public function test_it_accepts_rest_request_like_objects(): void {
$controller = $this->controller();
$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 = $this->controller();
self::assertSame(
array(
'accepted' => false,
'errors' => array( 'package is required and must be an object.' ),
),
$controller->receivePackage( array() )
);
}
public function test_it_returns_rejected_response_when_sync_import_fails(): void {
$controller = $this->controller( SyncResult::failure( array( 'Posts failed.' ) ) );
self::assertSame(
array(
'accepted' => false,
'schema_version' => '1.0',
'manifest' => array(
'posts' => 0,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'import' => array(
'successful' => false,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'conflicts' => 0,
'errors' => array( 'Posts failed.' ),
),
),
$controller->receivePackage(
array(
'package' => $this->validPackage(),
)
)
);
}
/**
* @return array<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,
),
'import' => array(
'successful' => true,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'conflicts' => 0,
'errors' => array(),
),
);
}
private function controller( ?SyncResult $result = null ): RestPackageController {
return new RestPackageController(
new PackageValidator(),
$this->syncEngine( $result ?? SyncResult::success() )
);
}
private function syncEngine( SyncResult $result ): SyncEngine {
return new SyncEngine(
new ContentHandlerRegistry( array( $this->handler( $result ) ) ),
new SyncStateRepository(),
new SettingsRepository(),
$this->logger()
);
}
private function handler( SyncResult $result ): ContentHandlerInterface {
return new class( $result ) implements ContentHandlerInterface {
private SyncResult $result;
public function __construct( SyncResult $result ) {
$this->result = $result;
}
public function bucket(): string {
return 'posts';
}
public function importRecords( array $records, SyncContext $context ): SyncResult {
return $this->result;
}
};
}
private function logger(): LoggerInterface {
return new class() implements LoggerInterface {
public function error( string $message, array $context = array() ): void {}
public function warning( string $message, array $context = array() ): void {}
public function info( string $message, array $context = array() ): void {}
public function debug( string $message, array $context = array() ): void {}
};
}
/**
* @return array<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 ),
),
);
}
}
+57 -3
View File
@@ -57,6 +57,53 @@ class SettingsTest extends TestCase {
self::assertTrue( $settings->automaticUrlReplacementEnabled() ); self::assertTrue( $settings->automaticUrlReplacementEnabled() );
} }
public function test_it_sanitizes_full_admin_workflow_settings(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => '<b>Production to Staging</b>',
'source_url' => 'https://example.test/',
'destination_url' => 'https://staging.example.test/',
'username' => '<script>codex</script>',
'application_password' => 'secret app password',
'default_direction' => 'push',
'content_types' => array( 'posts', 'terms', 'media', 'bad_type' ),
'url_mappings' => array(
array(
'source' => 'https://example.test',
'destination' => 'https://staging.example.test',
),
),
),
),
'log_retention' => '50',
'debug_logging' => '1',
)
);
$pairs = $settings->syncPairs();
self::assertSame( 'Production to Staging', $pairs[0]['name'] );
self::assertSame( 'https://example.test/', $pairs[0]['source_url'] );
self::assertSame( 'https://staging.example.test/', $pairs[0]['destination_url'] );
self::assertSame( 'codex', $pairs[0]['username'] );
self::assertSame( 'secret app password', $pairs[0]['application_password'] );
self::assertSame( 'push', $pairs[0]['default_direction'] );
self::assertSame( array( 'posts', 'terms', 'media' ), $pairs[0]['content_types'] );
self::assertSame(
array(
array(
'source' => 'https://example.test',
'destination' => 'https://staging.example.test',
),
),
$pairs[0]['url_mappings']
);
self::assertSame( 50, $settings->logRetention() );
self::assertTrue( $settings->debugLoggingEnabled() );
}
public function test_it_serializes_to_array(): void { public function test_it_serializes_to_array(): void {
$settings = Settings::fromArray( $settings = Settings::fromArray(
array( array(
@@ -74,14 +121,21 @@ class SettingsTest extends TestCase {
array( array(
'sync_pairs' => array( 'sync_pairs' => array(
array( array(
'name' => 'Staging', 'name' => 'Staging',
'source_url' => 'https://example.test', 'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test', 'destination_url' => 'https://staging.example.test',
'username' => '',
'application_password' => '',
'default_direction' => 'push',
'content_types' => array( 'posts', 'terms', 'media', 'custom_post_types' ),
'url_mappings' => array(),
), ),
), ),
'logging_level' => 'warning', 'logging_level' => 'warning',
'automatic_url_replacement' => true, 'automatic_url_replacement' => true,
'conflict_strategy' => 'last_write_wins', 'conflict_strategy' => 'last_write_wins',
'log_retention' => 200,
'debug_logging' => false,
), ),
$settings->toArray() $settings->toArray()
); );
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Tests for sync operation context.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncContext;
class SyncContextTest extends TestCase {
public function test_it_builds_import_context_from_package_sites(): void {
$context = SyncContext::forImport(
array( 'site_url' => 'https://source.test' ),
array( 'site_url' => 'https://destination.test' ),
'last_write_wins',
'operation-1'
);
self::assertSame( 'import', $context->direction() );
self::assertSame( 'operation-1', $context->operationId() );
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
self::assertSame( 'https://source.test', $context->sourceUrl() );
self::assertSame( 'https://destination.test', $context->destinationUrl() );
self::assertSame(
array( 'https://source.test' => 'https://destination.test' ),
$context->urlMappings()
);
}
public function test_it_falls_back_to_last_write_wins_for_invalid_strategy(): void {
$context = SyncContext::forImport( array(), array(), 'surprise', 'operation-2' );
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
/**
* Tests for sync engine import orchestration.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Content\ContentHandlerInterface;
use WPContentSync\Content\ContentHandlerRegistry;
use WPContentSync\Content\ContentImportException;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Settings\SettingsRepository;
use WPContentSync\Sync\SyncContext;
use WPContentSync\Sync\SyncEngine;
use WPContentSync\Sync\SyncResult;
use WPContentSync\Sync\SyncStateRepository;
class SyncEngineTest extends TestCase {
/** @var \ArrayObject<int, array<string, mixed>> */
private \ArrayObject $call_recorder;
/** @var \ArrayObject<int, array<string, mixed>> */
private \ArrayObject $log_recorder;
protected function setUp(): void {
$this->call_recorder = new \ArrayObject();
$this->log_recorder = new \ArrayObject();
parent::setUp();
}
protected function tearDown(): void {
unset(
$GLOBALS['wpcs_test_options'],
$GLOBALS['wpcs_test_option_autoloads'],
$GLOBALS['wpcs_test_transients'],
$GLOBALS['wpcs_test_transient_expiration']
);
parent::tearDown();
}
public function test_it_calls_handlers_in_registry_order_and_merges_results(): void {
$engine = $this->engine(
array(
$this->handler( 'media', SyncResult::success( array( 'created' => 1 ) ) ),
$this->handler( 'posts', SyncResult::success( array( 'updated' => 2 ) ) ),
$this->handler(
'terms',
SyncResult::success(
array(
'skipped' => 1,
'conflicts' => 1,
)
)
),
)
);
$result = $engine->importPackage( $this->package() );
self::assertTrue( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 2, $result->updated() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
$calls = $this->call_recorder->getArrayCopy();
self::assertSame( array( 'terms', 'posts', 'media' ), array_column( $calls, 'bucket' ) );
self::assertSame( array( array( 'id' => 20 ) ), $calls[0]['records'] );
self::assertSame( array( array( 'id' => 10 ) ), $calls[1]['records'] );
self::assertSame( array( array( 'id' => 30 ) ), $calls[2]['records'] );
self::assertSame( 'manual_review', $calls[0]['context']->conflictStrategy() );
}
public function test_it_saves_running_and_completed_state(): void {
$engine = $this->engine(
array(
$this->handler( 'posts', SyncResult::success( array( 'created' => 1 ) ) ),
$this->handler( 'media', SyncResult::success( array( 'created' => 1 ) ) ),
)
);
$engine->importPackage( $this->package() );
$states = array_values( $GLOBALS['wpcs_test_transients'] );
self::assertCount( 1, $GLOBALS['wpcs_test_transients'] );
self::assertSame( 'completed', $states[0]['status'] );
self::assertSame( 2, $states[0]['processed'] );
self::assertSame( 3, $states[0]['total'] );
}
public function test_it_logs_operation_start_and_completion(): void {
$engine = $this->engine(
array(
$this->handler( 'posts', SyncResult::success( array( 'created' => 1 ) ) ),
)
);
$engine->importPackage( $this->package() );
$logs = $this->log_recorder->getArrayCopy();
self::assertSame( 'Starting content package import.', $logs[0]['message'] );
self::assertSame( 'Completed content package import.', $logs[1]['message'] );
self::assertSame( 1, $logs[1]['context']['created'] );
}
public function test_it_returns_failure_when_handler_throws_import_exception(): void {
$engine = $this->engine(
array(
$this->throwingHandler( 'posts' ),
$this->handler( 'media', SyncResult::success( array( 'created' => 1 ) ) ),
)
);
$result = $engine->importPackage( $this->package() );
$states = array_values( $GLOBALS['wpcs_test_transients'] );
$logs = $this->log_recorder->getArrayCopy();
self::assertFalse( $result->isSuccessful() );
self::assertSame( array( 'Posts failed.' ), $result->errors() );
self::assertSame( 'failed', $states[0]['status'] );
self::assertSame( 'Content package import failed.', $logs[1]['message'] );
self::assertSame( 'posts', $logs[1]['context']['bucket'] );
}
/**
* @param array<int, ContentHandlerInterface> $handlers Handlers.
*/
private function engine( array $handlers ): SyncEngine {
update_option(
SettingsRepository::OPTION_NAME,
array(
'conflict_strategy' => 'manual_review',
),
false
);
return new SyncEngine(
new ContentHandlerRegistry( $handlers ),
new SyncStateRepository(),
new SettingsRepository(),
$this->logger()
);
}
private function package(): ContentPackage {
return ContentPackage::fromArray(
array(
'source' => array(
'site_url' => 'https://source.test',
),
'destination' => array(
'site_url' => 'https://destination.test',
),
'manifest' => array(
'posts' => 1,
'terms' => 1,
'media' => 1,
),
'records' => array(
'posts' => array(
array( 'id' => 10 ),
),
'terms' => array(
array( 'id' => 20 ),
),
'media' => array(
array( 'id' => 30 ),
),
),
)
);
}
private function handler( string $bucket, SyncResult $result ): ContentHandlerInterface {
return new class( $bucket, $result, $this->call_recorder ) implements ContentHandlerInterface {
private string $bucket;
private SyncResult $result;
/** @var \ArrayObject<int, array<string, mixed>> */
private \ArrayObject $recorder;
/**
* @param \ArrayObject<int, array<string, mixed>> $recorder Call recorder.
*/
public function __construct( string $bucket, SyncResult $result, \ArrayObject $recorder ) {
$this->bucket = $bucket;
$this->result = $result;
$this->recorder = $recorder;
}
public function bucket(): string {
return $this->bucket;
}
public function importRecords( array $records, SyncContext $context ): SyncResult {
$this->recorder->append(
array(
'bucket' => $this->bucket,
'records' => $records,
'context' => $context,
)
);
return $this->result;
}
};
}
private function throwingHandler( string $bucket ): ContentHandlerInterface {
return new class( $bucket, $this->call_recorder ) implements ContentHandlerInterface {
private string $bucket;
/** @var \ArrayObject<int, array<string, mixed>> */
private \ArrayObject $recorder;
/**
* @param \ArrayObject<int, array<string, mixed>> $recorder Call recorder.
*/
public function __construct( string $bucket, \ArrayObject $recorder ) {
$this->bucket = $bucket;
$this->recorder = $recorder;
}
public function bucket(): string {
return $this->bucket;
}
public function importRecords( array $records, SyncContext $context ): SyncResult {
$this->recorder->append(
array(
'bucket' => $this->bucket,
'records' => $records,
'context' => $context,
)
);
throw new ContentImportException( $this->bucket, array( 'id' => 10 ), 'Posts failed.' );
}
};
}
private function logger(): LoggerInterface {
return new class( $this->log_recorder ) implements LoggerInterface {
/** @var \ArrayObject<int, array<string, mixed>> */
private \ArrayObject $recorder;
/**
* @param \ArrayObject<int, array<string, mixed>> $recorder Log recorder.
*/
public function __construct( \ArrayObject $recorder ) {
$this->recorder = $recorder;
}
public function error( string $message, array $context = array() ): void {
$this->record( 'error', $message, $context );
}
public function warning( string $message, array $context = array() ): void {
$this->record( 'warning', $message, $context );
}
public function info( string $message, array $context = array() ): void {
$this->record( 'info', $message, $context );
}
public function debug( string $message, array $context = array() ): void {
$this->record( 'debug', $message, $context );
}
/**
* @param array<string, mixed> $context Context.
*/
private function record( string $level, string $message, array $context ): void {
$this->recorder->append(
array(
'level' => $level,
'message' => $message,
'context' => $context,
)
);
}
};
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* Tests for sync result summaries.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncResult;
class SyncResultTest extends TestCase {
public function test_it_tracks_successful_counts(): void {
$result = SyncResult::success(
array(
'created' => 2,
'updated' => 3,
'skipped' => 1,
'conflicts' => 1,
)
);
self::assertTrue( $result->isSuccessful() );
self::assertSame( 2, $result->created() );
self::assertSame( 3, $result->updated() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( array(), $result->errors() );
self::assertSame(
array(
'successful' => true,
'created' => 2,
'updated' => 3,
'skipped' => 1,
'conflicts' => 1,
'errors' => array(),
),
$result->toArray()
);
}
public function test_it_tracks_failed_results(): void {
$result = SyncResult::failure( array( 'posts import failed.' ) );
self::assertFalse( $result->isSuccessful() );
self::assertSame( array( 'posts import failed.' ), $result->errors() );
}
public function test_it_merges_multiple_results(): void {
$result = SyncResult::merge(
array(
SyncResult::success( array( 'created' => 1 ) ),
SyncResult::success(
array(
'updated' => 2,
'skipped' => 1,
'conflicts' => 1,
)
),
SyncResult::failure( array( 'terms import failed.' ) ),
)
);
self::assertFalse( $result->isSuccessful() );
self::assertSame( 1, $result->created() );
self::assertSame( 2, $result->updated() );
self::assertSame( 1, $result->skipped() );
self::assertSame( 1, $result->conflicts() );
self::assertSame( array( 'terms import failed.' ), $result->errors() );
}
}
@@ -0,0 +1,46 @@
<?php
/**
* Tests for sync state persistence.
*
* @package WPContentSync
*/
namespace WPContentSync\Tests\Unit\Sync;
use PHPUnit\Framework\TestCase;
use WPContentSync\Sync\SyncOperationState;
use WPContentSync\Sync\SyncStateRepository;
class SyncStateRepositoryTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_test_transients'], $GLOBALS['wpcs_test_transient_expiration'] );
parent::tearDown();
}
public function test_it_saves_and_reads_operation_state(): void {
$repository = new SyncStateRepository();
$state = SyncOperationState::running( 'operation-1', 'posts', 2, 10 );
$repository->save( $state );
$loaded = $repository->get( 'operation-1' );
self::assertInstanceOf( SyncOperationState::class, $loaded );
self::assertSame( 'operation-1', $loaded->operationId() );
self::assertSame( 'posts', $loaded->currentBucket() );
self::assertSame( 2, $loaded->processed() );
self::assertSame( 10, $loaded->total() );
self::assertSame( 'running', $loaded->status() );
}
public function test_it_deletes_operation_state(): void {
$repository = new SyncStateRepository();
$repository->save( SyncOperationState::completed( 'operation-1', 10, 10 ) );
$repository->delete( 'operation-1' );
self::assertNull( $repository->get( 'operation-1' ) );
self::assertArrayNotHasKey( 'wpcs_sync_state_operation-1', $GLOBALS['wpcs_test_transient_expiration'] );
}
}
@@ -0,0 +1,79 @@
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Package\PackageValidator;
use WPContentSync\Transport\JsonFileTransport;
class JsonFileTransportTest extends TestCase {
public function test_it_exports_pretty_json_packages(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$json = $transport->export( $this->package() );
self::assertStringContainsString( "\n", $json );
self::assertStringContainsString( '"schema_version": "1.0"', $json );
}
public function test_it_imports_valid_json_packages(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$package = $transport->import( $transport->export( $this->package() ) );
self::assertSame( '1.0', $package->schemaVersion() );
self::assertSame( 'https://example.test', $package->source()['site_url'] );
}
public function test_it_rejects_invalid_json(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'The selected file is not valid JSON.' );
$transport->import( '{"schema_version":' );
}
public function test_it_rejects_schema_errors(): void {
$transport = new JsonFileTransport( new PackageValidator() );
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'records is required.' );
$transport->import( '{"schema_version":"1.0"}' );
}
private function package(): ContentPackage {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-26T20:30:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example Production',
),
'destination' => array(
'site_url' => 'https://staging.example.test',
'name' => 'Example Staging',
),
'manifest' => array(
'posts' => 0,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => $records,
'checksums' => array(
'records' => PackageChecksum::records( $records ),
),
)
);
}
}
@@ -0,0 +1,99 @@
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Package\ContentPackage;
use WPContentSync\Package\PackageChecksum;
use WPContentSync\Transport\RestTransportClient;
use WPContentSync\Transport\RestTransportException;
class RestTransportClientTest extends TestCase {
protected function tearDown(): void {
unset( $GLOBALS['wpcs_http_response'], $GLOBALS['wpcs_last_http_request'] );
parent::tearDown();
}
public function test_it_tests_connections_with_application_password_auth(): void {
$client = new RestTransportClient();
self::assertTrue( $client->testConnection( 'https://destination.test', 'codex', 'app-pass' ) );
self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] );
self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Expected Basic auth value for application-password requests.
self::assertSame( 'Basic ' . base64_encode( 'codex:app-pass' ), $GLOBALS['wpcs_last_http_request']['args']['headers']['Authorization'] );
}
public function test_it_sends_packages_to_receive_endpoint(): void {
$client = new RestTransportClient();
self::assertTrue( $client->sendPackage( 'https://destination.test/', 'codex', 'app-pass', $this->package() ) );
self::assertSame( 'POST', $GLOBALS['wpcs_last_http_request']['method'] );
self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/package', $GLOBALS['wpcs_last_http_request']['url'] );
self::assertStringContainsString( '"package"', $GLOBALS['wpcs_last_http_request']['args']['body'] );
self::assertSame( 'application/json', $GLOBALS['wpcs_last_http_request']['args']['headers']['Content-Type'] );
}
public function test_it_throws_authentication_failures_for_unauthorized_status(): void {
$GLOBALS['wpcs_http_response'] = array(
'response' => array( 'code' => 401 ),
'body' => '{"message":"Unauthorized"}',
);
$client = new RestTransportClient();
$this->expectException( RestTransportException::class );
$this->expectExceptionMessage( 'REST authentication failed.' );
$client->testConnection( 'https://destination.test', 'codex', 'bad-pass' );
}
public function test_it_throws_remote_rejected_for_invalid_package_response(): void {
$GLOBALS['wpcs_http_response'] = array(
'response' => array( 'code' => 400 ),
'body' => '{"message":"Invalid package"}',
);
$client = new RestTransportClient();
$this->expectException( RestTransportException::class );
$this->expectExceptionMessage( 'Invalid package' );
$client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() );
}
private function package(): ContentPackage {
$records = array(
'posts' => array(),
'terms' => array(),
'media' => array(),
'custom_post_types' => array(),
);
return ContentPackage::fromArray(
array(
'schema_version' => '1.0',
'generated_at' => '2026-04-28T12:00:00+00:00',
'source' => array(
'site_url' => 'https://example.test',
'name' => 'Example',
),
'destination' => array(
'site_url' => 'https://destination.test',
'name' => 'Destination',
),
'manifest' => array(
'posts' => 0,
'terms' => 0,
'media' => 0,
'custom_post_types' => 0,
),
'records' => $records,
'checksums' => array(
'records' => PackageChecksum::records( $records ),
),
)
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace WPContentSync\Tests\Unit\Transport;
use PHPUnit\Framework\TestCase;
use WPContentSync\Transport\RestTransportException;
class RestTransportExceptionTest extends TestCase {
public function test_it_exposes_transport_failure_context(): void {
$exception = RestTransportException::connectionFailed(
'Connection timed out.',
array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' )
);
self::assertSame( 'connection_failed', $exception->failureCode() );
self::assertSame( 'Connection timed out.', $exception->getMessage() );
self::assertSame( array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' ), $exception->context() );
}
public function test_it_exposes_authentication_failures(): void {
$exception = RestTransportException::authenticationFailed( 'REST authentication failed.' );
self::assertSame( 'authentication_failed', $exception->failureCode() );
self::assertSame( 'REST authentication failed.', $exception->getMessage() );
self::assertSame( array(), $exception->context() );
}
}
+730 -3
View File
@@ -5,6 +5,8 @@
* @package WPContentSync * @package WPContentSync
*/ */
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- WordPress class and function stubs share this test bootstrap.
require_once dirname( __DIR__ ) . '/vendor/autoload.php'; require_once dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! defined( 'ABSPATH' ) ) { if ( ! defined( 'ABSPATH' ) ) {
@@ -23,6 +25,20 @@ if ( ! defined( 'WPCS_VERSION' ) ) {
define( 'WPCS_VERSION', '0.1.0' ); 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' ) ) { if ( ! function_exists( 'sanitize_text_field' ) ) {
/** /**
* Minimal WordPress-compatible text sanitizer for unit tests. * 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' ) ) { if ( ! function_exists( 'wp_strip_all_tags' ) ) {
/** /**
* Minimal tag stripper for unit tests. * 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' ) ) { if ( ! function_exists( 'esc_html' ) ) {
/** /**
* Minimal HTML escaper for unit tests. * Minimal HTML escaper for unit tests.
@@ -143,11 +187,12 @@ if ( ! function_exists( 'wp_json_encode' ) ) {
* Minimal JSON encoder for unit tests. * Minimal JSON encoder for unit tests.
* *
* @param mixed $value Value to encode. * @param mixed $value Value to encode.
* @param int $flags JSON encoding flags.
* @return string|false * @return string|false
*/ */
function wp_json_encode( $value ) { function wp_json_encode( $value, $flags = 0 ) {
// phpcs:ignore -- Test stub for WordPress' wp_json_encode(). // 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 ) { function delete_transient( $name ) {
unset( $GLOBALS['wpcs_test_transients'][ $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; return true;
} }
@@ -322,7 +397,659 @@ if ( ! function_exists( 'current_user_can' ) ) {
* @return bool * @return bool
*/ */
function current_user_can( $capability ) { 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 ) {
if ( empty( $args['post_mime_type'] ) ) {
return $wp_error ? new WP_Error( 'invalid_attachment_mime_type', 'Attachment mime type is required.' ) : 0;
}
$GLOBALS['wpcs_test_attachment_files'][] = $file;
$args['post_type'] = 'attachment';
$args['post_parent'] = (int) $parent_post_id;
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>';
} }
} }