feat: scaffold plugin foundation

This commit is contained in:
Keith Solomon
2026-04-26 12:44:16 -05:00
commit 557657344d
24 changed files with 5238 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase;
use WPContentSync\Container;
class ContainerTest extends TestCase {
public function test_it_returns_registered_service(): void {
$container = new Container();
$service = new \stdClass();
$container->set( 'example', $service );
self::assertSame( $service, $container->get( 'example' ) );
}
public function test_it_reuses_factory_result(): void {
$container = new Container();
$calls = 0;
$container->factory(
'example',
static function () use ( &$calls ): \stdClass {
++$calls;
return new \stdClass();
}
);
$first = $container->get( 'example' );
$second = $container->get( 'example' );
self::assertSame( $first, $second );
self::assertSame( 1, $calls );
}
public function test_it_throws_for_unknown_service(): void {
$container = new Container();
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'Service "missing" is not registered.' );
$container->get( 'missing' );
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase;
use WPContentSync\Logging\OptionLogger;
class OptionLoggerTest extends TestCase {
protected function setUp(): void {
$GLOBALS['wpcs_test_options'] = array();
}
public function test_it_records_log_entries(): void {
$logger = new OptionLogger( 10 );
$logger->warning( 'Connection failed.', array( 'url' => 'https://example.test' ) );
$entries = get_option( OptionLogger::OPTION_NAME, array() );
self::assertCount( 1, $entries );
self::assertSame( 'warning', $entries[0]['level'] );
self::assertSame( 'Connection failed.', $entries[0]['message'] );
self::assertSame( 'https://example.test', $entries[0]['context']['url'] );
self::assertArrayHasKey( 'timestamp', $entries[0] );
}
public function test_it_redacts_sensitive_context_values(): void {
$logger = new OptionLogger( 10 );
$logger->error(
'Authentication failed.',
array(
'application_password' => 'secret-value',
'client_secret' => 'client-secret-value',
'headers' => array(
'Authorization' => 'Bearer nested-token',
),
'token' => 'token-value',
'username' => 'admin',
)
);
$entries = get_option( OptionLogger::OPTION_NAME, array() );
self::assertSame( '[redacted]', $entries[0]['context']['application_password'] );
self::assertSame( '[redacted]', $entries[0]['context']['client_secret'] );
self::assertSame( '[redacted]', $entries[0]['context']['headers']['Authorization'] );
self::assertSame( '[redacted]', $entries[0]['context']['token'] );
self::assertSame( 'admin', $entries[0]['context']['username'] );
}
public function test_it_limits_retained_entries(): void {
$logger = new OptionLogger( 2 );
$logger->info( 'First' );
$logger->info( 'Second' );
$logger->info( 'Third' );
$entries = get_option( OptionLogger::OPTION_NAME, array() );
self::assertCount( 2, $entries );
self::assertSame( 'Second', $entries[0]['message'] );
self::assertSame( 'Third', $entries[1]['message'] );
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace WPContentSync\Tests\Unit;
use PHPUnit\Framework\TestCase;
use WPContentSync\Settings\Settings;
class SettingsTest extends TestCase {
public function test_it_provides_secure_defaults(): void {
$settings = Settings::fromArray( array() );
self::assertSame( array(), $settings->syncPairs() );
self::assertSame( 'warning', $settings->loggingLevel() );
self::assertTrue( $settings->automaticUrlReplacementEnabled() );
self::assertSame( 'last_write_wins', $settings->conflictStrategy() );
}
public function test_it_sanitizes_scalar_settings(): void {
$settings = Settings::fromArray(
array(
'logging_level' => '<b>debug</b>',
'conflict_strategy' => "manual_review\n",
'automatic_url_replacement' => false,
)
);
self::assertSame( 'debug', $settings->loggingLevel() );
self::assertSame( 'manual_review', $settings->conflictStrategy() );
self::assertFalse( $settings->automaticUrlReplacementEnabled() );
}
public function test_it_rejects_unknown_logging_level(): void {
$settings = Settings::fromArray(
array(
'logging_level' => 'verbose',
)
);
self::assertSame( 'warning', $settings->loggingLevel() );
}
public function test_it_normalizes_string_boolean_values(): void {
$settings = Settings::fromArray(
array(
'automatic_url_replacement' => 'false',
)
);
self::assertFalse( $settings->automaticUrlReplacementEnabled() );
$settings = Settings::fromArray(
array(
'automatic_url_replacement' => '1',
)
);
self::assertTrue( $settings->automaticUrlReplacementEnabled() );
}
public function test_it_serializes_to_array(): void {
$settings = Settings::fromArray(
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
),
),
)
);
self::assertSame(
array(
'sync_pairs' => array(
array(
'name' => 'Staging',
'source_url' => 'https://example.test',
'destination_url' => 'https://staging.example.test',
),
),
'logging_level' => 'warning',
'automatic_url_replacement' => true,
'conflict_strategy' => 'last_write_wins',
),
$settings->toArray()
);
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* PHPUnit and PHPStan bootstrap for isolated plugin tests.
*
* @package WPContentSync
*/
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
}
if ( ! defined( 'WPCS_PLUGIN_DIR' ) ) {
define( 'WPCS_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
}
if ( ! defined( 'WPCS_PLUGIN_URL' ) ) {
define( 'WPCS_PLUGIN_URL', 'https://example.test/wp-content/plugins/wp-content-sync/' );
}
if ( ! defined( 'WPCS_VERSION' ) ) {
define( 'WPCS_VERSION', '0.1.0' );
}
if ( ! function_exists( 'sanitize_text_field' ) ) {
/**
* Minimal WordPress-compatible text sanitizer for unit tests.
*
* @param mixed $value Value to sanitize.
* @return string
*/
function sanitize_text_field( $value ) {
return trim( preg_replace( '/[\r\n\t]+/', ' ', wp_strip_all_tags( (string) $value ) ) );
}
}
if ( ! function_exists( 'wp_strip_all_tags' ) ) {
/**
* Minimal tag stripper for unit tests.
*
* @param string $value Value to strip.
* @return string
*/
function wp_strip_all_tags( $value ) {
return preg_replace( '/<[^>]*>/', '', $value );
}
}
if ( ! function_exists( 'esc_html' ) ) {
/**
* Minimal HTML escaper for unit tests.
*
* @param mixed $value Value to escape.
* @return string
*/
function esc_html( $value ) {
return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
}
}
if ( ! function_exists( '__' ) ) {
/**
* Minimal translation helper for unit tests.
*
* @param string $text Text to translate.
* @param string $domain Text domain.
* @return string
*/
function __( $text, $domain = 'default' ) {
$GLOBALS['wpcs_test_text_domain'] = $domain;
return $text;
}
}
if ( ! function_exists( 'esc_html__' ) ) {
/**
* Minimal translated HTML escaper for unit tests.
*
* @param string $text Text to translate and escape.
* @param string $domain Text domain.
* @return string
*/
function esc_html__( $text, $domain = 'default' ) {
$GLOBALS['wpcs_test_text_domain'] = $domain;
return esc_html( $text );
}
}
if ( ! function_exists( 'esc_attr' ) ) {
/**
* Minimal attribute escaper for unit tests.
*
* @param mixed $value Value to escape.
* @return string
*/
function esc_attr( $value ) {
return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
}
}
if ( ! function_exists( 'esc_url' ) ) {
/**
* Minimal URL sanitizer for unit tests.
*
* @param mixed $value Value to sanitize.
* @return string
*/
function esc_url( $value ) {
return filter_var( (string) $value, FILTER_SANITIZE_URL );
}
}
if ( ! function_exists( 'esc_url_raw' ) ) {
/**
* Minimal raw URL sanitizer for unit tests.
*
* @param mixed $value Value to sanitize.
* @return string
*/
function esc_url_raw( $value ) {
return filter_var( (string) $value, FILTER_SANITIZE_URL );
}
}
if ( ! function_exists( 'get_option' ) ) {
/**
* Minimal WordPress option reader for unit tests.
*
* @param string $name Option name.
* @param mixed $default_value Default value.
* @return mixed
*/
function get_option( $name, $default_value = false ) {
return $GLOBALS['wpcs_test_options'][ $name ] ?? $default_value;
}
}
if ( ! function_exists( 'update_option' ) ) {
/**
* Minimal WordPress option writer for unit tests.
*
* @param string $name Option name.
* @param mixed $value Option value.
* @param mixed $autoload Autoload flag.
* @return bool
*/
function update_option( $name, $value, $autoload = null ) {
$GLOBALS['wpcs_test_options'][ $name ] = $value;
$GLOBALS['wpcs_test_option_autoloads'][ $name ] = $autoload;
return true;
}
}
if ( ! function_exists( 'delete_transient' ) ) {
/**
* Minimal WordPress transient deleter for unit tests.
*
* @param string $name Transient name.
* @return bool
*/
function delete_transient( $name ) {
unset( $GLOBALS['wpcs_test_transients'][ $name ] );
return true;
}
}
if ( ! function_exists( 'plugin_dir_path' ) ) {
/**
* Minimal plugin path helper for static analysis.
*
* @param string $file Plugin file.
* @return string
*/
function plugin_dir_path( $file ) {
return trailingslashit( dirname( $file ) );
}
}
if ( ! function_exists( 'plugin_dir_url' ) ) {
/**
* Minimal plugin URL helper for static analysis.
*
* @param string $file Plugin file.
* @return string
*/
function plugin_dir_url( $file ) {
return 'http://example.org/wp-content/plugins/' . basename( dirname( $file ) ) . '/';
}
}
if ( ! function_exists( 'trailingslashit' ) ) {
/**
* Minimal trailing slash helper for static analysis.
*
* @param string $value Value to slash.
* @return string
*/
function trailingslashit( $value ) {
return rtrim( $value, '/\\' ) . DIRECTORY_SEPARATOR;
}
}
if ( ! function_exists( 'register_activation_hook' ) ) {
/**
* Minimal activation hook registrar for static analysis.
*
* @param string $file Plugin file.
* @param callable $callback Activation callback.
* @return void
*/
function register_activation_hook( $file, $callback ) {
$GLOBALS['wpcs_test_activation_hooks'][ $file ] = $callback;
}
}
if ( ! function_exists( 'register_deactivation_hook' ) ) {
/**
* Minimal deactivation hook registrar for static analysis.
*
* @param string $file Plugin file.
* @param callable $callback Deactivation callback.
* @return void
*/
function register_deactivation_hook( $file, $callback ) {
$GLOBALS['wpcs_test_deactivation_hooks'][ $file ] = $callback;
}
}
if ( ! function_exists( 'add_action' ) ) {
/**
* Minimal action registrar for static analysis.
*
* @param string $hook_name Hook name.
* @param callable $callback Hook callback.
* @return bool
*/
function add_action( $hook_name, $callback ) {
$GLOBALS['wpcs_test_actions'][ $hook_name ][] = $callback;
return true;
}
}
if ( ! function_exists( 'add_management_page' ) ) {
/**
* Minimal management page registrar for static analysis.
*
* @param string $page_title Page title.
* @param string $menu_title Menu title.
* @param string $capability Capability required.
* @param string $menu_slug Menu slug.
* @param callable $callback Page callback.
* @return string
*/
function add_management_page( $page_title, $menu_title, $capability, $menu_slug, $callback ) {
$GLOBALS['wpcs_test_admin_pages'][ $menu_slug ] = array(
'page_title' => $page_title,
'menu_title' => $menu_title,
'capability' => $capability,
'callback' => $callback,
);
return $menu_slug;
}
}
if ( ! function_exists( 'register_setting' ) ) {
/**
* Minimal setting registrar for static analysis.
*
* @param string $option_group Option group.
* @param string $option_name Option name.
* @param array<string, mixed> $args Setting arguments.
* @return bool
*/
function register_setting( $option_group, $option_name, $args = array() ) {
$GLOBALS['wpcs_test_registered_settings'][ $option_name ] = array(
'option_group' => $option_group,
'args' => $args,
);
return true;
}
}
if ( ! function_exists( 'current_user_can' ) ) {
/**
* Minimal capability checker for static analysis.
*
* @param string $capability Capability to check.
* @return bool
*/
function current_user_can( $capability ) {
return 'manage_options' === $capability;
}
}
if ( ! function_exists( 'wp_die' ) ) {
/**
* Minimal WordPress die handler for unit tests.
*
* @param mixed $message Message to die with.
* @return void
*
* @throws \RuntimeException Always throws with the provided message.
*/
function wp_die( $message ) {
throw new \RuntimeException( esc_html( $message ) );
}
}