diff --git a/src/Sync/SyncContext.php b/src/Sync/SyncContext.php new file mode 100644 index 0000000..7352e6f --- /dev/null +++ b/src/Sync/SyncContext.php @@ -0,0 +1,92 @@ + */ + private array $url_mappings; + + /** + * @param array $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 $source Source site metadata. + * @param array $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 + */ + public function urlMappings(): array { + return $this->url_mappings; + } +} diff --git a/src/Sync/SyncOperationState.php b/src/Sync/SyncOperationState.php new file mode 100644 index 0000000..b941876 --- /dev/null +++ b/src/Sync/SyncOperationState.php @@ -0,0 +1,78 @@ +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 $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 + */ + 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, + ); + } +} diff --git a/src/Sync/SyncStateRepository.php b/src/Sync/SyncStateRepository.php new file mode 100644 index 0000000..495ea1d --- /dev/null +++ b/src/Sync/SyncStateRepository.php @@ -0,0 +1,38 @@ +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; + } +} diff --git a/tests/Unit/Sync/SyncContextTest.php b/tests/Unit/Sync/SyncContextTest.php new file mode 100644 index 0000000..cc2a6fd --- /dev/null +++ b/tests/Unit/Sync/SyncContextTest.php @@ -0,0 +1,38 @@ + '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() ); + } +} diff --git a/tests/Unit/Sync/SyncStateRepositoryTest.php b/tests/Unit/Sync/SyncStateRepositoryTest.php new file mode 100644 index 0000000..4be7c04 --- /dev/null +++ b/tests/Unit/Sync/SyncStateRepositoryTest.php @@ -0,0 +1,46 @@ +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'] ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d6903a4..648ecf6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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' ) ) { /** * Minimal tag stripper for unit tests. @@ -223,6 +235,36 @@ if ( ! function_exists( 'delete_transient' ) ) { */ function delete_transient( $name ) { unset( $GLOBALS['wpcs_test_transients'][ $name ] ); + unset( $GLOBALS['wpcs_test_transient_expiration'][ $name ] ); + + return true; + } +} + +if ( ! function_exists( 'get_transient' ) ) { + /** + * Minimal WordPress transient reader for unit tests. + * + * @param string $name Transient name. + * @return mixed + */ + function get_transient( $name ) { + return $GLOBALS['wpcs_test_transients'][ $name ] ?? false; + } +} + +if ( ! function_exists( 'set_transient' ) ) { + /** + * Minimal WordPress transient writer for unit tests. + * + * @param string $name Transient name. + * @param mixed $value Transient value. + * @param int $expiration Expiration in seconds. + * @return bool + */ + function set_transient( $name, $value, $expiration = 0 ) { + $GLOBALS['wpcs_test_transients'][ $name ] = $value; + $GLOBALS['wpcs_test_transient_expiration'][ $name ] = $expiration; return true; }