feat: add sync context and operation state
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Immutable sync operation context.
|
||||||
|
*
|
||||||
|
* @package WPContentSync
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WPContentSync\Sync;
|
||||||
|
|
||||||
|
final class SyncContext {
|
||||||
|
private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );
|
||||||
|
|
||||||
|
private string $direction;
|
||||||
|
private string $operation_id;
|
||||||
|
private string $source_url;
|
||||||
|
private string $destination_url;
|
||||||
|
private string $conflict_strategy;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $url_mappings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $url_mappings URL mappings.
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
string $direction,
|
||||||
|
string $operation_id,
|
||||||
|
string $source_url,
|
||||||
|
string $destination_url,
|
||||||
|
string $conflict_strategy,
|
||||||
|
array $url_mappings
|
||||||
|
) {
|
||||||
|
$this->direction = $direction;
|
||||||
|
$this->operation_id = $operation_id;
|
||||||
|
$this->source_url = $source_url;
|
||||||
|
$this->destination_url = $destination_url;
|
||||||
|
$this->conflict_strategy = in_array( $conflict_strategy, self::CONFLICT_STRATEGIES, true ) ? $conflict_strategy : 'last_write_wins';
|
||||||
|
$this->url_mappings = $url_mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $source Source site metadata.
|
||||||
|
* @param array<string, mixed> $destination Destination site metadata.
|
||||||
|
* @param string $conflict_strategy Conflict strategy.
|
||||||
|
* @param string $operation_id Operation ID.
|
||||||
|
*/
|
||||||
|
public static function forImport( array $source, array $destination, string $conflict_strategy, string $operation_id ): self {
|
||||||
|
$source_url = esc_url_raw( (string) ( $source['site_url'] ?? '' ) );
|
||||||
|
$destination_url = esc_url_raw( (string) ( $destination['site_url'] ?? '' ) );
|
||||||
|
$url_mappings = array();
|
||||||
|
|
||||||
|
if ( '' !== $source_url && '' !== $destination_url ) {
|
||||||
|
$url_mappings[ $source_url ] = $destination_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
'import',
|
||||||
|
sanitize_key( $operation_id ),
|
||||||
|
$source_url,
|
||||||
|
$destination_url,
|
||||||
|
$conflict_strategy,
|
||||||
|
$url_mappings
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function direction(): string {
|
||||||
|
return $this->direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operationId(): string {
|
||||||
|
return $this->operation_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceUrl(): string {
|
||||||
|
return $this->source_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destinationUrl(): string {
|
||||||
|
return $this->destination_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function conflictStrategy(): string {
|
||||||
|
return $this->conflict_strategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function urlMappings(): array {
|
||||||
|
return $this->url_mappings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Immutable sync operation progress state.
|
||||||
|
*
|
||||||
|
* @package WPContentSync
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WPContentSync\Sync;
|
||||||
|
|
||||||
|
final class SyncOperationState {
|
||||||
|
private string $operation_id;
|
||||||
|
private string $status;
|
||||||
|
private string $current_bucket;
|
||||||
|
private int $processed;
|
||||||
|
private int $total;
|
||||||
|
|
||||||
|
private function __construct( string $operation_id, string $status, string $current_bucket, int $processed, int $total ) {
|
||||||
|
$this->operation_id = sanitize_key( $operation_id );
|
||||||
|
$this->status = sanitize_key( $status );
|
||||||
|
$this->current_bucket = sanitize_key( $current_bucket );
|
||||||
|
$this->processed = max( 0, $processed );
|
||||||
|
$this->total = max( 0, $total );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function running( string $operation_id, string $current_bucket, int $processed, int $total ): self {
|
||||||
|
return new self( $operation_id, 'running', $current_bucket, $processed, $total );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function completed( string $operation_id, int $processed, int $total ): self {
|
||||||
|
return new self( $operation_id, 'completed', '', $processed, $total );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data State data.
|
||||||
|
*/
|
||||||
|
public static function fromArray( array $data ): self {
|
||||||
|
return new self(
|
||||||
|
(string) ( $data['operation_id'] ?? '' ),
|
||||||
|
(string) ( $data['status'] ?? '' ),
|
||||||
|
(string) ( $data['current_bucket'] ?? '' ),
|
||||||
|
(int) ( $data['processed'] ?? 0 ),
|
||||||
|
(int) ( $data['total'] ?? 0 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function operationId(): string {
|
||||||
|
return $this->operation_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(): string {
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentBucket(): string {
|
||||||
|
return $this->current_bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processed(): int {
|
||||||
|
return $this->processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function total(): int {
|
||||||
|
return $this->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array {
|
||||||
|
return array(
|
||||||
|
'operation_id' => $this->operation_id,
|
||||||
|
'status' => $this->status,
|
||||||
|
'current_bucket' => $this->current_bucket,
|
||||||
|
'processed' => $this->processed,
|
||||||
|
'total' => $this->total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Sync operation state persistence.
|
||||||
|
*
|
||||||
|
* @package WPContentSync
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WPContentSync\Sync;
|
||||||
|
|
||||||
|
final class SyncStateRepository {
|
||||||
|
private const DEFAULT_EXPIRATION = 86400;
|
||||||
|
|
||||||
|
public function save( SyncOperationState $state ): void {
|
||||||
|
set_transient( $this->key( $state->operationId() ), $state->toArray(), $this->expiration() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get( string $operation_id ): ?SyncOperationState {
|
||||||
|
$value = get_transient( $this->key( $operation_id ) );
|
||||||
|
|
||||||
|
if ( ! is_array( $value ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyncOperationState::fromArray( $value );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete( string $operation_id ): void {
|
||||||
|
delete_transient( $this->key( $operation_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key( string $operation_id ): string {
|
||||||
|
return 'wpcs_sync_state_' . sanitize_key( $operation_id );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expiration(): int {
|
||||||
|
return defined( 'DAY_IN_SECONDS' ) ? (int) DAY_IN_SECONDS : self::DEFAULT_EXPIRATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for sync operation context.
|
||||||
|
*
|
||||||
|
* @package WPContentSync
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WPContentSync\Tests\Unit\Sync;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use WPContentSync\Sync\SyncContext;
|
||||||
|
|
||||||
|
class SyncContextTest extends TestCase {
|
||||||
|
public function test_it_builds_import_context_from_package_sites(): void {
|
||||||
|
$context = SyncContext::forImport(
|
||||||
|
array( 'site_url' => 'https://source.test' ),
|
||||||
|
array( 'site_url' => 'https://destination.test' ),
|
||||||
|
'last_write_wins',
|
||||||
|
'operation-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame( 'import', $context->direction() );
|
||||||
|
self::assertSame( 'operation-1', $context->operationId() );
|
||||||
|
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
|
||||||
|
self::assertSame( 'https://source.test', $context->sourceUrl() );
|
||||||
|
self::assertSame( 'https://destination.test', $context->destinationUrl() );
|
||||||
|
self::assertSame(
|
||||||
|
array( 'https://source.test' => 'https://destination.test' ),
|
||||||
|
$context->urlMappings()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_falls_back_to_last_write_wins_for_invalid_strategy(): void {
|
||||||
|
$context = SyncContext::forImport( array(), array(), 'surprise', 'operation-2' );
|
||||||
|
|
||||||
|
self::assertSame( 'last_write_wins', $context->conflictStrategy() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,18 @@ if ( ! function_exists( 'sanitize_text_field' ) ) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'sanitize_key' ) ) {
|
||||||
|
/**
|
||||||
|
* Minimal WordPress-compatible key sanitizer for unit tests.
|
||||||
|
*
|
||||||
|
* @param mixed $key Key to sanitize.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function sanitize_key( $key ) {
|
||||||
|
return strtolower( preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $key ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ( ! function_exists( 'wp_strip_all_tags' ) ) {
|
if ( ! function_exists( 'wp_strip_all_tags' ) ) {
|
||||||
/**
|
/**
|
||||||
* Minimal tag stripper for unit tests.
|
* Minimal tag stripper for unit tests.
|
||||||
@@ -223,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user