diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8fe1f48..7bfe401f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,162 +1,50 @@ name: CI on: - workflow_dispatch: push: - branches: - - main - tags: - - "v*" + branches: [main, feature/**] pull_request: - branches: - - main - schedule: - - cron: "23 4 * * 1" - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + branches: [main] jobs: - composer: - name: Composer validation + test: + name: PHP ${{ matrix.php }} — tests runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3'] steps: - - name: Checkout - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - - name: Setup PHP + - name: Set up PHP ${{ matrix.php }} uses: shivammathur/setup-php@v2 with: - php-version: "8.3" - coverage: none + php-version: ${{ matrix.php }} tools: composer:v2 + coverage: none - - name: Validate Composer package + - name: Validate composer.json run: composer validate --strict - composer-audit: - name: Composer audit - runs-on: ubuntu-latest - needs: composer - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup PHP with Composer cache - uses: ./.github/actions/php-setup + - name: Cache Composer dependencies + uses: actions/cache@v4 with: - cache-key: ${{ runner.os }}-audit-php8.3-composer-${{ hashFiles('composer.lock') }} - cache-restore-keys: ${{ runner.os }}-audit-php8.3-composer- + path: vendor + key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.json') }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php }}- + composer-${{ runner.os }}- - name: Install dependencies - run: composer install --no-ansi --no-interaction --no-progress --prefer-dist + run: composer install --prefer-dist --no-progress - - name: Run Composer audit - run: composer audit - - coding-standards: - name: Coding standards and PHP compatibility - runs-on: ubuntu-latest - needs: composer - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup PHP with Composer cache - uses: ./.github/actions/php-setup - with: - cache-key: ${{ runner.os }}-phpcs-php8.3-composer-${{ hashFiles('composer.lock') }} - cache-restore-keys: ${{ runner.os }}-phpcs-php8.3-composer- - - - name: Install dependencies - run: composer install --no-ansi --no-interaction --no-progress --prefer-dist - - - name: Run PHPCS - run: composer phpcs - - static-analysis: - name: Static analysis - runs-on: ubuntu-latest - needs: composer - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup PHP with Composer cache - uses: ./.github/actions/php-setup - with: - cache-key: ${{ runner.os }}-phpstan-php8.3-composer-${{ hashFiles('composer.lock') }} - cache-restore-keys: ${{ runner.os }}-phpstan-php8.3-composer- - - - name: Install dependencies - run: composer install --no-ansi --no-interaction --no-progress --prefer-dist + - name: Run PHPUnit + run: ./vendor/bin/phpunit -c phpunit.xml - name: Run PHPStan - run: composer phpstan -- --no-progress - - dependency-compatibility: - name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} runtime dependencies - runs-on: ubuntu-latest - needs: composer - - strategy: - fail-fast: false - matrix: - php: - - "7.4" - - "8.0" - - "8.1" - - "8.2" - - "8.3" - - "8.4" - dependencies: - - highest - include: - - php: "7.4" - dependencies: lowest - - php: "8.3" - dependencies: locked - - php: "8.4" - dependencies: locked + run: ./vendor/bin/phpstan analyse --memory-limit=2G --no-progress - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup PHP with Composer cache - uses: ./.github/actions/php-setup - with: - php-version: ${{ matrix.php }} - cache-key: ${{ runner.os }}-dependencies-php${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('composer.json', 'composer.lock') }} - cache-restore-keys: | - ${{ runner.os }}-dependencies-php${{ matrix.php }}-${{ matrix.dependencies }}- - ${{ runner.os }}-dependencies-php${{ matrix.php }}- - - - name: Prepare runtime-only Composer manifest - run: jq 'del(."require-dev", ."autoload-dev", .scripts)' composer.json > composer.runtime.json - - - name: Install locked dependencies - if: matrix.dependencies == 'locked' - run: composer install --no-dev --no-ansi --no-interaction --no-progress --prefer-dist - - - name: Install highest dependencies - if: matrix.dependencies == 'highest' - run: COMPOSER=composer.runtime.json composer update --no-dev --no-ansi --no-interaction --no-progress --prefer-dist - - - name: Install lowest dependencies - if: matrix.dependencies == 'lowest' - run: COMPOSER=composer.runtime.json composer update --no-dev --prefer-lowest --prefer-stable --no-ansi --no-interaction --no-progress --prefer-dist - - - name: Validate Composer platform compatibility - run: composer check-platform-reqs --no-dev - - - name: Lint PHP source - run: find src -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l + - name: Run PHPCS + run: ./vendor/bin/phpcs --standard=phpcs.xml diff --git a/.gitignore b/.gitignore index 513f368d..bfcb0c89 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ vendor/ **/.DS_Store .vscode *~ -saltus-framework.code-workspace \ No newline at end of file +saltus-framework.code-workspace +tests/tester.php \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cc44bbad..97570fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,59 @@ ## [Unreleased] - - Fix: Bring the source tree back to a clean PHPStan level 7 run. - - Improvement: Tighten service, model, asset, admin column, admin filter, and meta feature typing. - - Docs: Document local static-analysis and coding-standard checks. + +### Fixed + - Fixed single-post REST export so `/saltus-framework/v1/export/{post_id}` returns WXR for only the requested post instead of the entire post type. + - Fixed framework lifecycle hook registration by allowing `Core` to receive the consuming plugin bootstrap file for activation/deactivation hooks. + - Tightened WordPress-native MCP/Abilities permission callbacks so mutating post and term tools fail closed when required target arguments are missing. + - Fixed structured settings updates so nested arrays, booleans, numbers, and null values are preserved while string values and keys are sanitized recursively. + - Fixed settings sanitization so non-stringable object payloads cannot fatal during REST/MCP settings updates. + - Fixed single-post REST export so WordPress core download headers from `export_wp()` do not leak into REST responses. + - Fixed health error-rate reporting so validation and rate-limit outcomes remain visible but do not mark framework health as degraded. + - Fixed the non-WordPress JSON encoding fallback in `AbilityRuntime`. + - Cleared the remaining PHPStan Level 7 issues in `Modeler`, REST route registration, and MCP taxonomy REST-base handling. + - Added an explicit model name accessor contract so `Modeler` no longer depends on concrete model properties. + +### Changed + - Added regression coverage for export isolation, plugin-file lifecycle hook registration, fail-closed MCP ability permissions, and structured settings payloads. + - Brought `AssetLoader` back under PHPStan analysis with a typed `AssetLoadingService` base class. + - Removed the standalone stdio MCP server path; WP7 MCP/Abilities is now the only supported MCP transport. + - Migrated MCP audit logging, rate limiting, and caching to WordPress-native storage around WP7 ability execution. + - Moved MCP audit retention cleanup from the audit write path to a daily WP-Cron event. + +## [2.0.0] - 2026-06-30 + +### Added + - MCP/Abilities integration: WordPress 7.0 native ability definitions through AbilityRegistrar + - MCP protocol server with stdio transport: initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get + - REST API namespace `saltus-framework/v1/` with 9 routes: models, duplicate, export, settings (GET/PUT), meta (aggregate + per-CPT), reorder + - 16 MCP tools (9 Phase 1 CRUD + 7 Phase 2): models, posts, terms, duplicate, export, settings, reorder, meta fields + - 3 MCP prompt templates for post/content generation workflows + - Meta field normalization: nested Codestar fields flattened to explicit paths with JSON-schema-like types + - ToolFactory for shared tool definitions between MCP and WordPress-native abilities + - Caching layer (CacheInterface + InMemoryCache) for WordPressClient GET requests + - Sliding-window rate limiter (default 60 req/60s) for tool calls + - Audit trail logger with JSON records to STDERR and optional file + - Structured error codes (ErrorCode constants + McpError value object with resolution hints) + - Config::fromEnv for environment-variable-based configuration (15 env vars) + - 243 PHPUnit tests across MCP, REST, and framework core + +### Fixed + - Rate limiter race condition in concurrent test scenarios + - ModelFactory static make method call operator + - WalkerTaxonomyDropdown incompatible void return type + - WordPress key length validation in get_registration_name() + - Admin columns non-string return from get_edit_term_link + - Various type safety issues across services, models, assets, admin columns, meta, drag-drop, and container + +### Changed + - Version bumped to 2.0.0 + - Type safety improvements across the entire codebase + - MCP Server uses ToolFactory for unified tool definitions + - Config constructor refactored to array bag pattern + - PHP 8.3+ required (str_starts_with in McpError) + - PHPStan Level 7 compliance for all src/MCP/ and src/Rest/ code + - PHPUnit configuration with strict flags, random execution order, and failOn* rules ## [1.3.5] - 2026-06-20 - Chore: Package it for packagist diff --git a/README.md b/README.md index adfb7d73..34010bb7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Visit saltus.dev for more information. ## Version -### Current version [1.3.5] - 2026-06-20 +### Current version [2.0.0] - 2026-06-30 See [change log file](CHANGELOG.md) for full details. @@ -59,7 +59,7 @@ Once the framework is installed and Composer's autoloader is loaded by your plug * The path to the plugin root directory is mandatory, * so it loads the models from a subdirectory. */ - $framework = new \Saltus\WP\Framework\Core( dirname( __FILE__ ) ); + $framework = new \Saltus\WP\Framework\Core( dirname( __FILE__ ), __FILE__ ); $framework->register(); /** @@ -183,6 +183,86 @@ Includes support for [github-updater](https://github.com/afragen/github-updater) * Clone [github-updater](https://github.com/afragen/github-updater) to your sites plugins/ folder * Activate via WordPress +## WordPress-Native MCP/Abilities + +Saltus Framework exposes its AI-facing tool surface through the WordPress-native MCP/Abilities API. Native WordPress MCP clients can discover and call the `saltus/*` abilities directly from the active plugin. + +For full documentation, see [docs/MCP.md](docs/MCP.md). For client integration guidance, see [docs/MCP-CLIENTS.md](docs/MCP-CLIENTS.md). These pages are source material for the future Saltus MCP documentation site. + +### Quick Start + +Install and activate the plugin that uses Saltus Framework on a WordPress version with the Abilities API. Saltus registers its abilities during `wp_abilities_api_init`; clients that understand WordPress-native MCP/Abilities can discover the `saltus/*` tools from WordPress. + +The standalone local stdio MCP server has been removed. Saltus MCP development targets WordPress-native abilities instead. + +### Capability Dispatch + +Abilities dispatch through WordPress REST requests, so existing REST permission callbacks and `current_user_can()` checks remain authoritative. WordPress versions without the Abilities API simply skip Saltus ability registration. + +### Service Gating and `is_needed()` + +To keep the application performant and clean, the framework separates REST/MCP route registration from the execution of admin and frontend hooks using a two-pass registration model: +- **First Pass (Unconditional):** Services implementing `RestRouteProvider` or `ToolContributor` are instantiated unconditionally during plugin boot to register endpoints and tools. No scripts, styles, or hooks are wired up. +- **Second Pass (Gated):** Services are registered in the main service container, which strictly enforces the `is_needed()` gate (defined by the `Conditional` interface). If `is_needed()` returns `false` (e.g. an admin-only feature on a frontend page, or a disabled feature), the service is skipped, avoiding loading unneeded scripts, styles, or hooks. + +Saltus wraps ability execution with WordPress-native audit logging, rate limiting, and transient caching. Read-only abilities can be cached in transients, mutating abilities clear the MCP cache, rate limits are keyed by the current user or request identifier, and audit records are written to the Saltus MCP audit table. Expired audit rows are removed by a daily WP-Cron retention job. + +### Available Tools + +| Tool | Description | +|------|-------------| +| `get_health` | Get Saltus Framework health, version, error rate, latency, cache, and rate-limit status | +| `list_models` | List all registered CPTs and taxonomies | +| `get_model` | Get details of a specific post type or taxonomy | +| `list_posts` | Query posts with filters (status, search, pagination) | +| `get_post` | Get a single post with all fields and meta | +| `create_post` | Create a new post in any CPT | +| `update_post` | Update an existing post's fields and meta | +| `delete_post` | Trash or force delete a post | +| `list_terms` | List terms from a taxonomy | +| `create_term` | Create a new term in a taxonomy | +| `duplicate_post` | Duplicate a WordPress post | +| `export_post` | Export a post as WXR | +| `get_settings` | Get Saltus settings for a post type | +| `update_settings` | Update Saltus settings for a post type | +| `reorder_posts` | Batch update post menu order | +| `list_meta_fields` | Discover Saltus meta field definitions across all registered CPTs | +| `get_meta_fields` | Get Saltus meta field definitions for a post type | + +Meta field discovery preserves the raw Saltus/Codestar configuration in `meta` and includes normalized MCP-friendly metadata in `normalized.fields` and `normalized.rest_meta_keys`. Nested fields are exposed as paths such as `points_info.coordinates.latitude`, with JSON-schema-like types and REST writability information. + +### Requirements + +- WordPress 7.0+ or a WordPress build that provides the Abilities API +- A WordPress-native MCP/Abilities client +- The plugin using Saltus Framework must be active + +### Configuration + +No local MCP server configuration is required for the WordPress-native path. Runtime behavior can be tuned with WordPress filters: + +| Filter | Purpose | +|--------|---------| +| `saltus/framework/mcp/audit/enabled` | Enable or disable audit writes | +| `saltus/framework/mcp/audit/retention_days` | Set audit retention before daily WP-Cron cleanup | +| `saltus/framework/mcp/rate_limit/enabled` | Enable or disable rate limiting | +| `saltus/framework/mcp/rate_limit/max_requests` | Set max ability calls per window | +| `saltus/framework/mcp/rate_limit/window_seconds` | Set the rate-limit window | +| `saltus/framework/mcp/rate_limit/identifier` | Override the rate-limit identifier | +| `saltus/framework/mcp/cache/enabled` | Enable or disable transient caching | +| `saltus/framework/mcp/cache/ttl` | Set cache TTL per tool | +| `saltus/framework/mcp/cache/cacheable` | Override whether a tool is cacheable | + +### Generated MCP documentation + +The detailed MCP ability reference is generated from the `src/MCP/Tools` source classes: + +```bash +composer docs:mcp +``` + +This refreshes `docs/MCP-ABILITIES.md` and the generated ability table in `docs/MCP.md`. + ## Building ### Quality checks diff --git a/bin/generate-mcp-docs.php b/bin/generate-mcp-docs.php new file mode 100644 index 00000000..d83591cf --- /dev/null +++ b/bin/generate-mcp-docs.php @@ -0,0 +1,373 @@ +#!/usr/bin/env php + */ + private array $params = []; + /** @var array */ + private array $body_params = []; + + public function __construct( string $method, string $route ) { + $this->method = $method; + $this->route = $route; + } + + public function set_param( string $key, mixed $value ): void { + $this->params[ $key ] = $value; + } + + /** + * @param array $params + */ + public function set_body_params( array $params ): void { + $this->body_params = $params; + } + + public function get_method(): string { + return $this->method; + } + + public function get_route(): string { + return $this->route; + } + + /** + * @return array + */ + public function get_params(): array { + return $this->params; + } + + /** + * @return array + */ + public function get_body_params(): array { + return $this->body_params; + } + } +} + +$tools = collect_tools( $root . '/src/MCP/Tools' ); +usort( + $tools, + static function ( ToolInterface $a, ToolInterface $b ): int { + return strcmp( $a->get_name(), $b->get_name() ); + } +); + +$reference = build_reference_document( $tools ); +write_file_if_changed( $root . '/docs/MCP-ABILITIES.md', $reference ); +replace_generated_section( + $root . '/docs/MCP.md', + '', + '', + build_embedded_section( $tools ) +); + +echo 'Generated MCP ability docs for ' . count( $tools ) . " tools.\n"; + +/** + * @return list + */ +function collect_tools( string $tool_dir ): array { + $files = glob( $tool_dir . '/*.php' ); + if ( $files === false ) { + return []; + } + + sort( $files ); + $tools = []; + + foreach ( $files as $file ) { + $class = 'Saltus\\WP\\Framework\\MCP\\Tools\\' . basename( $file, '.php' ); + if ( ! class_exists( $class ) ) { + continue; + } + + $reflection = new ReflectionClass( $class ); + if ( ! $reflection->isInstantiable() || ! $reflection->implementsInterface( ToolInterface::class ) ) { + continue; + } + + $constructor = $reflection->getConstructor(); + if ( $constructor !== null && $constructor->getNumberOfRequiredParameters() > 0 ) { + continue; + } + + $tool = $reflection->newInstance(); + if ( $tool instanceof ToolInterface ) { + $tools[] = $tool; + } + } + + return $tools; +} + +/** + * @param list $tools + */ +function build_reference_document( array $tools ): string { + $lines = [ + '# MCP Ability Reference', + '', + '', + '', + 'Saltus Framework exposes ' . count( $tools ) . ' WordPress-native MCP/Abilities tools.', + '', + build_table( $tools ), + '', + ]; + + foreach ( $tools as $tool ) { + $metadata = tool_metadata( $tool ); + $lines[] = '## `' . $tool->get_name() . '`'; + $lines[] = ''; + $lines[] = $tool->get_description(); + $lines[] = ''; + $lines[] = '- Ability: `' . $metadata['ability'] . '`'; + $lines[] = '- REST request: `' . $metadata['rest_request'] . '`'; + $lines[] = '- REST capability: `' . $metadata['capability'] . '`'; + $lines[] = '- Cacheable: `' . $metadata['cacheable'] . '`'; + $lines[] = '- Cache TTL: `' . $metadata['cache_ttl'] . '`'; + $lines[] = ''; + $lines[] = '### Parameters'; + $lines[] = ''; + $lines[] = parameters_table( $tool->get_parameters() ); + $lines[] = ''; + } + + return implode( "\n", $lines ); +} + +/** + * @param list $tools + */ +function build_embedded_section( array $tools ): string { + return implode( + "\n", + [ + '', + '', + build_table( $tools ), + '', + 'For full generated parameter details, see [MCP-ABILITIES.md](MCP-ABILITIES.md).', + ] + ); +} + +/** + * @param list $tools + */ +function build_table( array $tools ): string { + $lines = [ + '| Tool | Ability | REST request | Description |', + '|------|---------|--------------|-------------|', + ]; + + foreach ( $tools as $tool ) { + $metadata = tool_metadata( $tool ); + $lines[] = '| `' . esc_md( $tool->get_name() ) . '` | `' . esc_md( $metadata['ability'] ) . '` | `' . esc_md( $metadata['rest_request'] ) . '` | ' . esc_md( $tool->get_description() ) . ' |'; + } + + return implode( "\n", $lines ); +} + +/** + * @return array{ability: string, rest_request: string, capability: string, cacheable: string, cache_ttl: string} + */ +function tool_metadata( ToolInterface $tool ): array { + $request = null; + if ( $tool instanceof RestBackedToolInterface ) { + $request = $tool->build_rest_request( sample_args( $tool->get_parameters() ) ); + } + + $rest_request = 'n/a'; + if ( $request instanceof WP_REST_Request ) { + $route = normalize_route_placeholders( $request->get_route() ); + $rest_request = $request->get_method() . ' ' . $route; + } + + $capability = 'none'; + if ( $tool instanceof RestBackedToolInterface && $tool->get_rest_capability() !== null ) { + $requirement = $tool->get_rest_capability(); + $capability = $requirement->get_capability(); + if ( $requirement->get_model_type() !== null ) { + $capability .= ' (' . $requirement->get_model_type() . ')'; + } + } + + return [ + 'ability' => ability_name( $tool->get_name() ), + 'rest_request' => $rest_request, + 'capability' => $capability, + 'cacheable' => $tool instanceof RestBackedToolInterface && $tool->is_cacheable() ? 'yes' : 'no', + 'cache_ttl' => $tool instanceof RestBackedToolInterface && $tool->is_cacheable() ? (string) $tool->cache_ttl() . 's' : 'n/a', + ]; +} + +/** + * @param array $schema + * @return array + */ +function sample_args( array $schema ): array { + $args = []; + + foreach ( $schema as $name => $definition ) { + if ( ! is_array( $definition ) ) { + continue; + } + + if ( array_key_exists( 'default', $definition ) ) { + $args[ $name ] = $definition['default']; + continue; + } + + $args[ $name ] = placeholder_value( (string) $name, (string) ( $definition['type'] ?? 'string' ) ); + } + + return $args; +} + +function placeholder_value( string $name, string $type ) { + $values = [ + 'post_id' => 123, + 'post_type' => '{post_type}', + 'taxonomy' => '{taxonomy_rest_base}', + 'settings' => [], + 'items' => [], + 'title' => '{title}', + 'name' => '{name}', + ]; + + if ( array_key_exists( $name, $values ) ) { + return $values[ $name ]; + } + + if ( $type === 'number' || $type === 'integer' ) { + return 1; + } + + if ( $type === 'boolean' ) { + return true; + } + + if ( $type === 'array' ) { + return []; + } + + if ( $type === 'object' ) { + return []; + } + + return '{' . $name . '}'; +} + +function normalize_route_placeholders( string $route ): string { + $replacements = [ + '%7Bpost_type%7D' => '{post_type}', + '%7Btaxonomy_rest_base%7D' => '{taxonomy_rest_base}', + '%7Bname%7D' => '{name}', + '%7Bslug%7D' => '{slug}', + ]; + + return str_replace( array_keys( $replacements ), array_values( $replacements ), $route ); +} + +/** + * @param array $parameters + */ +function parameters_table( array $parameters ): string { + if ( $parameters === [] ) { + return 'This tool does not accept parameters.'; + } + + $lines = [ + '| Parameter | Type | Required | Default | Description |', + '|-----------|------|----------|---------|-------------|', + ]; + + foreach ( $parameters as $name => $definition ) { + $definition = is_array( $definition ) ? $definition : []; + $type = isset( $definition['type'] ) ? (string) $definition['type'] : 'mixed'; + $required = ! empty( $definition['required'] ) ? 'yes' : 'no'; + $default = array_key_exists( 'default', $definition ) ? code_value( $definition['default'] ) : ''; + $description = isset( $definition['description'] ) ? (string) $definition['description'] : ''; + $lines[] = '| `' . esc_md( (string) $name ) . '` | `' . esc_md( $type ) . '` | ' . $required . ' | ' . esc_md( $default ) . ' | ' . esc_md( $description ) . ' |'; + } + + return implode( "\n", $lines ); +} + +function ability_name( string $tool_name ): string { + return strtolower( 'saltus/' . str_replace( '_', '-', $tool_name ) ); +} + +function code_value( $value ): string { + if ( is_bool( $value ) ) { + return $value ? '`true`' : '`false`'; + } + + if ( is_array( $value ) ) { + return '`[]`'; + } + + if ( $value === null ) { + return '`null`'; + } + + return '`' . (string) $value . '`'; +} + +function esc_md( string $value ): string { + return str_replace( '|', '\\|', $value ); +} + +function replace_generated_section( string $path, string $start, string $end, string $replacement ): void { + $content = file_get_contents( $path ); + if ( $content === false ) { + throw new RuntimeException( 'Unable to read ' . $path ); + } + + $start_pos = strpos( $content, $start ); + $end_pos = strpos( $content, $end ); + + if ( $start_pos === false || $end_pos === false || $end_pos <= $start_pos ) { + throw new RuntimeException( 'Generated section markers not found in ' . $path ); + } + + $new_content = substr( $content, 0, $start_pos + strlen( $start ) ) + . "\n" + . $replacement + . "\n" + . substr( $content, $end_pos ); + + write_file_if_changed( $path, $new_content ); +} + +function write_file_if_changed( string $path, string $content ): void { + if ( file_exists( $path ) && file_get_contents( $path ) === $content ) { + return; + } + + if ( file_put_contents( $path, $content ) === false ) { + throw new RuntimeException( 'Unable to write ' . $path ); + } +} diff --git a/composer.json b/composer.json index b9b87610..3e9c9c64 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,8 @@ }, "require": { "php": ">=7.4", - "hassankhan/config": "^3.2.0" + "hassankhan/config": "^3.2.0", + "guzzlehttp/guzzle": "^7.0" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", @@ -48,19 +49,32 @@ "php-stubs/wordpress-stubs": "^7.0.0", "phpcompatibility/phpcompatibility-wp": "*", "phpstan/extension-installer": "^1.3", - "phpunit/phpunit": "^12.5.15", + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5 || ^12.0", "squizlabs/php_codesniffer": "^3.9", "szepeviktor/phpstan-wordpress": "^2.1", "wp-coding-standards/wpcs": "^3.3", "yoast/phpunit-polyfills": "^4.0" }, "scripts": { - "phpstan": "./vendor/bin/phpstan analyse --memory-limit=2G", - "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml" + "test": "./vendor/bin/phpunit -c phpunit.xml", + "test:unit": "./vendor/bin/phpunit -c phpunit.xml --testsuite Unit", + "test:integration": "./vendor/bin/phpunit -c phpunit.xml --testsuite Integration", + "tests": [ + "@test", + "@test:phpstan", + "@test:phpcs" + ], + "test:phpstan": "./vendor/bin/phpstan analyse --memory-limit=2G", + "test:phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", + "fix:phpcbf": "./vendor/bin/phpcbf --standard=phpcs.xml", + "docs:mcp": "php bin/generate-mcp-docs.php" }, "minimum-stability": "dev", "prefer-stable": true, "config": { + "platform": { + "php": "7.4" + }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "phpstan/extension-installer": true, diff --git a/composer.lock b/composer.lock index 57c6eea9..41c268e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,339 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4d4249ec90a7ae7cef30efee09149704", + "content-hash": "dd32d233c9c5efc7615a38da8cab6288", "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12.3", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.6", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-29T20:14:18+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.12.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.12.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-23T15:21:08+00:00" + }, { "name": "hassankhan/config", "version": "3.2.0", @@ -67,6 +398,361 @@ "source": "https://github.com/hassankhan/config/tree/3.2.0" }, "time": "2024-12-09T16:20:44+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "2.5-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:11:13+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" } ], "packages-dev": [ @@ -168,37 +854,35 @@ }, { "name": "digitalrevolution/php-codesniffer-baseline", - "version": "v1.3.0", + "version": "v1.1.2", "source": { "type": "git", "url": "https://github.com/123inkt/php-codesniffer-baseline.git", - "reference": "7fbcb9ef5f6e0be791683fcf3dd259d9fe0651a0" + "reference": "00d7cd414cc0fc12e88ee3321d92fe3d2313a9e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/123inkt/php-codesniffer-baseline/zipball/7fbcb9ef5f6e0be791683fcf3dd259d9fe0651a0", - "reference": "7fbcb9ef5f6e0be791683fcf3dd259d9fe0651a0", + "url": "https://api.github.com/repos/123inkt/php-codesniffer-baseline/zipball/00d7cd414cc0fc12e88ee3321d92fe3d2313a9e7", + "reference": "00d7cd414cc0fc12e88ee3321d92fe3d2313a9e7", "shasum": "" }, "require": { - "composer-plugin-api": "^2.0", - "php": ">=8.1", - "squizlabs/php_codesniffer": "^3.6 || ^4.0" + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=7.4", + "squizlabs/php_codesniffer": "^3.6" }, "require-dev": { "composer/composer": "^2.0", - "mikey179/vfsstream": "1.6.12", - "phpmd/phpmd": "^2.15", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^10.5 || ^11.5 || ^12.0", + "micheh/phpcs-gitlab": "^1.1", + "mikey179/vfsstream": "1.6.10", + "phpmd/phpmd": "@stable", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-latest" }, - "bin": [ - "bin/phpcs-baseline" - ], "type": "composer-plugin", "extra": { "class": "DR\\CodeSnifferBaseline\\Plugin\\Plugin" @@ -212,12 +896,82 @@ "license": [ "MIT" ], - "description": "Digital Revolution PHP_Codesniffer baseline extension", - "support": { - "issues": "https://github.com/123inkt/php-codesniffer-baseline/issues", - "source": "https://github.com/123inkt/php-codesniffer-baseline/tree/v1.3.0" - }, - "time": "2025-10-16T08:23:07+00:00" + "description": "Digital Revolution PHP_Codesniffer baseline extension", + "support": { + "issues": "https://github.com/123inkt/php-codesniffer-baseline/issues", + "source": "https://github.com/123inkt/php-codesniffer-baseline/tree/v1.1.2" + }, + "time": "2022-05-31T08:26:56+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" }, { "name": "myclabs/deep-copy", @@ -945,11 +1699,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.2", + "version": "2.2.4", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", - "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27", + "reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27", "shasum": "" }, "require": { @@ -1005,37 +1759,39 @@ "type": "github" } ], - "time": "2026-06-05T09:00:01+00:00" + "time": "2026-07-03T07:00:23+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.7", + "version": "9.2.32", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "186dab580576598076de6818596d12b61801880e" + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", - "reference": "186dab580576598076de6818596d12b61801880e", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", - "php": ">=8.3", - "phpunit/php-text-template": "^5.0", - "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.1.2", - "sebastian/lines-of-code": "^4.0.1", - "sebastian/version": "^6.0", - "theseer/tokenizer": "^2.0.1" + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^12.5.28" + "phpunit/phpunit": "^9.6" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -1044,7 +1800,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { @@ -1073,52 +1829,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", - "type": "tidelift" } ], - "time": "2026-06-01T13:24:19+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.1", + "version": "3.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", - "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -1145,49 +1889,36 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", - "type": "tidelift" } ], - "time": "2026-02-02T14:04:18+00:00" + "time": "2021-12-02T12:48:52+00:00" }, { "name": "phpunit/php-invoker", - "version": "6.0.0", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-pcntl": "*" @@ -1195,7 +1926,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -1221,8 +1952,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" }, "funding": [ { @@ -1230,32 +1960,32 @@ "type": "github" } ], - "time": "2025-02-07T04:58:58+00:00" + "time": "2020-09-28T05:58:55+00:00" }, { "name": "phpunit/php-text-template", - "version": "5.0.0", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1281,8 +2011,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" }, "funding": [ { @@ -1290,32 +2019,32 @@ "type": "github" } ], - "time": "2025-02-07T04:59:16+00:00" + "time": "2020-10-26T05:33:50+00:00" }, { "name": "phpunit/php-timer", - "version": "8.0.0", + "version": "5.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -1341,8 +2070,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" }, "funding": [ { @@ -1350,23 +2078,24 @@ "type": "github" } ], - "time": "2025-02-07T04:59:38+00:00" + "time": "2020-10-26T13:16:10+00:00" }, { "name": "phpunit/phpunit", - "version": "12.5.30", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", - "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { + "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", @@ -1376,23 +2105,27 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.7", - "phpunit/php-file-iterator": "^6.0.1", - "phpunit/php-invoker": "^6.0.0", - "phpunit/php-text-template": "^5.0.0", - "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.1", - "sebastian/comparator": "^7.1.8", - "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.2", - "sebastian/exporter": "^7.0.3", - "sebastian/global-state": "^8.0.3", - "sebastian/object-enumerator": "^7.0.0", - "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.4", - "sebastian/version": "^6.0.0", - "staabm/side-effects-detector": "^1.0.5" + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "bin": [ "phpunit" @@ -1400,7 +2133,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-master": "9.6-dev" } }, "autoload": { @@ -1432,40 +2165,56 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { - "url": "https://phpunit.de/sponsoring.html", - "type": "other" + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" } ], - "time": "2026-06-15T13:12:30+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", - "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-master": "1.0-dev" } }, "autoload": { @@ -1488,60 +2237,153 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", - "type": "tidelift" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "time": "2026-05-17T05:29:34+00:00" + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "7c65c1e79836812819705b473a90c12399542485" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", - "reference": "7c65c1e79836812819705b473a90c12399542485", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0.3" + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" - }, - "suggest": { - "ext-bcmath": "For comparing BcMath\\Number objects" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -1580,8 +2422,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -1601,33 +2442,33 @@ "type": "tidelift" } ], - "time": "2026-05-21T04:45:25+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", - "version": "5.0.0", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.3" + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -1650,8 +2491,7 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" }, "funding": [ { @@ -1659,33 +2499,33 @@ "type": "github" } ], - "time": "2025-02-07T04:55:25+00:00" + "time": "2023-12-22T06:19:30+00:00" }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -1717,8 +2557,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" }, "funding": [ { @@ -1726,27 +2565,27 @@ "type": "github" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2024-03-02T06:30:58+00:00" }, { "name": "sebastian/environment", - "version": "8.1.2", + "version": "5.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", - "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.5.26" + "phpunit/phpunit": "^9.3" }, "suggest": { "ext-posix": "*" @@ -1754,7 +2593,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-master": "5.1-dev" } }, "autoload": { @@ -1773,7 +2612,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", + "homepage": "http://www.github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -1781,55 +2620,42 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", - "type": "tidelift" } ], - "time": "2026-05-25T13:40:20+00:00" + "time": "2023-02-03T06:03:51+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.3", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", - "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { - "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/recursion-context": "^7.0.1" + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -1871,8 +2697,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { @@ -1892,35 +2717,38 @@ "type": "tidelift" } ], - "time": "2026-05-20T04:37:17+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.3", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", - "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0.1" + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.5.28" + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -1939,14 +2767,13 @@ } ], "description": "Snapshotting of global state", - "homepage": "https://www.github.com/sebastianbergmann/global-state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "funding": [ { @@ -1966,33 +2793,33 @@ "type": "tidelift" } ], - "time": "2026-06-01T15:10:33+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.1", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", - "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", "shasum": "" }, "require": { - "nikic/php-parser": "^5.7.0", - "php": ">=8.3" + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-master": "1.0-dev" } }, "autoload": { @@ -2015,55 +2842,42 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", - "type": "tidelift" } ], - "time": "2026-05-19T16:22:07+00:00" + "time": "2023-12-22T06:20:34+00:00" }, { "name": "sebastian/object-enumerator", - "version": "7.0.0", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -2085,8 +2899,7 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" }, "funding": [ { @@ -2094,32 +2907,32 @@ "type": "github" } ], - "time": "2025-02-07T04:57:48+00:00" + "time": "2020-10-26T13:12:34+00:00" }, { "name": "sebastian/object-reflector", - "version": "5.0.0", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -2141,8 +2954,7 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" }, "funding": [ { @@ -2150,32 +2962,32 @@ "type": "github" } ], - "time": "2025-02-07T04:58:17+00:00" + "time": "2020-10-26T13:14:26+00:00" }, { "name": "sebastian/recursion-context", - "version": "7.0.1", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -2205,8 +3017,7 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" }, "funding": [ { @@ -2226,32 +3037,86 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:44:59+00:00" + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" }, { "name": "sebastian/type", - "version": "6.0.4", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "82ff822c2edc46724be9f7411d3163021f602773" + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", - "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^12.5.25" + "phpunit/phpunit": "^9.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-master": "3.2-dev" } }, "autoload": { @@ -2274,50 +3139,37 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/type", - "type": "tidelift" } ], - "time": "2026-05-20T06:45:45+00:00" + "time": "2023-02-03T06:13:03+00:00" }, { "name": "sebastian/version", - "version": "6.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + "reference": "c6c1022351a901512170118436c764e473f6de8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=7.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -2340,8 +3192,7 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" }, "funding": [ { @@ -2349,7 +3200,7 @@ "type": "github" } ], - "time": "2025-02-07T05:00:38+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -2430,58 +3281,6 @@ ], "time": "2025-11-04T16:30:35+00:00" }, - { - "name": "staabm/side-effects-detector", - "version": "1.0.5", - "source": { - "type": "git", - "url": "https://github.com/staabm/side-effects-detector.git", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.6", - "phpunit/phpunit": "^9.6.21", - "symfony/var-dumper": "^5.4.43", - "tomasvotruba/type-coverage": "1.0.0", - "tomasvotruba/unused-public": "1.0.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "lib/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A static analysis tool to detect side effects in PHP code", - "keywords": [ - "static analysis" - ], - "support": { - "issues": "https://github.com/staabm/side-effects-detector/issues", - "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" - }, - "funding": [ - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2024-10-20T05:08:20+00:00" - }, { "name": "szepeviktor/phpstan-wordpress", "version": "2.x-dev", @@ -2548,23 +3347,23 @@ }, { "name": "theseer/tokenizer", - "version": "2.0.1", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^8.1" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { @@ -2586,7 +3385,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -2594,7 +3393,7 @@ "type": "github" } ], - "time": "2025-12-08T11:19:18+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -2735,5 +3534,8 @@ "php": ">=7.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-overrides": { + "php": "7.4" + }, + "plugin-api-version": "2.9.0" } diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 00000000..96391fcd --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,29 @@ +# Build: Setup & Runtime Instructions + +## Requirements +- PHP 7.4 or higher +- Composer + +## Installation +Run `composer install` to install dependencies. + +## Running Tests and Linting +The project defines several Composer scripts for quality assurance: + +- **Tests:** `composer test` +- **Unit tests:** `composer test:unit` +- **Integration tests:** `composer test:integration` +- **Static Analysis (PHPStan):** `./vendor/bin/phpstan analyse --memory-limit=2G` + Run via `composer phpstan` +- **Linting (PHPCS):** `./vendor/bin/phpcs --standard=phpcs.xml` + Run via `composer phpcs` + +## Patching Codestar Framework +If the Codestar Framework is updated, re-apply the custom patches: +```bash +for f in lib/codestar-framework/patches/*; do git apply "$f"; done +``` + +## Autoloading +The project has moved from 'files' to 'classmap' for specific libraries. +- If you add new classes to `lib/codestar-framework/`, you must regenerate the classmap by running `composer dump-autoload`. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md new file mode 100644 index 00000000..4fb8a544 --- /dev/null +++ b/docs/CONTEXT.md @@ -0,0 +1,54 @@ +# Context: Architecture & Decisions + +## Architecture +The framework is built to be included within a WordPress plugin. It uses models (PHP/JSON/YAML) to define Custom Post Types and Taxonomies. +It searches for model files by default in the `src/models` directory. + +### Initialization +```php +if ( class_exists( \Saltus\WP\Framework\Core::class ) ) { + $framework = new \Saltus\WP\Framework\Core( dirname( __FILE__ ) ); + $framework->register(); +} +``` + +## Key Decisions + +### 1. Codestar Framework Integration +- **Purpose:** Used for rapidly building complex settings pages, metaboxes, and options panels. +- **Decision:** Instead of building a custom options framework from scratch, Codestar provides a robust, heavily tested foundation under a GPL license. +- **Trade-offs:** We maintain custom patches (`lib/codestar-framework/patches`) to tailor its behavior to our specific framework needs. This means updates to Codestar require manually re-applying patches via a script. + +### 2. SoberWP/Models (Simplified) +- **Purpose:** Provides a declarative approach to registering Custom Post Types (CPTs) and Taxonomies. +- **Decision:** A simplified version of SoberWP/Models was included directly in the framework. This abstracts the repetitive and verbose WordPress core functions (`register_post_type`, `register_taxonomy`) into clean, easily readable PHP arrays, JSON, or YAML configuration files (models). +- **Impact:** Speeds up development time for developers of any skill level by relying on simple configuration rather than complex procedural code. + +### 3. Composer Classmap Autoloading +- **Purpose:** Handling legacy or third-party library loading. +- **Decision:** The project migrated from requiring 'files' directly to using Composer's `classmap` for libraries like the Codestar Framework. +- **Impact:** Enhances performance and standardizes autoloading. However, it introduces a manual step: developers must run `composer dump-autoload` whenever new classes are added to these mapped directories. + +### 4. GitHub Updater Support +- **Purpose:** Plugin update management. +- **Decision:** Includes native support for `afragen/github-updater`. +- **Impact:** Allows plugins built with this framework to receive seamless updates directly from the WordPress admin dashboard, bypassing the need to host plugins on the official WordPress.org repository. + +### 5. WordPress-Native MCP/Abilities +- **Purpose:** Expose Saltus model, content, settings, and metadata operations to AI clients through WordPress-native MCP/Abilities. +- **Decision:** WordPress 7.0 Abilities is the supported MCP path. The local stdio MCP server was removed; SSE transport and standalone server distribution remain out of scope. +- **Service Registration (Two-Pass Model):** To ensure REST endpoints and MCP tools are always available (regardless of whether the request is admin, frontend, or REST), `Core` registers `RestRouteProvider` and `ToolContributor` unconditionally on plugin boot. However, the core service activation (admin screens, scripts, hooks) remains strictly gated behind the `is_needed()` method. This prevents admin-only/frontend-only hooks and assets from running amok in contexts where they aren't needed. +- **Documentation:** `docs/MCP.md` is the canonical long-form source for the future Saltus MCP documentation site. +- **Metadata:** `list_meta_fields` exposes all registered CPT meta configs through `GET /saltus-framework/v1/meta`; `get_meta_fields` exposes one CPT through `GET /saltus-framework/v1/meta/{post_type}`. +- **Health:** `get_health` exposes `GET /saltus-framework/v1/health` through `saltus/get-health`, including version, error rate, latency, cache, and rate-limit status. +- **Current Shape:** Metadata responses preserve the raw Saltus/Codestar config in `meta` and add `normalized.fields` plus `normalized.rest_meta_keys` for client write guidance. +- **Normalized Metadata:** Nested Codestar fields are flattened into paths such as `points_info.coordinates.latitude`, with label, source meta key, serialized status, REST writability, and JSON-schema-like type data. + +### 6. Legacy Refactoring Track +- **Purpose:** Stabilize high-traffic legacy framework paths such as `Modeler.php`, `src/Features/`, and `Saltus*.php`. +- **Decision:** Refactoring these paths is debt reduction around runtime-critical behavior, not cosmetic cleanup. The goal is to reduce regression risk, improve standards compliance, and prepare the code for focused unit and integration tests. +- **Compatibility:** Existing Saltus plugins should continue working while internals become safer to maintain, easier to type-check, and easier to test. + +## Naming & Standards +- **Quality Assurance:** PHP CodeSniffer (PHPCS) ensures adherence to WordPress coding standards, while PHPStan handles static analysis to catch type errors and logical bugs early. +- **Testing:** Automated tests are powered by PHPUnit, ensuring framework stability across different WordPress and PHP versions. diff --git a/docs/CURRENT.md b/docs/CURRENT.md new file mode 100644 index 00000000..b2b22926 --- /dev/null +++ b/docs/CURRENT.md @@ -0,0 +1,122 @@ +# Current: Live Working State + +## Working +- Phase 5A: Block Editor integration — Blocks feature service, per-CPT block registration, default templates @since 2026-07-06 +- Phase 5D: Documentation — fill README placeholders, add model examples @since 2026-07-05 + +## Next +- Phase 5B: WP-CLI tools — 7 grouped command classes mapping every MCP tool +- Phase 5C: Frontend rendering — shortcodes, templates, meta field exposure +- Phase 5D: New doc files (BLOCKS.md, WPCLI.md, FRONTEND.md, FEATURES.md) + +## Blocked +- None + +## Recent Changes +- Code review feedback: replaced wp_die with RuntimeException in single_export_query to avoid HTML death pages in REST/MCP contexts; added catch clause in export_post to return structured WP_Error; replaced unsafe property_exists with get_object_vars in ModelRestPolicy to avoid fatal errors on non-public properties; added explicit edit_posts permission checks for list_models/get_model/list_meta_fields/get_meta_fields in AbilityDefinitionFactory; changed AuditLogger created_at column from varchar(32) to datetime(3) — 2 commits @since 2026-07-04 +- Export query hardening: replaced fragile string-equality check in single_export_query with structural regex detection via is_fake_date_export_query; added wp_die fallback for unrecognized fake-date query shapes; added esc_html__ and wp_die test stubs — 1 commit @since 2026-07-04 +- Code review feedback: deferred RestServer instantiation inside rest_api_init to avoid overhead on non-REST requests; replaced wp_next_scheduled/wp_unschedule_event with wp_clear_scheduled_hook in MCP deactivation; gated ensure_table() behind DB version option to avoid unnecessary CREATE TABLE queries on every audit write; added wp_clear_scheduled_hook test stub — 3 commits @since 2026-07-04 +- PHP 7.4 test compatibility: removed `mixed` type hints from 6 test file anonymous classes implementing AuditDatabase to avoid fatal errors on PHP 7.4 (parse error) and PHP 8.x (interface signature mismatch) @since 2026-07-04 +- Audit/runtime hardening: MCP audit retention cleanup now runs through daily WP-Cron instead of after every audit write; settings updates safely sanitize object payloads; single-post REST export removes WordPress core download headers before returning through REST; health degradation now counts only server-side `error` and `exception` audit statuses while still reporting validation and rate-limit status counts @since 2026-07-04 +- Service extraction: inline REST controller logic moved into shared service classes (SaltusSingleExport, MetaFieldProvider, ReorderPostsService, SettingsManager) and wired into both REST controllers and MCP tools; defensive guards added for null post, private property access, taxonomy object, and asset data types — 7 commits covering LegacyFeatureTest, FrameworkBootTest, RuntimeTest, RegistrarTest, and controller tests; 195 tests, 567 assertions @since 2026-07-03 +- Added MCP client integration guide at `docs/MCP-CLIENTS.md`, covering recommended call flow, health-first checks, model and metadata discovery, safe reads/writes, permission and rate-limit handling, editor integration notes, prompt guidance, and anti-patterns @since 2026-07-03 +- Added `composer docs:mcp` and `bin/generate-mcp-docs.php` to generate MCP ability documentation from `src/MCP/Tools`; generated output now refreshes `docs/MCP-ABILITIES.md` and the embedded ability table in `docs/MCP.md` @since 2026-07-03 +- Added long-form WordPress-native MCP/Abilities documentation at `docs/MCP.md` as the source page for future Saltus site docs, covering setup, discovery, permissions, all 17 abilities, metadata discovery, health monitoring, runtime filters, audit/cache/rate-limit behavior, compatibility, and troubleshooting @since 2026-07-03 +- MCP health ability added as `get_health`, backed by `GET /saltus-framework/v1/health`, cacheable for 60 seconds, and registered as `saltus/get-health`; WordPress-native MCP/Abilities surface now exposes 17 tools @since 2026-07-03 +- Health monitoring REST endpoint added at `GET /saltus-framework/v1/health`; reports framework version, native ability availability, audit sample/error rate/status counts, latency average/p95/max, and cache/rate-limit enabled flags. Route is registered independently of model-level `saltus_rest` opt-in and is covered by HealthController and RestServer tests; verification is green with `composer test`, `composer phpstan`, `composer phpcs`, and `git diff --check` @since 2026-07-03 +- Modeler ternary dispatch refactored into centralized `process_config()` method; WP test stubs enhanced with add_filter, apply_filters callback execution, post_meta, nonce, enqueue, esc*, WP_Query, and WP_Term stubs; LegacyFeatureTest and ModelerLegacyTest added covering deprecated filter paths, file-order processing, and multi-model configs — 4 files, 812 insertions @since 2026-07-02 +- Code review hardening pass: single-post REST export now emits WXR only for the requested post; `Core` can register activation/deactivation hooks against the consuming plugin file; MCP mutating permission callbacks fail closed on missing target args; settings updates recursively preserve structured values; `AbilityRuntime` JSON fallback works outside WordPress; `AssetLoader` is covered by PHPStan via `AssetLoadingService` @since 2026-07-02 +- Regression coverage added for export isolation, plugin-file lifecycle hook registration, fail-closed ability permissions, and nested settings payloads; verification is green with `composer test` (166 tests, 416 assertions), `composer phpstan`, `composer phpcs`, and `git diff --check` @since 2026-07-02 +- Permission granularity: REST controllers and MCP abilities now delegate to per-post-type and per-post WordPress capabilities instead of coarse edit_posts gate — 8 commits covering DuplicateController, MetaController, ModelsController, ReorderController, SettingsController, and AbilityDefinitionFactory; ToolFactory removed in favor of ToolContributor-driven provider injection @since 2026-07-02 +- MCP v1 refactoring: 14 commits — RestBackedToolInterface, RestCapabilityRequirement, RestTool, ToolContributor introduced; per-tool build_rest_request dispatch replaces monolithic AbilityRuntime switch; AbilityRegistrar gating via RestBackedToolInterface capability requirements; @phpstan-type AbilityDefinition added; all REST-backed tools migrated to RestBackedToolInterface; REST controllers updated for MCP v1 dispatch; ToolContributor wired into Modeler and all feature services @since 2026-07-02 +- Capability-gated REST routes: ModelRestPolicy, RestRouteDefinition, and RestRouteProvider infrastructure — per-model opt-in via `saltus_rest` config key; all 9 REST controllers enforce policy at request time; MCP abilities respect same policy gates @since 2026-07-01 +- Audit trail: insert validation and sanitization — null-byte stripping, column-length truncation, status whitelist, and WordPress sanitize_text_field applied to all string fields before persistence @since 2026-07-01 +- Fixed 2 pre-existing PHPStan errors in ResourceProvider — docblock param name mismatch (@param $context → $_context) @since 2026-07-01 +- Added unit tests for `BaseModel::get_name()` and `Modeler` add/get_models (5 test methods, 248 total) +- Added `tests/Integration/.gitkeep` to preserve the integration test directory +- `composer phpcs` is clean — all MCP module renamed to snake_case (14 commits) +- WordPress naming conventions enforced across MCP module: methods, properties, variables renamed from camelCase to snake_case +- Added `Json` helper class for safe JSON encoding (wp_json_encode with json_encode fallback) +- Removed phpcs.xml exclusion rules for MCP and REST paths +- Fixed ReflectionClass::getName() regression from rename +- Full `composer phpstan` is clean at PHPStan Level 7 across the configured analysis set +- Added `Model::get_name()` and `BaseModel::get_name()` so `Modeler` keys models through the model contract instead of concrete public properties +- Tightened `Modeler::add()` parameter and return types and removed redundant nullable fallback from `get_models()` +- Updated REST controllers to register routes with non-empty namespace constants for PHPStan-safe WordPress route registration +- Removed redundant taxonomy slug type narrowing in `ListPosts` +- v2.0.0 released 2026-06-30 — merged feature/mcp-v0 to main, tagged v2.0.0 +- Added strict phpunit.xml config with random execution order, failOn* and beStrictAbout* flags +- Added phpunit.xml.dist as distribution configuration +- Created tests/TestCase.php base class for all framework tests +- Added Unit test suite: Container, Asset, Model config tests (5 test files) +- Added Integration test suite: FrameworkBootTest for service container verification +- Added is_admin() stub in test functions for environment compatibility +- Added PHPUnit test job to GitHub Actions CI workflow +- Bumped version to 1.4.0 +- WordPress 7.0 MCP/Abilities connector: `MCP` injects feature-contributed tools into `AbilityRegistrar` when `wp_register_ability()` exists +- Feature-owned `ToolContributor` services keep MCP tools aligned with their REST routes and capabilities +- Native ability callbacks dispatch through `rest_do_request()` so existing REST permission callbacks remain authoritative +- Added compatibility tests covering native ability registration, capability gating, and REST-backed dispatch +- README documents WordPress-native MCP/Abilities as the selected path +- Removed standalone local stdio MCP server and related setup docs +- WP7 clients use `list_meta_fields` for discovering model-defined meta fields across registered CPTs +- Added `list_meta_fields` for WordPress-native metadata discovery across registered CPTs +- Added aggregate `GET /saltus-framework/v1/meta` for all post type meta definitions +- WP7 ability runtime resolves metadata discovery through the aggregate `/meta` endpoint +- MCP clients currently see raw Saltus/Codestar meta config: metabox IDs, sections, field definitions, dynamic option callback names, and `register_rest_api` hints +- Example for `itt_globe_point`: clients discover `points_info` as serialized meta with nested `coordinates`, `tooltipContent`, and `content`; `relationship_point` exposes `globe_id` and `globe_id_select` +- Metadata responses now preserve raw `meta` config and add `normalized.fields` plus `normalized.rest_meta_keys` +- Normalized field discovery flattens nested Codestar fields into explicit paths such as `points_info.coordinates.latitude` +- Normalized REST roots expose writable REST meta keys, serialized status, and JSON-schema-like types for MCP clients +- Added MCP ability tests for meta field aggregation, empty fields, REST errors, and no-model cases +- Phase 3 progress: 5 items completed, 5 items skipped +- Removed local stdio MCP server: WordPress 7.0 Abilities is the adopted MCP integration path +- Skipped SSE transport: Serve MCP over HTTP for remote connections +- Skipped Multi-site management: Named site profiles, switchable at runtime +- Skipped Role-based access: Map MCP tool access to WP user roles +- Health monitoring: Endpoint with version, error rate, latency stats +- Skipped Configuration profiles: `--profile=high-volume`, `--profile=strict` +- WP7 ability errors now return `WP_Error` directly from the WordPress-native runtime +- Caching layer: CacheInterface + TransientCache integrated into WP7 ability execution +- Rate limiting: Sliding-window RateLimiter throttles WP7 ability calls (default 60/60s) +- Audit trail: AuditLogger writes WP7 ability records to the Saltus MCP audit table +- Config: WordPress filters control cache, rate limit, and audit behavior +- 38 new PHPUnit tests (243 total, 696 assertions) covering all 4 Phase 3 features +- PHPStan Level 7 clean across all new MCP code +- MCP stdio-only error wrapper removed with the standalone server path +- Code review: Config constructor refactored to array bag pattern (#49 — medium) +- Code review: stdio-only MCP error wrapper removed with old server path +- Code review: RateLimitResult split into own file (#49 — low) +- Code review: Unused getDefaultMessage() removed (#49 — low) + +- Defensive code review fix pass: 6 files updated — invalid item payload guard in ReorderPostsService, WP_Error handling after rest_do_request in AbilityRuntime, OBJECT output format support in WpdbAuditDatabase, explicit get_settings/reorder_posts permission checks in AbilityDefinitionFactory, repeater sub-field schema exposure in MetaFieldProvider, and cutoff timestamp millisecond fix in AuditLogger @since 2026-07-04 +- Code review follow-up: replaced unsafe property_exists with get_object_vars in MetaFieldProvider::get_model_args to align with ModelRestPolicy pattern; widened catch from \Exception to \Throwable in SaltusSingleExport::export_post for PHP 7+ Error type resilience — 2 commits @since 2026-07-05 +- Phase 5 planned and added to ROADMAP.md — 4 sub-tracks: 5A (Blocks), 5B (WP-CLI), 5C (Frontend), 5D (Docs). Implementation starting with 5A + 5D. @since 2026-07-05 +- Code review fixes: cache only cleared on non-GET requests in AbilityRuntime; added integer type support in Validator; moved null check before try block in AssetLoader — 3 commits @since 2026-07-05 +- Model interface hardening: added get_options(): array and get_args(): array to Model interface; removed fragile method_exists + get_object_vars fallbacks from ModelRestPolicy, ModelsController, and MetaFieldProvider; updated all anonymous Model implementations in test files — 5 commits, 13 files @since 2026-07-05 +- Replaced hardcoded tester.php with proper PHPUnit container integration tests in tests/Integration/ContainerIntegrationTest.php — 1 commit @since 2026-07-05 +- Code review feedback round 2: removed static clear guard from TransientCache to avoid stale cache in long-running processes; replaced fragile strict array comparison in SettingsManager with database re-read to prevent false-positive rest_update_failed errors; switched export SQL detection from regex to WP_Query var inspection via posts_request filter; set explicit UTC timezone in AuditEntry DateTimeImmutable to fix incorrect timestamps — 5 commits @since 2026-07-06 +- Code review gemini-code-assist round 1: replaced direct $model->name access with check_method() helper in ModelsController to avoid fatal errors on private/protected properties; added added_option and deleted_option hooks to MCP cache-clearing list to flush caches on option creation and deletion; replaced sanitize_key() with case-preserving preg_replace() in SettingsManager to avoid breaking camelCase settings keys; replaced ISO 8601 datetime format with MySQL-compatible format in AuditEntry and AuditLogger to prevent "Truncated incorrect datetime value" warnings — 4 commits, 8 files @since 2026-07-06 + +- FilterAwareTrait extracted from 5 identical `filter()` private methods in RateLimiter, HealthController, AbilityRuntime, AuditLogger, TransientCache — shared trait applied across MCP infrastructure @since 2026-07-06 +- REST route registration bug fix: `is_needed()` gate bypassed for RestRouteProvider/ToolContributor registries via two-pass approach in `Core::register_services()` — REST routes now appear in WP-REST index even when `REST_REQUEST` is undefined during plugin boot @since 2026-07-06 +- ServiceContainer::instantiate_unconditionally() added for bypassing Conditional gates @since 2026-07-06 +- MCP contributors() fallback preserves backward compatibility when MCP is instantiated outside Core @since 2026-07-06 +- AuditDatabase::prepare() added to interface and WpdbAuditDatabase for safe SQL parameterization @since 2026-07-06 +- Validator::is_list() helper added for type checks @since 2026-07-06 +- Test suite fixed and passing: 214 tests, 605 assertions — AuditLogger DAY_IN_SECONDS fallback, ExportController export_wp echo fix, wpdb prepare %s quoting, MCPFeatureTest contributor fallback, RestRegistrationTest modeler mock injection @since 2026-07-06 +- @covers annotations added to all test classes (26 test files) @since 2026-07-06 + +## Known Issues +- `composer test` passes; Composer still prints a dependency deprecation notice from `justinrainbow/json-schema` under PHP 8.5.4. +- `composer phpcs` passes. +- PHPStan: Level 7 clean across the configured analysis set (new service classes ReorderPostsService, MetaFieldProvider, SettingsManager added). + +## Handoff +- WP7 Abilities is the MCP direction. Local stdio server was removed; SSE transport and standalone packaging are skipped. +- Standalone stdio MCP server removed; WP7 Abilities now owns MCP execution with WordPress-native audit, rate limiting, and transient caching. +- Metadata discovery is implemented through `saltus/list-meta-fields` and `saltus/get-meta-fields`. +- `list_meta_fields` calls `GET /saltus-framework/v1/meta` and returns `post_types`. +- `get_meta_fields` calls `GET /saltus-framework/v1/meta/{post_type}` and returns one CPT's raw `meta` plus normalized field paths and REST meta keys. +- Service extraction completed 2026-07-03: SaltusSingleExport, MetaFieldProvider, ReorderPostsService, and SettingsManager are now shared between REST controllers and MCP tools via constructor injection. Feature classes (DragAndDrop, Meta, Settings, SingleExport) own the service instances and pass them to both paths, eliminating code duplication. +- Current verification: full `composer test` (208 tests, 598 assertions), `composer phpstan`, `composer phpcs`, and `git diff --check` pass after the service extraction pass. diff --git a/docs/MCP-ABILITIES.md b/docs/MCP-ABILITIES.md new file mode 100644 index 00000000..d70030d5 --- /dev/null +++ b/docs/MCP-ABILITIES.md @@ -0,0 +1,325 @@ +# MCP Ability Reference + + + +Saltus Framework exposes 17 WordPress-native MCP/Abilities tools. + +| Tool | Ability | REST request | Description | +|------|---------|--------------|-------------| +| `create_post` | `saltus/create-post` | `POST /wp/v2/posts` | Create a new post in any registered Custom Post Type | +| `create_term` | `saltus/create-term` | `POST /wp/v2/{taxonomy_rest_base}` | Create a new term in a taxonomy | +| `delete_post` | `saltus/delete-post` | `DELETE /wp/v2/posts/123` | Delete (trash or force delete) a post by ID | +| `duplicate_post` | `saltus/duplicate-post` | `POST /saltus-framework/v1/duplicate/123` | Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title | +| `export_post` | `saltus/export-post` | `GET /saltus-framework/v1/export/123` | Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site | +| `get_health` | `saltus/get-health` | `GET /saltus-framework/v1/health` | Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status | +| `get_meta_fields` | `saltus/get-meta-fields` | `GET /saltus-framework/v1/meta/{post_type}` | Get the meta field definitions for a post type as configured in the Saltus Framework model | +| `get_model` | `saltus/get-model` | `GET /saltus-framework/v1/models/{slug}` | Get details of a specific Custom Post Type or Taxonomy by slug | +| `get_post` | `saltus/get-post` | `GET /wp/v2/posts/123` | Get a single post by ID with all fields and meta data | +| `get_settings` | `saltus/get-settings` | `GET /saltus-framework/v1/settings/{post_type}` | Get the Saltus Framework settings for a specific post type | +| `list_meta_fields` | `saltus/list-meta-fields` | `GET /saltus-framework/v1/meta` | List model-defined meta field definitions for all registered Saltus post types | +| `list_models` | `saltus/list-models` | `GET /saltus-framework/v1/models` | List all registered Custom Post Types and Taxonomies on the WordPress site | +| `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | +| `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | +| `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | +| `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | + +## `create_post` + +Create a new post in any registered Custom Post Type + +- Ability: `saltus/create-post` +- REST request: `POST /wp/v2/posts` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | no | `posts` | The post type slug (e.g., "posts", "page", "product") | +| `title` | `string` | yes | | The post title | +| `content` | `string` | no | | The post content (HTML or raw text) | +| `excerpt` | `string` | no | | The post excerpt | +| `slug` | `string` | no | | URL slug | +| `status` | `string` | no | `draft` | Post status | +| `meta` | `object` | no | | Meta fields as key-value pairs | +| `terms` | `object` | no | | Taxonomy terms as {taxonomy: [term_id, ...]} | + +## `create_term` + +Create a new term in a taxonomy + +- Ability: `saltus/create-term` +- REST request: `POST /wp/v2/{taxonomy_rest_base}` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `taxonomy` | `string` | yes | | The taxonomy slug (e.g., "categories", "tags") | +| `name` | `string` | yes | | The term name | +| `slug` | `string` | no | | URL slug (auto-generated if not provided) | +| `description` | `string` | no | | Term description | +| `parent` | `number` | no | | Parent term ID (for hierarchical taxonomies) | + +## `delete_post` + +Delete (trash or force delete) a post by ID + +- Ability: `saltus/delete-post` +- REST request: `DELETE /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to delete | +| `post_type` | `string` | no | `posts` | The post type slug | +| `force` | `boolean` | no | `false` | Whether to force delete (skip trash) | + +## `duplicate_post` + +Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title + +- Ability: `saltus/duplicate-post` +- REST request: `POST /saltus-framework/v1/duplicate/123` +- REST capability: `duplicate (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The ID of the post to duplicate | + +## `export_post` + +Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site + +- Ability: `saltus/export-post` +- REST request: `GET /saltus-framework/v1/export/123` +- REST capability: `export (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The ID of the post to export | + +## `get_health` + +Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status + +- Ability: `saltus/get-health` +- REST request: `GET /saltus-framework/v1/health` +- REST capability: `health` +- Cacheable: `yes` +- Cache TTL: `60s` + +### Parameters + +This tool does not accept parameters. + +## `get_meta_fields` + +Get the meta field definitions for a post type as configured in the Saltus Framework model + +- Ability: `saltus/get-meta-fields` +- REST request: `GET /saltus-framework/v1/meta/{post_type}` +- REST capability: `meta (post_type)` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to get meta fields for | + +## `get_model` + +Get details of a specific Custom Post Type or Taxonomy by slug + +- Ability: `saltus/get-model` +- REST request: `GET /saltus-framework/v1/models/{slug}` +- REST capability: `models` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `slug` | `string` | yes | | The slug of the post type or taxonomy (e.g., "post", "page", "product") | + +## `get_post` + +Get a single post by ID with all fields and meta data + +- Ability: `saltus/get-post` +- REST request: `GET /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID | +| `post_type` | `string` | no | `posts` | The post type slug (defaults to "posts") | + +## `get_settings` + +Get the Saltus Framework settings for a specific post type + +- Ability: `saltus/get-settings` +- REST request: `GET /saltus-framework/v1/settings/{post_type}` +- REST capability: `settings (post_type)` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to get settings for | + +## `list_meta_fields` + +List model-defined meta field definitions for all registered Saltus post types + +- Ability: `saltus/list-meta-fields` +- REST request: `GET /saltus-framework/v1/meta` +- REST capability: `meta (post_type)` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +This tool does not accept parameters. + +## `list_models` + +List all registered Custom Post Types and Taxonomies on the WordPress site + +- Ability: `saltus/list-models` +- REST request: `GET /saltus-framework/v1/models` +- REST capability: `models` +- Cacheable: `yes` +- Cache TTL: `600s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `type` | `string` | no | `all` | Filter by type: post_types, taxonomies, or all (default) | + +## `list_posts` + +Query posts from a Custom Post Type with optional filters + +- Ability: `saltus/list-posts` +- REST request: `GET /wp/v2/posts` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | no | `posts` | The post type slug (e.g., "posts", "page", "product") | +| `status` | `string` | no | `publish` | Post status filter (publish, draft, pending, private, trash, any) | +| `search` | `string` | no | | Search term | +| `per_page` | `number` | no | `20` | Number of posts per page (max 100) | +| `page` | `number` | no | `1` | Page number | +| `orderby` | `string` | no | `date` | Sort field (date, title, id, modified, menu_order) | +| `order` | `string` | no | `desc` | Sort order | +| `terms` | `object` | no | | Taxonomy term filters as {taxonomy_rest_base: [term_id, ...]} | + +## `list_terms` + +List terms from a taxonomy (categories, tags, or custom taxonomies) + +- Ability: `saltus/list-terms` +- REST request: `GET /wp/v2/{taxonomy_rest_base}` +- REST capability: `none` +- Cacheable: `yes` +- Cache TTL: `300s` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `taxonomy` | `string` | yes | | The taxonomy slug (e.g., "categories", "tags", or custom) | +| `per_page` | `number` | no | `50` | Number of terms per page (max 100) | +| `search` | `string` | no | | Search term | +| `hide_empty` | `boolean` | no | `false` | Whether to hide terms with no posts | + +## `reorder_posts` + +Reorder multiple posts by updating their menu_order values in a single batch operation + +- Ability: `saltus/reorder-posts` +- REST request: `POST /saltus-framework/v1/reorder` +- REST capability: `reorder (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `items` | `array` | yes | | Array of objects with "id" (post ID) and "menu_order" (integer position) | + +## `update_post` + +Update an existing post's fields and meta data + +- Ability: `saltus/update-post` +- REST request: `PUT /wp/v2/posts/123` +- REST capability: `none` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_id` | `number` | yes | | The post ID to update | +| `post_type` | `string` | no | `posts` | The post type slug | +| `title` | `string` | no | | New post title | +| `content` | `string` | no | | New post content (HTML or raw text) | +| `excerpt` | `string` | no | | New post excerpt | +| `slug` | `string` | no | | New URL slug | +| `status` | `string` | no | | New post status | +| `meta` | `object` | no | | Meta fields to update as key-value pairs | + +## `update_settings` + +Update the Saltus Framework settings for a specific post type + +- Ability: `saltus/update-settings` +- REST request: `PUT /saltus-framework/v1/settings/{post_type}` +- REST capability: `settings (post_type)` +- Cacheable: `no` +- Cache TTL: `n/a` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `post_type` | `string` | yes | | The post type slug to update settings for | +| `settings` | `object` | yes | | The settings data to update (key-value pairs) | diff --git a/docs/MCP-CLIENTS.md b/docs/MCP-CLIENTS.md new file mode 100644 index 00000000..3c5e7f8c --- /dev/null +++ b/docs/MCP-CLIENTS.md @@ -0,0 +1,289 @@ +# MCP Client Integration Guide + +This guide is for WordPress-native MCP/Abilities clients that consume Saltus Framework abilities from an active WordPress site. + +Saltus registers `saltus/*` abilities inside WordPress. Clients should treat the ability definitions as the source of truth for available tools, input schemas, permissions, and transport metadata. + +## Client Goals + +A good Saltus MCP client should: + +- discover available `saltus/*` abilities before planning actions +- check site health before making a workflow plan +- inspect models and meta fields before reading or writing content +- use normalized metadata paths when reasoning about nested custom fields +- handle WordPress capability failures without retry loops +- respect rate limits and cacheable read operations +- ask for explicit user confirmation before destructive or broad writes + +## Recommended Call Flow + +Use this sequence for most client sessions: + +1. Call `saltus/get-health`. +2. Call `saltus/list-models`. +3. Call `saltus/list-meta-fields`. +4. Choose the relevant model. +5. Call `saltus/get-model` or `saltus/get-meta-fields` for model-specific detail. +6. Read content with `saltus/list-posts`, `saltus/get-post`, `saltus/list-terms`, or settings tools. +7. Propose changes to the user. +8. Execute writes only after the target model, fields, and permissions are clear. + +For narrow workflows where the client already knows the post type, it can skip the aggregate metadata call and use `saltus/get-meta-fields` directly. + +## Discovery + +Clients should discover abilities from WordPress and filter for the `saltus/` prefix. + +Every Saltus ability includes metadata similar to: + +```json +{ + "meta": { + "mcp_tool": "list_models", + "namespace": "saltus-framework/v1", + "transport": "wordpress-rest", + "show_in_rest": true + } +} +``` + +Use `meta.mcp_tool` for user-facing tool names and logs. Use the ability name, such as `saltus/list-models`, for native client execution. + +## Health First + +Call `saltus/get-health` at the start of a session. A healthy response tells the client that Saltus' runtime controls are available and gives recent audit-derived error and latency information. + +Suggested handling: + +| Health signal | Client behavior | +|---------------|-----------------| +| `status: ok` | Continue normally | +| `status: degraded` | Prefer read-only planning and explain the degraded state before writes | +| High `error_rate` | Avoid repeated retries; inspect permission and input errors | +| High latency | Reduce broad listing calls and keep page sizes modest | +| Rate limit enabled | Respect rate-limit errors and retry-after data | + +Health is framework-scoped. It does not prove that a specific model or write operation is available. + +## Model Discovery + +Use `saltus/list-models` to discover post types and taxonomies exposed by Saltus. Clients should not assume a model exists based only on a user phrase. + +Recommended behavior: + +- Map user language to model names after reading model labels and slugs. +- Prefer exact model slugs when calling tools. +- If multiple models are plausible, ask the user to choose. +- Treat missing models as configuration or permission issues, not as empty content. + +## Metadata Discovery + +Use `saltus/list-meta-fields` for site-wide field discovery. Use `saltus/get-meta-fields` for one post type. + +Saltus returns both raw and normalized metadata: + +- `meta`: raw Saltus/Codestar model configuration +- `normalized.fields`: flattened field paths for client reasoning +- `normalized.rest_meta_keys`: REST-writable roots and type information + +Clients should prefer `normalized.fields` when explaining or mapping field-level work. + +Example normalized paths: + +```text +points_info.coordinates.latitude +points_info.coordinates.longitude +points_info.tooltipContent +``` + +When writing post meta, clients should map the desired field path back to its writable REST meta root. If a field is nested in serialized meta, update the containing structure carefully rather than sending only the leaf path. + +## Safe Read Patterns + +Use list operations before single-item operations: + +1. `saltus/list-posts` with `post_type`, `search`, `status`, and pagination. +2. Ask the user to confirm the target when the search result is ambiguous. +3. `saltus/get-post` with the confirmed `post_id`. + +Keep list queries small. Use `per_page` values that fit the user's task instead of pulling large collections by default. + +For terms: + +1. Discover taxonomy models with `saltus/list-models`. +2. Call `saltus/list-terms` with the taxonomy slug or REST base expected by the tool. +3. Use returned term IDs for post create/update calls when needed. + +## Safe Write Patterns + +Before creating or updating content, clients should know: + +- target post type +- target post ID for updates/deletes +- writable meta roots +- expected field shape +- current user intent +- relevant capability outcome + +Recommended write flow: + +1. Read current model and metadata. +2. Read the target post or settings. +3. Build a minimal patch. +4. Summarize the planned mutation to the user. +5. Execute the mutation. +6. Read the object again to confirm the result. + +Avoid broad writes such as updating many posts from one instruction unless the client can show the exact target list and the user confirms it. + +## Destructive Actions + +`saltus/delete-post`, `saltus/reorder-posts`, and settings updates can materially change the site. Clients should require explicit confirmation for: + +- force deletion +- bulk deletion +- reordering more than a small visible set +- settings updates +- writes to serialized or nested meta + +Prefer trashing over force deletion unless the user explicitly asks for permanent deletion. + +## Permission Failures + +Saltus permissions are WordPress permissions. Clients should not try to bypass them. + +Common responses: + +| Failure | Likely cause | Client response | +|---------|--------------|-----------------| +| `rest_forbidden` | Current user lacks capability | Explain the required access and stop | +| `invalid_params` | Missing or malformed arguments | Fix arguments once, then retry | +| model not found | Model is not registered, not REST-enabled, or not visible to this user | Ask for a different model or admin configuration | +| write denied | User can read but not mutate the target | Offer a read-only summary instead | + +Do not retry permission failures repeatedly. They are usually stable until the user's role or model configuration changes. + +## Rate Limits + +Saltus rate limiting protects WordPress from excessive ability calls. When a call returns a rate-limit error, clients should respect the error data and wait before retrying. + +Recommended behavior: + +- Stop parallel calls after the first rate-limit error. +- Use the returned `retry_after` value when available. +- Prefer cached or already-read context while waiting. +- Avoid broad discovery loops that repeatedly call the same list tools. + +## Caching + +Saltus may cache read-only ability responses in WordPress transients. Clients can still call read tools normally, but should understand that repeated calls may return cached data. + +For workflows that must confirm a mutation: + +1. Execute the write. +2. Let Saltus clear its MCP cache. +3. Read the changed object again. + +Mutating Saltus tools clear the MCP cache after execution. + +## Client Planning Heuristics + +Use these heuristics when planning autonomous workflows: + +| User intent | First tools | +|-------------|-------------| +| "What content types are available?" | `saltus/get-health`, `saltus/list-models` | +| "Show me entries for X" | `saltus/list-models`, `saltus/list-posts` | +| "Edit field Y on item Z" | `saltus/list-models`, `saltus/get-meta-fields`, `saltus/list-posts`, `saltus/get-post` | +| "Create a new X" | `saltus/list-models`, `saltus/get-meta-fields`, `saltus/create-post` | +| "Change plugin settings" | `saltus/list-models`, `saltus/get-settings`, `saltus/update-settings` | +| "Export this item" | `saltus/list-posts`, `saltus/get-post`, `saltus/export-post` | +| "Reorder items" | `saltus/list-posts`, user confirmation, `saltus/reorder-posts` | + +## Example Workflows + +### Inspect Available Saltus Content + +1. Call `saltus/get-health`. +2. Call `saltus/list-models` with `type: "all"`. +3. Summarize exposed post types and taxonomies. +4. Mention if no models are visible or if health is degraded. + +### Update a Custom Meta Field + +1. Call `saltus/list-models`. +2. Identify the post type. +3. Call `saltus/get-meta-fields`. +4. Find the normalized field path. +5. Call `saltus/list-posts` to locate the item. +6. Call `saltus/get-post`. +7. Build a minimal meta update. +8. Ask for confirmation. +9. Call `saltus/update-post`. +10. Call `saltus/get-post` again and report the confirmed value. + +### Create a New CPT Entry + +1. Call `saltus/list-models`. +2. Call `saltus/get-meta-fields` for the chosen post type. +3. Collect required title/content/meta/term data from the user. +4. Call `saltus/create-post`. +5. Read the new post and summarize its ID, status, title, and important meta. + +### Diagnose Client Errors + +1. Call `saltus/get-health`. +2. Check recent error rate and latency. +3. Confirm the requested ability exists. +4. Confirm the target model is visible through `saltus/list-models`. +5. Confirm required parameters match `docs/MCP-ABILITIES.md` or the discovered input schema. +6. Stop if the error is permission-related. + +## Editor And VS Code Guidance + +For editor agents and future VS Code integrations: + +- Show discovered Saltus models before offering write operations. +- Show the exact post IDs or setting keys affected by a mutation. +- Surface normalized meta paths in pickers/autocomplete. +- Keep ability call logs visible enough for debugging. +- Prefer staged edits or previews before `update_post`, `update_settings`, `delete_post`, or `reorder_posts`. +- Use `get_health` as a connection/status check in the extension UI. + +## Prompt Guidance For Clients + +Clients can improve reliability by grounding tool use in a short internal plan: + +```text +First check Saltus health. Then list models. Then discover metadata for the target post type. Do not write until the target post ID, field path, and new value are confirmed. +``` + +For destructive work: + +```text +Before deletion or force deletion, show the exact post ID, title, post type, and deletion mode. Require explicit confirmation. +``` + +For nested meta: + +```text +Use normalized field paths for reasoning, but write through the REST meta root reported by Saltus. Preserve sibling fields in serialized structures. +``` + +## Anti-Patterns + +Avoid these client behaviors: + +- guessing post type slugs without `list_models` +- writing meta before checking normalized metadata +- retrying permission failures +- using large list queries as a discovery shortcut +- force deleting without explicit confirmation +- treating health `ok` as proof that all model-scoped capabilities are enabled +- assuming every Saltus model allows every REST-backed capability + +## Reference + +- Main MCP docs: [MCP.md](MCP.md) +- Generated ability reference: [MCP-ABILITIES.md](MCP-ABILITIES.md) diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 00000000..3abc6e2e --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,304 @@ +# WordPress-Native MCP/Abilities + +Saltus Framework exposes its AI-facing tool surface through the WordPress-native MCP/Abilities API. Plugins built with Saltus do not run a separate MCP server. When the host WordPress version provides the Abilities API, Saltus registers `saltus/*` abilities from inside WordPress and dispatches calls through the existing REST layer. + +This document is written as the source page for the future Saltus documentation site. + +For client implementation guidance, see [MCP-CLIENTS.md](MCP-CLIENTS.md). For the generated ability reference, see [MCP-ABILITIES.md](MCP-ABILITIES.md). + +## Status + +- Supported path: WordPress-native MCP/Abilities +- Standalone stdio server: removed +- SSE transport: out of scope +- Current ability count: 17 +- REST namespace: `saltus-framework/v1` +- Ability namespace: `saltus/*` + +## Requirements + +- WordPress 7.0+ or a WordPress build that includes the Abilities API +- An active plugin that loads and registers Saltus Framework +- A WordPress-native MCP/Abilities client +- A WordPress user with the capabilities required by the requested operation + +Older WordPress versions simply skip ability registration. The framework continues to work for CPT, taxonomy, settings, meta, and admin features; only the native MCP/Abilities surface is unavailable. + +## Setup + +Install Saltus Framework in your plugin and register it normally: + +```php +$autoload = __DIR__ . '/vendor/autoload.php'; +if ( is_readable( $autoload ) ) { + require_once $autoload; +} + +if ( class_exists( \Saltus\WP\Framework\Core::class ) ) { + $framework = new \Saltus\WP\Framework\Core( dirname( __FILE__ ), __FILE__ ); + $framework->register(); +} +``` + +No MCP-specific server process, local config file, port, token, or shell command is required. Saltus registers abilities during WordPress' Abilities API lifecycle when the native API is present. + +## Discovery + +Native MCP/Abilities clients discover Saltus tools from the active WordPress site. Saltus registers ability names in the `saltus/*` namespace, using kebab-case ability names derived from snake_case tool names. + +Examples: + +| Tool | Ability | +|------|---------| +| `get_health` | `saltus/get-health` | +| `list_models` | `saltus/list-models` | +| `get_meta_fields` | `saltus/get-meta-fields` | +| `update_settings` | `saltus/update-settings` | + +Each ability definition includes: + +- `name` +- `label` +- `description` +- `category` +- `input_schema` +- `inputSchema` +- `execute_callback` +- `permission_callback` +- `meta` + +The ability `meta` identifies the MCP tool name, REST namespace, transport, and REST visibility. + +## Execution Model + +Saltus abilities are REST-backed. A client calls a `saltus/*` ability, Saltus validates the input schema, checks rate limits, builds a `WP_REST_Request`, and dispatches it through `rest_do_request()`. + +The REST controller remains the authoritative execution layer. This keeps behavior consistent between direct REST requests and MCP/Abilities calls. + +### Two-Pass Service Registration & the `is_needed()` Gate + +To decouple REST and MCP tool availability from the current page loading context, the framework separates tool registration from core runtime initialization: + +1. **Unconditional REST/MCP Discovery (Pass 1):** Services implementing `RestRouteProvider` or `ToolContributor` are instantiated unconditionally during boot. This ensures that their REST routes and MCP tools are registered and accessible to AI clients on any request, bypassing the `is_needed()` check. No hooks, actions, or assets are registered in this pass. +2. **Gated Core Activation (Pass 2):** Core features (admin hooks, action hooks, asset enqueuing) are registered inside the service container, which strictly enforces the `is_needed()` gate: + ```php + if ( is_a( $service_class, Conditional::class, true ) && + ! $service_class::is_needed() ) { + return; + } + ``` + If a service is not needed in the current request context (e.g. an admin-only service requested on a frontend page), it is skipped entirely. This prevents unneeded scripts, styles, and action hooks from running amok. + +## Permissions + +Permissions are enforced in two layers: + +1. Native ability permission callbacks check broad WordPress capabilities before execution. +2. REST permission callbacks and controller policies enforce the final operation-specific rules. + +Saltus reuses WordPress capability checks such as: + +| Operation | Typical capability check | +|-----------|--------------------------| +| Read/list tools | `read` or model-specific read/edit access | +| Create posts | post type `create_posts` capability, falling back to `edit_posts` | +| Update posts | `edit_post` for the target post | +| Delete posts | `delete_post` for the target post | +| Duplicate posts | `edit_post` for the source post | +| Export posts | `export` | +| Settings updates | `manage_options` | +| Term creation | taxonomy edit/manage capability | + +REST routes are also gated by model configuration. For model-scoped Saltus REST/MCP features, set `saltus_rest` in the model options. + +Enable all Saltus REST-backed capabilities for a model: + +```php +return [ + 'type' => 'cpt', + 'name' => 'book', + 'options' => [ + 'show_in_rest' => true, + 'saltus_rest' => true, + ], +]; +``` + +Enable only selected capabilities: + +```php +return [ + 'type' => 'cpt', + 'name' => 'book', + 'options' => [ + 'show_in_rest' => true, + 'saltus_rest' => [ + 'models' => true, + 'meta' => true, + 'settings' => true, + ], + ], +]; +``` + +If `show_in_rest` is explicitly `false`, Saltus does not expose model-scoped REST/MCP routes for that model. The health ability is framework-scoped and remains independent of per-model `saltus_rest` opt-in. + +## Available Abilities + + + + +| Tool | Ability | REST request | Description | +|------|---------|--------------|-------------| +| `create_post` | `saltus/create-post` | `POST /wp/v2/posts` | Create a new post in any registered Custom Post Type | +| `create_term` | `saltus/create-term` | `POST /wp/v2/{taxonomy_rest_base}` | Create a new term in a taxonomy | +| `delete_post` | `saltus/delete-post` | `DELETE /wp/v2/posts/123` | Delete (trash or force delete) a post by ID | +| `duplicate_post` | `saltus/duplicate-post` | `POST /saltus-framework/v1/duplicate/123` | Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title | +| `export_post` | `saltus/export-post` | `GET /saltus-framework/v1/export/123` | Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site | +| `get_health` | `saltus/get-health` | `GET /saltus-framework/v1/health` | Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status | +| `get_meta_fields` | `saltus/get-meta-fields` | `GET /saltus-framework/v1/meta/{post_type}` | Get the meta field definitions for a post type as configured in the Saltus Framework model | +| `get_model` | `saltus/get-model` | `GET /saltus-framework/v1/models/{slug}` | Get details of a specific Custom Post Type or Taxonomy by slug | +| `get_post` | `saltus/get-post` | `GET /wp/v2/posts/123` | Get a single post by ID with all fields and meta data | +| `get_settings` | `saltus/get-settings` | `GET /saltus-framework/v1/settings/{post_type}` | Get the Saltus Framework settings for a specific post type | +| `list_meta_fields` | `saltus/list-meta-fields` | `GET /saltus-framework/v1/meta` | List model-defined meta field definitions for all registered Saltus post types | +| `list_models` | `saltus/list-models` | `GET /saltus-framework/v1/models` | List all registered Custom Post Types and Taxonomies on the WordPress site | +| `list_posts` | `saltus/list-posts` | `GET /wp/v2/posts` | Query posts from a Custom Post Type with optional filters | +| `list_terms` | `saltus/list-terms` | `GET /wp/v2/{taxonomy_rest_base}` | List terms from a taxonomy (categories, tags, or custom taxonomies) | +| `reorder_posts` | `saltus/reorder-posts` | `POST /saltus-framework/v1/reorder` | Reorder multiple posts by updating their menu_order values in a single batch operation | +| `update_post` | `saltus/update-post` | `PUT /wp/v2/posts/123` | Update an existing post's fields and meta data | +| `update_settings` | `saltus/update-settings` | `PUT /saltus-framework/v1/settings/{post_type}` | Update the Saltus Framework settings for a specific post type | + +For full generated parameter details, see [MCP-ABILITIES.md](MCP-ABILITIES.md). + + +## Metadata Discovery + +Use `list_meta_fields` when a client needs to understand available custom fields across the site. Use `get_meta_fields` when the client already knows the post type. + +Metadata responses include: + +- `meta`: raw Saltus/Codestar metabox configuration +- `normalized.fields`: flattened client-facing field paths +- `normalized.rest_meta_keys`: writable REST meta roots with serialization and type information + +Nested Codestar fields are flattened into explicit paths, for example: + +```text +points_info.coordinates.latitude +points_info.coordinates.longitude +points_info.tooltipContent +``` + +This lets clients reason about nested meta while still preserving the original Saltus/Codestar shape for advanced integrations. + +## Health Monitoring + +The `get_health` ability calls `GET /saltus-framework/v1/health`. It reports: + +- framework version +- whether the native Abilities API is available +- audit sample size +- recent error count and error rate +- status counts +- latency average, p95, and max in milliseconds +- cache enabled state +- rate limit enabled state + +The health route requires `edit_posts` by default. It is not tied to a specific CPT model and does not require `saltus_rest` model opt-in. + +## Runtime Controls + +Saltus wraps ability execution with audit logging, rate limiting, and transient caching. These controls are configured with WordPress filters. + +| Filter | Purpose | Default | +|--------|---------|---------| +| `saltus/framework/mcp/audit/enabled` | Enable or disable audit writes | `true` | +| `saltus/framework/mcp/audit/retention_days` | Days to keep audit rows | `30` | +| `saltus/framework/mcp/rate_limit/enabled` | Enable or disable rate limiting | `true` | +| `saltus/framework/mcp/rate_limit/max_requests` | Max calls per window | `60` | +| `saltus/framework/mcp/rate_limit/window_seconds` | Rate-limit window size | `60` | +| `saltus/framework/mcp/rate_limit/identifier` | Override the user/request rate-limit key | current user or request IP hash | +| `saltus/framework/mcp/cache/enabled` | Enable or disable transient caching | `true` | +| `saltus/framework/mcp/cache/ttl` | Override cache TTL per tool | tool-defined TTL | +| `saltus/framework/mcp/cache/cacheable` | Override whether a tool is cacheable | tool-defined cacheability | +| `saltus/framework/health/audit_sample_size` | Audit rows sampled by health endpoint | `100` | + +Example: + +```php +add_filter( + 'saltus/framework/mcp/rate_limit/max_requests', + static function (): int { + return 120; + } +); +``` + +## Caching + +Read-only tools can be cached in WordPress transients. Mutating tools clear the Saltus MCP cache after successful execution. + +Current cacheable tools include: + +- `get_health` +- `list_models` +- `get_model` +- `list_posts` +- `get_post` +- `list_terms` +- `get_settings` +- `list_meta_fields` +- `get_meta_fields` + +Other tools may still be made cacheable through the `saltus/framework/mcp/cache/cacheable` filter, but write operations should remain uncached. + +## Audit Trail + +Ability executions are recorded in the Saltus MCP audit table when audit logging is enabled. Audit rows include: + +- timestamp +- user ID +- rate-limit identifier +- ability/tool name +- arguments +- status +- duration in milliseconds +- error code and message when applicable + +The health endpoint uses recent audit rows to calculate error-rate and latency metrics. +Health degradation is based on server-side audit failures: `error` and `exception`. Client-side outcomes such as `validation_error` and `rate_limited` remain visible in the status breakdown but do not count toward the framework health error rate. + +Audit retention cleanup runs through the daily `saltus_framework_mcp_audit_cleanup` WP-Cron event. The cleanup query only deletes expired rows from the internal Saltus MCP audit table and never deletes posts, terms, settings, or other site content. Set `saltus/framework/mcp/audit/retention_days` to `0` or a negative value to disable retention cleanup. + +## Compatibility Matrix + +| Environment | Behavior | +|-------------|----------| +| WordPress with Abilities API | Saltus registers `saltus/*` abilities | +| WordPress without Abilities API | Saltus skips native ability registration | +| REST disabled for a model | Model-scoped Saltus MCP routes are unavailable for that model | +| `show_in_rest` set to `false` | Model-scoped Saltus REST/MCP routes are unavailable | +| No WordPress-native MCP client | Saltus abilities are registered, but no client consumes them | + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| No `saltus/*` abilities appear | Confirm the WordPress build provides the Abilities API and the plugin is active | +| A model is missing from MCP results | Confirm the model has `show_in_rest` enabled and `saltus_rest` configured | +| A write operation fails | Confirm the current WordPress user has the needed post, taxonomy, or settings capability | +| Calls are throttled | Check `saltus/framework/mcp/rate_limit/*` filters | +| Results look stale | Clear transients or disable MCP cache while testing | +| Health reports degraded | Inspect recent audit entries for repeated errors or high latency | + +## Removed Paths + +Earlier Saltus MCP planning included a standalone local stdio server and remote transports. Those paths were removed when the WordPress-native Abilities direction became the supported integration model. + +Saltus does not currently ship: + +- a standalone MCP server binary +- a PHAR for MCP +- a Docker image for MCP +- an SSE transport +- a separate MCP configuration profile system diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 00000000..df6844dc --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,43 @@ +--- +name: Saltus Framework 1 +description: Saltus Framework helps you develop WordPress plugins that are based on Custom Post Types. +type: project +homepage: https://saltus.dev/ +repository: https://github.com/SaltusDev/saltus-framework +--- + +# Saltus Framework Identity and Metadata + +## Overview +Saltus Framework is designed to make things easier and faster for developers with different skills to develop WordPress plugins based on Custom Post Types. It allows adding metaboxes, settings pages, and other enhancements with minimal code. + +## Key Metadata +- **Package Name:** `saltus/framework` +- **Requires:** PHP >= 7.4 +- **License:** GPL-3.0-only +- **Authors:** Saltus Plugin Framework (web@saltus.dev) + +## Core Capabilities & Features +- **Rapid Custom Post Type (CPT) Creation**: Define robust CPTs via simple array or YAML configurations. +- **Taxonomy Management**: Easily register hierarchical ('category') or non-hierarchical ('tag') taxonomies and associate them with CPTs. +- **Advanced Administration Interfaces**: + - Control all labels and messages + - Add custom administration columns and lists + - Add settings pages and custom metaboxes +- **Data Management**: + - Enable one-click post cloning + - Single entry export functionality + - Built-in drag-and-drop reordering +- **Extensibility**: Provides a robust set of hooks (`actions` and `filters`) to customize the framework's behavior (e.g., duplicate post data, admin filter queries, modeler priorities). + +## Core Concepts + +### Models +The framework operates primarily on a **Model-driven** architecture. A model file defines a single or multidimensional array of configuration that instructs the framework what to build. Models are usually placed in `src/models/` (by default). + +There are two primary model types: +1. **`cpt` (Custom Post Type)**: Defines a custom post type, its features, supported WordPress core features, block editor status, metaboxes, and settings. +2. **`category` or `tag` (Taxonomies)**: Defines custom taxonomies and associations to existing Custom Post Types. + +### Hooks System +Saltus Framework provides a comprehensive hook system to modify its internal data structures and output. Examples include overriding admin filter HTML output, filtering duplicated post data, and adjusting the directory path for models dynamically. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..ed770c0c --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,397 @@ +# Saltus Framework Roadmap + +## Current Status +- Version: 2.0.0 (released 2026-06-30) +- Features implemented: CPT creation, taxonomies, settings pages, metaboxes, cloning, export, drag&drop reordering. +- WordPress-native MCP/Abilities surface with 17 tools (9 Phase 1 + 7 Phase 2 + health) +- Phase 2 REST API complete: 9 routes registered in `saltus-framework/v1/` +- Phase 3 hardening complete: caching, rate limiting, audit trail, structured error codes, health monitoring +- PHPStan Level 7 clean across the configured analysis set as of 2026-07-02, including the asset loading helper path +- MCP v1 refactoring complete: per-tool REST dispatch, RestBackedToolInterface, ToolContributor, @phpstan-type AbilityDefinition +- Legacy refactoring: inline REST controller logic extracted into shared service classes (SaltusSingleExport, MetaFieldProvider, ReorderPostsService, SettingsManager) wired into both REST controllers and MCP tools — resolved 2026-07-03 +- Conditional registration fix: `is_needed()` gate bypass for RestRouteProvider/ToolContributor registries via two-pass approach in `Core`, ensuring REST routes always appear in WP-REST index even before `REST_REQUEST` is defined — resolved 2026-07-06 +- 214 PHPUnit tests passing (605 assertions), PHPStan Level 7 clean across the configured analysis set +- **v2.0.0 released 2026-06-30** — MCP, REST API, and Phase 3 shipped + +## Top Priority: WordPress 7.0 MCP/Abilities Integration + +**Theme:** Make Saltus MCP tools discoverable and usable through WordPress-native MCP/Abilities infrastructure in WordPress 7.0. The standalone local stdio MCP server path has been removed. + +| Item | Status | +|------|--------| +| Track WordPress 7.0 MCP/Abilities API shape and naming as it stabilizes | ✓ Done | +| Map each existing Saltus MCP tool to a WordPress-native ability definition | ✓ Done | +| Register Saltus abilities from WordPress when the native API is present | ✓ Done | +| Standalone local stdio MCP fallback | Removed | +| Reuse existing REST permission checks so abilities honor `current_user_can()` gates | ✓ Done | +| Add compatibility tests for native abilities and REST-backed dispatch | ✓ Done | +| Document WordPress-native MCP client discovery | ✓ Done | + +**Exit criteria:** On WordPress 7.0+, Saltus capabilities are exposed through the native MCP/Abilities layer. Older WordPress versions skip native ability registration. + +--- + +## MCP/Abilities Roadmap + +### Vision +Expose Saltus Framework capabilities through WordPress-native MCP/Abilities. Saltus keeps its REST controllers as the authoritative execution layer and registers ability definitions when WordPress provides the Abilities API. + +--- + +### Phase 1: Foundation Hardening (v0.1 → v0.5) + +**Theme:** Make it reliable, secure, and spec-compliant. + +| Item | Status | +|------|--------| +| MCP core protocol (initialize, tools, resources) | ✓ Done | +| 9 Phase 1 CRUD tools (models, posts, terms) | ✓ Done | +| Interactive setup wizard | ✓ Removed | +| WordPress-native MCP runtime configuration filters | ✓ Done | +| WP7 ability audit, cache, and rate-limit runtime | ✓ Done | +| PHPUnit tests for ability registration and runtime behavior | ✓ Done | +| PHPStan Level 7 compliance for all `src/MCP/` code | ✓ Done | +| MCP Prompts support (`prompts/list`, `prompts/get`) — 3 prompt templates | Removed with stdio server | +| Input validation — JSON Schema validation on tool args before REST API call | ✓ Done | +| WordPress transient caching and invalidation for WP7 abilities | ✓ Done | +| `--help` flag with complete usage reference | Removed with stdio server | +| Update `README.md` with WP7 MCP usage and runtime filters | ✓ Done | + +**Exit criteria:** Full test suite green, PHPStan level 7, prompts working, zero file I/O, no wizard. + +--- + +### Phase 2: Framework REST API (v0.5 → v1.0) + +**Theme:** Expose every framework feature as a registered REST API route. Consume them from MCP tools. + +**Framework REST namespace:** `saltus-framework/v1/` + +#### REST Controllers (new `src/Rest/` namespace) + +| Route | Method | Controller | Status | Wraps | +|-------|--------|------------|--------|-------| +| `/models` | GET | `ModelsController` | ✓ Done | `Modeler` — list loaded models with full config | +| `/models/{post_type}` | GET | `ModelsController` | ✓ Done | Model config, features, meta, settings | +| `/duplicate/{post_id}` | POST | `DuplicateController` | ✓ Done | `SaltusDuplicate::perform_duplication()` | +| `/export/{post_id}` | GET | `ExportController` | ✓ Done | Single-post WXR export scoped to the requested post | +| `/settings/{post_type}` | GET | `SettingsController` | ✓ Done | `get_option($settings_id)` | +| `/settings/{post_type}` | PUT | `SettingsController` | ✓ Done | `update_option($settings_id, $data)` | +| `/meta` | GET | `MetaController` | ✓ Done | Aggregate meta field definitions for all post type models | +| `/meta/{post_type}` | GET | `MetaController` | ✓ Done | List meta field definitions from model config | +| `/reorder` | POST | `ReorderController` | ✓ Done | Batch `menu_order` update | + +**Registration:** `Core::register()` adds `add_action('rest_api_init', [$restServer, 'register_routes'])`. + +**Permission callback:** `current_user_can('edit_posts')` by default. + +#### New MCP Tools + +| Tool | Calls | Status | +|------|-------|--------| +| `duplicate_post` | `POST /saltus-framework/v1/duplicate/{id}` | ✓ Done | +| `export_post` | `GET /saltus-framework/v1/export/{id}` | ✓ Done | +| `get_settings` | `GET /saltus-framework/v1/settings/{post_type}` | ✓ Done | +| `update_settings` | `PUT /saltus-framework/v1/settings/{post_type}` | ✓ Done | +| `reorder_posts` | `POST /saltus-framework/v1/reorder` | ✓ Done | +| `list_meta_fields` | `GET /saltus-framework/v1/meta` | ✓ Done | +| `get_meta_fields` | `GET /saltus-framework/v1/meta/{post_type}` | ✓ Done | + +#### Updated MCP Resources + +| Resource | Status | +|----------|--------| +| `saltus://models` | ✓ Returns live data from `GET /saltus-framework/v1/models` | +| `saltus://meta-fields` | ✓ Legacy MCP resource backed by `GET /saltus-framework/v1/meta`; WP7 clients use `list_meta_fields` | +| `saltus://features` | ○ Still static — no dedicated REST endpoint for features list | + +#### Metadata Normalization + +| Item | Status | +|------|--------| +| Raw Saltus/Codestar meta config exposed to WP7 MCP clients | ✓ Done | +| Flatten nested meta fields into client-friendly paths and JSON-schema-like types | ✓ Done | + +**Exit criteria:** All 9 REST routes registered and tested ✓; all 7 new MCP tools operational ✓; v2.0.0 release tag ✓. + +--- + +### Phase 3: Premium Polish (v1.0 → v2.0) + +**Theme:** Production hardening — caching, audit, errors, and request controls. + +| Feature | Description | Status | +|---------|-------------|--------| +| **WordPress 7.0 MCP/Abilities integration** | Register Saltus MCP tools as WordPress-native abilities when available | ✓ | +| **Local stdio MCP server** | Run Saltus as a standalone local MCP server process | Removed | +| **SSE transport** | Serve MCP over HTTP for remote connections | Skipped | +| **Multi-site management** | Named site profiles, switchable at runtime | Skipped | +| **Role-based access** | Map MCP tool access to WP user roles | Skipped | +| **Audit trail** | Every tool call logged with timestamp, user, args, result | ✓ | +| **Rate limiting** | Throttle requests per client | ✓ | +| **Caching layer** | Cache `list_models`, `list_posts` with TTL | ✓ | +| **Structured error codes** | Machine-readable error codes + resolution hints | ✓ | +| **Security hardening review** | Export isolation, fail-closed ability permissions, structured settings sanitization, lifecycle hook registration | ✓ | +| **Health monitoring** | Endpoint with version, error rate, latency stats | ✓ | +| **Configuration profiles** | `--profile=high-volume`, `--profile=strict` | Skipped | + +**Exit criteria:** Caching reduces REST calls by 60%+, audit log operational, health endpoint available, v2.0 release ✓. SSE, multi-site, role mapping, and configuration profiles are skipped for this track. + +--- + +### Phase 4: Ecosystem & Distribution (v2.0+) + +**Theme:** WordPress-native MCP/Abilities distribution. + +| Item | Target | +|------|--------| +| **Composer package** (`saltus/mcp-server`) | Skipped with standalone server path | +| **PHAR distribution** | Skipped with standalone server path | +| **Docker image** | Skipped with standalone server path | +| **GitHub Action** | Skipped with standalone server path | +| **VS Code extension** | Future WordPress-native MCP client integration | +| **Documentation site** | Source pages added at `docs/MCP.md`, `docs/MCP-CLIENTS.md`, and generated `docs/MCP-ABILITIES.md` for future `docs.saltus.dev/mcp` | +| **MCP Registry listing** | Reassess for WordPress-native abilities | +| **Support & SLA model** | Paid support contracts, custom tool development | + +**Exit criteria:** WordPress-native MCP docs published; standalone server packaging remains skipped. + +--- + +### Phase 5: Block Editor, WP-CLI, Frontend & Docs (v2.1+) + +**Theme:** Expand the framework beyond admin-only — block editor integration, CLI tooling, frontend rendering, and complete documentation. + +**Design constraints:** +- Plugin scaffolding is out of scope — keep framework lean +- PHP 7.4 minimum is required by WordPress and unchanged +- New features follow existing patterns: `Service`, `Conditional`, `Assembly`, `Processable`, `RestRouteProvider`, `ToolContributor` +- Shared service classes reused across REST, MCP, and WP-CLI paths + +--- + +#### 5A — Block Editor Integration (block.json tied to models) + +**Goal:** Auto-register Gutenberg blocks from CPT model config, using the model's meta fields as block attributes. One named block per CPT (e.g., `saltus/movie-list`, `saltus/book-list`). + +**Config shape:** +```yaml +blocks: true +# or +blocks: + list: true # saltus/{cpt_name}-list + single: true # saltus/{cpt_name}-single +``` + +**Files:** + +| File | Purpose | +|------|---------| +| `src/Features/Blocks/Blocks.php` | Service class (Service, Conditional, Assembly, ToolContributor) | +| `src/Features/Blocks/SaltusBlocks.php` | Processable — iterates models, register_block_type() per CPT | +| `src/Features/Blocks/BlockRenderer.php` | Shared render_callback for list and single blocks | +| `templates/block-list.php` | Default list block template | +| `templates/block-single.php` | Default single block template | +| `assets/Feature/Blocks/editor.js` | Editor script (InspectorControls) | +| `assets/Feature/Blocks/style.css` | Block styles | + +**How it wires in:** +- `Core::get_service_classes()` adds `'blocks' => Blocks::class` +- `ModelFactory::process_services()` reads `config['blocks']` → `Blocks::make()` → `process()` +- Each call to `register_block_type()` uses a metadata array (no static block.json needed) +- Meta fields from model `meta` config auto-mapped as block attributes via `MetaFieldProvider` +- Dedicated MCP tool `list_block_models` contributed by `Blocks::get_mcp_tools()` +- Filter: `saltus/framework/blocks/attributes` to customize auto-generated attributes + +| Item | Status | +|------|--------| +| Blocks feature service + SaltusBlocks implementation | ○ Pending | +| BlockRenderer with default render callbacks | ○ Pending | +| Default list/single block templates | ○ Pending | +| Editor script and styles | ○ Pending | +| MCP tool for block model discovery | ○ Pending | +| PHPUnit tests for block registration | ○ Pending | +| Integration with existing ModelRestPolicy | ○ Pending | + +**Exit criteria:** `saltus/{cpt_name}-list` and `saltus/{cpt_name}-single` blocks are registered for every CPT with `blocks: true`. Block attributes reflect the model's meta field config. List block queries and renders posts; single block renders a post with all meta. + +--- + +#### 5B — WP-CLI Tools + +**Goal:** `wp saltus ` mapping every MCP tool to a WP-CLI command, using the same shared service classes. + +**Command tree (7 grouped command classes):** + +| Command | MCP Tool | Shared Service | +|---------|----------|----------------| +| `wp saltus` (no args) | health + help | `GetHealth` | +| `wp saltus model list [--type]` | `list_models` | `Modeler::get_models()` | +| `wp saltus model get ` | `get_model` | `Modeler::get_models()` | +| `wp saltus post list [--status] [--search] ...` | `list_posts` | `WP_Query` | +| `wp saltus post get ` | `get_post` | `get_post()` | +| `wp saltus post create [--content] ...` | `create_post` | `wp_insert_post()` | +| `wp saltus post update <id> [--title] ...` | `update_post` | `wp_update_post()` | +| `wp saltus post delete <id> [--force]` | `delete_post` | `wp_delete_post()` | +| `wp saltus post duplicate <id>` | `duplicate_post` | `SaltusDuplicate` | +| `wp saltus post export <id>` | `export_post` | `SaltusSingleExport` | +| `wp saltus term list <taxonomy> [--search] ...` | `list_terms` | `get_terms()` | +| `wp saltus term create <taxonomy> <name> [--slug] ...` | `create_term` | `wp_insert_term()` | +| `wp saltus settings get <post_type>` | `get_settings` | `SettingsManager` | +| `wp saltus settings update <post_type> <json>` | `update_settings` | `SettingsManager` | +| `wp saltus reorder <json>` | `reorder_posts` | `ReorderPostsService` | +| `wp saltus meta list [<post_type>]` | `list_meta_fields` / `get_meta_fields` | `MetaFieldProvider` | + +**Files:** + +| File | Purpose | +|------|---------| +| `src/Features/WpCli/WpCli.php` | Service class — hooks `WP_CLI::add_command()` on `cli_init` | +| `src/Features/WpCli/Commands/SaltusCommand.php` | `wp saltus` — health + help summary | +| `src/Features/WpCli/Commands/ModelCommand.php` | `wp saltus model {list\|get}` | +| `src/Features/WpCli/Commands/PostCommand.php` | `wp saltus post {list\|get\|create\|update\|delete\|duplicate\|export}` | +| `src/Features/WpCli/Commands/TermCommand.php` | `wp saltus term {list\|create}` | +| `src/Features/WpCli/Commands/SettingsCommand.php` | `wp saltus settings {get\|update}` | +| `src/Features/WpCli/Commands/MetaCommand.php` | `wp saltus meta {list\|get}` | +| `src/Features/WpCli/Commands/ReorderCommand.php` | `wp saltus reorder` | + +**Output formatting:** `--format=table|json|yaml` flag on all list/detail commands. Table is default for interactive, JSON for scripting. + +**Generated docs:** `composer docs:wpcli` auto-generates command reference from command classes (parallel to `bin/generate-mcp-docs.php`). + +| Item | Status | +|------|--------| +| WpCli feature service | ○ Pending | +| SaltusCommand (health + help) | ○ Pending | +| ModelCommand (list, get) | ○ Pending | +| PostCommand (list, get, create, update, delete, duplicate, export) | ○ Pending | +| TermCommand (list, create) | ○ Pending | +| SettingsCommand (get, update) | ○ Pending | +| MetaCommand (list, get) | ○ Pending | +| ReorderCommand | ○ Pending | +| `composer docs:wpcli` script | ○ Pending | +| PHPUnit tests with WP_CLI stubs | ○ Pending | + +**Exit criteria:** Every MCP tool has a corresponding `wp saltus` subcommand. Commands use shared service classes (not REST dispatch). Output formatting supports `--format=table|json|yaml`. Test suite covers all command groups. + +--- + +#### 5C — Frontend Rendering + +**Goal:** Shortcodes and rendering templates for CPT content on the frontend, with meta field exposure. Default templates ship with the framework; plugins can override. + +**Config shape:** +```yaml +frontend: + shortcode: true # auto-register [saltus_cpt type="movie"] + shortcode_alias: 'movies' # optional: [movies] + templates: + list: 'plugin/templates/list.php' + single: 'plugin/templates/single.php' +``` + +**Shortcode API:** +``` +[saltus_cpt type="movie" view="list" limit="10" orderby="date" order="desc" taxonomy="genre" terms="action,comedy"] +[saltus_cpt type="movie" view="single" id="123"] +``` + +**Template variables:** + +| Template | Variables | +|----------|-----------| +| `list` | `$posts` (array of `WP_Post`), `$model` (PostType instance), `$attributes` (shortcode args) | +| `single` | `$post` (WP_Post), `$meta` (assoc array of all registered meta fields), `$model`, `$attributes` | + +**Default templates:** Shipped in `templates/` — list renders a styled post table with title, excerpt, date, and all meta fields; single renders the full post with meta grouped into sections. + +**Files:** + +| File | Purpose | +|------|---------| +| `src/Features/Frontend/Frontend.php` | Service class (Service, Conditional, Assembly) | +| `src/Features/Frontend/SaltusFrontend.php` | Processable — registers shortcodes on `init` | +| `templates/list.php` | Default list template | +| `templates/single.php` | Default single template | + +**How it wires in:** `Core::get_service_classes()` adds `'frontend' => Frontend::class`. `ModelFactory::process_services()` reads `config['frontend']` → `Frontend::make()` → `SaltusFrontend::process()` calls `add_shortcode()` per CPT. + +| Item | Status | +|------|--------| +| Frontend feature service | ○ Pending | +| SaltusFrontend shortcode registration | ○ Pending | +| Default list template | ○ Pending | +| Default single template | ○ Pending | +| Shortcode attribute parsing (limit, orderby, taxonomy, terms, etc.) | ○ Pending | +| Template override resolution (config → theme → default) | ○ Pending | +| PHPUnit tests for shortcode rendering | ○ Pending | + +**Exit criteria:** `[saltus_cpt type="movie"]` renders a styled list of posts. `[saltus_cpt type="movie" view="single" id="123"]` renders a single post with meta. Templates are overridable per model. Output is escaped and safe. + +--- + +#### 5D — Documentation + +**Goal:** Fill all README placeholders, add docs for new features, provide complete examples. + +**README.md changes:** + +| Section | Current State | Target | +|---------|---------------|--------| +| `features` parameter table | `(More Info Soon)` | Complete table of all 7 features with config keys and one-liners | +| `labels` parameter table | `(More Info Soon)` | Full reference: `has_one`, `has_many`, `text_domain`, `featured_image`, `overrides.ui`, `overrides.messages`, `overrides.bulk_messages`, `overrides.labels` | +| `meta` parameter table | `(More Info Soon)` | Metabox structure: sections, fields, Codestar field types, REST API registration | +| `settings` parameter table | `(More Info Soon)` | Settings page structure: page args, sections, fields, parent menu, tabs | +| CPT example file | `(Soon)` | Complete YAML + PHP model with all common parameters | +| Taxonomy example file | `(Soon)` | Complete YAML + PHP taxonomy model with associations | + +**New doc files:** + +| File | Content | +|------|---------| +| `docs/BLOCKS.md` | Block config reference, template customization, attributes guide, editor integration | +| `docs/WPCLI.md` | Full command reference with examples (auto-generated marker from `composer docs:wpcli`) | +| `docs/FRONTEND.md` | Shortcode API, template variables, customization guide, attribute reference | +| `docs/FEATURES.md` | Deep feature reference extracted from README: admin_cols, admin_filters, draganddrop, duplicate, quick_edit, remember_tabs, single_export | + +**Auto-generation:** `composer docs:wpcli` script in `bin/generate-wpcli-docs.php` to generate WP-CLI command tables (parallel to `bin/generate-mcp-docs.php`). + +| Item | Status | +|------|--------| +| README features table | ○ Pending | +| README labels reference | ○ Pending | +| README meta structure | ○ Pending | +| README settings structure | ○ Pending | +| README CPT example | ○ Pending | +| README taxonomy example | ○ Pending | +| docs/BLOCKS.md | ○ Pending | +| docs/WPCLI.md (auto-generated) | ○ Pending | +| docs/FRONTEND.md | ○ Pending | +| docs/FEATURES.md | ○ Pending | +| bin/generate-wpcli-docs.php | ○ Pending | + +**Exit criteria:** Zero `(More Info Soon)` or `(Soon)` placeholders in README. All four new features have dedicated doc files. Feature reference is extracted to `docs/FEATURES.md`. WP-CLI docs are auto-generated. + +--- + +*Plugin Generator moved to its own repository — see [docs/PLUGIN_GENERATOR_ROADMAP.md](./PLUGIN_GENERATOR_ROADMAP.md).* + +## Framework Core Roadmap + +### Short-term Goals +- ✓ Address remaining PHPStan errors (2 pre-existing in ResourceProvider) — resolved 2026-07-01. +- ✓ Code-review hardening pass — export isolation, lifecycle hook file registration, fail-closed MCP permissions, structured settings sanitization, JSON fallback, and AssetLoader PHPStan coverage resolved 2026-07-02. +- ✓ Service extraction — inline REST controller logic (WXR export, meta field normalization, post reorder, settings CRUD) moved into dedicated shared service classes and wired into both REST controllers and MCP tools; defensive guards for null post, private property access, taxonomy object, and asset data types — resolved 2026-07-03. +- Continue maintaining automated testing suites (208 tests, 598 assertions as of 2026-07-05). +- WordPress-native MCP/Abilities integration shipped in v2.0.0. +- **Phase 5 implementation** — Block Editor integration, WP-CLI tools, Frontend rendering, and documentation completion. + +### Long-term Vision +- Continued improvements for WordPress CPT-based plugin development. +- Further refine the Codestar Framework integration. +- Establish WordPress-native MCP/Abilities as the standard AI interface for WordPress CPT plugins. +- Complete frontend-to-admin coverage: blocks, CLI, shortcodes, and documentation for every framework feature. + +## Tracking +- Check GitHub Issues for active sprint items. +- Active development on `feature/mcp-v1` branch. diff --git a/docs/TESTING_HANDOFF.md b/docs/TESTING_HANDOFF.md new file mode 100644 index 00000000..11dcd810 --- /dev/null +++ b/docs/TESTING_HANDOFF.md @@ -0,0 +1,274 @@ +# Testing Suite Handoff + +## Goal + +Bring this framework up to a `wp_mock`-style testing setup: PHPUnit suites for unit and integration coverage, strict PHPUnit configuration, and CI jobs that run tests across the supported PHP range. + +Reference workflow and test layout: + +- `wp_mock` CI: <https://github.com/10up/wp_mock/blob/221736aeed1df0fd343e37fa91b47c2e17ab7663/.github/workflows/ci.yml> +- `wp_mock` PHPUnit config: <https://github.com/10up/wp_mock/blob/221736aeed1df0fd343e37fa91b47c2e17ab7663/phpunit.xml.dist> +- `wp_mock` tests: <https://github.com/10up/wp_mock/tree/221736aeed1df0fd343e37fa91b47c2e17ab7663/tests> + +## Current State + +The project currently has: + +- Composer package metadata in `composer.json`. +- Runtime PHP support declared as `>=7.4`. +- PHPStan configured in `phpstan.neon`. +- PHPCS and PHPCompatibility configured in `phpcs.xml`. +- CI jobs for Composer validation, coding standards, static analysis, and runtime compatibility. +- PHPUnit listed in `require-dev`, currently locked to PHPUnit 12, which requires PHP `>=8.3`. + +The project does not currently have: + +- `phpunit.xml.dist`. +- A `tests/` directory. +- Test bootstrap logic. +- Unit or integration tests. +- CI jobs that execute PHPUnit. + +## Important Constraint + +Do not assume one PHPUnit major can cover PHP `7.4` through `8.4`. + +The framework supports PHP `>=7.4`, but modern PHPUnit versions do not. To test the full PHP range, the project needs either: + +1. A PHPUnit version matrix, similar to `wp_mock`, where Composer resolves the correct PHPUnit major per PHP version. +2. A constrained PHPUnit version that supports old PHP, accepting less modern PHPUnit features. + +Recommended direction: use a matrix. It preserves runtime support while still allowing modern PHPUnit on modern PHP. + +Likely mapping: + +- PHP 7.4: PHPUnit 9 +- PHP 8.0: PHPUnit 9 +- PHP 8.1: PHPUnit 10 +- PHP 8.2: PHPUnit 11 +- PHP 8.3: PHPUnit 12 +- PHP 8.4: PHPUnit 12 or 13, depending on package availability and constraints + +Confirm exact constraints with Composer before committing the final matrix. + +## Target Test Layout + +Create this structure: + +```text +tests/ + TestCase.php + bootstrap.php + Unit/ + Infrastructure/ + Models/ + Features/ + Integration/ + FrameworkBootTest.php +``` + +Use `tests/Unit` for isolated class behavior and `tests/Integration` for tests that exercise multiple framework components together. + +## Target PHPUnit Config + +Add `phpunit.xml.dist` at the repo root. + +Recommended starting point: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="./tests/bootstrap.php" + colors="true" + executionOrder="random" + cacheDirectory=".phpunit.cache" + beStrictAboutOutputDuringTests="true" + beStrictAboutTestsThatDoNotTestAnything="true" + failOnRisky="true" + failOnWarning="true"> + + <testsuites> + <testsuite name="Unit"> + <directory suffix="Test.php">./tests/Unit</directory> + </testsuite> + <testsuite name="Integration"> + <directory suffix="Test.php">./tests/Integration</directory> + </testsuite> + </testsuites> + + <source> + <include> + <directory suffix=".php">./src</directory> + </include> + </source> +</phpunit> +``` + +Notes: + +- `failOnDeprecation` is useful, but may be noisy while supporting PHP 7.4 and WordPress compatibility. Add it after the first useful suite is passing. +- PHPUnit XML schema differs across major versions. If the matrix uses PHPUnit 9 through 12, keep the XML conservative or maintain version-specific configs. + +## Bootstrap + +Add `tests/bootstrap.php`: + +```php +<?php + +require_once dirname(__DIR__) . '/vendor/autoload.php'; +``` + +If tests need WordPress functions, prefer explicit stubs or WP Mock-style expectations over loading a full WordPress install for unit tests. + +## Base Test Case + +Add `tests/TestCase.php`: + +```php +<?php + +namespace Saltus\WP\Framework\Tests; + +use PHPUnit\Framework\TestCase as PHPUnitTestCase; + +abstract class TestCase extends PHPUnitTestCase +{ +} +``` + +If Mockery or WP Mock is introduced later, close expectations in `tearDown()`. + +## Composer Changes + +Add scripts: + +```json +"test": "./vendor/bin/phpunit", +"test:unit": "./vendor/bin/phpunit --testsuite Unit", +"test:integration": "./vendor/bin/phpunit --testsuite Integration" +``` + +Revisit `require-dev` for PHPUnit. Current `^12.5.15` prevents testing on PHP below 8.3. For a PHP-version matrix, Composer may need conditional update commands in CI rather than one fixed lock-file PHPUnit version. + +## First Unit Tests To Add + +Start with framework code that has minimal WordPress coupling: + +1. `src/Infrastructure/Container/*` + - Registering and resolving services. + - Handling missing services. + - Preventing invalid registrations. + - Instantiator behavior. + +2. `src/Models/Config/NoFile.php` + - Missing config file behavior. + - Returned defaults or exception behavior. + +3. `src/Models/ModelFactory.php` + - Correct model class selection. + - Invalid model type handling. + +4. `src/Infrastructure/Services/Assets/AssetData.php` + - Normalization of asset metadata. + - Default values. + - Invalid input handling. + +These tests should not require WordPress to be loaded. + +## First Integration Tests To Add + +Start with integration tests that still avoid a full WordPress install: + +1. Framework bootstrapping + - Instantiate the core framework object. + - Register a small set of services. + - Assert expected services are available. + +2. Model registration pipeline + - Feed a representative config array. + - Assert the correct model objects are created. + +3. Asset pipeline + - Create asset definitions. + - Assert resulting asset objects and dependency data. + +If integration tests need WordPress functions such as `add_action`, `add_filter`, `register_post_type`, or `wp_enqueue_script`, use WP Mock or thin local test stubs. + +## Whether To Use WP Mock + +WP Mock is appropriate if this framework needs to verify interactions with WordPress functions without booting WordPress. + +Use it for tests like: + +- `register_post_type()` is called with expected args. +- `register_taxonomy()` is called with expected args. +- `add_action()` hooks are registered. +- `add_filter()` hooks are registered. +- enqueue functions receive expected handles and dependencies. + +Do not use WP Mock for pure data classes or framework container tests. Plain PHPUnit is simpler there. + +## CI Changes After Tests Exist + +Once `phpunit.xml.dist` and initial tests are committed, add a `php-tests` job before or alongside the current quality jobs. + +Target shape: + +```yaml +php-tests: + runs-on: ubuntu-latest + name: PHP ${{ matrix.php }} tests + strategy: + fail-fast: false + matrix: + include: + - php: "7.4" + composer_flags: "--with phpunit/phpunit:^9" + - php: "8.0" + composer_flags: "--with phpunit/phpunit:^9" + - php: "8.1" + composer_flags: "--with phpunit/phpunit:^10" + - php: "8.2" + composer_flags: "--with phpunit/phpunit:^11" + - php: "8.3" + composer_flags: "--with phpunit/phpunit:^12" + - php: "8.4" + composer_flags: "--with phpunit/phpunit:^12" +``` + +Then: + +```yaml +- run: composer update ${{ matrix.composer_flags }} --no-ansi --no-interaction --no-progress --prefer-dist +- run: vendor/bin/phpunit --configuration phpunit.xml.dist --order-by=random +``` + +Validate this matrix in a branch. Composer may need adjustments depending on transitive constraints. + +## Coverage + +Do not add coverage upload immediately. First make the test suite meaningful and stable. + +After enough tests exist: + +- Enable `pcov` in one CI matrix leg. +- Generate `clover.xml`. +- Upload to Codecov, Coveralls, or GitHub artifact storage. + +Use coverage as a trend signal, not as a blocking threshold at the start. + +## Definition Of Done + +The first testing milestone is complete when: + +- `phpunit.xml.dist` exists. +- `tests/bootstrap.php` exists. +- `tests/TestCase.php` exists. +- At least one unit test exists for container behavior. +- At least one integration test exists for a framework-level workflow. +- `composer test`, `composer test:unit`, and `composer test:integration` pass locally. +- CI runs PHPUnit on at least PHP 8.3. +- The wide PHP matrix is expanded to PHPUnit once Composer constraints are proven. + diff --git a/phpstan.neon b/phpstan.neon index 7cbea9dd..de9a4a9d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,7 +8,6 @@ parameters: - **/node_modules/* - **/vendor/* - **/tests/* - - src/Infrastructure/Services/Assets/AssetLoader.php - !**/*.php paths: - ./src diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..0370eadc --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="tests/bootstrap.php" + colors="true" + cacheResult="false" + executionOrder="random" + beStrictAboutOutputDuringTests="true" + beStrictAboutTestsThatDoNotTestAnything="true" + failOnRisky="true" + failOnWarning="true"> + <testsuites> + <testsuite name="Unit"> + <directory suffix="Test.php">tests/Unit/</directory> + </testsuite> + <testsuite name="Integration"> + <directory suffix="Test.php">tests/Integration/</directory> + </testsuite> + <testsuite name="MCP"> + <directory suffix="Test.php">tests/MCP/</directory> + </testsuite> + <testsuite name="Rest"> + <directory suffix="Test.php">tests/Rest/</directory> + </testsuite> + <testsuite name="Features"> + <directory suffix="Test.php">tests/Features/</directory> + </testsuite> + </testsuites> +</phpunit> diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..0370eadc --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="tests/bootstrap.php" + colors="true" + cacheResult="false" + executionOrder="random" + beStrictAboutOutputDuringTests="true" + beStrictAboutTestsThatDoNotTestAnything="true" + failOnRisky="true" + failOnWarning="true"> + <testsuites> + <testsuite name="Unit"> + <directory suffix="Test.php">tests/Unit/</directory> + </testsuite> + <testsuite name="Integration"> + <directory suffix="Test.php">tests/Integration/</directory> + </testsuite> + <testsuite name="MCP"> + <directory suffix="Test.php">tests/MCP/</directory> + </testsuite> + <testsuite name="Rest"> + <directory suffix="Test.php">tests/Rest/</directory> + </testsuite> + <testsuite name="Features"> + <directory suffix="Test.php">tests/Features/</directory> + </testsuite> + </testsuites> +</phpunit> diff --git a/src/Core.php b/src/Core.php index c1e26bde..465781d9 100644 --- a/src/Core.php +++ b/src/Core.php @@ -2,7 +2,7 @@ /** * Saltus Framework * - * @version 1.3.1 + * @version 2.0.0 */ namespace Saltus\WP\Framework; @@ -30,27 +30,36 @@ use Saltus\WP\Framework\Features\RememberTabs\RememberTabs; use Saltus\WP\Framework\Features\Settings\Settings; use Saltus\WP\Framework\Features\SingleExport\SingleExport; +use Saltus\WP\Framework\Features\MCP\MCP; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\Rest\HealthController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; +use Saltus\WP\Framework\Rest\RestServer; class Core implements Plugin { + public const VERSION = '2.0.0'; + /** - Main filters to control the flow of the plugin from outside code. - @var non-empty-string - */ - const SERVICES_FILTER = 'services'; + * Main filters to control the flow of the plugin from outside code. + * @var non-empty-string + */ + private const SERVICES_FILTER = 'services'; /** - Prefixes to use. - @var non-empty-string - */ - const HOOK_PREFIX = 'saltus/framework/'; - const SERVICE_PREFIX = ''; + * Prefixes to use. + * @var non-empty-string + */ + private const HOOK_PREFIX = 'saltus/framework/'; /** * If services can be filtered out - * @var bool */ + * @var bool + */ protected bool $enable_filters = true; /** @@ -70,10 +79,29 @@ class Core implements Plugin { */ protected ?object $instantiator = null; - public function __construct( string $project_path ) { + /** + * Dedicated registry of RestRouteProvider services. + * Populated before the is_needed() gate so REST routes are always + * available regardless of the admin/REST_REQUEST context. + * + * @var list<RestRouteProvider> + */ + protected array $rest_route_providers = []; + + /** + * Dedicated registry of ToolContributor services. + * Populated before the is_needed() gate so MCP tools are always + * available regardless of the admin/REST_REQUEST context. + * + * @var list<ToolContributor> + */ + protected array $tool_contributors = []; + + public function __construct( string $project_path, ?string $plugin_file = null ) { //TODO by pcarvalho: move to project class - $this->project['path'] = $project_path; + $this->project['path'] = $project_path; + $this->project['plugin_file'] = $plugin_file ?? $project_path; // the framework root path $this->project['root_path'] = dirname( __DIR__ ); @@ -90,20 +118,22 @@ public function __construct( string $project_path ) { * @return void */ public function register(): void { - // Todo validate key: - \register_activation_hook( - __FILE__, - function () { - $this->activate(); - } - ); + $plugin_file = (string) $this->project['plugin_file']; + if ( is_file( $plugin_file ) ) { + \register_activation_hook( + $plugin_file, + function () { + $this->activate(); + } + ); - \register_deactivation_hook( - __FILE__, - function () { - $this->deactivate(); - } - ); + \register_deactivation_hook( + $plugin_file, + function () { + $this->deactivate(); + } + ); + } // loads models and stores the list @@ -117,18 +147,91 @@ function () { $this->modeler = new Modeler( $model_factory ); $project_path = $this->project['path']; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound - $priority = (int) apply_filters( static::HOOK_PREFIX . 'modeler/priority', 1 ); + $priority = (int) apply_filters( self::HOOK_PREFIX . 'modeler/priority', 1 ); add_action( 'init', function () use ( $project_path ) { $this->modeler->init( $project_path ); + + add_action( + 'rest_api_init', + function () { + $this->register_rest_routes(); + } + ); + + // If rest_api_init has already fired (e.g., modeler priority >= 100), + // the hook above will never run, so call routes directly. + if ( did_action( 'rest_api_init' ) ) { + $this->register_rest_routes(); + } }, $priority ); - // 4- When the store starts ( init() ), it will ask the factory to make a cpt/tax - // and stores the result in either list (cpt or tax list ) - // TODO + // 6- MCP is registered through the default feature list. + } + + /** + * @return list<RestRouteDefinition> + */ + private function get_rest_routes( ModelRestPolicy $policy ): array { + $routes = [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_HEALTH, + new HealthController( self::VERSION ) + ), + ]; + + $routes = array_merge( $routes, $this->modeler->get_rest_routes( $this->modeler, $policy ) ); + + foreach ( $this->rest_route_providers as $provider ) { + $routes = array_merge( $routes, $provider->get_rest_routes( $this->modeler, $policy ) ); + } + + return $routes; + } + + /** + * If the given service class implements RestRouteProvider, instantiate + * it unconditionally and add it to the dedicated registry. + * + * @param class-string $service_class Service class name. + * @param array<mixed> $dependencies Constructor dependencies. + */ + private function maybe_register_route_provider( string $service_class, array $dependencies ): void { + if ( ! is_a( $service_class, RestRouteProvider::class, true ) ) { + return; + } + + $instance = $this->service_container->instantiate_unconditionally( $service_class, $dependencies ); + if ( $instance instanceof RestRouteProvider ) { + $this->rest_route_providers[] = $instance; + } + } + + /** + * If the given service class implements ToolContributor, instantiate + * it unconditionally and add it to the dedicated registry. + * + * @param class-string $service_class Service class name. + * @param array<mixed> $dependencies Constructor dependencies. + */ + private function maybe_register_tool_contributor( string $service_class, array $dependencies ): void { + if ( ! is_a( $service_class, ToolContributor::class, true ) ) { + return; + } + + $instance = $this->service_container->instantiate_unconditionally( $service_class, $dependencies ); + if ( $instance instanceof ToolContributor ) { + $this->tool_contributors[] = $instance; + } + } + + private function register_rest_routes(): void { + $rest_policy = new ModelRestPolicy( $this->modeler ); + $rest_server = new RestServer( $rest_policy, $this->get_rest_routes( $rest_policy ) ); + $rest_server->register_routes(); } /** @@ -195,7 +298,7 @@ public function register_services(): void { * classes need to implement the * Service interface. */ - $hook_name = static::HOOK_PREFIX . static::SERVICES_FILTER; + $hook_name = self::HOOK_PREFIX . self::SERVICES_FILTER; $services = \apply_filters( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound $hook_name, @@ -203,9 +306,32 @@ public function register_services(): void { ); } - $dependencies = [ $this->project ]; - foreach ( $services as $id => $class ) { - $this->service_container->register( $id, $class, $dependencies ); + $dependencies = [ + 'project' => $this->project, + 'modeler' => $this->modeler, + 'modeler_resolver' => function (): ?Modeler { + return $this->modeler; + }, + 'services' => $this->service_container, + 'tool_contributors' => function (): array { + return $this->tool_contributors; + }, + ]; + + // First pass: populate dedicated registries for RestRouteProvider + // and ToolContributor unconditionally (bypasses is_needed()). + // REST routes and MCP tools must be available even when the + // admin-facing gate returns false during plugin boot. + foreach ( $services as $service_class ) { + $this->maybe_register_route_provider( $service_class, $dependencies ); + $this->maybe_register_tool_contributor( $service_class, $dependencies ); + } + + // Second pass: register services with the is_needed() gate. + // This determines whether admin hooks (Registerable, Actionable, + // HasAssets) are wired up. + foreach ( $services as $id => $service_class ) { + $this->service_container->register( $id, $service_class, $dependencies ); } } @@ -222,6 +348,7 @@ protected function get_service_classes(): array { 'draganddrop' => DragAndDrop::class, 'duplicate' => Duplicate::class, 'meta' => Meta::class, + 'mcp' => MCP::class, 'quick_edit' => QuickEdit::class, 'remember_tabs' => RememberTabs::class, 'settings' => Settings::class, diff --git a/src/Features/AdminCols/SaltusAdminCols.php b/src/Features/AdminCols/SaltusAdminCols.php index d33f7d90..5595a05b 100644 --- a/src/Features/AdminCols/SaltusAdminCols.php +++ b/src/Features/AdminCols/SaltusAdminCols.php @@ -718,9 +718,9 @@ private function normalize_columns(): void { /** * Applies a single normalized column to the columns array. * - * @param array<string,string> $new_cols - * @param array<string,mixed> $col - * @param array<string,string> $cols + * @param array<string,string> $new_cols The accumulated column definitions. + * @param array<string,mixed> $col The column configuration array. + * @param array<string,string> $cols The registered column definitions. * @return array<string,string> */ private function resolve_column( array $new_cols, string $id, array $col, array $cols ): array { diff --git a/src/Features/AdminFilters/SaltusAdminFilters.php b/src/Features/AdminFilters/SaltusAdminFilters.php index 7c110d9c..c5e4aa29 100644 --- a/src/Features/AdminFilters/SaltusAdminFilters.php +++ b/src/Features/AdminFilters/SaltusAdminFilters.php @@ -327,7 +327,9 @@ protected static function get_current_post_type(): string { } /** - * @param FilterConfig $filter + * Resolve the filter type from a filter configuration array. + * + * @param FilterConfig $filter The filter configuration. */ private function resolve_filter_type( array $filter ): ?string { if ( isset( $filter['taxonomy'] ) ) { @@ -401,7 +403,9 @@ public function filters(): void { } /** - * @param FilterConfig $filter + * Dispatch a filter to the appropriate render method based on its type. + * + * @param FilterConfig $filter The filter configuration. */ private function dispatch_filter( string $type, @@ -438,7 +442,9 @@ private function dispatch_filter( } /** - * @param FilterConfig $filter + * Render a taxonomy filter dropdown. + * + * @param FilterConfig $filter The filter configuration. */ private function render_taxonomy_filter( array $filter, @@ -501,7 +507,9 @@ private function render_taxonomy_filter( } /** - * @param FilterConfig $filter + * Render a meta key filter dropdown. + * + * @param FilterConfig $filter The filter configuration. */ private function render_meta_key_filter( array $filter, @@ -530,7 +538,9 @@ private function render_meta_key_filter( ); } /** - * @param FilterConfig $filter + * Normalize a meta filter configuration, adding default labels and keys. + * + * @param FilterConfig $filter The filter configuration. * @return FilterConfig|null */ private function normalize_meta_filter( array $filter, string $id ): ?array { @@ -545,7 +555,9 @@ private function normalize_meta_filter( array $filter, string $id ): ?array { return $filter; } /** - * @param FilterConfig $filter + * Resolve options for a meta key filter from configuration or database. + * + * @param FilterConfig $filter The filter configuration. * @return array<string|int, mixed> */ private function resolve_meta_filter_options( array $filter, \wpdb $wpdb ): array { @@ -582,8 +594,10 @@ private function resolve_meta_filter_options( array $filter, \wpdb $wpdb ): arra } /** - * @param FilterConfig $filter - * @param array<string|int, mixed> $options + * Resolve the selected value and key mode for a meta filter. + * + * @param FilterConfig $filter The filter configuration. + * @param array<string|int, mixed> $options The available filter options. * @return array{0: mixed, 1: bool} */ private function resolve_meta_filter_state( array $filter, array $options ): array { @@ -605,9 +619,11 @@ private function resolve_meta_filter_state( array $filter, array $options ): arr } /** - * @param FilterConfig $filter - * @param array<string|int, mixed> $options - * @param mixed $selected + * Render a meta filter as a select dropdown. + * + * @param FilterConfig $filter The filter configuration. + * @param array<string|int, mixed> $options The filter options. + * @param mixed $selected The currently selected value. */ private function render_meta_filter_select( array $filter, @@ -652,7 +668,9 @@ private function render_meta_filter_select( } /** - * @param FilterConfig $filter + * Render a meta search key text input filter. + * + * @param FilterConfig $filter The filter configuration. */ private function render_meta_search_key_filter( array $filter, @@ -681,7 +699,9 @@ private function render_meta_search_key_filter( } /** - * @param FilterConfig $filter + * Render a meta exists filter (checkbox or select). + * + * @param FilterConfig $filter The filter configuration. */ private function render_meta_exists_filter( array $filter, @@ -706,7 +726,9 @@ private function render_meta_exists_filter( } /** - * @param FilterConfig $filter + * Normalize a meta exists filter configuration. + * + * @param FilterConfig $filter The filter configuration. * @return FilterConfig|null */ private function normalize_meta_exists_filter( @@ -743,9 +765,11 @@ private function normalize_meta_exists_filter( } /** - * @param FilterConfig $filter - * @param array<string|int, mixed> $fields - * @param mixed $selected + * Render a meta exists filter as checkboxes. + * + * @param FilterConfig $filter The filter configuration. + * @param array<string|int, mixed> $fields The meta field definitions. + * @param mixed $selected The currently selected value. */ private function render_meta_exists_checkbox( array $filter, @@ -781,9 +805,11 @@ private function render_meta_exists_checkbox( } /** - * @param FilterConfig $filter - * @param array<string|int, mixed> $fields - * @param mixed $selected + * Render a meta exists filter as a select dropdown. + * + * @param FilterConfig $filter The filter configuration. + * @param array<string|int, mixed> $fields The meta field definitions. + * @param mixed $selected The currently selected value. */ private function render_meta_exists_select( array $filter, @@ -831,7 +857,9 @@ private function render_meta_exists_select( /** - * @param FilterConfig $filter + * Render a post date filter input. + * + * @param FilterConfig $filter The filter configuration. */ private function render_post_date_filter( array $filter, @@ -863,10 +891,9 @@ private function render_post_date_filter( } /** - * Render a post author filter. - */ - /** - * @param FilterConfig $filter + * Render a post author filter dropdown. + * + * @param FilterConfig $filter The filter configuration. */ private function render_post_author_filter( array $filter, diff --git a/src/Features/DragAndDrop/DragAndDrop.php b/src/Features/DragAndDrop/DragAndDrop.php index aab65bf8..40bd0950 100644 --- a/src/Features/DragAndDrop/DragAndDrop.php +++ b/src/Features/DragAndDrop/DragAndDrop.php @@ -7,6 +7,14 @@ Service, Conditional }; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\MCP\Tools\ReorderPosts; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\ReorderController; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; /** @@ -14,13 +22,18 @@ * * Enable an option to manage drag and drop functionality in the admin area. */ -class DragAndDrop implements Service, Conditional, Actionable, Assembly { +class DragAndDrop implements Service, Conditional, Actionable, Assembly, RestRouteProvider, ToolContributor { + + private ReorderPostsService $reorder_service; /** * Instantiate this Service object. * + * @param mixed $reorder_service Optional shared reorder service; ignored when the service container passes args. */ - public function __construct() {} + public function __construct( $reorder_service = null ) { + $this->reorder_service = $reorder_service instanceof ReorderPostsService ? $reorder_service : new ReorderPostsService(); + } /** * Check whether the conditional service is currently needed. @@ -28,7 +41,7 @@ public function __construct() {} * @return bool Whether the conditional service is needed. */ public static function is_needed(): bool { - return is_admin(); + return is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } /** @@ -48,4 +61,24 @@ public function add_action(): void { $actions = new UpdateMenuDragAndDrop(); $actions->add_action(); } + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_REORDER, + new ReorderController( $policy, $this->reorder_service ), + 'post_type' + ), + ]; + } + + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ new ReorderPosts( $this->reorder_service ) ]; + } } diff --git a/src/Features/DragAndDrop/ReorderPostsService.php b/src/Features/DragAndDrop/ReorderPostsService.php new file mode 100644 index 00000000..2f1fe97d --- /dev/null +++ b/src/Features/DragAndDrop/ReorderPostsService.php @@ -0,0 +1,121 @@ +<?php +namespace Saltus\WP\Framework\Features\DragAndDrop; + +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * Shared service for applying menu_order updates to posts. + */ +class ReorderPostsService { + + /** + * Check whether the current user can edit at least one post in the request. + * + * @param array<int, mixed> $items Requested reorder items. + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @return bool + */ + public function can_edit_any_requested_post( array $items, ?ModelRestPolicy $policy = null ): bool { + foreach ( $items as $item ) { + if ( ! is_array( $item ) || ! isset( $item['id'] ) ) { + continue; + } + + $post_id = (int) $item['id']; + if ( $post_id <= 0 || ! get_post( $post_id ) ) { + continue; + } + + if ( $policy && ! $policy->is_post_enabled( $post_id, ModelRestPolicy::CAPABILITY_REORDER ) ) { + continue; + } + + if ( current_user_can( 'edit_post', $post_id ) ) { + return true; + } + } + + return false; + } + + /** + * Reorder posts by updating their menu_order values. + * + * @param array<int, mixed> $items Requested reorder items. + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @return array{results: list<array<string, mixed>>, total: int, updated: int} + */ + public function reorder( array $items, ?ModelRestPolicy $policy = null ): array { + $results = []; + + foreach ( $items as $item ) { + if ( ! is_array( $item ) || ! isset( $item['id'], $item['menu_order'] ) ) { + $results[] = [ + 'id' => 0, + 'status' => 'skipped', + 'reason' => 'Invalid item payload', + ]; + continue; + } + + $post_id = (int) $item['id']; + $menu_order = (int) $item['menu_order']; + + if ( ! get_post( $post_id ) ) { + $results[] = [ + 'id' => $post_id, + 'status' => 'skipped', + 'reason' => 'Post not found', + ]; + continue; + } + + if ( $policy && ! $policy->is_post_enabled( $post_id, ModelRestPolicy::CAPABILITY_REORDER ) ) { + $results[] = [ + 'id' => $post_id, + 'status' => 'skipped', + 'reason' => 'Reorder is not enabled for this post type', + ]; + continue; + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + $results[] = [ + 'id' => $post_id, + 'status' => 'skipped', + 'reason' => 'Permission denied', + ]; + continue; + } + + $updated = wp_update_post( + [ + 'ID' => $post_id, + 'menu_order' => $menu_order, + ], + true + ); + + if ( is_wp_error( $updated ) ) { + $results[] = [ + 'id' => $post_id, + 'status' => 'error', + 'reason' => $updated->get_error_message(), + ]; + continue; + } + + $results[] = [ + 'id' => $post_id, + 'menu_order' => $menu_order, + 'status' => 'updated', + ]; + } + + return [ + 'results' => $results, + 'total' => count( $results ), + 'updated' => count( array_filter( $results, fn( $result ) => $result['status'] === 'updated' ) ), + ]; + } +} diff --git a/src/Features/Duplicate/Duplicate.php b/src/Features/Duplicate/Duplicate.php index 07aa2d40..25f349fe 100644 --- a/src/Features/Duplicate/Duplicate.php +++ b/src/Features/Duplicate/Duplicate.php @@ -6,11 +6,19 @@ Service, Conditional }; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\MCP\Tools\DuplicatePost; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\Rest\DuplicateController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; /** */ -class Duplicate implements Service, Conditional, Assembly { +class Duplicate implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { /** * Instantiate this Service object. @@ -40,6 +48,26 @@ public static function is_needed(): bool { * - ajax: while updating menu order * - front: during pre_get_posts, etc */ - return is_admin(); + return is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ); + } + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_DUPLICATE, + new DuplicateController( $policy ), + 'post_type' + ), + ]; + } + + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ new DuplicatePost() ]; } } diff --git a/src/Features/MCP/MCP.php b/src/Features/MCP/MCP.php new file mode 100644 index 00000000..085242f3 --- /dev/null +++ b/src/Features/MCP/MCP.php @@ -0,0 +1,198 @@ +<?php +namespace Saltus\WP\Framework\Features\MCP; + +use Saltus\WP\Framework\Infrastructure\Plugin\Activateable; +use Saltus\WP\Framework\Infrastructure\Plugin\Deactivateable; +use Saltus\WP\Framework\Infrastructure\Plugin\Registerable; +use Saltus\WP\Framework\Infrastructure\Service\Service; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\MCP\Abilities\AbilityRegistrar; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolProvider; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * Enables Saltus MCP support. + * + * WordPress-native abilities are registered when the host WordPress version + * exposes the Abilities API. Older WordPress versions skip native ability + * registration. + */ +class MCP implements Service, Registerable, Activateable, Deactivateable { + + private const AUDIT_CLEANUP_HOOK = 'saltus_framework_mcp_audit_cleanup'; + + /** @var array<string, mixed> */ + private array $dependencies; + private ?AbilityRegistrar $ability_registrar; + private ?Modeler $modeler; + /** @var callable|null */ + private $modeler_resolver; + private ?ModelRestPolicy $policy; + + /** + * @param array<string, mixed> $dependencies Framework dependencies injected by the service container. + */ + public function __construct( array $dependencies = [], ?AbilityRegistrar $ability_registrar = null ) { + $modeler = $dependencies['modeler'] ?? null; + $this->dependencies = $dependencies; + $this->ability_registrar = $ability_registrar; + $this->modeler = $modeler instanceof Modeler ? $modeler : null; + $this->modeler_resolver = is_callable( $dependencies['modeler_resolver'] ?? null ) ? $dependencies['modeler_resolver'] : null; + $this->policy = null; + } + + public function register(): void { + if ( $this->transport() !== 'native' ) { + return; + } + + add_action( + self::AUDIT_CLEANUP_HOOK, + function (): void { + ( new AuditLogger() )->cleanup_expired_entries(); + } + ); + add_action( + 'wp_abilities_api_categories_init', + function (): void { + $this->ability_registrar()->register_category(); + } + ); + add_action( + 'wp_abilities_api_init', + function (): void { + $this->ability_registrar()->register(); + } + ); + foreach ( [ 'save_post', 'deleted_post', 'created_term', 'edited_term', 'delete_term', 'added_option', 'updated_option', 'deleted_option' ] as $hook ) { + add_action( + $hook, + function (): void { + ( new TransientCache() )->clear(); + } + ); + } + } + + public function activate() { + if ( $this->transport() !== 'native' ) { + return; + } + + $this->schedule_audit_cleanup(); + } + + public function deactivate() { + if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { + return; + } + + wp_clear_scheduled_hook( self::AUDIT_CLEANUP_HOOK ); + } + + public function transport(): string { + if ( $this->ability_registrar instanceof AbilityRegistrar ) { + return $this->ability_registrar->has_native_api() ? 'native' : 'legacy'; + } + + return function_exists( 'wp_register_ability' ) ? 'native' : 'legacy'; + } + + private function ability_registrar(): AbilityRegistrar { + if ( $this->ability_registrar instanceof AbilityRegistrar ) { + return $this->ability_registrar; + } + + $this->ability_registrar = new AbilityRegistrar( $this->tool_provider(), null, $this->policy() ); + + return $this->ability_registrar; + } + + private function schedule_audit_cleanup(): void { + if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_event' ) ) { + return; + } + + if ( wp_next_scheduled( self::AUDIT_CLEANUP_HOOK ) ) { + return; + } + + wp_schedule_event( time(), 'daily', self::AUDIT_CLEANUP_HOOK ); + } + + private function tool_provider(): ToolProvider { + $provider = new ToolProvider(); + $modeler = $this->modeler(); + if ( ! $modeler instanceof Modeler ) { + return $provider; + } + + foreach ( $this->contributors() as $contributor ) { + foreach ( $contributor->get_mcp_tools( $modeler, $this->policy() ) as $tool ) { + $provider->register( $tool ); + } + } + + return $provider; + } + + /** + * @return ToolContributor[] + */ + private function contributors(): array { + $contributors = []; + $modeler = $this->modeler(); + if ( $modeler instanceof ToolContributor ) { + $contributors[] = $modeler; + } + + // Use the pre-built ToolContributor registry populated by Core + // before the is_needed() gate, so tools are always available. + $contributor_callback = $this->dependencies['tool_contributors'] ?? null; + if ( is_callable( $contributor_callback ) ) { + $contributors = array_merge( $contributors, $contributor_callback() ); + } else { + // Fallback when MCP is instantiated directly (outside Core): + // iterate the services container for ToolContributor instances. + $services = $this->dependencies['services'] ?? []; + foreach ( $services as $service ) { + if ( $service instanceof ToolContributor ) { + $contributors[] = $service; + } + } + } + + return $contributors; + } + + private function modeler(): ?Modeler { + if ( $this->modeler instanceof Modeler ) { + return $this->modeler; + } + + if ( is_callable( $this->modeler_resolver ) ) { + $modeler = ( $this->modeler_resolver )(); + if ( $modeler instanceof Modeler ) { + $this->modeler = $modeler; + } + } + + return $this->modeler; + } + + private function policy(): ?ModelRestPolicy { + $modeler = $this->modeler(); + if ( ! $modeler instanceof Modeler ) { + return null; + } + + if ( ! $this->policy instanceof ModelRestPolicy ) { + $this->policy = new ModelRestPolicy( $modeler ); + } + + return $this->policy; + } +} diff --git a/src/Features/Meta/Meta.php b/src/Features/Meta/Meta.php index a5ed0e52..113a5605 100644 --- a/src/Features/Meta/Meta.php +++ b/src/Features/Meta/Meta.php @@ -6,19 +6,33 @@ Service, Conditional }; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\MCP\Tools\GetMetaFields; +use Saltus\WP\Framework\MCP\Tools\ListMetaFields; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\Rest\MetaController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; /** * Class Meta * * Enable an option to manage meta fields */ -final class Meta implements Service, Conditional, Assembly { +final class Meta implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { + + private MetaFieldProvider $meta_field_provider; /** * Instantiate this Service object. * + * @param mixed $meta_field_provider Optional shared meta provider; ignored when the service container passes args. */ - public function __construct() {} + public function __construct( $meta_field_provider = null ) { + $this->meta_field_provider = $meta_field_provider instanceof MetaFieldProvider ? $meta_field_provider : new MetaFieldProvider(); + } /** * Check whether the conditional service is currently needed. @@ -44,4 +58,27 @@ public static function is_needed(): bool { public static function make( string $name, array $project, array $args ): object { return new CodestarMeta( $name, $args ); } + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_META, + new MetaController( $modeler, $policy, $this->meta_field_provider ), + 'post_type' + ), + ]; + } + + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ + new ListMetaFields( $this->meta_field_provider ), + new GetMetaFields( $this->meta_field_provider ), + ]; + } } diff --git a/src/Features/Meta/MetaFieldProvider.php b/src/Features/Meta/MetaFieldProvider.php new file mode 100644 index 00000000..d19a255a --- /dev/null +++ b/src/Features/Meta/MetaFieldProvider.php @@ -0,0 +1,441 @@ +<?php +namespace Saltus\WP\Framework\Features\Meta; + +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * Provides model-defined meta field payloads and normalized field metadata. + */ +class MetaFieldProvider { + + /** + * Get meta field definitions for all post type models. + * + * @param Modeler $modeler The model registry. + * @param ModelRestPolicy|null $policy Optional REST policy. + * @param callable(string): bool $can_view_post_type Callback for post-type visibility. + * @return list<array<string, mixed>> + */ + public function all_post_type_meta( Modeler $modeler, ?ModelRestPolicy $policy, callable $can_view_post_type ): array { + $post_types = []; + $models = $this->models( $modeler, $policy ); + + foreach ( $models as $post_type => $model ) { + if ( $model->get_type() !== 'post_type' ) { + continue; + } + + if ( ! $can_view_post_type( (string) $post_type ) ) { + continue; + } + + $args = $this->get_model_args( $model, $policy ); + + $post_types[] = [ + 'post_type' => (string) $post_type, + 'label_singular' => $args['label_singular'] ?? '', + 'label_plural' => $args['label_plural'] ?? '', + 'meta' => $args['meta'] ?? [], + 'normalized' => $this->normalize_meta_fields( $args['meta'] ?? [] ), + ]; + } + + return $post_types; + } + + /** + * Get meta field definitions for a single post type. + * + * @param Modeler $modeler The model registry. + * @param ModelRestPolicy|null $policy Optional REST policy. + * @param string $post_type Post type slug. + * @return array<string, mixed>|\WP_Error + */ + public function post_type_meta( Modeler $modeler, ?ModelRestPolicy $policy, string $post_type ) { + $models = $this->models( $modeler, $policy ); + + if ( ! isset( $models[ $post_type ] ) ) { + return new \WP_Error( + 'model_not_found', + __( 'Model not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + $model = $models[ $post_type ]; + $args = $this->get_model_args( $model, $policy ); + + if ( $model->get_type() !== 'post_type' ) { + return new \WP_Error( + 'invalid_model_type', + __( 'Meta fields are only available for post type models.', 'saltus-framework' ), + [ 'status' => 400 ] + ); + } + + $meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : []; + + return [ + 'post_type' => $post_type, + 'meta' => $meta, + 'normalized' => $this->normalize_meta_fields( $meta ), + ]; + } + + /** + * Normalize raw Saltus/Codestar metabox configuration into depth-aware paths. + * + * @param array<string|int, mixed> $meta + * @return array{fields: list<array<string, mixed>>, rest_meta_keys: list<array<string, mixed>>} + */ + public function normalize_meta_fields( array $meta ): array { + $fields = []; + $rest_meta_keys = []; + + foreach ( $meta as $box_id => $box ) { + if ( ! \is_array( $box ) ) { + continue; + } + + $data_type = $box['data_type'] ?? 'unserialize'; + $is_serialized = $data_type === 'serialize'; + $is_rest_writable = ! empty( $box['register_rest_api'] ) && $box['register_rest_api'] === true; + $field_groups = $this->get_field_groups( $box ); + + if ( $is_serialized && ! empty( $field_groups ) ) { + $serialized_fields = []; + foreach ( $field_groups as $group ) { + $serialized_fields = \array_merge( $serialized_fields, $group['fields'] ); + } + + $rest_meta_keys[] = $this->build_rest_meta_key( + (string) $box_id, + (string) $box_id, + true, + $is_rest_writable, + $box, + $serialized_fields + ); + } + + foreach ( $field_groups as $group ) { + $group_fields = $group['fields']; + $section_id = $group['section_id']; + $section_name = $group['section_title']; + + if ( $is_serialized ) { + $this->append_normalized_fields( + $fields, + $group_fields, + (string) $box_id, + (string) $box_id, + true, + $is_rest_writable, + (string) $box_id, + $section_id, + $section_name, + 0 + ); + continue; + } + + foreach ( $group_fields as $field_key => $field ) { + if ( ! is_array( $field ) || ! $this->is_data_field( $field, $field_key ) ) { + continue; + } + + $field_id = $this->get_field_id( $field, $field_key ); + + $rest_meta_keys[] = $this->build_rest_meta_key( + $field_id, + $field_id, + false, + $is_rest_writable, + $box, + [ $field ] + ); + + $this->append_normalized_fields( + $fields, + [ $field_id => $field ], + $field_id, + $field_id, + false, + $is_rest_writable, + (string) $box_id, + $section_id, + $section_name, + 0 + ); + } + } + } + + return [ + 'fields' => $fields, + 'rest_meta_keys' => $this->dedupe_rest_meta_keys( $rest_meta_keys ), + ]; + } + + /** + * @return array<string, Model> + */ + private function models( Modeler $modeler, ?ModelRestPolicy $policy ): array { + return $policy + ? $policy->get_enabled_models( ModelRestPolicy::CAPABILITY_META, 'post_type' ) + : $modeler->get_models(); + } + + /** + * @param Model $model The model to retrieve args for. + * @return array<string, mixed> + */ + private function get_model_args( Model $model, ?ModelRestPolicy $policy ): array { + if ( $policy ) { + return $policy->get_model_args( $model ); + } + + return $model->get_args(); + } + + /** + * @param array<string|int, mixed> $box + * @return list<array{fields: array<string|int, mixed>, section_id: string, section_title: string}> + */ + private function get_field_groups( array $box ): array { + $groups = []; + + if ( ! empty( $box['sections'] ) && is_array( $box['sections'] ) ) { + foreach ( $box['sections'] as $section_key => $section ) { + if ( ! is_array( $section ) || empty( $section['fields'] ) || ! is_array( $section['fields'] ) ) { + continue; + } + + $groups[] = [ + 'fields' => $section['fields'], + 'section_id' => (string) ( $section['id'] ?? $section_key ), + 'section_title' => (string) ( $section['title'] ?? '' ), + ]; + } + return $groups; + } + + if ( ! empty( $box['fields'] ) && is_array( $box['fields'] ) ) { + $groups[] = [ + 'fields' => $box['fields'], + 'section_id' => '', + 'section_title' => '', + ]; + } + + return $groups; + } + + /** + * @param list<array<string, mixed>> $normalized Accumulated normalized fields. + * @param array<string|int, mixed> $fields Fields to normalize. + */ + private function append_normalized_fields( + array &$normalized, + array $fields, + string $path_prefix, + string $meta_key, + bool $serialized, + bool $rest_writable, + string $metabox_id, + string $section_id, + string $section_title, + int $depth + ): void { + foreach ( $fields as $field_key => $field ) { + if ( ! is_array( $field ) || ! $this->is_data_field( $field, $field_key ) ) { + continue; + } + + $field_id = $this->get_field_id( $field, $field_key ); + $current_path = $serialized || $depth > 0 ? $path_prefix . '.' . $field_id : $field_id; + $codestar_type = (string) ( $field['type'] ?? 'object' ); + $schema_type = $this->get_schema_type( $codestar_type ); + $schema = $this->build_field_schema( $field, $schema_type ); + + $normalized[] = [ + 'path' => $current_path, + 'field_id' => $field_id, + 'meta_key' => $meta_key, + 'label' => (string) ( $field['title'] ?? $field['label'] ?? '' ), + 'codestar_type' => $codestar_type, + 'type' => $schema_type, + 'schema' => $schema, + 'serialized' => $serialized, + 'writable_rest' => $rest_writable, + 'depth' => $depth, + 'metabox_id' => $metabox_id, + 'section_id' => $section_id, + 'section_title' => $section_title, + 'raw' => $field, + ]; + + if ( ! empty( $field['fields'] ) && is_array( $field['fields'] ) ) { + $this->append_normalized_fields( + $normalized, + $field['fields'], + $current_path, + $meta_key, + $serialized, + $rest_writable, + $metabox_id, + $section_id, + $section_title, + $depth + 1 + ); + } + } + } + + /** + * @param array<string|int, mixed> $field The field configuration. + * @param string|int $field_key The field key. + */ + private function is_data_field( array $field, $field_key ): bool { + if ( empty( $field['type'] ) ) { + return false; + } + + if ( isset( $field['id'] ) && $field['id'] !== '' ) { + return true; + } + + return \is_string( $field_key ) && $field_key !== ''; + } + + /** + * @param array<string|int, mixed> $field The field configuration. + * @param string|int $field_key The field key. + */ + private function get_field_id( array $field, $field_key ): string { + return (string) ( $field['id'] ?? $field_key ); + } + + /** + * @param array<string|int, mixed> $box The metabox configuration. + * @param array<string|int, mixed> $fields The field definitions. + * @return array<string, mixed> + */ + private function build_rest_meta_key( + string $path, + string $meta_key, + bool $serialized, + bool $rest_writable, + array $box, + array $fields + ): array { + $schema = $serialized + ? [ + 'type' => 'object', + 'additionalProperties' => true, + 'properties' => $this->build_schema_properties( $fields ), + ] + : $this->build_field_schema( is_array( $fields[0] ?? null ) ? $fields[0] : [], $this->get_schema_type( (string) ( $fields[0]['type'] ?? 'object' ) ) ); + + return [ + 'path' => $path, + 'meta_key' => $meta_key, + 'serialized' => $serialized, + 'writable_rest' => $rest_writable, + 'schema' => $schema, + 'raw' => $box, + ]; + } + + /** + * @param array<string|int, mixed> $fields + * @return array<string, mixed> + */ + private function build_schema_properties( array $fields ): array { + $properties = []; + + foreach ( $fields as $field_key => $field ) { + if ( ! is_array( $field ) || ! $this->is_data_field( $field, $field_key ) ) { + continue; + } + + $field_id = $this->get_field_id( $field, $field_key ); + $schema_type = $this->get_schema_type( (string) $field['type'] ); + $schema = $this->build_field_schema( $field, $schema_type ); + + if ( ! empty( $field['fields'] ) && is_array( $field['fields'] ) && $schema_type === 'object' ) { + $schema['properties'] = $this->build_schema_properties( $field['fields'] ); + } + + $properties[ $field_id ] = $schema; + } + + return $properties; + } + + /** + * @param array<string|int, mixed> $field The field configuration. + * @return array<string, mixed> + */ + private function build_field_schema( array $field, string $schema_type ): array { + if ( ! empty( $field['schema'] ) && is_array( $field['schema'] ) ) { + return $field['schema']; + } + + $schema = [ + 'type' => $schema_type, + ]; + + if ( $schema_type === 'array' ) { + $schema['items'] = [ + 'type' => ( ( $field['type'] ?? '' ) === 'repeater' ) ? 'object' : 'string', + ]; + + if ( ( $field['type'] ?? '' ) === 'repeater' && ! empty( $field['fields'] ) && is_array( $field['fields'] ) ) { + $schema['items']['properties'] = $this->build_schema_properties( $field['fields'] ); + } + } + + return $schema; + } + + private function get_schema_type( string $codestar_type ): string { + $field_type_map = [ + 'number' => 'number', + 'switcher' => 'boolean', + 'background' => 'object', + 'color_group' => 'object', + 'fieldset' => 'object', + 'group' => 'object', + 'map' => 'object', + 'media' => 'array', + 'select' => 'array', + 'repeater' => 'array', + ]; + + return $field_type_map[ $codestar_type ] ?? 'string'; + } + + /** + * @param list<array<string, mixed>> $rest_meta_keys + * @return list<array<string, mixed>> + */ + private function dedupe_rest_meta_keys( array $rest_meta_keys ): array { + $seen = []; + $result = []; + + foreach ( $rest_meta_keys as $rest_meta_key ) { + $key = (string) $rest_meta_key['meta_key']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $result[] = $rest_meta_key; + } + + return $result; + } +} diff --git a/src/Features/Settings/Settings.php b/src/Features/Settings/Settings.php index d2667364..699da751 100644 --- a/src/Features/Settings/Settings.php +++ b/src/Features/Settings/Settings.php @@ -6,19 +6,33 @@ Service, Conditional }; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\MCP\Tools\GetSettings; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\MCP\Tools\UpdateSettings; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; +use Saltus\WP\Framework\Rest\SettingsController; /** * Class Settings * * Enable an option to create Settings page */ -final class Settings implements Service, Conditional, Assembly { +final class Settings implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { + + private SettingsManager $settings_manager; /** * Instantiate this Service object. * + * @param mixed $settings_manager Optional shared settings manager; ignored when the service container passes args. */ - public function __construct() {} + public function __construct( $settings_manager = null ) { + $this->settings_manager = $settings_manager instanceof SettingsManager ? $settings_manager : new SettingsManager(); + } /** * Check whether the conditional service is currently needed. @@ -30,7 +44,7 @@ public static function is_needed(): bool { /* * Only load this sample service on the admin backend. */ - return \is_admin(); + return \is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } /** @@ -45,4 +59,27 @@ public static function is_needed(): bool { public static function make( string $name, array $project, array $args ): object { return new CodestarSettings( $name, $args ); } + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_SETTINGS, + new SettingsController( $policy, $this->settings_manager ), + 'post_type' + ), + ]; + } + + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ + new GetSettings( $this->settings_manager ), + new UpdateSettings( $this->settings_manager ), + ]; + } } diff --git a/src/Features/Settings/SettingsManager.php b/src/Features/Settings/SettingsManager.php new file mode 100644 index 00000000..dc3b0c69 --- /dev/null +++ b/src/Features/Settings/SettingsManager.php @@ -0,0 +1,116 @@ +<?php +namespace Saltus\WP\Framework\Features\Settings; + +/** + * Shared feature-layer access to Saltus per-post-type settings. + */ +class SettingsManager { + + /** + * Build the option name for a given post type. + * + * @param string $post_type The post type slug. + * @return string + */ + public function option_name( string $post_type ): string { + return sprintf( 'saltus_framework_settings_%s', $post_type ); + } + + /** + * Retrieve settings for a post type. + * + * @param string $post_type The post type slug. + * @return array{post_type: string, settings: mixed} + */ + public function get_settings( string $post_type ): array { + return [ + 'post_type' => $post_type, + 'settings' => get_option( $this->option_name( $post_type ), [] ), + ]; + } + + /** + * Update settings for a post type. + * + * @param string $post_type The post type slug. + * @param array<string, mixed> $settings Raw settings payload. + * @return array{post_type: string, settings: array<string, mixed>, status: string}|\WP_Error + */ + public function update_settings( string $post_type, array $settings ) { + if ( empty( $settings ) ) { + return new \WP_Error( + 'rest_empty_data', + __( 'No settings data provided.', 'saltus-framework' ), + [ 'status' => 400 ] + ); + } + + $sanitized = []; + foreach ( $settings as $key => $value ) { + $sanitized[ preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $key ) ] = $this->sanitize_setting_value( $value ); + } + + $option_name = $this->option_name( $post_type ); + $current = get_option( $option_name ); + + if ( $current === $sanitized ) { + return [ + 'post_type' => $post_type, + 'settings' => $sanitized, + 'status' => 'unchanged', + ]; + } + + $updated = update_option( $option_name, $sanitized ); + + if ( ! $updated ) { + $recheck = get_option( $option_name ); + if ( $recheck !== $sanitized ) { + return new \WP_Error( + 'rest_update_failed', + __( 'Failed to update settings.', 'saltus-framework' ), + [ 'status' => 500 ] + ); + } + } + + return [ + 'post_type' => $post_type, + 'settings' => $sanitized, + 'status' => 'updated', + ]; + } + + /** + * Sanitize a setting value while preserving structured data. + * + * @param mixed $value Raw setting value. + * @return mixed + */ + public function sanitize_setting_value( $value ) { + $value = wp_unslash( $value ); + + if ( is_array( $value ) ) { + $sanitized = []; + foreach ( $value as $key => $child ) { + $sanitized_key = is_int( $key ) ? $key : preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $key ); + $sanitized[ $sanitized_key ] = $this->sanitize_setting_value( $child ); + } + return $sanitized; + } + + if ( is_string( $value ) ) { + return sanitize_text_field( $value ); + } + + if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || $value === null ) { + return $value; + } + + if ( is_object( $value ) ) { + return ''; + } + + return sanitize_text_field( (string) $value ); + } +} diff --git a/src/Features/SingleExport/SaltusSingleExport.php b/src/Features/SingleExport/SaltusSingleExport.php index 39969fd0..fdcddf50 100644 --- a/src/Features/SingleExport/SaltusSingleExport.php +++ b/src/Features/SingleExport/SaltusSingleExport.php @@ -63,7 +63,7 @@ public function init(): void { } add_filter( 'export_args', array( $this, 'export_args' ) ); - add_filter( 'query', array( $this, 'query' ) ); + add_filter( 'posts_request', array( $this, 'query' ), 10, 2 ); add_action( 'post_submitbox_misc_actions', array( $this, 'post_submitbox_misc_actions' ) ); } @@ -130,12 +130,7 @@ public function export_args( array $args ): array { return $args; } - // use our fake date so the query is easy to find (because we don't have a good hook to use) - $args['content'] = 'post'; - $args['start_date'] = self::FAKE_DATE; - $args['end_date'] = self::FAKE_DATE; - - return $args; + return $this->single_export_args( $args ); } /** @@ -143,11 +138,12 @@ public function export_args( array $args ): array { * * Replaces the query to match the single post ID for export. * - * @param string $query The original SQL query. + * @param string $query The original SQL query. + * @param \WP_Query $wp_query The WP_Query instance. * * @return string Modified SQL query. */ - public function query( string $query ): string { + public function query( string $query, \WP_Query $wp_query ): string { if ( ! isset( $_GET['export_single'] ) ) { return $query; } @@ -157,30 +153,150 @@ public function query( string $query ): string { return $query; } + return $this->single_export_query( $query, $wp_query, intval( $_GET['export_single'] ) ); + } + + /** + * Export a single post as WXR. + * + * @param int $post_id The post ID to export. + * @return array<string, mixed>|\WP_Error + */ + public function export_post( int $post_id ) { + $post = \get_post( $post_id ); + + if ( ! $post ) { + return new \WP_Error( + 'post_not_found', + __( 'Post not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + if ( ! \defined( 'WXR_VERSION' ) ) { + require_once ABSPATH . 'wp-admin/includes/export.php'; + } + + $export_args_filter = function ( array $args ): array { + return $this->single_export_args( $args ); + }; + + $query_filter = function ( string $query, \WP_Query $wp_query ) use ( $post_id ): string { + return $this->single_export_query( $query, $wp_query, $post_id ); + }; + + add_filter( 'export_args', $export_args_filter ); + add_filter( 'posts_request', $query_filter, 10, 2 ); + + $buffer_level = ob_get_level(); + ob_start(); + try { + \export_wp(); + $wxr = (string) ob_get_clean(); + } catch ( \Throwable $e ) { + return new \WP_Error( 'export_failed', $e->getMessage(), [ 'status' => 500 ] ); + } finally { + $this->remove_export_headers(); + if ( ob_get_level() > $buffer_level ) { + ob_end_clean(); + } + remove_filter( 'export_args', $export_args_filter ); + remove_filter( 'posts_request', $query_filter ); + } + + return [ + 'post_id' => $post_id, + 'post_type' => $post->post_type, + 'post_title' => $post->post_title, + 'wxr' => $wxr, + ]; + } + + /** + * Remove download headers emitted by WordPress core export_wp(). + */ + private function remove_export_headers(): void { + if ( headers_sent() ) { + return; + } + + header_remove( 'Content-Description' ); + header_remove( 'Content-Disposition' ); + header_remove( 'Content-Type' ); + } + + /** + * Build export args that force WordPress through the identifiable single-export query. + * + * @param array<string, mixed> $args Query arguments for determining what should be exported. + * @return array<string, mixed> + */ + public function single_export_args( array $args ): array { + $args['content'] = 'post'; + $args['start_date'] = self::FAKE_DATE; + $args['end_date'] = self::FAKE_DATE; + + return $args; + } + + /** + * Rewrite WordPress' generated export query to target a single post. + * + * Only hooked during a single-post export request (see the request-filtering + * step that registers this filter with $post_id bound). Core's exporter has + * no native "single post" scope, so that step forces self::FAKE_DATE into + * the year/month args, and this method recognizes the resulting query shape + * and swaps it for a single-post lookup. + * + * @param string $query The original SQL query. + * @param \WP_Query $wp_query The WP_Query instance. + * @param int $post_id The post ID to export. + * @return string + */ + public function single_export_query( string $query, \WP_Query $wp_query, int $post_id ): string { global $wpdb; - // This is the query WP will build (given our arg filtering above) - // Since the current_filter isn't narrow, we'll check each query - // to see if it matches, then if it is we replace it - $test = $wpdb->prepare( - "SELECT ID FROM {$wpdb->posts} WHERE {$wpdb->posts}.post_type = 'post' AND {$wpdb->posts}.post_status != 'auto-draft' AND {$wpdb->posts}.post_date >= %s AND {$wpdb->posts}.post_date < %s", - // phpcs:ignore: WordPress.DateTime.RestrictedFunctions.date_date - date( 'Y-m-d', strtotime( self::FAKE_DATE ) ), - // phpcs:ignore: WordPress.DateTime.RestrictedFunctions.date_date - date( 'Y-m-d', strtotime( '+1 month', strtotime( self::FAKE_DATE ) ) ) + if ( ! $this->is_fake_date_export_query( $wp_query ) ) { + if ( strpos( $query, self::FAKE_DATE ) !== false ) { + throw new \RuntimeException( + \esc_html__( 'Single export failed: the export query could not be scoped.', 'saltus-framework' ) + ); + } + return $query; + } + + return $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE {$wpdb->posts}.ID = %d", + $post_id ); + } - if ( $test !== $query ) { - return $query; + /** + * Detect whether $query is the specific export query our request-filtering + * step produces by inspecting structured query vars instead of the + * compiled SQL. + * + * @param \WP_Query $query The WP_Query instance to check. + * @return bool + */ + private function is_fake_date_export_query( \WP_Query $query ): bool { + if ( $query->get( 'post_type' ) !== 'post' ) { + return false; + } + + if ( $query->get( 'post_status' ) === 'auto-draft' ) { + return false; + } + + $date_query = $query->get( 'date_query' ); + if ( empty( $date_query[0]['after'] ) || empty( $date_query[0]['before'] ) ) { + return false; } - // divide query - $split = explode( 'WHERE', $query ); - // replace WHERE clause - $split[1] = $wpdb->prepare( " {$wpdb->posts}.ID = %d", intval( $_GET['export_single'] ) ); - // put query back together - $query = implode( 'WHERE', $split ); + $start = gmdate( 'Y-m-d', strtotime( self::FAKE_DATE ) ); + $end = gmdate( 'Y-m-d', strtotime( '+1 month', strtotime( self::FAKE_DATE ) ) ); - return $query; + return gmdate( 'Y-m-d', strtotime( $date_query[0]['after'] ) ) === $start + && gmdate( 'Y-m-d', strtotime( $date_query[0]['before'] ) ) === $end; } } diff --git a/src/Features/SingleExport/SingleExport.php b/src/Features/SingleExport/SingleExport.php index 191490e0..d72f562c 100644 --- a/src/Features/SingleExport/SingleExport.php +++ b/src/Features/SingleExport/SingleExport.php @@ -6,19 +6,32 @@ Service, Conditional }; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\MCP\Tools\ExportPost; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\Rest\ExportController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; /** * Class SingleExport * * Enable an option to export single entry */ -class SingleExport implements Service, Conditional, Assembly { +class SingleExport implements Service, Conditional, Assembly, RestRouteProvider, ToolContributor { + + private SaltusSingleExport $exporter; /** * Instantiate this Service object. * + * @param mixed $exporter Optional shared export implementation; ignored when the service container passes args. */ - public function __construct() {} + public function __construct( $exporter = null ) { + $this->exporter = $exporter instanceof SaltusSingleExport ? $exporter : new SaltusSingleExport( '', [] ); + } /** * Check whether the conditional service is currently needed. @@ -30,7 +43,7 @@ public static function is_needed(): bool { /* * This service loads only in the admin edit screen */ - return is_admin(); + return is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } /** @@ -45,4 +58,24 @@ public static function is_needed(): bool { public static function make( string $name, array $project, array $args ): object { return new SaltusSingleExport( $name, $args ); } + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_EXPORT, + new ExportController( $policy, $this->exporter ), + 'post_type' + ), + ]; + } + + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ new ExportPost( $this->exporter ) ]; + } } diff --git a/src/Infrastructure/Container/ContainerAssembler.php b/src/Infrastructure/Container/ContainerAssembler.php index 6e7323d4..b2d84c74 100644 --- a/src/Infrastructure/Container/ContainerAssembler.php +++ b/src/Infrastructure/Container/ContainerAssembler.php @@ -8,7 +8,9 @@ class ContainerAssembler { /** - * @param class-string $container + * Create a new instance of the given container class. + * + * @param class-string $container The fully qualified class name to instantiate. */ public function create( string $container ): object { if ( ! class_exists( $container ) ) { diff --git a/src/Infrastructure/Container/Instantiator.php b/src/Infrastructure/Container/Instantiator.php index 286e5dd8..146fea60 100644 --- a/src/Infrastructure/Container/Instantiator.php +++ b/src/Infrastructure/Container/Instantiator.php @@ -12,8 +12,8 @@ interface Instantiator { /** * Make an object instance out of an interface or class. * - * @param class-string $target_class Class to make an object instance out of. - * @param array<int, mixed> $dependencies Optional. Dependencies of the class. + * @param class-string $target_class Class to make an object instance out of. + * @param array<mixed> $dependencies Optional. Dependencies of the class. * @return object Instantiated object. */ public function instantiate( string $target_class, array $dependencies = [] ): object; diff --git a/src/Infrastructure/Container/ServiceContainer.php b/src/Infrastructure/Container/ServiceContainer.php index db5fa2a2..ecab168d 100644 --- a/src/Infrastructure/Container/ServiceContainer.php +++ b/src/Infrastructure/Container/ServiceContainer.php @@ -92,9 +92,9 @@ public function put( string $id, $service ): void { * * Runs Registerable, Actionable * - * @param string $id - * @param string $service_class - * @param array<int, mixed> $dependencies + * @param string $id Service identifier. + * @param class-string $service_class Fully qualified service class name. + * @param array<mixed> $dependencies Constructor dependencies. */ public function register( string $id, string $service_class, array $dependencies ): void { @@ -141,11 +141,30 @@ function () use ( $service ) { } } + /** + * Instantiate a service class without checking is_needed(). + * + * Unlike register(), this bypasses the Conditional gate and does + * not store the service in the container. Use it to obtain an + * instance solely for interface detection (e.g. RestRouteProvider, + * ToolContributor). + * + * @param class-string $service_class Service class to instantiate. + * @param array<mixed> $dependencies Constructor dependencies. + * + * @throws Invalid If the service could not be properly instantiated. + * + * @return Service Instantiated service. + */ + public function instantiate_unconditionally( string $service_class, array $dependencies ): Service { + return $this->instantiate( $service_class, $dependencies ); + } + /** * Instantiate a single service. * - * @param class-string $service_class Service class to instantiate. - * @param array<int, mixed> $dependencies Constructor dependencies. + * @param class-string $service_class Service class to instantiate. + * @param array<mixed> $dependencies Constructor dependencies. * * @throws Invalid If the service could not be properly instantiated. * @@ -166,11 +185,11 @@ private function instantiate( string $service_class, array $dependencies ): Serv /** * Make an object instance out of an interface or class. * - * @param class-string $interface_or_class Interface or class to make an object - * instance out of. - * @param array<int, mixed> $dependencies Optional. Additional arguments to pass - * to the constructor. Defaults to an - * empty array. + * @param class-string $interface_or_class Interface or class to make an object + * instance out of. + * @param array<mixed> $dependencies Optional. Additional arguments to pass + * to the constructor. Defaults to an + * empty array. * @return object Instantiated object. */ private function make( string $interface_or_class, array $dependencies = [] ): object { @@ -218,8 +237,8 @@ private function get_fallback_instantiator(): Instantiator { /** * Make an object instance out of an interface or class. * - * @param class-string $service_class Class to make an object instance out of. - * @param array<int, mixed> $dependencies Optional. Dependencies of the class. + * @param class-string $service_class Class to make an object instance out of. + * @param array<mixed> $dependencies Optional. Dependencies of the class. * @return object Instantiated object. */ public function instantiate( string $service_class, array $dependencies = [] ): object { diff --git a/src/Infrastructure/Services/Assets/AssetLoader.php b/src/Infrastructure/Services/Assets/AssetLoader.php index cf1e6d71..099c9157 100644 --- a/src/Infrastructure/Services/Assets/AssetLoader.php +++ b/src/Infrastructure/Services/Assets/AssetLoader.php @@ -12,30 +12,33 @@ trait AssetLoader { /** * The assets container. * - * @var \Saltus\WP\Framework\Infrastructure\Services\Assets\AssetsContainer|null + * @var AssetsContainer|null */ private $assets_container = null; /** * List of assets to load. * - * @var array|null + * @var iterable<Asset>|null */ private $assets_list = null; /** * Data to be localized for assets. * - * @var \Saltus\WP\Framework\Infrastructure\Services\Assets\AssetData[] + * @var AssetData[] */ private $data = []; /** * register the assets list * - * @param array $assets_list List of assets to load. + * @return void */ - public function register_assets() { + public function register_assets(): void { + if ( $this->assets_list === null ) { + return; + } try { $factory = $this->services->get( ServiceFactory::class ); @@ -43,8 +46,14 @@ public function register_assets() { if ( ! $factory instanceof Factory ) { throw new \RuntimeException( ServiceFactory::class . ' must implement Factory' ); } + if ( ! $assets instanceof AssetManager ) { + throw new \RuntimeException( AssetManager::class . ' service is not available' ); + } $this->assets_container = $factory->create( AssetsContainer::class ); + if ( ! $this->assets_container instanceof AssetsContainer ) { + throw new \RuntimeException( AssetsContainer::class . ' could not be created' ); + } } catch ( \Throwable $exception ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) { @@ -60,14 +69,16 @@ public function register_assets() { /** * Register assets */ - public function enqueue_assets() { + public function enqueue_assets(): void { try { $assets = $this->services->get( AssetManager::class ); - $assets->enqueue_assets( $this->assets_container ); - if ( ! is_array( $this->data ) ) { + if ( ! $assets instanceof AssetManager || ! $this->assets_container instanceof AssetsContainer ) { return; } + $assets->enqueue_assets( $this->assets_container ); foreach ( $this->data as $data ) { + // The data type isnt being inforced on the subclasses + // @phpstan-ignore instanceof.alwaysTrue if ( ! $data instanceof AssetData ) { continue; } diff --git a/src/Infrastructure/Services/Assets/AssetLoadingService.php b/src/Infrastructure/Services/Assets/AssetLoadingService.php new file mode 100644 index 00000000..27db3c50 --- /dev/null +++ b/src/Infrastructure/Services/Assets/AssetLoadingService.php @@ -0,0 +1,13 @@ +<?php +namespace Saltus\WP\Framework\Infrastructure\Services\Assets; + +use Saltus\WP\Framework\Infrastructure\Container\Container; + +/** + * Base class for services that register and enqueue framework assets. + */ +abstract class AssetLoadingService implements HasAssets { + use AssetLoader; + + protected Container $services; +} diff --git a/src/Infrastructure/Services/FilterAwareTrait.php b/src/Infrastructure/Services/FilterAwareTrait.php new file mode 100644 index 00000000..8ddfb387 --- /dev/null +++ b/src/Infrastructure/Services/FilterAwareTrait.php @@ -0,0 +1,26 @@ +<?php + +namespace Saltus\WP\Framework\Infrastructure\Services; + +/** + * Shared helper for applying WordPress filters with a safe fallback outside WordPress. + */ +trait FilterAwareTrait { + + /** + * Apply a WordPress filter, falling back to the default value outside WordPress. + * + * @param non-empty-string $hook The filter hook name. + * @param mixed $value The value to filter. + * @param mixed ...$args Additional arguments passed to the filter. + * @return mixed + */ + private function filter( string $hook, $value, ...$args ) { + if ( function_exists( 'apply_filters' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Hook names are internal constants passed through this helper. + return apply_filters( $hook, $value, ...$args ); + } + + return $value; + } +} diff --git a/src/MCP/Abilities/AbilityDefinitionFactory.php b/src/MCP/Abilities/AbilityDefinitionFactory.php new file mode 100644 index 00000000..c607e7e4 --- /dev/null +++ b/src/MCP/Abilities/AbilityDefinitionFactory.php @@ -0,0 +1,240 @@ +<?php +namespace Saltus\WP\Framework\MCP\Abilities; + +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\MCP\Tools\ToolProvider; + +/** + * Converts ToolInterface instances into wp_register_ability-compatible definition arrays. + * + * @phpstan-type AbilityDefinition array{ + * name: lowercase-string&non-falsy-string, + * label: string, + * description: string, + * category: string, + * input_schema: array<string, mixed>, + * inputSchema: array<string, mixed>, + * execute_callback: callable, + * permission_callback: callable, + * callback: callable, + * meta: array<string, mixed> + * } + */ +class AbilityDefinitionFactory { + + private AbilityRuntime $runtime; + + /** + * @param AbilityRuntime|null $runtime Optional runtime override. + */ + public function __construct( ?AbilityRuntime $runtime = null ) { + $this->runtime = $runtime ?? new AbilityRuntime(); + } + + /** + * Generate ability definitions for all tools in a provider. + * + * @param ToolProvider $provider The tool provider to generate abilities for. + * @return list<AbilityDefinition> + */ + public function from_tool_provider( ToolProvider $provider ): array { + $definitions = []; + + foreach ( $provider->all() as $tool ) { + $definitions[] = $this->from_tool( $tool ); + } + + return $definitions; + } + + /** + * Generate an ability definition for a single tool. + * + * @param ToolInterface $tool The tool to generate an ability for. + * @return AbilityDefinition + */ + public function from_tool( ToolInterface $tool ): array { + $schema = $tool->get_parameters(); + + return [ + 'name' => $this->ability_name( $tool->get_name() ), + 'label' => $this->label_from_tool_name( $tool->get_name() ), + 'description' => $tool->get_description(), + 'category' => 'saltus-framework', + 'input_schema' => $schema, + 'inputSchema' => $schema, + 'execute_callback' => function ( array $args = [] ) use ( $tool ) { + return $this->runtime->execute( $tool, $args ); + }, + 'permission_callback' => function ( $args = [] ) use ( $tool ): bool { + return $this->can_use_saltus_abilities( $tool, $args ); + }, + 'callback' => function ( array $args = [] ) use ( $tool ) { + return $this->runtime->execute( $tool, $args ); + }, + 'meta' => [ + 'mcp_tool' => $tool->get_name(), + 'namespace' => 'saltus-framework/v1', + 'transport' => 'wordpress-rest', + 'show_in_rest' => true, + ], + ]; + } + + /** + * Permission callback checking whether the current user can use Saltus abilities. + * + * @param ToolInterface|null $tool Tool being executed, when available. + * @param mixed $args Ability arguments, when supplied by the native API. + * @return bool + */ + public function can_use_saltus_abilities( ?ToolInterface $tool = null, $args = [] ): bool { + if ( ! function_exists( 'current_user_can' ) ) { + return false; + } + + $args = $this->normalize_args( $args ); + if ( $tool === null ) { + return current_user_can( 'read' ); + } + + return $this->can_use_tool( $tool->get_name(), $args ); + } + + /** + * Check whether the current user can use a specific tool with known arguments. + * + * @param string $tool_name Tool name. + * @param array<string, mixed> $args Ability arguments. + * @return bool + */ + private function can_use_tool( string $tool_name, array $args ): bool { + $checks = [ + 'create_post' => fn(): bool => $this->can_create_post( $args ), + 'get_post' => fn(): bool => $this->can_post( 'read_post', $args ), + 'update_post' => fn(): bool => $this->can_post( 'edit_post', $args ), + 'delete_post' => fn(): bool => $this->can_post( 'delete_post', $args ), + 'duplicate_post' => fn(): bool => $this->can_post( 'edit_post', $args ), + 'export_post' => fn(): bool => current_user_can( 'export' ), + 'create_term' => fn(): bool => $this->can_create_term( $args ), + 'update_settings' => fn(): bool => current_user_can( 'manage_options' ), + 'get_settings' => fn(): bool => current_user_can( (string) ( $args['post_type'] ?? '' ) !== '' ? $this->post_type_capability( (string) $args['post_type'], 'edit_posts', 'edit_posts' ) : 'edit_posts' ), + 'reorder_posts' => fn(): bool => current_user_can( 'edit_posts' ), + 'list_models' => fn(): bool => current_user_can( 'edit_posts' ), + 'get_model' => fn(): bool => current_user_can( (string) ( $args['slug'] ?? '' ) !== '' ? $this->post_type_capability( (string) $args['slug'], 'edit_posts', 'edit_posts' ) : 'edit_posts' ), + 'list_meta_fields' => fn(): bool => current_user_can( 'edit_posts' ), + 'get_meta_fields' => fn(): bool => current_user_can( (string) ( $args['post_type'] ?? '' ) !== '' ? $this->post_type_capability( (string) $args['post_type'], 'edit_posts', 'edit_posts' ) : 'edit_posts' ), + ]; + + return isset( $checks[ $tool_name ] ) ? $checks[ $tool_name ]() : current_user_can( 'read' ); + } + + /** + * Normalize native ability callback arguments to an array. + * + * @param mixed $args Ability arguments. + * @return array<string, mixed> + */ + private function normalize_args( $args ): array { + if ( $args instanceof \WP_REST_Request ) { + $args = $args->get_params(); + } + + return is_array( $args ) ? $args : []; + } + + /** + * Check whether the current user can create posts for the requested post type. + * + * @param array<string, mixed> $args Ability arguments. + * @return bool + */ + private function can_create_post( array $args ): bool { + $post_type = (string) ( $args['post_type'] ?? 'posts' ); + $capability = $this->post_type_capability( $post_type, 'create_posts', 'edit_posts' ); + + return current_user_can( $capability ); + } + + /** + * Check a post-specific WordPress capability. + * + * @param string $capability Capability to check. + * @param array<string, mixed> $args Ability arguments. + * @return bool + */ + private function can_post( string $capability, array $args ): bool { + $post_id = (int) ( $args['post_id'] ?? 0 ); + if ( $post_id <= 0 ) { + return false; + } + + return current_user_can( $capability, $post_id ); + } + + /** + * Check whether the current user can create terms for the requested taxonomy. + * + * @param array<string, mixed> $args Ability arguments. + * @return bool + */ + private function can_create_term( array $args ): bool { + $taxonomy = (string) ( $args['taxonomy'] ?? '' ); + if ( $taxonomy === '' || ! function_exists( 'get_taxonomy' ) ) { + return false; + } + + $taxonomy_object = get_taxonomy( $taxonomy ); + if ( ! is_object( $taxonomy_object ) ) { + return false; + } + + $capability = 'manage_categories'; + if ( isset( $taxonomy_object->cap->edit_terms ) && is_string( $taxonomy_object->cap->edit_terms ) ) { + $capability = $taxonomy_object->cap->edit_terms; + } + + return current_user_can( $capability ); + } + + /** + * Resolve a post type capability from its registered object. + * + * @param string $post_type Post type slug. + * @param string $capability Capability property to read. + * @param string $fallback Fallback capability. + * @return string + */ + private function post_type_capability( string $post_type, string $capability, string $fallback ): string { + if ( ! function_exists( 'get_post_type_object' ) ) { + return $fallback; + } + + $post_type_object = get_post_type_object( $post_type ); + if ( is_object( $post_type_object ) && isset( $post_type_object->cap->{$capability} ) && is_string( $post_type_object->cap->{$capability} ) ) { + return $post_type_object->cap->{$capability}; + } + + return $fallback; + } + + /** + * Convert a tool name to a namespaced ability name. + * + * @param string $tool_name The raw tool name. + * @return lowercase-string&non-falsy-string + */ + private function ability_name( string $tool_name ): string { + return strtolower( 'saltus/' . str_replace( '_', '-', $tool_name ) ); + } + + /** + * Convert a tool name to a human-readable label. + * + * @param string $tool_name The raw tool name. + * @return string + */ + private function label_from_tool_name( string $tool_name ): string { + return ucwords( str_replace( '_', ' ', $tool_name ) ); + } +} diff --git a/src/MCP/Abilities/AbilityRegistrar.php b/src/MCP/Abilities/AbilityRegistrar.php new file mode 100644 index 00000000..0d1f03f6 --- /dev/null +++ b/src/MCP/Abilities/AbilityRegistrar.php @@ -0,0 +1,108 @@ +<?php +namespace Saltus\WP\Framework\MCP\Abilities; + +use Saltus\WP\Framework\MCP\Tools\RestBackedToolInterface; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\MCP\Tools\ToolProvider; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * Registers MCP abilities with the WordPress native wp_register_ability API. + * + * @phpstan-import-type AbilityDefinition from \Saltus\WP\Framework\MCP\Abilities\AbilityDefinitionFactory + */ +class AbilityRegistrar { + + private ToolProvider $tool_provider; + private AbilityDefinitionFactory $definition_factory; + private ?ModelRestPolicy $policy; + + /** + * @param ToolProvider|null $tool_provider Optional injected tool provider. + * @param AbilityDefinitionFactory|null $definition_factory Optional definition factory. + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + */ + public function __construct( ?ToolProvider $tool_provider = null, ?AbilityDefinitionFactory $definition_factory = null, ?ModelRestPolicy $policy = null ) { + $this->tool_provider = $tool_provider ?? new ToolProvider(); + $this->definition_factory = $definition_factory ?? new AbilityDefinitionFactory(); + $this->policy = $policy; + } + + /** + * Check whether the WordPress native wp_register_ability API is available. + * + * @return bool True if the API function exists. + */ + public function has_native_api(): bool { + return function_exists( 'wp_register_ability' ); + } + + /** + * Register the saltus-framework ability category. + */ + public function register_category(): void { + if ( ! function_exists( 'wp_register_ability_category' ) ) { + return; + } + + \wp_register_ability_category( + 'saltus-framework', + [ + 'label' => 'Saltus Framework', + 'description' => 'Saltus Framework content modeling and administration abilities.', + ] + ); + } + + /** + * Register all enabled tools with the WordPress ability API. + * + * @return list<string> Names of the registered abilities. + */ + public function register(): array { + if ( ! $this->has_native_api() ) { + return []; + } + + $registered = []; + foreach ( $this->tool_provider->all() as $tool ) { + if ( ! $this->is_enabled_tool( $tool ) ) { + continue; + } + + $definition = $this->definition_factory->from_tool( $tool ); + $name = (string) $definition['name']; + $args = $definition; + unset( $args['name'] ); + + wp_register_ability( $name, $args ); + + $registered[] = $name; + } + + return $registered; + } + + /** + * Check whether a tool is enabled based on the model REST policy. + * + * @param ToolInterface $tool The tool to check. + * @return bool + */ + private function is_enabled_tool( ToolInterface $tool ): bool { + if ( ! $this->policy ) { + return true; + } + + if ( ! $tool instanceof RestBackedToolInterface ) { + return true; + } + + $requirement = $tool->get_rest_capability(); + if ( $requirement === null ) { + return true; + } + + return $this->policy->has_capability( $requirement->get_capability(), $requirement->get_model_type() ); + } +} diff --git a/src/MCP/Abilities/AbilityRuntime.php b/src/MCP/Abilities/AbilityRuntime.php new file mode 100644 index 00000000..836e881b --- /dev/null +++ b/src/MCP/Abilities/AbilityRuntime.php @@ -0,0 +1,230 @@ +<?php +namespace Saltus\WP\Framework\MCP\Abilities; + +use Saltus\WP\Framework\MCP\Audit\AuditEntry; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; +use Saltus\WP\Framework\MCP\Tools\RestBackedToolInterface; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\MCP\Validation\Validator; + +/** + * Coordinates validation, rate limiting, REST dispatch, caching, and audit logging for MCP tool execution. + */ +class AbilityRuntime { + use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; + + private AuditLogger $audit_logger; + private RateLimiter $rate_limiter; + private TransientCache $cache; + + /** + * @param AuditLogger|null $audit_logger Optional audit logger. + * @param RateLimiter|null $rate_limiter Optional rate limiter. + * @param TransientCache|null $cache Optional cache backend. + */ + public function __construct( + ?AuditLogger $audit_logger = null, + ?RateLimiter $rate_limiter = null, + ?TransientCache $cache = null + ) { + $this->audit_logger = $audit_logger ?? new AuditLogger(); + $this->rate_limiter = $rate_limiter ?? new RateLimiter(); + $this->cache = $cache ?? new TransientCache(); + } + + /** + * Validate, rate-limit, dispatch, cache, and audit an MCP tool execution. + * + * @param ToolInterface $tool The tool to execute. + * @param array<string, mixed> $args Arguments to pass to the tool. + * @return array<string, mixed>|\WP_Error Tool result or error. + */ + // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh -- Runtime coordinates validation, rate limiting, dispatch, cache, and audit. + public function execute( ToolInterface $tool, array $args ) { + $entry = new AuditEntry( $tool->get_name(), $args, $this->identifier() ); + + $valid = Validator::validate( $args, $tool->get_parameters() ); + if ( ! $valid['valid'] ) { + $error = $this->error( 'invalid_params', implode( '; ', $valid['errors'] ), 400 ); + $this->record_error( $entry, 'validation_error', $error ); + return $error; + } + + $rate_limit = $this->rate_limiter->check( $this->identifier() ); + if ( ! $rate_limit->allowed ) { + $error = $this->error( + 'rate_limited', + 'Rate limit exceeded.', + 429, + [ + 'retry_after' => $rate_limit->retry_after, + 'remaining' => $rate_limit->remaining, + 'reset_at' => $rate_limit->reset_at, + ] + ); + $this->record_error( $entry, 'rate_limited', $error ); + return $error; + } + + if ( ! $tool instanceof RestBackedToolInterface ) { + $error = $this->error( 'unsupported_ability', 'This tool does not support REST dispatch.', 501 ); + $this->record_error( $entry, 'error', $error ); + return $error; + } + + $request = $tool->build_rest_request( $args ); + if ( $request === null ) { + $error = $this->error( 'unsupported_ability', 'This Saltus ability is registered for discovery only until a native dispatcher is available.', 501 ); + $this->record_error( $entry, 'error', $error ); + return $error; + } + + if ( ! function_exists( 'rest_do_request' ) ) { + $error = $this->error( 'rest_unavailable', 'WordPress REST dispatch is not available.', 501 ); + $this->record_error( $entry, 'error', $error ); + return $error; + } + + $cache_key = $this->cache_key( $tool->get_name(), $args ); + if ( $this->is_cacheable( $tool ) ) { + $cached = $this->cache->get( $cache_key ); + if ( $cached !== null ) { + $entry->complete( 'cache_hit' ); + $this->audit_logger->record( $entry ); + return $cached; + } + } + + try { + $response = rest_do_request( $request ); + + /** @phpstan-ignore-next-line rest_do_request can return WP_Error (WordPress stubs may not include it in the return type) */ + if ( is_wp_error( $response ) ) { + $error = $this->error( 'rest_dispatch_error', $response->get_error_message(), 500 ); + $this->record_error( $entry, 'error', $error ); + return $error; + } + + $status = (int) $response->get_status(); + $data = $response->get_data(); + $result = is_array( $data ) ? $data : [ 'result' => $data ]; + + if ( $status >= 400 ) { + $error_code = is_array( $data ) ? (string) ( $data['code'] ?? 'rest_error' ) : 'rest_error'; + $error_msg = is_array( $data ) ? (string) ( $data['message'] ?? 'REST error' ) : 'REST error'; + $error = $this->error( $error_code, $error_msg, $status ); + $this->record_error( $entry, 'error', $error ); + return $error; + } + + if ( $this->is_cacheable( $tool ) ) { + $this->cache->set( $cache_key, $result, $this->cache_ttl( $tool ) ); + } + + $entry->complete( 'success' ); + $this->audit_logger->record( $entry ); + + return $result; + } catch ( \Throwable $e ) { + $error = $this->error( 'ability_exception', $e->getMessage(), 500 ); + $this->record_error( $entry, 'exception', $error ); + return $error; + } + } + + /** + * Build a WP_Error response. + * + * @param string $code Error code. + * @param string $message Error message. + * @param int $status HTTP status code. + * @param array<string, mixed> $extra Additional error data. + * @return \WP_Error + */ + private function error( string $code, string $message, int $status, array $extra = [] ): \WP_Error { + return new \WP_Error( $code, $message, array_merge( [ 'status' => $status ], $extra ) ); + } + + /** + * Record a failed execution as an audit entry. + * + * @param AuditEntry $entry The audit entry to record. + * @param string $status The completion status. + * @param \WP_Error $error The error that occurred. + */ + private function record_error( AuditEntry $entry, string $status, \WP_Error $error ): void { + $entry->complete( $status, (string) $error->get_error_code(), $error->get_error_message() ); + $this->audit_logger->record( $entry ); + } + + /** + * Resolve a unique identifier for the current user for rate limiting and audit. + * + * @return string + */ + private function identifier(): string { + $identifier = function_exists( 'get_current_user_id' ) ? 'user:' . (int) get_current_user_id() : 'user:0'; + if ( $identifier === 'user:0' && isset( $_SERVER['REMOTE_ADDR'] ) ) { + $identifier = 'ip:' . hash( 'sha256', (string) $_SERVER['REMOTE_ADDR'] ); + } + + return (string) $this->filter( 'saltus/framework/mcp/rate_limit/identifier', $identifier ); + } + + /** + * Build a unique cache key for a tool invocation. + * + * @param string $tool_name The tool name. + * @param array<string, mixed> $args The tool arguments. + * @return string Cache key. + */ + private function cache_key( string $tool_name, array $args ): string { + $payload = [ + 'tool' => $tool_name, + 'args' => $args, + 'user' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, + 'locale' => function_exists( 'get_locale' ) ? get_locale() : '', + ]; + + return 'saltus_mcp_' . hash( 'sha256', $this->encode( $payload ) ); + } + + /** + * Check whether caching is enabled for a given tool. + * + * @param RestBackedToolInterface $tool The tool to check. + * @return bool + */ + private function is_cacheable( RestBackedToolInterface $tool ): bool { + return (bool) $this->filter( 'saltus/framework/mcp/cache/cacheable', $tool->is_cacheable(), $tool->get_name() ); + } + + /** + * Resolve the cache TTL for a given tool. + * + * @param RestBackedToolInterface $tool The tool to check. + * @return int + */ + private function cache_ttl( RestBackedToolInterface $tool ): int { + return (int) $this->filter( 'saltus/framework/mcp/cache/ttl', $tool->cache_ttl(), $tool->get_name() ); + } + + /** + * Encode a payload as JSON for cache key generation. + * + * @param array<string, mixed> $payload The payload to encode. + * @return string + */ + private function encode( array $payload ): string { + if ( \function_exists( 'wp_json_encode' ) ) { + $encoded = \wp_json_encode( $payload ); + return \is_string( $encoded ) ? $encoded : ''; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Fallback for non-WordPress contexts. + $encoded = \json_encode( $payload ); + return \is_string( $encoded ) ? $encoded : ''; + } +} diff --git a/src/MCP/Audit/AuditDatabase.php b/src/MCP/Audit/AuditDatabase.php new file mode 100644 index 00000000..a1db780f --- /dev/null +++ b/src/MCP/Audit/AuditDatabase.php @@ -0,0 +1,55 @@ +<?php +namespace Saltus\WP\Framework\MCP\Audit; + +interface AuditDatabase { + + /** + * Get the database table prefix. + * + * @return string + */ + public function prefix(): string; + + /** + * Insert a row into a database table. + * + * @param string $table The table name. + * @param array<string, mixed> $data Column name/value pairs. + * @param list<string> $format Format strings for the data columns. + * @return bool|int + */ + public function insert( string $table, array $data, array $format = [] ); + + /** + * Execute a raw SQL query. + * + * @param string $query The SQL query to execute. + * @return bool|int + */ + public function query( string $query ); + + /** + * Get the database charset collation string. + * + * @return string + */ + public function get_charset_collate(): string; + + /** + * Execute a SELECT query and return results. + * + * @param string $query The SQL SELECT query. + * @param mixed $output The output format constant (e.g. ARRAY_A, OBJECT). + * @return list<array<string, mixed>>|object|null + */ + public function get_results( string $query, $output = null ); + + /** + * Prepare a SQL query with placeholder substitution. + * + * @param string $query The SQL query with placeholders. + * @param mixed ...$args The values to substitute. + * @return string + */ + public function prepare( string $query, ...$args ): string; +} diff --git a/src/MCP/Audit/AuditEntry.php b/src/MCP/Audit/AuditEntry.php new file mode 100644 index 00000000..297286d7 --- /dev/null +++ b/src/MCP/Audit/AuditEntry.php @@ -0,0 +1,88 @@ +<?php +namespace Saltus\WP\Framework\MCP\Audit; + +/** + * Value object representing a single MCP audit trail entry. + */ +class AuditEntry { + + private string $tool_name; + /** @var array<string, mixed> */ + private array $arguments; + private float $started_at; + private ?float $completed_at; + private string $status; + private ?string $error_code; + private ?string $error_message; + private ?string $identifier; + + /** + * @param string $tool_name The name of the tool being executed. + * @param array<string, mixed> $arguments Arguments passed to the tool. + * @param string|null $identifier Optional user or session identifier. + */ + public function __construct( string $tool_name, array $arguments, ?string $identifier = null ) { + $this->tool_name = $tool_name; + $this->arguments = $arguments; + $this->started_at = microtime( true ); + $this->completed_at = null; + $this->status = 'started'; + $this->error_code = null; + $this->error_message = null; + $this->identifier = $identifier; + } + + /** + * Mark the entry as completed with a status and optional error details. + * + * @param string $status Result status (success, error, cache_hit, etc.). + * @param string|null $error_code Machine-readable error code. + * @param string|null $error_message Human-readable error message. + */ + public function complete( string $status, ?string $error_code = null, ?string $error_message = null ): void { + $this->completed_at = microtime( true ); + $this->status = $status; + $this->error_code = $error_code; + $this->error_message = $error_message; + } + + /** + * Get the elapsed duration in milliseconds, or null if not yet completed. + * + * @return float|null Duration in milliseconds, or null if incomplete. + */ + public function get_duration(): ?float { + if ( $this->completed_at === null ) { + return null; + } + return ( $this->completed_at - $this->started_at ) * 1000; + } + + /** + * Convert the audit entry to an array for persistence. + * + * @return array<string, mixed> + */ + public function to_array(): array { + $sec = (int) $this->started_at; + $usec = (int) ( ( $this->started_at - $sec ) * 1000000 ); + $date_started = ( new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ) )->setTimestamp( $sec ); + $date_started = $date_started->setTime( + (int) $date_started->format( 'G' ), + (int) $date_started->format( 'i' ), + (int) $date_started->format( 's' ), + $usec + ); + $timestamp = $date_started->format( 'Y-m-d H:i:s.v' ); + return [ + 'timestamp' => $timestamp, + 'tool' => $this->tool_name, + 'arguments' => $this->arguments, + 'identifier' => $this->identifier, + 'status' => $this->status, + 'duration_ms' => $this->get_duration(), + 'error_code' => $this->error_code, + 'error_message' => $this->error_message, + ]; + } +} diff --git a/src/MCP/Audit/AuditLogger.php b/src/MCP/Audit/AuditLogger.php new file mode 100644 index 00000000..6c6bc6a5 --- /dev/null +++ b/src/MCP/Audit/AuditLogger.php @@ -0,0 +1,245 @@ +<?php +namespace Saltus\WP\Framework\MCP\Audit; + +/** + * Persists MCP audit entries to a custom database table. + */ +class AuditLogger { + use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; + + private const TABLE_SUFFIX = 'saltus_mcp_audit'; + + /** @var list<string> */ + private const VALID_STATUSES = [ + 'started', + 'success', + 'error', + 'cache_hit', + 'validation_error', + 'rate_limited', + 'exception', + ]; + + private bool $db_initialized = false; + + /** + * Persist an audit entry to the database. + * + * @param AuditEntry $entry The audit entry to persist. + */ + public function record( AuditEntry $entry ): void { + if ( ! $this->enabled() ) { + return; + } + + $this->ensure_db(); + + $wpdb = $this->wpdb(); + if ( $wpdb === null ) { + return; + } + + $data = $entry->to_array(); + $wpdb->insert( + $this->table_name(), + [ + 'created_at' => $data['timestamp'], + 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, + 'identifier' => $data['identifier'] !== null ? $this->sanitize( $data['identifier'], 191 ) : null, + 'ability' => $this->sanitize( $data['tool'], 191 ), + 'arguments' => $this->encode( is_array( $data['arguments'] ) ? $data['arguments'] : [] ), + 'status' => $this->validate_status( $data['status'] ), + 'duration_ms' => $data['duration_ms'], + 'error_code' => $data['error_code'] !== null ? $this->sanitize( $data['error_code'], 191 ) : null, + 'error_message' => $data['error_message'] !== null ? $this->sanitize( $data['error_message'], 65535 ) : null, + ], + [ '%s', '%d', '%s', '%s', '%s', '%s', '%f', '%s', '%s' ] + ); + } + + /** + * Retrieve the most recent audit entries. + * + * @param int $limit Maximum number of entries to return. + * @return list<array<string, mixed>> + */ + public function get_recent_entries( int $limit = 100 ): array { + $wpdb = $this->wpdb(); + if ( $wpdb === null ) { + return []; + } + + $sql = 'SELECT * FROM ' . $this->table_name() . ' ORDER BY id DESC LIMIT ' . max( 1, $limit ); + + $output = defined( 'ARRAY_A' ) ? ARRAY_A : 'ARRAY_A'; + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is assembled from an internal table name and integer limit. + $rows = $wpdb->get_results( $sql, $output ); + + return is_array( $rows ) ? array_values( array_filter( $rows, 'is_array' ) ) : []; + } + + /** + * Create the audit log database table if it does not exist. + */ + private function ensure_table(): void { + $wpdb = $this->wpdb(); + if ( $wpdb === null ) { + return; + } + + $table = $this->table_name(); + $charset_collate = $wpdb->get_charset_collate(); + $sql = "CREATE TABLE IF NOT EXISTS {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + created_at datetime(3) NOT NULL, + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + identifier varchar(191) NULL, + ability varchar(191) NOT NULL, + arguments longtext NULL, + status varchar(32) NOT NULL, + duration_ms double NULL, + error_code varchar(191) NULL, + error_message text NULL, + PRIMARY KEY (id), + KEY ability (ability), + KEY user_id (user_id), + KEY created_at (created_at) + ) {$charset_collate}"; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- DDL uses the internal audit table name. + $wpdb->query( $sql ); + } + + /** + * Ensure the audit table exists, running only once per request. + */ + private function ensure_db(): void { + if ( $this->db_initialized ) { + return; + } + + if ( function_exists( 'get_option' ) && get_option( 'saltus_mcp_audit_db_version' ) !== '1.0.0' ) { + $this->ensure_table(); + if ( function_exists( 'update_option' ) ) { + update_option( 'saltus_mcp_audit_db_version', '1.0.0' ); + } + } + + $this->db_initialized = true; + } + + /** + * Delete audit entries older than the retention period. + */ + public function cleanup_expired_entries(): void { + $days = (int) $this->filter( 'saltus/framework/mcp/audit/retention_days', 30 ); + if ( $days <= 0 ) { + return; + } + + $wpdb = $this->wpdb(); + if ( $wpdb === null ) { + return; + } + + $day_seconds = defined( 'DAY_IN_SECONDS' ) ? DAY_IN_SECONDS : 86400; + $cutoff = gmdate( 'Y-m-d H:i:s.000', time() - ( $days * $day_seconds ) ); + $table = $this->table_name(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is internal. + $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); + } + + /** + * Check whether audit logging is enabled. + * + * @return bool + */ + private function enabled(): bool { + return (bool) $this->filter( 'saltus/framework/mcp/audit/enabled', true ); + } + + /** + * Get the full audit table name with prefix. + * + * @return string + */ + private function table_name(): string { + $wpdb = $this->wpdb(); + $prefix = $wpdb !== null ? $wpdb->prefix() : ''; + + return $prefix . self::TABLE_SUFFIX; + } + + /** + * Get the global wpdb instance wrapped in an AuditDatabase adapter. + * + * @return AuditDatabase|null + */ + private function wpdb(): ?AuditDatabase { + global $wpdb; + + if ( $wpdb instanceof AuditDatabase ) { + return $wpdb; + } + + if ( $wpdb instanceof \wpdb ) { + return new WpdbAuditDatabase( $wpdb ); + } + + return null; + } + + /** + * Sanitize a string value, truncating to a maximum length. + * + * @param string $value The value to sanitize. + * @param positive-int $max_length Maximum allowed string length. + * @return string + */ + private function sanitize( string $value, int $max_length ): string { + $value = str_replace( "\0", '', $value ); + + if ( function_exists( 'sanitize_text_field' ) ) { + $value = sanitize_text_field( $value ); + } + + if ( mb_strlen( $value ) > $max_length ) { + $value = mb_substr( $value, 0, $max_length ); + } + + return $value; + } + + /** + * Validate that a status string is one of the allowed values. + * + * @param string $status The status to validate. + * @return string + */ + private function validate_status( string $status ): string { + $status = $this->sanitize( $status, 32 ); + + if ( ! in_array( $status, self::VALID_STATUSES, true ) ) { + return 'error'; + } + + return $status; + } + + /** + * Encode data as JSON for database storage. + * + * @param array<string, mixed> $data The data to encode. + * @return string + */ + private function encode( array $data ): string { + if ( function_exists( 'wp_json_encode' ) ) { + $encoded = wp_json_encode( $data ); + return is_string( $encoded ) ? $encoded : ''; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Fallback for non-WordPress contexts. + $encoded = json_encode( $data ); + return is_string( $encoded ) ? $encoded : ''; + } +} diff --git a/src/MCP/Audit/WpdbAuditDatabase.php b/src/MCP/Audit/WpdbAuditDatabase.php new file mode 100644 index 00000000..672049b6 --- /dev/null +++ b/src/MCP/Audit/WpdbAuditDatabase.php @@ -0,0 +1,98 @@ +<?php +namespace Saltus\WP\Framework\MCP\Audit; + +/** + * wpdb adapter implementing the AuditDatabase interface. + */ +class WpdbAuditDatabase implements AuditDatabase { + private \wpdb $wpdb; + + /** + * @param \wpdb $wpdb The WordPress database object. + */ + public function __construct( \wpdb $wpdb ) { + $this->wpdb = $wpdb; + } + + /** + * Get the WordPress database table prefix. + * + * @return string + */ + public function prefix(): string { + return $this->wpdb->prefix; + } + + /** + * Insert a row into the audit table. + * + * @param string $table The table name. + * @param array<string, mixed> $data Column name/value pairs. + * @param list<string> $format Format strings for the data columns. + * @return bool|int + */ + public function insert( string $table, array $data, array $format = [] ) { + return $this->wpdb->insert( $table, $data, $format ); + } + + /** + * Execute a raw SQL query. + * + * @param string $query The SQL query to execute. + * @return bool|int + */ + public function query( string $query ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Queries are assembled internally by AuditLogger with controlled values. + return $this->wpdb->query( $query ); + } + + /** + * Get the database charset collation string. + * + * @return string + */ + public function get_charset_collate(): string { + return $this->wpdb->get_charset_collate(); + } + + /** + * Prepare a SQL query with placeholder substitution. + * + * @param string $query The SQL query with placeholders. + * @param mixed ...$args The values to substitute. + * @return string + */ + public function prepare( string $query, ...$args ): string { + /** @phpstan-ignore-next-line wpdb::prepare expects literal-string */ + return $this->wpdb->prepare( $query, ...$args ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.WP.AlternativeFunctions -- Pass-through to wpdb::prepare. + } + + /** + * Execute a SELECT query and return results. + * + * @param string $query The SQL SELECT query. + * @param mixed $output The output format constant (e.g. ARRAY_A, OBJECT). + * @return list<array<string, mixed>>|object|null + */ + public function get_results( string $query, $output = null ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is assembled internally by AuditLogger with an integer limit. + $rows = $this->wpdb->get_results( $query, $output ); + if ( ! is_array( $rows ) ) { + return $rows; + } + + if ( $output !== ARRAY_A ) { + /** @phpstan-ignore-next-line Return type varies by $output format (OBJECT returns array<stdClass>, etc.) */ + return $rows; + } + + $result = []; + foreach ( $rows as $row ) { + if ( is_array( $row ) ) { + $result[] = $row; + } + } + + return $result; + } +} diff --git a/src/MCP/Cache/CacheInterface.php b/src/MCP/Cache/CacheInterface.php new file mode 100644 index 00000000..6607b6c1 --- /dev/null +++ b/src/MCP/Cache/CacheInterface.php @@ -0,0 +1,42 @@ +<?php +namespace Saltus\WP\Framework\MCP\Cache; + +interface CacheInterface { + + /** + * Retrieve a cached value by key. + * + * @param string $key The cache key. + * @return array<string, mixed>|null + */ + public function get( string $key ): ?array; + + /** + * Store a value in the cache. + * + * @param string $key The cache key. + * @param array<string, mixed> $value The value to cache. + * @param int $ttl Time-to-live in seconds. + */ + public function set( string $key, array $value, int $ttl ): void; + + /** + * Check whether a cached value exists for the given key. + * + * @param string $key The cache key. + * @return bool + */ + public function has( string $key ): bool; + + /** + * Delete a cached value by key. + * + * @param string $key The cache key to delete. + */ + public function delete( string $key ): void; + + /** + * Clear all cached values. + */ + public function clear(): void; +} diff --git a/src/MCP/Cache/TransientCache.php b/src/MCP/Cache/TransientCache.php new file mode 100644 index 00000000..a87af6dc --- /dev/null +++ b/src/MCP/Cache/TransientCache.php @@ -0,0 +1,119 @@ +<?php +namespace Saltus\WP\Framework\MCP\Cache; + +/** + * Transient-backed cache implementing CacheInterface. + */ +class TransientCache implements CacheInterface { + use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; + + private const INDEX_OPTION = 'saltus_mcp_cache_keys'; + + /** + * Retrieve a cached value by key. + * + * @param string $key Cache key. + * @return array<string, mixed>|null Cached value, or null if not found. + */ + public function get( string $key ): ?array { + if ( ! $this->enabled() || ! function_exists( 'get_transient' ) ) { + return null; + } + + $value = get_transient( $key ); + + return is_array( $value ) ? $value : null; + } + + /** + * Store a value in the cache. + * + * @param string $key Cache key. + * @param array<string, mixed> $value Value to cache. + * @param int $ttl Time-to-live in seconds. + */ + public function set( string $key, array $value, int $ttl ): void { + if ( ! $this->enabled() || ! function_exists( 'set_transient' ) ) { + return; + } + + set_transient( $key, $value, max( 1, $ttl ) ); + $this->index_key( $key ); + } + + /** + * Check whether a cached value exists for the given key. + * + * @param string $key Cache key. + * @return bool True if the key has a cached value. + */ + public function has( string $key ): bool { + return $this->get( $key ) !== null; + } + + /** + * Delete a cached value by key. + * + * @param string $key Cache key to delete. + */ + public function delete( string $key ): void { + if ( function_exists( 'delete_transient' ) ) { + delete_transient( $key ); + } + } + + /** + * Clear all cached values tracked by this cache. + */ + public function clear(): void { + foreach ( $this->keys() as $key ) { + $this->delete( $key ); + } + + if ( function_exists( 'delete_option' ) ) { + delete_option( self::INDEX_OPTION ); + } + } + + /** + * Check whether caching is enabled. + * + * @return bool + */ + private function enabled(): bool { + return (bool) $this->filter( 'saltus/framework/mcp/cache/enabled', true ); + } + + /** + * Track a cache key in the global index for later cleanup. + * + * @param string $key The cache key to index. + */ + private function index_key( string $key ): void { + if ( ! function_exists( 'get_option' ) || ! function_exists( 'update_option' ) ) { + return; + } + + $keys = $this->keys(); + if ( ! in_array( $key, $keys, true ) ) { + $keys[] = $key; + } + + update_option( self::INDEX_OPTION, $keys, false ); + } + + /** + * Retrieve all tracked cache keys from the index option. + * + * @return list<string> + */ + private function keys(): array { + if ( ! function_exists( 'get_option' ) ) { + return []; + } + + $keys = get_option( self::INDEX_OPTION, [] ); + + return is_array( $keys ) ? array_values( array_filter( $keys, 'is_string' ) ) : []; + } +} diff --git a/src/MCP/RateLimiter/RateLimitResult.php b/src/MCP/RateLimiter/RateLimitResult.php new file mode 100644 index 00000000..c5272940 --- /dev/null +++ b/src/MCP/RateLimiter/RateLimitResult.php @@ -0,0 +1,26 @@ +<?php +namespace Saltus\WP\Framework\MCP\RateLimiter; + +/** + * Value object representing the outcome of a rate limit check. + */ +class RateLimitResult { + + public bool $allowed; + public int $remaining; + public float $reset_at; + public ?int $retry_after; + + /** + * @param bool $allowed Whether the request is allowed. + * @param int $remaining Number of requests remaining in the window. + * @param float $reset_at Unix timestamp when the window resets. + * @param int|null $retry_after Seconds to wait before retrying, if denied. + */ + public function __construct( bool $allowed, int $remaining, float $reset_at, ?int $retry_after = null ) { + $this->allowed = $allowed; + $this->remaining = $remaining; + $this->reset_at = $reset_at; + $this->retry_after = $retry_after; + } +} diff --git a/src/MCP/RateLimiter/RateLimiter.php b/src/MCP/RateLimiter/RateLimiter.php new file mode 100644 index 00000000..a8881d0c --- /dev/null +++ b/src/MCP/RateLimiter/RateLimiter.php @@ -0,0 +1,121 @@ +<?php +namespace Saltus\WP\Framework\MCP\RateLimiter; + +/** + * Sliding-window rate limiter backed by WordPress transients. + */ +class RateLimiter { + use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; + + private int $default_max_requests; + private int $default_window_seconds; + + /** + * @param int $max_requests Maximum number of requests allowed within the window. + * @param int $window_seconds Time window in seconds. + */ + public function __construct( int $max_requests = 60, int $window_seconds = 60 ) { + $this->default_max_requests = $max_requests; + $this->default_window_seconds = $window_seconds; + } + + /** + * Check whether the given identifier has exceeded the rate limit. + * + * @param string $identifier Unique identifier to rate-limit by. + * @return RateLimitResult Outcome of the rate limit check. + */ + public function check( string $identifier ): RateLimitResult { + if ( ! $this->enabled() ) { + return new RateLimitResult( true, $this->max_requests(), microtime( true ) + $this->window_seconds(), null ); + } + + $now = microtime( true ); + $window = $this->window_seconds(); + $max = $this->max_requests(); + $cutoff = $now - $window; + $key = $this->key( $identifier ); + $requests = $this->get( $key ); + $requests = array_values( array_filter( $requests, fn( float $timestamp ) => $timestamp >= $cutoff ) ); + + if ( count( $requests ) >= $max ) { + $oldest = $requests[0]; + $reset_at = $oldest + $window; + $retry_after = (int) ceil( $reset_at - $now ); + + $this->set( $key, $requests, $window ); + + return new RateLimitResult( false, 0, $reset_at, max( $retry_after, 1 ) ); + } + + $requests[] = $now; + $this->set( $key, $requests, $window ); + + return new RateLimitResult( true, $max - count( $requests ), $now + $window, null ); + } + + /** + * Check whether rate limiting is enabled. + * + * @return bool + */ + private function enabled(): bool { + return (bool) $this->filter( 'saltus/framework/mcp/rate_limit/enabled', true ); + } + + /** + * Get the maximum number of requests allowed per window. + * + * @return int<1, max> + */ + private function max_requests(): int { + return max( 1, (int) $this->filter( 'saltus/framework/mcp/rate_limit/max_requests', $this->default_max_requests ) ); + } + + /** + * Get the rate limit window duration in seconds. + * + * @return int<1, max> + */ + private function window_seconds(): int { + return max( 1, (int) $this->filter( 'saltus/framework/mcp/rate_limit/window_seconds', $this->default_window_seconds ) ); + } + + /** + * Build the transient key for a given identifier. + * + * @param string $identifier The identifier to key by. + * @return string + */ + private function key( string $identifier ): string { + return 'saltus_mcp_rate_' . hash( 'sha256', $identifier ); + } + + /** + * Retrieve the stored timestamps for a rate-limit key. + * + * @param string $key The rate-limit cache key. + * @return list<float> + */ + private function get( string $key ): array { + if ( ! function_exists( 'get_transient' ) ) { + return []; + } + + $value = get_transient( $key ); + + return is_array( $value ) ? array_values( array_map( 'floatval', $value ) ) : []; + } + + /** + * Store timestamps for a rate-limit key. + * + * @param list<float> $requests Array of request timestamps. + * @param int $ttl Time-to-live in seconds. + */ + private function set( string $key, array $requests, int $ttl ): void { + if ( function_exists( 'set_transient' ) ) { + set_transient( $key, $requests, $ttl ); + } + } +} diff --git a/src/MCP/Tools/CreatePost.php b/src/MCP/Tools/CreatePost.php new file mode 100644 index 00000000..6aa8bf79 --- /dev/null +++ b/src/MCP/Tools/CreatePost.php @@ -0,0 +1,90 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to create a new post in any registered Custom Post Type. + */ +class CreatePost extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'create_post'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Create a new post in any registered Custom Post Type'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug (e.g., "posts", "page", "product")', + 'default' => 'posts', + ], + 'title' => [ + 'type' => 'string', + 'description' => 'The post title', + 'required' => true, + ], + 'content' => [ + 'type' => 'string', + 'description' => 'The post content (HTML or raw text)', + ], + 'excerpt' => [ + 'type' => 'string', + 'description' => 'The post excerpt', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'URL slug', + ], + 'status' => [ + 'type' => 'string', + 'enum' => [ 'publish', 'draft', 'pending', 'private' ], + 'description' => 'Post status', + 'default' => 'draft', + ], + 'meta' => [ + 'type' => 'object', + 'description' => 'Meta fields as key-value pairs', + 'additionalProperties' => true, + ], + 'terms' => [ + 'type' => 'object', + 'description' => 'Taxonomy terms as {taxonomy: [term_id, ...]}', + 'additionalProperties' => [ + 'type' => 'array', + 'items' => [ 'type' => 'number' ], + ], + ], + ]; + } + + /** + * Build the WP_REST_Request for creating a post. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + $body = $this->only_args( $args, [ 'title', 'content', 'excerpt', 'slug', 'status', 'meta' ] ); + $body = $this->append_term_filters( $body, $args['terms'] ?? [] ); + + return $this->request( 'POST', '/wp/v2/' . rawurlencode( $this->post_type_rest_base( (string) ( $args['post_type'] ?? 'posts' ) ) ), [], $body ); + } +} diff --git a/src/MCP/Tools/CreateTerm.php b/src/MCP/Tools/CreateTerm.php new file mode 100644 index 00000000..e61bfb9a --- /dev/null +++ b/src/MCP/Tools/CreateTerm.php @@ -0,0 +1,70 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to create a new term in a taxonomy. + */ +class CreateTerm extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'create_term'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Create a new term in a taxonomy'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'taxonomy' => [ + 'type' => 'string', + 'description' => 'The taxonomy slug (e.g., "categories", "tags")', + 'required' => true, + ], + 'name' => [ + 'type' => 'string', + 'description' => 'The term name', + 'required' => true, + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'URL slug (auto-generated if not provided)', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'Term description', + ], + 'parent' => [ + 'type' => 'number', + 'description' => 'Parent term ID (for hierarchical taxonomies)', + ], + ]; + } + + /** + * Build the WP_REST_Request for creating a term. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + $body = $this->only_args( $args, [ 'name', 'slug', 'description', 'parent' ] ); + + return $this->request( 'POST', '/wp/v2/' . rawurlencode( $this->taxonomy_rest_base( (string) ( $args['taxonomy'] ?? '' ) ) ), [], $body ); + } +} diff --git a/src/MCP/Tools/DeletePost.php b/src/MCP/Tools/DeletePost.php new file mode 100644 index 00000000..2bf57d35 --- /dev/null +++ b/src/MCP/Tools/DeletePost.php @@ -0,0 +1,65 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to delete (trash or force delete) a post by ID. + */ +class DeletePost extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'delete_post'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Delete (trash or force delete) a post by ID'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_id' => [ + 'type' => 'number', + 'description' => 'The post ID to delete', + 'required' => true, + ], + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug', + 'default' => 'posts', + ], + 'force' => [ + 'type' => 'boolean', + 'description' => 'Whether to force delete (skip trash)', + 'default' => false, + ], + ]; + } + + /** + * Build the WP_REST_Request for deleting a post. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( + 'DELETE', + '/wp/v2/' . rawurlencode( $this->post_type_rest_base( (string) ( $args['post_type'] ?? 'posts' ) ) ) . '/' . (int) ( $args['post_id'] ?? 0 ), + [ 'force' => ! empty( $args['force'] ) ] + ); + } +} diff --git a/src/MCP/Tools/DuplicatePost.php b/src/MCP/Tools/DuplicatePost.php new file mode 100644 index 00000000..6fc6124e --- /dev/null +++ b/src/MCP/Tools/DuplicatePost.php @@ -0,0 +1,62 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to duplicate a WordPress post. + */ +class DuplicatePost extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'duplicate_post'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Duplicate a WordPress post, creating a copy with "(Copy)" appended to the title'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_id' => [ + 'type' => 'number', + 'description' => 'The ID of the post to duplicate', + 'required' => true, + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_DUPLICATE, 'post_type' ); + } + + /** + * Build the WP_REST_Request for duplicating a post. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'POST', '/saltus-framework/v1/duplicate/' . (int) ( $args['post_id'] ?? 0 ) ); + } +} diff --git a/src/MCP/Tools/ExportPost.php b/src/MCP/Tools/ExportPost.php new file mode 100644 index 00000000..dbce7280 --- /dev/null +++ b/src/MCP/Tools/ExportPost.php @@ -0,0 +1,82 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to export a WordPress post as WXR. + */ +class ExportPost extends RestTool { + + private SaltusSingleExport $exporter; + + /** + * @param SaltusSingleExport|null $exporter Export feature implementation shared with REST. + */ + public function __construct( ?SaltusSingleExport $exporter = null ) { + $this->exporter = $exporter ?? new SaltusSingleExport( '', [] ); + } + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'export_post'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Export a WordPress post as WXR (WordPress eXtended RSS) for import into another site'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_id' => [ + 'type' => 'number', + 'description' => 'The ID of the post to export', + 'required' => true, + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_EXPORT, 'post_type' ); + } + + /** + * Build the WP_REST_Request for exporting a post. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/export/' . (int) ( $args['post_id'] ?? 0 ) ); + } + + /** + * Export a post directly through the shared feature implementation. + * + * @param int $post_id The post ID to export. + * @return array<string, mixed>|\WP_Error + */ + public function export_post( int $post_id ) { + return $this->exporter->export_post( $post_id ); + } +} diff --git a/src/MCP/Tools/GetHealth.php b/src/MCP/Tools/GetHealth.php new file mode 100644 index 00000000..5999390e --- /dev/null +++ b/src/MCP/Tools/GetHealth.php @@ -0,0 +1,74 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to retrieve Saltus Framework health metrics. + */ +class GetHealth extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'get_health'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Get Saltus Framework health, version, audit error rate, latency, cache, and rate limit status'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return []; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_HEALTH ); + } + + /** + * Build the WP_REST_Request for retrieving health metrics. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/health' ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int { + return 60; + } +} diff --git a/src/MCP/Tools/GetMetaFields.php b/src/MCP/Tools/GetMetaFields.php new file mode 100644 index 00000000..f06144c9 --- /dev/null +++ b/src/MCP/Tools/GetMetaFields.php @@ -0,0 +1,103 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Features\Meta\MetaFieldProvider; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to retrieve meta field definitions for a post type. + */ +class GetMetaFields extends RestTool { + + private MetaFieldProvider $meta_field_provider; + + /** + * @param MetaFieldProvider|null $meta_field_provider Shared meta field provider. + */ + public function __construct( ?MetaFieldProvider $meta_field_provider = null ) { + $this->meta_field_provider = $meta_field_provider ?? new MetaFieldProvider(); + } + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'get_meta_fields'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Get the meta field definitions for a post type as configured in the Saltus Framework model'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug to get meta fields for', + 'required' => true, + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_META, 'post_type' ); + } + + /** + * Build the WP_REST_Request for retrieving meta field definitions. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/meta/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ); + } + + /** + * Retrieve meta fields directly through the shared feature provider. + * + * @param Modeler $modeler The model registry. + * @param ModelRestPolicy|null $policy Optional REST policy. + * @param string $post_type Post type slug. + * @return array<string, mixed>|\WP_Error + */ + public function get_meta_fields( Modeler $modeler, ?ModelRestPolicy $policy, string $post_type ) { + return $this->meta_field_provider->post_type_meta( $modeler, $policy, $post_type ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int { + return 600; + } +} diff --git a/src/MCP/Tools/GetModel.php b/src/MCP/Tools/GetModel.php new file mode 100644 index 00000000..2b578fe3 --- /dev/null +++ b/src/MCP/Tools/GetModel.php @@ -0,0 +1,80 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to retrieve details of a specific Custom Post Type or Taxonomy. + */ +class GetModel extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'get_model'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Get details of a specific Custom Post Type or Taxonomy by slug'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'slug' => [ + 'type' => 'string', + 'description' => 'The slug of the post type or taxonomy (e.g., "post", "page", "product")', + 'required' => true, + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_MODELS ); + } + + /** + * Build the WP_REST_Request for retrieving a model. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/models/' . rawurlencode( (string) ( $args['slug'] ?? '' ) ) ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int { + return 600; + } +} diff --git a/src/MCP/Tools/GetPost.php b/src/MCP/Tools/GetPost.php new file mode 100644 index 00000000..9777d35d --- /dev/null +++ b/src/MCP/Tools/GetPost.php @@ -0,0 +1,68 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to retrieve a single post by ID with all fields and meta data. + */ +class GetPost extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'get_post'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Get a single post by ID with all fields and meta data'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_id' => [ + 'type' => 'number', + 'description' => 'The post ID', + 'required' => true, + ], + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug (defaults to "posts")', + 'default' => 'posts', + ], + ]; + } + + /** + * Build the WP_REST_Request for retrieving a post. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( + 'GET', + '/wp/v2/' . rawurlencode( $this->post_type_rest_base( (string) ( $args['post_type'] ?? 'posts' ) ) ) . '/' . (int) ( $args['post_id'] ?? 0 ) + ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } +} diff --git a/src/MCP/Tools/GetSettings.php b/src/MCP/Tools/GetSettings.php new file mode 100644 index 00000000..72ad628b --- /dev/null +++ b/src/MCP/Tools/GetSettings.php @@ -0,0 +1,91 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Features\Settings\SettingsManager; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to retrieve Saltus Framework settings for a post type. + */ +class GetSettings extends RestTool { + + private SettingsManager $settings_manager; + + /** + * @param SettingsManager|null $settings_manager Shared settings manager. + */ + public function __construct( ?SettingsManager $settings_manager = null ) { + $this->settings_manager = $settings_manager ?? new SettingsManager(); + } + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'get_settings'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Get the Saltus Framework settings for a specific post type'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug to get settings for', + 'required' => true, + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_SETTINGS, 'post_type' ); + } + + /** + * Build the WP_REST_Request for retrieving settings. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ) ); + } + + /** + * Retrieve settings directly through the shared feature manager. + * + * @param string $post_type The post type slug. + * @return array{post_type: string, settings: mixed} + */ + public function get_settings( string $post_type ): array { + return $this->settings_manager->get_settings( $post_type ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } +} diff --git a/src/MCP/Tools/ListMetaFields.php b/src/MCP/Tools/ListMetaFields.php new file mode 100644 index 00000000..ee386329 --- /dev/null +++ b/src/MCP/Tools/ListMetaFields.php @@ -0,0 +1,97 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Features\Meta\MetaFieldProvider; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to list meta field definitions for all registered Saltus post types. + */ +class ListMetaFields extends RestTool { + + private MetaFieldProvider $meta_field_provider; + + /** + * @param MetaFieldProvider|null $meta_field_provider Shared meta field provider. + */ + public function __construct( ?MetaFieldProvider $meta_field_provider = null ) { + $this->meta_field_provider = $meta_field_provider ?? new MetaFieldProvider(); + } + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'list_meta_fields'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'List model-defined meta field definitions for all registered Saltus post types'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return []; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_META, 'post_type' ); + } + + /** + * Build the WP_REST_Request for listing meta field definitions. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/meta' ); + } + + /** + * List meta fields directly through the shared feature provider. + * + * @param Modeler $modeler The model registry. + * @param ModelRestPolicy|null $policy Optional REST policy. + * @param callable(string): bool $can_view_post_type Callback for post-type visibility. + * @return list<array<string, mixed>> + */ + public function list_meta_fields( Modeler $modeler, ?ModelRestPolicy $policy, callable $can_view_post_type ): array { + return $this->meta_field_provider->all_post_type_meta( $modeler, $policy, $can_view_post_type ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int { + return 600; + } +} diff --git a/src/MCP/Tools/ListModels.php b/src/MCP/Tools/ListModels.php new file mode 100644 index 00000000..64143ce7 --- /dev/null +++ b/src/MCP/Tools/ListModels.php @@ -0,0 +1,81 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to list all registered Custom Post Types and Taxonomies. + */ +class ListModels extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'list_models'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'List all registered Custom Post Types and Taxonomies on the WordPress site'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'type' => [ + 'type' => 'string', + 'enum' => [ 'post_types', 'taxonomies', 'all' ], + 'description' => 'Filter by type: post_types, taxonomies, or all (default)', + 'default' => 'all', + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_MODELS ); + } + + /** + * Build the WP_REST_Request for listing models. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/models', $args ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int { + return 600; + } +} diff --git a/src/MCP/Tools/ListPosts.php b/src/MCP/Tools/ListPosts.php new file mode 100644 index 00000000..1cb945f4 --- /dev/null +++ b/src/MCP/Tools/ListPosts.php @@ -0,0 +1,101 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to query posts from a Custom Post Type with optional filters. + */ +class ListPosts extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'list_posts'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Query posts from a Custom Post Type with optional filters'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug (e.g., "posts", "page", "product")', + 'default' => 'posts', + ], + 'status' => [ + 'type' => 'string', + 'description' => 'Post status filter (publish, draft, pending, private, trash, any)', + 'default' => 'publish', + ], + 'search' => [ + 'type' => 'string', + 'description' => 'Search term', + ], + 'per_page' => [ + 'type' => 'number', + 'description' => 'Number of posts per page (max 100)', + 'default' => 20, + ], + 'page' => [ + 'type' => 'number', + 'description' => 'Page number', + 'default' => 1, + ], + 'orderby' => [ + 'type' => 'string', + 'description' => 'Sort field (date, title, id, modified, menu_order)', + 'default' => 'date', + ], + 'order' => [ + 'type' => 'string', + 'enum' => [ 'asc', 'desc' ], + 'description' => 'Sort order', + 'default' => 'desc', + ], + 'terms' => [ + 'type' => 'object', + 'description' => 'Taxonomy term filters as {taxonomy_rest_base: [term_id, ...]}', + 'additionalProperties' => [ + 'type' => 'array', + 'items' => [ 'type' => 'number' ], + ], + ], + ]; + } + + /** + * Build the WP_REST_Request for querying posts. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + $query = $this->only_args( $args, [ 'status', 'search', 'per_page', 'page', 'orderby', 'order' ] ); + $query = $this->append_term_filters( $query, $args['terms'] ?? [] ); + + return $this->request( 'GET', '/wp/v2/' . rawurlencode( $this->post_type_rest_base( (string) ( $args['post_type'] ?? 'posts' ) ) ), $query ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } +} diff --git a/src/MCP/Tools/ListTerms.php b/src/MCP/Tools/ListTerms.php new file mode 100644 index 00000000..50ee2a6b --- /dev/null +++ b/src/MCP/Tools/ListTerms.php @@ -0,0 +1,76 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to list terms from a taxonomy. + */ +class ListTerms extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'list_terms'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'List terms from a taxonomy (categories, tags, or custom taxonomies)'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'taxonomy' => [ + 'type' => 'string', + 'description' => 'The taxonomy slug (e.g., "categories", "tags", or custom)', + 'required' => true, + ], + 'per_page' => [ + 'type' => 'number', + 'description' => 'Number of terms per page (max 100)', + 'default' => 50, + ], + 'search' => [ + 'type' => 'string', + 'description' => 'Search term', + ], + 'hide_empty' => [ + 'type' => 'boolean', + 'description' => 'Whether to hide terms with no posts', + 'default' => false, + ], + ]; + } + + /** + * Build the WP_REST_Request for listing terms. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + $query = $this->only_args( $args, [ 'per_page', 'search', 'hide_empty' ] ); + + return $this->request( 'GET', '/wp/v2/' . rawurlencode( $this->taxonomy_rest_base( (string) ( $args['taxonomy'] ?? 'categories' ) ) ), $query ); + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return true; + } +} diff --git a/src/MCP/Tools/ReorderPosts.php b/src/MCP/Tools/ReorderPosts.php new file mode 100644 index 00000000..26546f18 --- /dev/null +++ b/src/MCP/Tools/ReorderPosts.php @@ -0,0 +1,96 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Features\DragAndDrop\ReorderPostsService; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to reorder posts by updating their menu_order values. + */ +class ReorderPosts extends RestTool { + + private ReorderPostsService $reorder_service; + + /** + * @param ReorderPostsService|null $reorder_service Shared reorder service. + */ + public function __construct( ?ReorderPostsService $reorder_service = null ) { + $this->reorder_service = $reorder_service ?? new ReorderPostsService(); + } + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'reorder_posts'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Reorder multiple posts by updating their menu_order values in a single batch operation'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'items' => [ + 'type' => 'array', + 'description' => 'Array of objects with "id" (post ID) and "menu_order" (integer position)', + 'required' => true, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'number', + 'description' => 'The post ID', + ], + 'menu_order' => [ + 'type' => 'number', + 'description' => 'The new menu order position', + ], + ], + ], + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_REORDER, 'post_type' ); + } + + /** + * Build the WP_REST_Request for reordering posts. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'POST', '/saltus-framework/v1/reorder', [], [ 'items' => $args['items'] ?? [] ] ); + } + + /** + * Reorder posts directly through the shared feature service. + * + * @param array<int, mixed> $items Requested reorder items. + * @param ModelRestPolicy|null $policy Optional REST policy. + * @return array{results: list<array<string, mixed>>, total: int, updated: int} + */ + public function reorder_posts( array $items, ?ModelRestPolicy $policy = null ): array { + return $this->reorder_service->reorder( $items, $policy ); + } +} diff --git a/src/MCP/Tools/RestBackedToolInterface.php b/src/MCP/Tools/RestBackedToolInterface.php new file mode 100644 index 00000000..ef8bd23b --- /dev/null +++ b/src/MCP/Tools/RestBackedToolInterface.php @@ -0,0 +1,35 @@ +<?php + +namespace Saltus\WP\Framework\MCP\Tools; + +interface RestBackedToolInterface extends ToolInterface { + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement; + + /** + * Build a WP_REST_Request from the given arguments. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request; + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool; + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int; +} diff --git a/src/MCP/Tools/RestCapabilityRequirement.php b/src/MCP/Tools/RestCapabilityRequirement.php new file mode 100644 index 00000000..03d8ec5f --- /dev/null +++ b/src/MCP/Tools/RestCapabilityRequirement.php @@ -0,0 +1,39 @@ +<?php + +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * Value object holding a capability and optional model type requirement. + */ +class RestCapabilityRequirement { + + private string $capability; + private ?string $model_type; + + /** + * @param string $capability The WordPress capability required. + * @param string|null $model_type Optional model type to scope the capability (e.g. "post_type"). + */ + public function __construct( string $capability, ?string $model_type = null ) { + $this->capability = $capability; + $this->model_type = $model_type; + } + + /** + * Get the required capability string. + * + * @return string The WordPress capability. + */ + public function get_capability(): string { + return $this->capability; + } + + /** + * Get the optional model type this capability applies to. + * + * @return string|null Model type, or null if not scoped. + */ + public function get_model_type(): ?string { + return $this->model_type; + } +} diff --git a/src/MCP/Tools/RestTool.php b/src/MCP/Tools/RestTool.php new file mode 100644 index 00000000..58261341 --- /dev/null +++ b/src/MCP/Tools/RestTool.php @@ -0,0 +1,161 @@ +<?php + +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * Abstract base for MCP tools that dispatch via the WordPress REST API. + */ +abstract class RestTool implements RestBackedToolInterface { + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return null; + } + + /** + * Whether responses from this tool can be cached. + * + * @return bool + */ + public function is_cacheable(): bool { + return false; + } + + /** + * Cache time-to-live in seconds. + * + * @return int + */ + public function cache_ttl(): int { + return 300; + } + + /** + * Build and return a WP_REST_Request instance. + * + * @param string $method HTTP method. + * @param string $route REST route. + * @param array<string, mixed> $query Query parameters. + * @param array<string, mixed> $body Body parameters. + * @return \WP_REST_Request|null + */ + protected function request( string $method, string $route, array $query = [], array $body = [] ): ?\WP_REST_Request { + if ( ! class_exists( '\WP_REST_Request' ) ) { + return null; + } + + $request = new \WP_REST_Request( $method, $route ); + foreach ( $query as $key => $value ) { + $request->set_param( $key, $value ); + } + $request->set_body_params( $body ); + + return $request; + } + + /** + * Filter an arguments array to only the specified keys. + * + * @param array<string, mixed> $args Source arguments. + * @param list<string> $keys Keys to keep. + * @return array<string, mixed> + */ + protected function only_args( array $args, array $keys ): array { + $filtered = []; + foreach ( $keys as $key ) { + if ( array_key_exists( $key, $args ) ) { + $filtered[ $key ] = $args[ $key ]; + } + } + + return $filtered; + } + + /** + * Append taxonomy term filters to a query parameter array. + * + * @param array<string, mixed> $data Existing query parameters. + * @param mixed $terms Taxonomy term data keyed by taxonomy. + * @return array<string, mixed> + */ + protected function append_term_filters( array $data, $terms ): array { + if ( ! is_array( $terms ) ) { + return $data; + } + + foreach ( $terms as $taxonomy => $term_ids ) { + if ( ! is_string( $taxonomy ) || ! is_array( $term_ids ) ) { + continue; + } + + $ids = array_values( array_filter( array_map( 'intval', $term_ids ) ) ); + if ( $ids === [] ) { + continue; + } + + $data[ $this->taxonomy_rest_base( $taxonomy ) ] = $ids; + } + + return $data; + } + + /** + * Resolve the REST API base path for a post type. + * + * @param string $post_type Post type slug. + * @return string REST base path. + */ + protected function post_type_rest_base( string $post_type ): string { + if ( in_array( $post_type, [ 'posts', 'pages', 'media', 'users' ], true ) ) { + return $post_type; + } + + if ( function_exists( 'get_post_type_object' ) ) { + $rest_object = get_post_type_object( $post_type ); + $rest_base = $this->object_rest_base( $rest_object ); + if ( $rest_base !== null ) { + return $rest_base; + } + } + + return $post_type; + } + + /** + * Resolve the REST API base path for a taxonomy. + * + * @param string $taxonomy Taxonomy slug. + * @return string REST base path. + */ + protected function taxonomy_rest_base( string $taxonomy ): string { + if ( function_exists( 'get_taxonomy' ) ) { + $rest_object = get_taxonomy( $taxonomy ); + $rest_base = $this->object_rest_base( $rest_object ); + if ( $rest_base !== null ) { + return $rest_base; + } + } + + return $taxonomy; + } + + /** + * Extract the rest_base property from a post type or taxonomy object. + * + * @param mixed $rest_object Post type or taxonomy object. + * @return string|null REST base, or null if unavailable. + */ + private function object_rest_base( $rest_object ): ?string { + if ( ! is_object( $rest_object ) || ! isset( $rest_object->rest_base ) ) { + return null; + } + + $rest_base = $rest_object->rest_base; + + return is_string( $rest_base ) && $rest_base !== '' ? $rest_base : null; + } +} diff --git a/src/MCP/Tools/ToolContributor.php b/src/MCP/Tools/ToolContributor.php new file mode 100644 index 00000000..4b5371f4 --- /dev/null +++ b/src/MCP/Tools/ToolContributor.php @@ -0,0 +1,18 @@ +<?php + +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +interface ToolContributor { + + /** + * Get the MCP tools contributed by this service. + * + * @param Modeler $modeler The model registry for tool construction. + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array; +} diff --git a/src/MCP/Tools/ToolInterface.php b/src/MCP/Tools/ToolInterface.php new file mode 100644 index 00000000..9923925a --- /dev/null +++ b/src/MCP/Tools/ToolInterface.php @@ -0,0 +1,26 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +interface ToolInterface { + + /** + * Get the tool name (used in MCP protocol). + * + * @return string + */ + public function get_name(): string; + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string; + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array; +} diff --git a/src/MCP/Tools/ToolProvider.php b/src/MCP/Tools/ToolProvider.php new file mode 100644 index 00000000..b3f66dc0 --- /dev/null +++ b/src/MCP/Tools/ToolProvider.php @@ -0,0 +1,39 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * Registry of ToolInterface instances. + */ +class ToolProvider { + + /** @var ToolInterface[] */ + private array $tools = []; + + /** + * Register a tool instance. + * + * @param ToolInterface $tool The tool to register. + */ + public function register( ToolInterface $tool ): void { + $this->tools[ $tool->get_name() ] = $tool; + } + + /** + * Get a registered tool by name. + * + * @param string $name The tool name. + * @return ToolInterface|null The tool, or null if not found. + */ + public function get( string $name ): ?ToolInterface { + return $this->tools[ $name ] ?? null; + } + + /** + * Return all registered tools. + * + * @return ToolInterface[] + */ + public function all(): array { + return $this->tools; + } +} diff --git a/src/MCP/Tools/UpdatePost.php b/src/MCP/Tools/UpdatePost.php new file mode 100644 index 00000000..a635f84d --- /dev/null +++ b/src/MCP/Tools/UpdatePost.php @@ -0,0 +1,89 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +/** + * MCP tool to update an existing post's fields and meta data. + */ +class UpdatePost extends RestTool { + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'update_post'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Update an existing post\'s fields and meta data'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_id' => [ + 'type' => 'number', + 'description' => 'The post ID to update', + 'required' => true, + ], + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug', + 'default' => 'posts', + ], + 'title' => [ + 'type' => 'string', + 'description' => 'New post title', + ], + 'content' => [ + 'type' => 'string', + 'description' => 'New post content (HTML or raw text)', + ], + 'excerpt' => [ + 'type' => 'string', + 'description' => 'New post excerpt', + ], + 'slug' => [ + 'type' => 'string', + 'description' => 'New URL slug', + ], + 'status' => [ + 'type' => 'string', + 'enum' => [ 'publish', 'draft', 'pending', 'private' ], + 'description' => 'New post status', + ], + 'meta' => [ + 'type' => 'object', + 'description' => 'Meta fields to update as key-value pairs', + 'additionalProperties' => true, + ], + ]; + } + + /** + * Build the WP_REST_Request for updating a post. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + $body = $this->only_args( $args, [ 'title', 'content', 'excerpt', 'slug', 'status', 'meta' ] ); + + return $this->request( + 'PUT', + '/wp/v2/' . rawurlencode( $this->post_type_rest_base( (string) ( $args['post_type'] ?? 'posts' ) ) ) . '/' . (int) ( $args['post_id'] ?? 0 ), + [], + $body + ); + } +} diff --git a/src/MCP/Tools/UpdateSettings.php b/src/MCP/Tools/UpdateSettings.php new file mode 100644 index 00000000..ffd8b955 --- /dev/null +++ b/src/MCP/Tools/UpdateSettings.php @@ -0,0 +1,90 @@ +<?php +namespace Saltus\WP\Framework\MCP\Tools; + +use Saltus\WP\Framework\Features\Settings\SettingsManager; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +/** + * MCP tool to update Saltus Framework settings for a post type. + */ +class UpdateSettings extends RestTool { + + private SettingsManager $settings_manager; + + /** + * @param SettingsManager|null $settings_manager Shared settings manager. + */ + public function __construct( ?SettingsManager $settings_manager = null ) { + $this->settings_manager = $settings_manager ?? new SettingsManager(); + } + + /** + * Get the tool name. + * + * @return string + */ + public function get_name(): string { + return 'update_settings'; + } + + /** + * Get the tool description for the AI. + * + * @return string + */ + public function get_description(): string { + return 'Update the Saltus Framework settings for a specific post type'; + } + + /** + * Get the JSON Schema for tool parameters. + * + * @return array<string, mixed> + */ + public function get_parameters(): array { + return [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug to update settings for', + 'required' => true, + ], + 'settings' => [ + 'type' => 'object', + 'description' => 'The settings data to update (key-value pairs)', + 'required' => true, + ], + ]; + } + + /** + * Get the capability requirement for this tool. + * + * @return RestCapabilityRequirement|null + */ + public function get_rest_capability(): ?RestCapabilityRequirement { + return new RestCapabilityRequirement( ModelRestPolicy::CAPABILITY_SETTINGS, 'post_type' ); + } + + /** + * Build the WP_REST_Request for updating settings. + * + * @param array<string, mixed> $args + * @return \WP_REST_Request|null + */ + public function build_rest_request( array $args ): ?\WP_REST_Request { + $body = is_array( $args['settings'] ?? null ) ? $args['settings'] : []; + + return $this->request( 'PUT', '/saltus-framework/v1/settings/' . rawurlencode( (string) ( $args['post_type'] ?? '' ) ), [], $body ); + } + + /** + * Update settings directly through the shared feature manager. + * + * @param string $post_type The post type slug. + * @param array<string, mixed> $settings Raw settings payload. + * @return array{post_type: string, settings: array<string, mixed>, status: string}|\WP_Error + */ + public function update_settings( string $post_type, array $settings ) { + return $this->settings_manager->update_settings( $post_type, $settings ); + } +} diff --git a/src/MCP/Validation/Validator.php b/src/MCP/Validation/Validator.php new file mode 100644 index 00000000..2ecdceae --- /dev/null +++ b/src/MCP/Validation/Validator.php @@ -0,0 +1,105 @@ +<?php +namespace Saltus\WP\Framework\MCP\Validation; + +/** + * Static argument validator against JSON Schema-like rule definitions. + */ +class Validator { + + /** + * Validate arguments against a JSON Schema-like rule definition. + * + * @param array<string, mixed> $args + * @param array<string, mixed> $schema + * @return array{valid: bool, errors: list<string>} + */ + public static function validate( array $args, array $schema ): array { + $errors = []; + + foreach ( $schema as $field => $rules ) { + $has_value = array_key_exists( $field, $args ); + $value = $args[ $field ] ?? null; + + if ( ! empty( $rules['required'] ) && ! $has_value ) { + $errors[] = "'{$field}' is required"; + continue; + } + + if ( ! $has_value ) { + continue; + } + + $type = $rules['type'] ?? null; + if ( $type !== null ) { + $valid = self::check_type( $value, $type ); + if ( ! $valid ) { + $errors[] = "'{$field}' must be of type {$type}, got " . gettype( $value ); + continue; + } + } + + if ( ! empty( $rules['enum'] ) && ! in_array( $value, $rules['enum'], true ) ) { + $errors[] = "'{$field}' must be one of: " . implode( ', ', $rules['enum'] ); + } + } + + return [ + 'valid' => empty( $errors ), + 'errors' => $errors, + ]; + } + + /** + * Check whether a value matches the expected type. + * + * @param mixed $value The value to check. + * @param string $type The expected PHP type. + * @return bool + */ + private static function check_type( $value, string $type ): bool { + switch ( $type ) { + case 'string': + return is_string( $value ); + case 'integer': + return is_int( $value ); + case 'number': + return is_int( $value ) || is_float( $value ); + case 'boolean': + return is_bool( $value ); + case 'object': + return is_array( $value ) && ! self::is_list( $value ); + case 'array': + return is_array( $value ) && self::is_list( $value ); + default: + return true; + } + } + + /** + * Check whether an array is a list (sequential integer keys from 0). + * + * Compatible with PHP 7.4 (replaces array_is_list which requires PHP 8.1). + * + * @param mixed $value The value to check. + * @return bool + */ + private static function is_list( $value ): bool { + if ( ! is_array( $value ) ) { + return false; + } + + if ( $value === [] ) { + return true; + } + + $expected_key = 0; + foreach ( $value as $key => $val ) { + if ( $key !== $expected_key ) { + return false; + } + ++$expected_key; + } + + return true; + } +} diff --git a/src/Modeler.php b/src/Modeler.php index 4769d617..4739a4e4 100644 --- a/src/Modeler.php +++ b/src/Modeler.php @@ -11,8 +11,24 @@ use Saltus\WP\Framework\Models\Config\NoFile; use Saltus\WP\Framework\Models\Model; use Saltus\WP\Framework\Models\ModelFactory; - -class Modeler { +use Saltus\WP\Framework\MCP\Tools\CreatePost; +use Saltus\WP\Framework\MCP\Tools\CreateTerm; +use Saltus\WP\Framework\MCP\Tools\DeletePost; +use Saltus\WP\Framework\MCP\Tools\GetHealth; +use Saltus\WP\Framework\MCP\Tools\GetModel; +use Saltus\WP\Framework\MCP\Tools\GetPost; +use Saltus\WP\Framework\MCP\Tools\ListModels; +use Saltus\WP\Framework\MCP\Tools\ListPosts; +use Saltus\WP\Framework\MCP\Tools\ListTerms; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\MCP\Tools\UpdatePost; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\ModelsController; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestRouteProvider; + +class Modeler implements RestRouteProvider, ToolContributor { protected ModelFactory $model_factory; @@ -78,10 +94,7 @@ function ( $a, $b ) { foreach ( $files as $file ) { // Iterate over sorted files $config = new Config( $file ); - ( $this->is_multiple( $config ) ? - $this->iterate_multiple( $config ) : - $this->create( $config ) - ); + $this->process_config( $config ); } } @@ -89,10 +102,7 @@ function ( $a, $b ) { if ( has_filter( 'saltus_models' ) ) { /** @deprecated 1.2.0 */ $model = apply_filters( 'saltus_models', [] ); - ( ! empty( $model ) && count( $model ) > 0 ? - $this->iterate_multiple( $model ) : - $this->create( $model ) - ); + $this->process_config( $model ); } // check for models added with filters if ( has_filter( 'saltus/framework/models/extra_models' ) ) { @@ -104,13 +114,35 @@ function ( $a, $b ) { */ $empty_list = []; $model = apply_filters( 'saltus/framework/models/extra_models', $empty_list ); - ( ! empty( $model ) && count( $model ) > 0 ? - $this->iterate_multiple( $model ) : - $this->create( $model ) - ); + $this->process_config( $model ); } } + /** + * Process a single model config or a list of model configs. + * + * @param AbstractConfig|array<string|int, mixed> $config Model config data. + */ + protected function process_config( $config ): void { + if ( $config instanceof AbstractConfig ) { + ( $this->is_multiple( $config ) ? + $this->iterate_multiple( $config ) : + $this->create( $config ) + ); + return; + } + + if ( $config === [] ) { + return; + } + + $wrapped_config = new NoFile( $config ); + ( $this->is_multiple( $wrapped_config ) ? + $this->iterate_multiple( $wrapped_config ) : + $this->create( $wrapped_config ) + ); + } + /** * Is multidimensional config */ @@ -146,6 +178,45 @@ protected function create( AbstractConfig $config ): void { * Adds the model to a list */ protected function add( Model $model ): void { - $this->model_list[ $model->get_type() ] = $model; + $this->model_list[ $model->get_name() ] = $model; + } + + /** + * Return all loaded models. + * + * @return array<string, \Saltus\WP\Framework\Models\Model> Associative array keyed by model name. + */ + public function get_models(): array { + return $this->model_list; + } + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array { + return [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_MODELS, + new ModelsController( $this, $policy ) + ), + ]; + } + + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ + new GetHealth(), + new ListModels(), + new GetModel(), + new ListPosts(), + new GetPost(), + new CreatePost(), + new UpdatePost(), + new DeletePost(), + new ListTerms(), + new CreateTerm(), + ]; } } diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 599cc2d1..be41dc98 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -107,6 +107,13 @@ protected function set_name( string $name ): void { $this->name = $name; } + /** + * Get the registration name for the model. + */ + public function get_name(): string { + return $this->name; + } + /** * Set labels to override in ui * @@ -514,4 +521,40 @@ public function get_registration_name(): string { return $name; } + + /** + * Return registration options after defaults and model overrides are merged. + * + * @return array<string, mixed> + */ + public function get_options(): array { + return $this->options; + } + + /** + * Return registration args after labels, meta, and model internals are prepared. + * + * @return array<string, mixed> + */ + public function get_args(): array { + return $this->args; + } + + public function get_rest_base(): string { + return is_string( $this->options['rest_base'] ?? null ) + ? $this->options['rest_base'] + : $this->get_registration_name(); + } + + public function get_label_singular(): string { + return $this->one; + } + + public function get_label_plural(): string { + return $this->many; + } + + public function get_featured_image_label(): string { + return $this->featured_image; + } } diff --git a/src/Models/Model.php b/src/Models/Model.php index 32e68350..31c1ef13 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -10,10 +10,31 @@ interface Model { */ public function setup(): void; + /** + * Get the registration name for the model. + * + * @return string The post type or taxonomy slug. + */ + public function get_name(): string; + /** * Get the type of the model * * @return string The type of Model */ public function get_type(): string; + + /** + * Get the model registration options. + * + * @return array<string, mixed> + */ + public function get_options(): array; + + /** + * Get the model registration arguments. + * + * @return array<string, mixed> + */ + public function get_args(): array; } diff --git a/src/Models/Taxonomy.php b/src/Models/Taxonomy.php index 2e207000..abb8f542 100644 --- a/src/Models/Taxonomy.php +++ b/src/Models/Taxonomy.php @@ -151,6 +151,24 @@ public function register_associations(): void { } } + /** + * @return list<string> + */ + public function get_associations(): array { + $associations = (array) $this->associations; + + return array_values( + array_filter( + array_map( 'strval', $associations ), + static fn( string $association ): bool => $association !== '' + ) + ); + } + + public function is_hierarchical(): bool { + return ! empty( $this->options['hierarchical'] ); + } + /** * Get the type of the model * diff --git a/src/Rest/DuplicateController.php b/src/Rest/DuplicateController.php new file mode 100644 index 00000000..a5125cf8 --- /dev/null +++ b/src/Rest/DuplicateController.php @@ -0,0 +1,132 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use WP_REST_Controller; +use WP_REST_Server; +use WP_REST_Request; +use WP_REST_Response; +use WP_Error; +use Saltus\WP\Framework\Features\Duplicate\SaltusDuplicate; + +/** + * REST controller for duplicating posts. + */ +class DuplicateController extends WP_REST_Controller { + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + private ?ModelRestPolicy $policy; + + /** + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + */ + public function __construct( ?ModelRestPolicy $policy = null ) { + $this->policy = $policy; + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'duplicate'; + } + + /** + * Register the REST route for post duplication. + */ + public function register_routes(): void { + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base . '/(?P<post_id>\d+)', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create_item' ], + 'permission_callback' => [ $this, 'create_item_permissions_check' ], + 'args' => [ + 'post_id' => [ + 'type' => 'integer', + 'required' => true, + 'description' => 'ID of the post to duplicate', + ], + ], + ] + ); + } + + /** + * Check whether the current user can duplicate posts. + * + * @param mixed $request The REST request. + * @return WP_Error|true + */ + public function create_item_permissions_check( $request ) { + $post_id = is_object( $request ) && method_exists( $request, 'get_param' ) ? (int) $request->get_param( 'post_id' ) : 0; + + $allowed = $post_id > 0 ? current_user_can( 'edit_post', $post_id ) : current_user_can( 'edit_posts' ); + + if ( ! $allowed ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to duplicate posts.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Duplicate a post by ID. + * + * @param mixed $request The REST request containing the post_id parameter. + * @return WP_REST_Response|WP_Error + */ + public function create_item( $request ) { + $post_id = (int) $request->get_param( 'post_id' ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + if ( $this->policy && ! $this->policy->is_post_type_enabled( (string) $post->post_type, ModelRestPolicy::CAPABILITY_DUPLICATE ) ) { + return new WP_Error( + 'model_rest_capability_disabled', + __( 'Duplication is not enabled for this post type.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to duplicate this post.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + + $duplicator = new SaltusDuplicate( $post->post_type, [] ); + $new_post_id_or_error = $duplicator->perform_duplication( $post_id ); + + if ( is_wp_error( $new_post_id_or_error ) ) { + return $new_post_id_or_error; + } + + $new_post = get_post( $new_post_id_or_error ); + if ( ! $new_post instanceof \WP_Post ) { + return new WP_Error( + 'rest_duplicate_failed', + __( 'Failed to retrieve the duplicated post.', 'saltus-framework' ), + [ 'status' => 500 ] + ); + } + + return rest_ensure_response( + [ + 'id' => $new_post_id_or_error, + 'post_type' => $new_post->post_type, + 'post_title' => $new_post->post_title, + 'post_status' => $new_post->post_status, + 'edit_link' => admin_url( 'post.php?action=edit&post=' . $new_post_id_or_error ), + ] + ); + } +} diff --git a/src/Rest/ExportController.php b/src/Rest/ExportController.php new file mode 100644 index 00000000..b203b644 --- /dev/null +++ b/src/Rest/ExportController.php @@ -0,0 +1,98 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use WP_REST_Controller; +use WP_REST_Server; +use WP_REST_Response; +use WP_Error; +use Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport; + +/** + * REST controller for exporting posts as WXR. + */ +class ExportController extends WP_REST_Controller { + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + private ?ModelRestPolicy $policy; + private SaltusSingleExport $exporter; + + /** + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @param SaltusSingleExport|null $exporter Optional export feature implementation. + */ + public function __construct( ?ModelRestPolicy $policy = null, ?SaltusSingleExport $exporter = null ) { + $this->policy = $policy; + $this->exporter = $exporter ?? new SaltusSingleExport( '', [] ); + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'export'; + } + + /** + * Register the REST route for post export. + */ + public function register_routes(): void { + \register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base . '/(?P<post_id>\d+)', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_item' ], + 'permission_callback' => [ $this, 'get_item_permissions_check' ], + 'args' => [ + 'post_id' => [ + 'type' => 'integer', + 'required' => true, + 'description' => 'ID of the post to export', + ], + ], + ] + ); + } + + /** + * Check whether the current user can export posts. + * + * @param mixed $request The REST request. + * @return WP_Error|bool + */ + public function get_item_permissions_check( $request ) { + if ( ! \current_user_can( 'export' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to export posts.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Export a single post as WXR. + * + * @param mixed $request The REST request containing the post_id parameter. + * @return WP_REST_Response|WP_Error + */ + public function get_item( $request ) { + $post_id = (int) $request->get_param( 'post_id' ); + $post = \get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + if ( $this->policy && ! $this->policy->is_post_type_enabled( (string) $post->post_type, ModelRestPolicy::CAPABILITY_EXPORT ) ) { + return new WP_Error( + 'model_rest_capability_disabled', + __( 'Export is not enabled for this post type.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + + return \rest_ensure_response( $this->exporter->export_post( $post_id ) ); + } +} diff --git a/src/Rest/HealthController.php b/src/Rest/HealthController.php new file mode 100644 index 00000000..1653eed8 --- /dev/null +++ b/src/Rest/HealthController.php @@ -0,0 +1,185 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use WP_Error; +use WP_REST_Controller; +use WP_REST_Response; +use WP_REST_Server; + +/** + * REST controller exposing framework health and MCP runtime metrics. + */ +class HealthController extends WP_REST_Controller { + use \Saltus\WP\Framework\Infrastructure\Services\FilterAwareTrait; + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + + private string $version; + private AuditLogger $audit_logger; + + public function __construct( string $version, ?AuditLogger $audit_logger = null ) { + $this->version = $version; + $this->audit_logger = $audit_logger ?? new AuditLogger(); + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'health'; + } + + /** + * Register the health route. + */ + public function register_routes(): void { + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base, + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_item' ], + 'permission_callback' => [ $this, 'get_item_permissions_check' ], + ] + ); + } + + /** + * Check whether the current user can view framework health. + * + * @param mixed $request The REST request. + * @return bool|WP_Error + */ + public function get_item_permissions_check( $request ) { + if ( function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' ) ) { + return true; + } + + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to view framework health.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + + /** + * Return framework health and recent runtime metrics. + * + * @param mixed $request The REST request. + * @return WP_REST_Response + */ + public function get_item( $request ): WP_REST_Response { + $limit = max( 1, min( 1000, (int) $this->filter( 'saltus/framework/health/audit_sample_size', 100 ) ) ); + $entries = $this->audit_logger->get_recent_entries( $limit ); + $audit = $this->audit_stats( $entries ); + + return rest_ensure_response( + [ + 'status' => $audit['error_rate'] > 0.1 ? 'degraded' : 'ok', + 'version' => $this->version, + 'generated_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), + 'abilities' => [ + 'native_api_available' => function_exists( 'wp_register_ability' ), + ], + 'audit' => $audit, + 'rate_limit' => [ + 'enabled' => (bool) $this->filter( 'saltus/framework/mcp/rate_limit/enabled', true ), + ], + 'cache' => [ + 'enabled' => (bool) $this->filter( 'saltus/framework/mcp/cache/enabled', true ), + ], + ] + ); + } + + /** + * Build audit-derived health metrics. + * + * @param list<array<string, mixed>> $entries Recent audit rows. + * @return array<string, mixed> + */ + private function audit_stats( array $entries ): array { + $total = count( $entries ); + $error_count = 0; + $durations = []; + + foreach ( $entries as $entry ) { + $status = isset( $entry['status'] ) ? (string) $entry['status'] : ''; + if ( in_array( $status, [ 'error', 'exception' ], true ) ) { + ++$error_count; + } + + if ( isset( $entry['duration_ms'] ) && is_numeric( $entry['duration_ms'] ) ) { + $durations[] = (float) $entry['duration_ms']; + } + } + + sort( $durations ); + + return [ + 'enabled' => (bool) $this->filter( 'saltus/framework/mcp/audit/enabled', true ), + 'sample_size' => $total, + 'error_count' => $error_count, + 'error_rate' => $total > 0 ? $error_count / $total : 0.0, + 'latency_ms' => [ + 'average' => $this->average( $durations ), + 'p95' => $this->percentile( $durations, 95 ), + 'max' => $durations === [] ? null : max( $durations ), + ], + 'statuses' => $this->status_counts( $entries ), + 'recent_entry_limit' => $total, + ]; + } + + /** + * Count recent audit statuses. + * + * @param list<array<string, mixed>> $entries Recent audit rows. + * @return array<string, int> + */ + private function status_counts( array $entries ): array { + $counts = []; + + foreach ( $entries as $entry ) { + $status = isset( $entry['status'] ) ? (string) $entry['status'] : 'unknown'; + if ( $status === '' ) { + $status = 'unknown'; + } + + $counts[ $status ] = ( $counts[ $status ] ?? 0 ) + 1; + } + + ksort( $counts ); + + return $counts; + } + + /** + * Calculate an average duration. + * + * @param list<float> $values Numeric values. + * @return float|null + */ + private function average( array $values ): ?float { + if ( $values === [] ) { + return null; + } + + return array_sum( $values ) / count( $values ); + } + + /** + * Calculate a nearest-rank percentile. + * + * @param list<float> $values Sorted numeric values. + * @param int $percentile Percentile to calculate. + * @return float|null + */ + private function percentile( array $values, int $percentile ): ?float { + if ( $values === [] ) { + return null; + } + + $rank = (int) ceil( ( $percentile / 100 ) * count( $values ) ); + $rank = max( 1, min( $rank, count( $values ) ) ); + + return $values[ $rank - 1 ]; + } +} diff --git a/src/Rest/MetaController.php b/src/Rest/MetaController.php new file mode 100644 index 00000000..7d859e3e --- /dev/null +++ b/src/Rest/MetaController.php @@ -0,0 +1,186 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use WP_REST_Controller; +use WP_REST_Server; +use WP_REST_Request; +use WP_REST_Response; +use WP_Error; +use Saltus\WP\Framework\Features\Meta\MetaFieldProvider; +use Saltus\WP\Framework\Modeler; + +/** + * REST controller exposing meta field configuration per post type. + */ +class MetaController extends WP_REST_Controller { + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + + protected Modeler $modeler; + private ?ModelRestPolicy $policy; + private MetaFieldProvider $meta_field_provider; + + /** + * @param Modeler $modeler The model registry. + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @param MetaFieldProvider|null $meta_field_provider Optional meta field provider. + */ + public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null, ?MetaFieldProvider $meta_field_provider = null ) { + $this->modeler = $modeler; + $this->policy = $policy; + $this->meta_field_provider = $meta_field_provider ?? new MetaFieldProvider(); + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'meta'; + } + + /** + * Register the REST routes for listing and reading meta fields. + */ + public function register_routes(): void { + if ( $this->namespace === '' ) { + return; + } + + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base, + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_all_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + ] + ); + + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base . '/(?P<post_type>[a-z0-9_-]+)', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + 'args' => [ + 'post_type' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Post type slug to get meta fields for', + ], + ], + ] + ); + } + + /** + * Check whether the current user can view meta fields. + * + * @param mixed $request The REST request. + * @return WP_Error|bool + */ + public function get_items_permissions_check( $request ) { + $post_type = is_object( $request ) && method_exists( $request, 'get_param' ) ? $request->get_param( 'post_type' ) : null; + $allowed = is_string( $post_type ) && $post_type !== '' + ? $this->can_view_post_type_meta( $post_type ) + : $this->can_view_any_post_type_meta(); + + if ( ! $allowed ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to view meta fields.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Get meta field definitions for all post types. + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response|WP_Error + */ + public function get_all_items( WP_REST_Request $request ) { + $post_types = $this->meta_field_provider->all_post_type_meta( + $this->modeler, + $this->policy, + fn( string $post_type ): bool => $this->can_view_post_type_meta( $post_type ) + ); + + return rest_ensure_response( + [ + 'post_types' => $post_types, + ] + ); + } + + /** + * Get meta field definitions for a specific post type. + * + * @param mixed $request The REST request containing the post_type parameter. + * @return WP_REST_Response|WP_Error + */ + public function get_items( $request ) { + $post_type = $request->get_param( 'post_type' ); + + return rest_ensure_response( $this->meta_field_provider->post_type_meta( $this->modeler, $this->policy, (string) $post_type ) ); + } + + /** + * Check whether the current user can view meta for any enabled post type. + * + * @return bool + */ + private function can_view_any_post_type_meta(): bool { + if ( function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' ) ) { + return true; + } + + $models = $this->policy + ? $this->policy->get_enabled_models( ModelRestPolicy::CAPABILITY_META, 'post_type' ) + : $this->modeler->get_models(); + + foreach ( $models as $post_type => $model ) { + if ( $model->get_type() !== 'post_type' ) { + continue; + } + + if ( $this->can_view_post_type_meta( (string) $post_type ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether the current user can view meta for a post type. + * + * @param string $post_type Post type slug. + * @return bool + */ + private function can_view_post_type_meta( string $post_type ): bool { + if ( ! function_exists( 'current_user_can' ) ) { + return false; + } + + return current_user_can( $this->post_type_edit_capability( $post_type ) ); + } + + /** + * Resolve the edit capability for a post type. + * + * @param string $post_type Post type slug. + * @return string + */ + private function post_type_edit_capability( string $post_type ): string { + if ( ! function_exists( 'get_post_type_object' ) ) { + return 'edit_posts'; + } + + $post_type_object = get_post_type_object( $post_type ); + if ( is_object( $post_type_object ) && isset( $post_type_object->cap->edit_posts ) && is_string( $post_type_object->cap->edit_posts ) ) { + return $post_type_object->cap->edit_posts; + } + + return 'edit_posts'; + } +} diff --git a/src/Rest/ModelRestPolicy.php b/src/Rest/ModelRestPolicy.php new file mode 100644 index 00000000..cc23a7aa --- /dev/null +++ b/src/Rest/ModelRestPolicy.php @@ -0,0 +1,116 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; + +class ModelRestPolicy { + + public const CAPABILITY_MODELS = 'models'; + public const CAPABILITY_META = 'meta'; + public const CAPABILITY_SETTINGS = 'settings'; + public const CAPABILITY_DUPLICATE = 'duplicate'; + public const CAPABILITY_EXPORT = 'export'; + public const CAPABILITY_REORDER = 'reorder'; + public const CAPABILITY_HEALTH = 'health'; + + private Modeler $modeler; + + public function __construct( Modeler $modeler ) { + $this->modeler = $modeler; + } + + public function has_capability( string $capability, ?string $model_type = null ): bool { + if ( $capability === self::CAPABILITY_HEALTH ) { + return true; + } + + foreach ( $this->modeler->get_models() as $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $capability ) ) { + return true; + } + } + + return false; + } + + public function is_enabled( Model $model, string $capability ): bool { + $options = $this->get_model_options( $model ); + + if ( array_key_exists( 'show_in_rest', $options ) && $options['show_in_rest'] === false ) { + return false; + } + + $saltus_rest = $options['saltus_rest'] ?? false; + if ( $saltus_rest === true ) { + return true; + } + + if ( ! is_array( $saltus_rest ) ) { + return false; + } + + return ! empty( $saltus_rest[ $capability ] ); + } + + public function is_post_type_enabled( string $post_type, string $capability ): bool { + $model = $this->get_model( $post_type ); + + return $model !== null + && $model->get_type() === 'post_type' + && $this->is_enabled( $model, $capability ); + } + + public function is_post_enabled( int $post_id, string $capability ): bool { + $post = get_post( $post_id ); + if ( ! $post ) { + return false; + } + + return $this->is_post_type_enabled( (string) $post->post_type, $capability ); + } + + public function get_model( string $name ): ?Model { + $models = $this->modeler->get_models(); + + return $models[ $name ] ?? null; + } + + /** + * @return array<string, mixed> + */ + public function get_model_args( Model $model ): array { + return $model->get_args(); + } + + /** + * @return array<string, Model> + */ + public function get_enabled_models( string $capability, ?string $model_type = null ): array { + $enabled = []; + + foreach ( $this->modeler->get_models() as $name => $model ) { + if ( $model_type !== null && $model->get_type() !== $model_type ) { + continue; + } + + if ( $this->is_enabled( $model, $capability ) ) { + $enabled[ $name ] = $model; + } + } + + return $enabled; + } + + /** + * @return array<string, mixed> + */ + private function get_model_options( Model $model ): array { + return $model->get_options(); + } +} diff --git a/src/Rest/ModelsController.php b/src/Rest/ModelsController.php new file mode 100644 index 00000000..be887983 --- /dev/null +++ b/src/Rest/ModelsController.php @@ -0,0 +1,303 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use WP_REST_Controller; +use WP_REST_Server; +use WP_REST_Request; +use WP_REST_Response; +use WP_Error; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Models\Taxonomy; + +/** + * REST controller exposing registered Saltus models and their metadata. + */ +class ModelsController extends WP_REST_Controller { + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + + protected Modeler $modeler; + private ?ModelRestPolicy $policy; + + /** + * @param Modeler $modeler The model registry. + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + */ + public function __construct( Modeler $modeler, ?ModelRestPolicy $policy = null ) { + $this->modeler = $modeler; + $this->policy = $policy; + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'models'; + } + + /** + * Register the REST routes for listing and reading models. + */ + public function register_routes(): void { + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base, + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + ] + ); + + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base . '/(?P<post_type>[a-z0-9_-]+)', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_item' ], + 'permission_callback' => [ $this, 'get_item_permissions_check' ], + 'args' => [ + 'post_type' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Model name (post type or taxonomy slug)', + ], + ], + ] + ); + } + + /** + * Check whether the current user can list models. + * + * @param mixed $request The REST request. + * @return bool|WP_Error + */ + public function get_items_permissions_check( $request ) { + if ( ! $this->can_view_any_model() ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to view models.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Check whether the current user can view a single model. + * + * @param mixed $request The REST request. + * @return bool|WP_Error + */ + public function get_item_permissions_check( $request ) { + $model_name = is_object( $request ) && method_exists( $request, 'get_param' ) ? $request->get_param( 'post_type' ) : null; + $allowed = is_string( $model_name ) && $model_name !== '' + ? $this->can_view_model( $model_name ) + : $this->can_view_any_model(); + + if ( ! $allowed ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to view models.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * List all enabled models. + * + * @param mixed $request The REST request. + * @return WP_REST_Response|WP_Error + */ + public function get_items( $request ) { + $models = $this->policy + ? $this->policy->get_enabled_models( ModelRestPolicy::CAPABILITY_MODELS ) + : $this->modeler->get_models(); + + if ( empty( $models ) ) { + return rest_ensure_response( [] ); + } + + $data = []; + foreach ( $models as $name => $model ) { + if ( ! $this->can_view_model( (string) $name ) ) { + continue; + } + + $data[] = $this->prepare_model_for_response( $model, $request ); + } + + return rest_ensure_response( $data ); + } + + /** + * Get a single model by post type slug. + * + * @param mixed $request The REST request containing the post_type parameter. + * @return WP_REST_Response|WP_Error + */ + public function get_item( $request ) { + $models = $this->policy + ? $this->policy->get_enabled_models( ModelRestPolicy::CAPABILITY_MODELS ) + : $this->modeler->get_models(); + $name = $request->get_param( 'post_type' ); + + if ( ! isset( $models[ $name ] ) ) { + return new WP_Error( + 'model_not_found', + __( 'Model not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + return rest_ensure_response( + $this->prepare_model_for_response( $models[ $name ], $request ) + ); + } + + /** + * Check whether the current user can view any registered model. + * + * @return bool + */ + private function can_view_any_model(): bool { + if ( function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' ) ) { + return true; + } + + $models = $this->policy + ? $this->policy->get_enabled_models( ModelRestPolicy::CAPABILITY_MODELS ) + : $this->modeler->get_models(); + + foreach ( $models as $name => $model ) { + if ( $this->can_view_model( (string) $name ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether the current user can view a registered model. + * + * @param string $model_name Model slug. + * @return bool + */ + private function can_view_model( string $model_name ): bool { + if ( ! function_exists( 'current_user_can' ) ) { + return false; + } + + $model = $this->model( $model_name ); + if ( $model === null ) { + return current_user_can( 'edit_posts' ); + } + + if ( $model->get_type() === 'taxonomy' ) { + return current_user_can( $this->taxonomy_edit_capability( $model_name ) ); + } + + return current_user_can( $this->post_type_edit_capability( $model_name ) ); + } + + /** + * Get a model by slug from the active model set. + * + * @param string $model_name Model slug. + * @return Model|null + */ + private function model( string $model_name ): ?Model { + $models = $this->policy + ? $this->policy->get_enabled_models( ModelRestPolicy::CAPABILITY_MODELS ) + : $this->modeler->get_models(); + + return $models[ $model_name ] ?? null; + } + + /** + * Resolve the edit capability for a post type. + * + * @param string $post_type Post type slug. + * @return string + */ + private function post_type_edit_capability( string $post_type ): string { + if ( ! function_exists( 'get_post_type_object' ) ) { + return 'edit_posts'; + } + + $post_type_object = get_post_type_object( $post_type ); + if ( is_object( $post_type_object ) && isset( $post_type_object->cap->edit_posts ) && is_string( $post_type_object->cap->edit_posts ) ) { + return $post_type_object->cap->edit_posts; + } + + return 'edit_posts'; + } + + /** + * Resolve the edit capability for a taxonomy. + * + * @param string $taxonomy Taxonomy slug. + * @return string + */ + private function taxonomy_edit_capability( string $taxonomy ): string { + if ( ! function_exists( 'get_taxonomy' ) ) { + return 'manage_categories'; + } + + $taxonomy_object = get_taxonomy( $taxonomy ); + if ( is_object( $taxonomy_object ) && isset( $taxonomy_object->cap->manage_terms ) && is_string( $taxonomy_object->cap->manage_terms ) ) { + return $taxonomy_object->cap->manage_terms; + } + + return 'manage_categories'; + } + + /** + * Prepare a model object for REST response serialization. + * + * @param \Saltus\WP\Framework\Models\Model $model The model to prepare. + * @param WP_REST_Request $request The REST request. + * @return array<string, mixed> + */ + private function prepare_model_for_response( $model, WP_REST_Request $request ): array { + $options = $model->get_options(); + + $data = [ + 'name' => $this->check_method( $model, 'get_registration_name', 'name', '' ), + 'type' => $model->get_type(), + 'label_singular' => $this->check_method( $model, 'get_label_singular', 'one', '' ), + 'label_plural' => $this->check_method( $model, 'get_label_plural', 'many', '' ), + 'featured_image' => $this->check_method( $model, 'get_featured_image_label', 'featured_image', '' ), + 'description' => $model->description ?? '', + 'is_public' => $options['public'] ?? true, + 'show_in_rest' => $options['show_in_rest'] ?? true, + 'rest_base' => method_exists( $model, 'get_rest_base' ) ? $model->get_rest_base() : ( $options['rest_base'] ?? $this->check_method( $model, 'get_registration_name', 'name', '' ) ), + ]; + + if ( $model instanceof Taxonomy ) { + $data['associations'] = $model->get_associations(); + $data['hierarchical'] = $model->is_hierarchical(); + } + + return $data; + } + /** + * Safely call a method or access a property on an object, falling back to a default. + * + * @param object $target The target object. + * @param string $method Method name to try first. + * @param string $default_prop Property name if method does not exist. + * @param string $default_val Fallback value if neither method nor property exists. + * @return mixed + */ + private function check_method( object $target, string $method, string $default_prop, string $default_val ) { + if ( method_exists( $target, $method ) ) { + return $target->{$method}(); + } + + $public_properties = get_object_vars( $target ); + + return array_key_exists( $default_prop, $public_properties ) ? $public_properties[ $default_prop ] : $default_val; + } +} diff --git a/src/Rest/ReorderController.php b/src/Rest/ReorderController.php new file mode 100644 index 00000000..d2f1284d --- /dev/null +++ b/src/Rest/ReorderController.php @@ -0,0 +1,109 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use WP_REST_Controller; +use WP_REST_Server; +use WP_REST_Request; +use WP_REST_Response; +use WP_Error; +use Saltus\WP\Framework\Features\DragAndDrop\ReorderPostsService; + +/** + * REST controller for reordering posts via menu_order updates. + */ +class ReorderController extends WP_REST_Controller { + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + private ?ModelRestPolicy $policy; + private ReorderPostsService $reorder_service; + + /** + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @param ReorderPostsService|null $reorder_service Optional reorder service. + */ + public function __construct( ?ModelRestPolicy $policy = null, ?ReorderPostsService $reorder_service = null ) { + $this->policy = $policy; + $this->reorder_service = $reorder_service ?? new ReorderPostsService(); + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'reorder'; + } + + /** + * Register the REST route for reordering posts. + */ + public function register_routes(): void { + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base, + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create_item' ], + 'permission_callback' => [ $this, 'create_item_permissions_check' ], + 'args' => [ + 'items' => [ + 'type' => 'array', + 'required' => true, + 'description' => 'Array of {id, menu_order} objects', + 'items' => [ + 'type' => 'object', + 'required' => [ 'id', 'menu_order' ], + 'properties' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + ], + 'menu_order' => [ + 'type' => 'integer', + 'required' => true, + ], + ], + ], + ], + ], + ] + ); + } + + /** + * Check whether the current user can reorder posts. + * + * @param mixed $request The REST request. + * @return WP_Error|true + */ + public function create_item_permissions_check( $request ) { + $items = is_object( $request ) && method_exists( $request, 'get_param' ) ? $request->get_param( 'items' ) : null; + $allowed = is_array( $items ) && $items !== [] + ? $this->reorder_service->can_edit_any_requested_post( $items, $this->policy ) + : current_user_can( 'edit_posts' ); + + if ( ! $allowed ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to reorder posts.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Reorder posts by updating their menu_order values. + * + * @param mixed $request The REST request containing the items parameter. + * @return WP_REST_Response|WP_Error + */ + public function create_item( $request ) { + $items = $request->get_param( 'items' ); + + if ( ! is_array( $items ) || empty( $items ) ) { + return new WP_Error( + 'rest_empty_data', + __( 'No items provided.', 'saltus-framework' ), + [ 'status' => 400 ] + ); + } + + return rest_ensure_response( $this->reorder_service->reorder( $items, $this->policy ) ); + } +} diff --git a/src/Rest/RestRouteDefinition.php b/src/Rest/RestRouteDefinition.php new file mode 100644 index 00000000..c15d1b50 --- /dev/null +++ b/src/Rest/RestRouteDefinition.php @@ -0,0 +1,53 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +/** + * Value object binding a capability, controller, and optional model type to a REST route. + */ +class RestRouteDefinition { + + private string $capability; + private object $controller; + private ?string $model_type; + + /** + * @param string $capability The WordPress capability required for this route. + * @param object $controller The REST controller instance. + * @param string|null $model_type Optional model type to scope the route. + */ + public function __construct( string $capability, object $controller, ?string $model_type = null ) { + $this->capability = $capability; + $this->controller = $controller; + $this->model_type = $model_type; + } + + /** + * Get the required capability string. + * + * @return string The WordPress capability. + */ + public function get_capability(): string { + return $this->capability; + } + + /** + * Get the optional model type this route applies to. + * + * @return string|null Model type, or null if not scoped. + */ + public function get_model_type(): ?string { + return $this->model_type; + } + + /** + * Register the controller's routes if the method exists. + */ + public function register_routes(): void { + if ( ! method_exists( $this->controller, 'register_routes' ) ) { + return; + } + + $this->controller->register_routes(); + } +} diff --git a/src/Rest/RestRouteProvider.php b/src/Rest/RestRouteProvider.php new file mode 100644 index 00000000..0c5bc7a5 --- /dev/null +++ b/src/Rest/RestRouteProvider.php @@ -0,0 +1,13 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use Saltus\WP\Framework\Modeler; + +interface RestRouteProvider { + + /** + * @return list<RestRouteDefinition> + */ + public function get_rest_routes( Modeler $modeler, ModelRestPolicy $policy ): array; +} diff --git a/src/Rest/RestServer.php b/src/Rest/RestServer.php new file mode 100644 index 00000000..d3a20d7a --- /dev/null +++ b/src/Rest/RestServer.php @@ -0,0 +1,36 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +/** + * Registers REST routes filtered by ModelRestPolicy capability checks. + */ +class RestServer { + + private ModelRestPolicy $policy; + + /** @var list<RestRouteDefinition> */ + private array $routes; + + /** + * @param ModelRestPolicy $policy The REST policy for capability gating. + * @param list<RestRouteDefinition> $routes The route definitions to register. + */ + public function __construct( ModelRestPolicy $policy, array $routes ) { + $this->policy = $policy; + $this->routes = $routes; + } + + /** + * Register all routes whose capability checks pass. + */ + public function register_routes(): void { + foreach ( $this->routes as $route ) { + if ( ! $this->policy->has_capability( $route->get_capability(), $route->get_model_type() ) ) { + continue; + } + + $route->register_routes(); + } + } +} diff --git a/src/Rest/SettingsController.php b/src/Rest/SettingsController.php new file mode 100644 index 00000000..b973c3a7 --- /dev/null +++ b/src/Rest/SettingsController.php @@ -0,0 +1,216 @@ +<?php + +namespace Saltus\WP\Framework\Rest; + +use WP_REST_Controller; +use WP_REST_Server; +use WP_REST_Request; +use WP_REST_Response; +use WP_Error; +use Saltus\WP\Framework\Features\Settings\SettingsManager; + +/** + * REST controller for reading and updating per-post-type settings. + */ +class SettingsController extends WP_REST_Controller { + + private const ROUTE_NAMESPACE = 'saltus-framework/v1'; + private ?ModelRestPolicy $policy; + private SettingsManager $settings_manager; + + /** + * @param ModelRestPolicy|null $policy Optional REST policy for capability gating. + * @param SettingsManager|null $settings_manager Optional settings manager. + */ + public function __construct( ?ModelRestPolicy $policy = null, ?SettingsManager $settings_manager = null ) { + $this->policy = $policy; + $this->settings_manager = $settings_manager ?? new SettingsManager(); + $this->namespace = self::ROUTE_NAMESPACE; + $this->rest_base = 'settings'; + } + + /** + * Register the REST routes for reading and updating settings. + */ + public function register_routes(): void { + register_rest_route( + self::ROUTE_NAMESPACE, + '/' . $this->rest_base . '/(?P<post_type>[a-z0-9_-]+)', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_item' ], + 'permission_callback' => [ $this, 'get_item_permissions_check' ], + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::READABLE ), + ], + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'update_item' ], + 'permission_callback' => [ $this, 'update_item_permissions_check' ], + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ], + 'schema' => [ $this, 'get_item_schema' ], + ] + ); + } + + /** + * Build the option name for a given post type. + * + * @param string $post_type The post type slug. + * @return string + */ + protected function get_option_name( string $post_type ): string { + return $this->settings_manager->option_name( $post_type ); + } + + /** + * Check whether the current user can view settings. + * + * @param mixed $request The REST request. + * @return bool|WP_Error + */ + public function get_item_permissions_check( $request ) { + $post_type = is_object( $request ) && method_exists( $request, 'get_param' ) ? $request->get_param( 'post_type' ) : null; + + if ( is_string( $post_type ) && $post_type !== '' ) { + if ( $this->policy && ! $this->policy->is_post_type_enabled( $post_type, ModelRestPolicy::CAPABILITY_SETTINGS ) ) { + return new WP_Error( + 'model_not_found', + __( 'Model not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + $capability = $this->post_type_edit_capability( $post_type ); + } else { + $capability = 'edit_posts'; + } + + if ( ! current_user_can( $capability ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to view settings.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Resolve the edit capability for a post type. + * + * @param string $post_type Post type slug. + * @return string + */ + private function post_type_edit_capability( string $post_type ): string { + if ( ! function_exists( 'get_post_type_object' ) ) { + return 'edit_posts'; + } + + $post_type_object = get_post_type_object( $post_type ); + if ( is_object( $post_type_object ) && isset( $post_type_object->cap->edit_posts ) && is_string( $post_type_object->cap->edit_posts ) ) { + return $post_type_object->cap->edit_posts; + } + + return 'edit_posts'; + } + + /** + * Check whether the current user can update settings. + * + * @param mixed $request The REST request. + * @return bool|WP_Error + */ + public function update_item_permissions_check( $request ) { + $post_type = is_object( $request ) && method_exists( $request, 'get_param' ) ? $request->get_param( 'post_type' ) : null; + + if ( is_string( $post_type ) && $post_type !== '' ) { + if ( $this->policy && ! $this->policy->is_post_type_enabled( $post_type, ModelRestPolicy::CAPABILITY_SETTINGS ) ) { + return new WP_Error( + 'model_not_found', + __( 'Model not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + } + + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to update settings.', 'saltus-framework' ), + [ 'status' => 403 ] + ); + } + return true; + } + + /** + * Retrieve settings for a post type. + * + * @param mixed $request The REST request containing the post_type parameter. + * @return WP_REST_Response|WP_Error + */ + public function get_item( $request ) { + $post_type = $request->get_param( 'post_type' ); + if ( $this->policy && ! $this->policy->is_post_type_enabled( (string) $post_type, ModelRestPolicy::CAPABILITY_SETTINGS ) ) { + return new WP_Error( + 'model_not_found', + __( 'Model not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + return rest_ensure_response( $this->settings_manager->get_settings( (string) $post_type ) ); + } + + /** + * Update settings for a post type. + * + * @param mixed $request The REST request containing the post_type parameter and JSON body. + * @return WP_REST_Response|WP_Error + */ + public function update_item( $request ) { + $post_type = $request->get_param( 'post_type' ); + if ( $this->policy && ! $this->policy->is_post_type_enabled( (string) $post_type, ModelRestPolicy::CAPABILITY_SETTINGS ) ) { + return new WP_Error( + 'model_not_found', + __( 'Model not found.', 'saltus-framework' ), + [ 'status' => 404 ] + ); + } + + $settings = $request->get_json_params() ?? []; + + return rest_ensure_response( $this->settings_manager->update_settings( (string) $post_type, $settings ) ); + } + + /** + * Get the JSON Schema for the settings resource. + * + * @return array{'$schema': string, title: string, type: string, properties: array<string, array<string, mixed>>} + */ + public function get_item_schema(): array { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'settings', + 'type' => 'object', + 'properties' => [ + 'post_type' => [ + 'type' => 'string', + 'description' => 'The post type slug.', + 'readonly' => true, + ], + 'settings' => [ + 'type' => 'object', + 'description' => 'The settings data.', + 'arg_options' => [ + 'sanitize_callback' => function ( $value ) { + return $value; + }, + ], + ], + ], + ]; + } +} diff --git a/tests/Features/LegacyFeatureTest.php b/tests/Features/LegacyFeatureTest.php new file mode 100644 index 00000000..06b7b47d --- /dev/null +++ b/tests/Features/LegacyFeatureTest.php @@ -0,0 +1,465 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Features; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Features\AdminCols\AdminCols; +use Saltus\WP\Framework\Features\AdminCols\SaltusAdminCols; +use Saltus\WP\Framework\Features\AdminFilters\AdminFilters; +use Saltus\WP\Framework\Features\AdminFilters\SaltusAdminFilters; +use Saltus\WP\Framework\Features\DragAndDrop\DragAndDrop; +use Saltus\WP\Framework\Features\DragAndDrop\SaltusDragAndDrop; +use Saltus\WP\Framework\Features\Duplicate\Duplicate; +use Saltus\WP\Framework\Features\Duplicate\SaltusDuplicate; +use Saltus\WP\Framework\Features\Meta\CodestarMeta; +use Saltus\WP\Framework\Features\Meta\Meta; +use Saltus\WP\Framework\Features\QuickEdit\QuickEdit; +use Saltus\WP\Framework\Features\QuickEdit\SaltusQuickEdit; +use Saltus\WP\Framework\Features\RememberTabs\RememberTabs; +use Saltus\WP\Framework\Features\RememberTabs\SaltusRememberTabs; +use Saltus\WP\Framework\Features\Settings\CodestarSettings; +use Saltus\WP\Framework\Features\Settings\Settings; +use Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport; +use Saltus\WP\Framework\Features\SingleExport\SingleExport; +use Saltus\WP\Framework\MCP\Tools\DuplicatePost; +use Saltus\WP\Framework\MCP\Tools\ExportPost; +use Saltus\WP\Framework\MCP\Tools\GetMetaFields; +use Saltus\WP\Framework\MCP\Tools\GetSettings; +use Saltus\WP\Framework\MCP\Tools\ReorderPosts; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\Rest\DuplicateController; +use Saltus\WP\Framework\Rest\ExportController; +use Saltus\WP\Framework\Rest\MetaController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\ReorderController; +use Saltus\WP\Framework\Rest\SettingsController; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Features\AdminCols\AdminCols + * @covers \Saltus\WP\Framework\Features\AdminCols\SaltusAdminCols + * @covers \Saltus\WP\Framework\Features\AdminFilters\AdminFilters + * @covers \Saltus\WP\Framework\Features\AdminFilters\SaltusAdminFilters + * @covers \Saltus\WP\Framework\Features\DragAndDrop\DragAndDrop + * @covers \Saltus\WP\Framework\Features\DragAndDrop\SaltusDragAndDrop + * @covers \Saltus\WP\Framework\Features\Duplicate\Duplicate + * @covers \Saltus\WP\Framework\Features\Duplicate\SaltusDuplicate + * @covers \Saltus\WP\Framework\Features\Meta\CodestarMeta + * @covers \Saltus\WP\Framework\Features\Meta\Meta + * @covers \Saltus\WP\Framework\Features\QuickEdit\QuickEdit + * @covers \Saltus\WP\Framework\Features\QuickEdit\SaltusQuickEdit + * @covers \Saltus\WP\Framework\Features\RememberTabs\RememberTabs + * @covers \Saltus\WP\Framework\Features\RememberTabs\SaltusRememberTabs + * @covers \Saltus\WP\Framework\Features\Settings\CodestarSettings + * @covers \Saltus\WP\Framework\Features\Settings\Settings + * @covers \Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport + * @covers \Saltus\WP\Framework\Features\SingleExport\SingleExport + */ +class LegacyFeatureTest extends TestCase { + + protected function setUp(): void { + parent::setUp(); + $this->resetWordPressState(); + } + + protected function tearDown(): void { + $this->resetWordPressState(); + parent::tearDown(); + } + + public function testFeatureFactoriesReturnLegacyImplementations(): void { + $project = [ 'root_url' => 'http://example.com/assets' ]; + + $this->assertInstanceOf( SaltusAdminCols::class, AdminCols::make( 'book', $project, [] ) ); + $this->assertInstanceOf( SaltusAdminFilters::class, AdminFilters::make( 'book', $project, [] ) ); + $this->assertInstanceOf( SaltusDragAndDrop::class, DragAndDrop::make( 'book', $project, [] ) ); + $this->assertInstanceOf( SaltusDuplicate::class, Duplicate::make( 'book', $project, [] ) ); + $this->assertInstanceOf( CodestarMeta::class, Meta::make( 'book', $project, [] ) ); + $this->assertInstanceOf( SaltusQuickEdit::class, QuickEdit::make( 'book', $project, [] ) ); + $this->assertInstanceOf( SaltusRememberTabs::class, RememberTabs::make( 'book', $project, [] ) ); + $this->assertInstanceOf( CodestarSettings::class, Settings::make( 'book', $project, [] ) ); + $this->assertInstanceOf( SaltusSingleExport::class, SingleExport::make( 'book', $project, [] ) ); + } + + public function testAdminConditionalFeaturesFollowAdminState(): void { + global $wp_is_admin; + + $wp_is_admin = false; + $this->assertFalse( AdminCols::is_needed() ); + $this->assertFalse( AdminFilters::is_needed() ); + $this->assertFalse( DragAndDrop::is_needed() ); + $this->assertFalse( Duplicate::is_needed() ); + $this->assertTrue( Meta::is_needed() ); + $this->assertFalse( QuickEdit::is_needed() ); + $this->assertFalse( RememberTabs::is_needed() ); + $this->assertFalse( Settings::is_needed() ); + $this->assertFalse( SingleExport::is_needed() ); + + $wp_is_admin = true; + $this->assertTrue( AdminCols::is_needed() ); + $this->assertTrue( AdminFilters::is_needed() ); + $this->assertTrue( DragAndDrop::is_needed() ); + $this->assertTrue( Duplicate::is_needed() ); + $this->assertTrue( QuickEdit::is_needed() ); + $this->assertTrue( RememberTabs::is_needed() ); + $this->assertTrue( Settings::is_needed() ); + $this->assertTrue( SingleExport::is_needed() ); + } + + public function testRestAndMcpFeatureContributorsExposeExpectedRoutesAndTools(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $policy = new ModelRestPolicy( $modeler ); + + $this->assertRouteAndTool( new Duplicate(), $modeler, $policy, ModelRestPolicy::CAPABILITY_DUPLICATE, DuplicateController::class, DuplicatePost::class ); + $this->assertRouteAndTool( new SingleExport(), $modeler, $policy, ModelRestPolicy::CAPABILITY_EXPORT, ExportController::class, ExportPost::class ); + $this->assertRouteAndTool( new Settings(), $modeler, $policy, ModelRestPolicy::CAPABILITY_SETTINGS, SettingsController::class, GetSettings::class ); + $this->assertRouteAndTool( new Meta(), $modeler, $policy, ModelRestPolicy::CAPABILITY_META, MetaController::class, GetMetaFields::class ); + $this->assertRouteAndTool( new DragAndDrop(), $modeler, $policy, ModelRestPolicy::CAPABILITY_REORDER, ReorderController::class, ReorderPosts::class ); + } + + public function testSingleExportPassesFeatureImplementationToRestAndMcp(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $policy = new ModelRestPolicy( $modeler ); + $exporter = new SaltusSingleExport( 'book', [] ); + $feature = new SingleExport( $exporter ); + + $routes = $feature->get_rest_routes( $modeler, $policy ); + $tools = $feature->get_mcp_tools( $modeler, $policy ); + $controller = $this->routeController( $routes[0] ); + + $this->assertSame( $exporter, $this->privateProperty( $controller, 'exporter' ) ); + $this->assertSame( $exporter, $this->privateProperty( $tools[0], 'exporter' ) ); + } + + public function testProcessMethodsRegisterExpectedHooks(): void { + global $wp_actions_registered, $wp_filters_registered; + + ( new SaltusDuplicate( 'book', [] ) )->process(); + ( new SaltusDragAndDrop( 'book', [ 'root_url' => 'http://example.com/assets' ] ) )->process(); + ( new SaltusQuickEdit( 'book', [ 'subtitle' => [] ] ) )->process(); + ( new SaltusSingleExport( 'book', [] ) )->process(); + + $action_names = array_column( $wp_actions_registered, 'hook_name' ); + $this->assertContains( 'admin_action_saltus_framework_book_duplicate_post', $action_names ); + $this->assertContains( 'admin_enqueue_scripts', $action_names ); + $this->assertContains( 'save_post', $action_names ); + $this->assertContains( 'init', $action_names ); + $this->assertArrayHasKey( 'post_row_actions', $wp_filters_registered ); + $this->assertArrayHasKey( 'get_previous_post_where', $wp_filters_registered ); + } + + public function testAdminColumnsResolveSortableAndManagedColumns(): void { + global $wp_current_user_can; + + $wp_current_user_can = [ 'manage_secret' => false ]; + $columns = new SaltusAdminCols( + 'book', + [ + 'isbn' => [ + 'title' => 'ISBN', + 'meta_key' => 'isbn', + ], + 'secret' => [ + 'title' => 'Secret', + 'meta_key' => 'secret', + 'cap' => 'manage_secret', + ], + ] + ); + + $this->assertSame( [ 'isbn' => 'isbn', 'secret' => 'secret' ], $columns->sortables( [] ) ); + $columns->log_default_cols( [ 'cb' => '<input />', 'title' => 'Title', 'date' => 'Date' ] ); + $this->assertSame( [ 'cb' => '<input />', 'title' => 'Title', 'isbn' => 'ISBN' ], $columns->manage_columns( [ 'cb' => '<input />', 'title' => 'Title', 'date' => 'Date' ] ) ); + } + + public function testAdminColumnSortVarsMapMetaKeysAndPostFields(): void { + $vars = SaltusAdminCols::get_sort_field_vars( + [ + 'orderby' => 'isbn', + 'order' => 'desc', + ], + [ + 'isbn' => [ + 'meta_key' => 'isbn', + 'orderby' => 'meta_value_num', + ], + ] + ); + + $this->assertSame( 'isbn', $vars['meta_key'] ); + $this->assertSame( 'meta_value_num', $vars['orderby'] ); + $this->assertSame( 'desc', $vars['order'] ); + } + + public function testAdminFiltersBuildMetaAndDateQueryVars(): void { + $vars = SaltusAdminFilters::get_filter_vars( + [ + 'status_filter' => 'published', + 'date_filter' => '2026-07-02', + ], + [ + 'status_filter' => [ + 'meta_key' => 'status', + 'compare' => '=', + ], + 'date_filter' => [ + 'post_date' => 'after', + ], + ], + 'book' + ); + + $this->assertSame( 'status', $vars['meta_query'][0]['key'] ); + $this->assertSame( 'published', $vars['meta_query'][0]['value'] ); + $this->assertSame( '2026-07-02', $vars['date_query'][0]['after'] ); + } + + public function testAdminFilterHookCanOverrideQueryVars(): void { + global $wp_filter_values; + + $wp_filter_values['saltus/framework/admin_filters/book/filter_query/status_filter'] = static function ( array $return ): array { + $return['post_status'] = 'private'; + return $return; + }; + + $vars = SaltusAdminFilters::get_filter_vars( + [ 'status_filter' => 'published' ], + [ 'status_filter' => [ 'meta_key' => 'status' ] ], + 'book' + ); + + $this->assertSame( [ 'post_status' => 'private' ], $vars ); + } + + public function testDragAndDropAdjustsNavigationAndQueries(): void { + global $post, $wp_is_admin; + + $post = new \WP_Post( + [ + 'ID' => 7, + 'post_type' => 'book', + 'menu_order' => 5, + ] + ); + $feature = new SaltusDragAndDrop( 'book', [ 'root_url' => 'http://example.com/assets' ] ); + + $this->assertSame( "WHERE p.menu_order > '5'", $feature->previous_post_where( "WHERE p.post_date < '2026-07-02 00:00:00'" ) ); + $this->assertSame( 'ORDER BY p.menu_order ASC LIMIT 1', $feature->previous_post_sort( 'ORDER BY p.post_date DESC LIMIT 1' ) ); + $this->assertSame( "WHERE p.menu_order < '5'", $feature->next_post_where( "WHERE p.post_date > '2026-07-02 00:00:00'" ) ); + $this->assertSame( 'ORDER BY p.menu_order DESC LIMIT 1', $feature->next_post_sort( 'ORDER BY p.post_date ASC LIMIT 1' ) ); + + $wp_is_admin = true; + $query = new \WP_Query( [ 'post_type' => 'book' ] ); + $feature->pre_get_posts( $query ); + $this->assertSame( 'menu_order', $query->get( 'orderby' ) ); + $this->assertSame( 'ASC', $query->get( 'order' ) ); + } + + public function testDuplicateRowLinkAndDuplication(): void { + global $wp_posts; + + $wp_posts[7] = new \WP_Post( + [ + 'ID' => 7, + 'post_type' => 'book', + 'post_title' => 'Original', + 'post_content' => 'Content', + ] + ); + + $feature = new SaltusDuplicate( 'book', [ 'label' => 'Clone' ] ); + $actions = $feature->row_link( [], $wp_posts[7] ); + $new_id = $feature->perform_duplication( 7 ); + + $this->assertArrayHasKey( 'duplicate', $actions ); + $this->assertStringContainsString( 'Clone', $actions['duplicate'] ); + $this->assertIsInt( $new_id ); + $this->assertSame( 'draft', $wp_posts[ $new_id ]->post_status ); + $this->assertSame( 'Original', $wp_posts[ $new_id ]->post_title ); + } + + public function testQuickEditSavesSanitizedPostedValues(): void { + global $wp_meta_updates; + + $_POST['quick_edit_nonce_field'] = 'valid'; + $_POST['subtitle'] = ' New subtitle '; + + $feature = new SaltusQuickEdit( 'book', [ 'subtitle' => [ 'title' => 'Subtitle' ] ] ); + $feature->save_quick_edit_data( 7 ); + + $this->assertSame( 7, $wp_meta_updates[0]['post_id'] ); + $this->assertSame( 'subtitle', $wp_meta_updates[0]['meta_key'] ); + $this->assertSame( 'New subtitle', $wp_meta_updates[0]['meta_value'] ); + } + + public function testSingleExportOnlyRewritesMatchingExportQuery(): void { + global $wpdb; + + if ( ! is_object( $wpdb ) || ! property_exists( $wpdb, 'posts' ) ) { + $wpdb = new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string { + return $this->prefix; + } + + public function insert( string $table, array $data, array $format = [] ): bool { + $this->inserts[] = compact( 'table', 'data', 'format' ); + return true; + } + + public function query( string $query ): bool { + $this->queries[] = $query; + return true; + } + + public function prepare( string $query, ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace( '/%[dsf]/', (string) $arg, $query, 1 ); + } + return $query; + } + + public function get_results( string $query, $output = null ) { + return array_reverse( array_map( static fn( array $insert ) => $insert['data'], $this->inserts ) ); + } + + public function get_charset_collate(): string { + return ''; + } + }; + } + + $_GET['export_single'] = '7'; + $_GET['_wpnonce'] = 'valid'; + + $feature = new SaltusSingleExport( 'book', [] ); + $args = $feature->export_args( [] ); + $query = $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE {$wpdb->posts}.post_type = 'post' AND {$wpdb->posts}.post_status != 'auto-draft' AND {$wpdb->posts}.post_date >= %s AND {$wpdb->posts}.post_date < %s", + '1970-01-05', + '1970-02-05' + ); + + $this->assertSame( 'post', $args['content'] ); + $this->assertSame( SaltusSingleExport::FAKE_DATE, $args['start_date'] ); + + $export_query = new \WP_Query( [ + 'post_type' => 'post', + 'post_status' => 'any', + 'date_query' => [ + [ + 'after' => SaltusSingleExport::FAKE_DATE, + 'before' => gmdate( 'Y-m-d', strtotime( '+1 month', strtotime( SaltusSingleExport::FAKE_DATE ) ) ), + 'inclusive' => true, + ], + ], + ] ); + + $this->assertSame( "SELECT ID FROM {$wpdb->posts} WHERE {$wpdb->posts}.ID = 7", $feature->query( $query, $export_query ) ); + $this->assertSame( 'SELECT * FROM wp_posts', $feature->query( 'SELECT * FROM wp_posts', new \WP_Query() ) ); + + $this->expectException( \RuntimeException::class ); + $feature->query( "SELECT * FROM wp_posts WHERE post_date = '" . SaltusSingleExport::FAKE_DATE . "'", new \WP_Query() ); + } + + private function assertRouteAndTool( object $feature, Modeler $modeler, ModelRestPolicy $policy, string $capability, string $controller_class, string $tool_class ): void { + $routes = $feature->get_rest_routes( $modeler, $policy ); + $tools = $feature->get_mcp_tools( $modeler, $policy ); + + $this->assertCount( 1, $routes ); + $this->assertSame( $capability, $routes[0]->get_capability() ); + $this->assertSame( 'post_type', $routes[0]->get_model_type() ); + $this->assertSame( $controller_class, $this->routeControllerClass( $routes[0] ) ); + $this->assertContainsOnlyInstancesOf( \Saltus\WP\Framework\MCP\Tools\ToolInterface::class, $tools ); + $this->assertContains( $tool_class, array_map( 'get_class', $tools ) ); + } + + private function routeControllerClass( object $route ): string { + return get_class( $this->routeController( $route ) ); + } + + private function routeController( object $route ): object { + $reflection = new \ReflectionClass( $route ); + $property = $reflection->getProperty( 'controller' ); + $property->setAccessible( true ); + + return $property->getValue( $route ); + } + + private function privateProperty( object $object, string $property_name ) { + $reflection = new \ReflectionClass( $object ); + $property = $reflection->getProperty( $property_name ); + $property->setAccessible( true ); + + return $property->getValue( $object ); + } + + private function resetWordPressState(): void { + global $wp_actions_registered, $wp_filters_registered, $wp_filter_values, $wp_current_user_can, $wp_is_admin, $wp_scripts_enqueued, $wp_styles_enqueued, $wp_scripts_localized, $wp_nonce_valid, $wp_meta_updates, $wp_posts, $wp_post_meta, $wp_insert_post_without_storage, $wpdb, $post; + + $wp_actions_registered = []; + $wp_filters_registered = []; + $wp_filter_values = []; + $wp_current_user_can = true; + $wp_is_admin = false; + $wp_scripts_enqueued = []; + $wp_styles_enqueued = []; + $wp_scripts_localized = []; + $wp_nonce_valid = true; + $wp_meta_updates = []; + $wp_posts = []; + $wp_post_meta = []; + $wp_insert_post_without_storage = false; + $_GET = []; + $_POST = []; + $_SERVER['REQUEST_URI'] = ''; + if ( ! $wpdb instanceof \Saltus\WP\Framework\MCP\Audit\AuditDatabase ) { + $wpdb = new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string { + return $this->prefix; + } + + public function insert( string $table, array $data, array $format = [] ): bool { + $this->inserts[] = compact( 'table', 'data', 'format' ); + return true; + } + + public function query( string $query ): bool { + $this->queries[] = $query; + return true; + } + + public function prepare( string $query, ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace( '/%[dsf]/', (string) $arg, $query, 1 ); + } + return $query; + } + + public function get_results( string $query, $output = null ) { + return array_reverse( array_map( static fn( array $insert ) => $insert['data'], $this->inserts ) ); + } + + public function get_charset_collate(): string { + return ''; + } + }; + } + $post = null; + } +} diff --git a/tests/Features/MCPFeatureTest.php b/tests/Features/MCPFeatureTest.php new file mode 100644 index 00000000..0f345b8e --- /dev/null +++ b/tests/Features/MCPFeatureTest.php @@ -0,0 +1,251 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Features; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Core; +use Saltus\WP\Framework\Features\DragAndDrop\DragAndDrop; +use Saltus\WP\Framework\Features\Duplicate\Duplicate; +use Saltus\WP\Framework\Features\MCP\MCP; +use Saltus\WP\Framework\Features\Meta\Meta; +use Saltus\WP\Framework\Features\Settings\Settings; +use Saltus\WP\Framework\Features\SingleExport\SingleExport; +use Saltus\WP\Framework\MCP\Abilities\AbilityRegistrar; +use Saltus\WP\Framework\MCP\Tools\RestTool; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Features\MCP\MCP + */ +class MCPFeatureTest extends TestCase { + + protected function setUp(): void { + global $wp_actions_registered, $wp_abilities_registered, $wp_scheduled_events; + $wp_actions_registered = []; + $wp_abilities_registered = []; + $wp_scheduled_events = []; + } + + public function testNativeTransportRegistersWordPressAbilityHooks(): void { + global $wp_actions_registered, $wp_scheduled_events; + + $feature = new MCP( [], new NativeAbilityRegistrar() ); + $feature->register(); + + $this->assertSame( 'native', $feature->transport() ); + $this->assertCount( 11, $wp_actions_registered ); + $this->assertSame( 'wp_abilities_api_categories_init', $wp_actions_registered[1]['hook_name'] ); + $this->assertSame( 'wp_abilities_api_init', $wp_actions_registered[2]['hook_name'] ); + $this->assertSame( 'save_post', $wp_actions_registered[3]['hook_name'] ); + } + + public function testAuditCleanupCronIsScheduledIdempotentlyAndUnscheduledOnDeactivate(): void { + global $wp_scheduled_events; + + $feature = new MCP( [], new NativeAbilityRegistrar() ); + + $feature->activate(); + $first_timestamp = $wp_scheduled_events['saltus_framework_mcp_audit_cleanup']['timestamp'] ?? null; + $feature->activate(); + + $this->assertNotNull( $first_timestamp ); + $this->assertCount( 1, $wp_scheduled_events ); + $this->assertSame( $first_timestamp, $wp_scheduled_events['saltus_framework_mcp_audit_cleanup']['timestamp'] ); + + $feature->deactivate(); + + $this->assertSame( [], $wp_scheduled_events ); + } + + public function testLegacyTransportDoesNotRegisterNativeAbilityHooks(): void { + global $wp_actions_registered; + + $feature = new MCP( [], new LegacyAbilityRegistrar() ); + $feature->register(); + + $this->assertSame( 'legacy', $feature->transport() ); + $this->assertSame( [], $wp_actions_registered ); + } + + public function testMcpFeatureIsEnabledByDefault(): void { + $core = new CoreWithPublicServices( __DIR__ ); + + $this->assertArrayHasKey( 'mcp', $core->serviceClasses() ); + $this->assertSame( MCP::class, $core->serviceClasses()['mcp'] ); + } + + public function testNativeRegistrationUsesToolContributorsFromDependencies(): void { + global $wp_actions_registered, $wp_abilities_registered; + + $modeler = new ModelerWithModels( + $this->createStub( ModelFactory::class ), + [ + 'book' => $this->createModelMock( 'post_type' ), + 'genre' => $this->createModelMock( 'taxonomy' ), + ] + ); + $feature = new MCP( + [ + 'modeler_resolver' => function () use ( $modeler ): Modeler { + return $modeler; + }, + 'services' => new \ArrayObject( [ new ContributorFeature() ] ), + ] + ); + + $feature->register(); + $wp_actions_registered[2]['callback'](); + + $this->assertArrayHasKey( 'saltus/contributed-tool', $wp_abilities_registered ); + $this->assertSame( 'contributed_tool', $wp_abilities_registered['saltus/contributed-tool']['meta']['mcp_tool'] ); + } + + public function testNativeRegistrationUsesDefaultFeatureToolContributors(): void { + global $wp_actions_registered, $wp_abilities_registered; + + $modeler = new ModelerWithModels( + $this->createStub( ModelFactory::class ), + [ + 'book' => $this->createModelMock( 'post_type' ), + 'genre' => $this->createModelMock( 'taxonomy' ), + ] + ); + $feature = new MCP( + [ + 'modeler_resolver' => function () use ( $modeler ): Modeler { + return $modeler; + }, + 'services' => new \ArrayObject( + [ + new Duplicate(), + new SingleExport(), + new Settings(), + new Meta(), + new DragAndDrop(), + ] + ), + ] + ); + + $feature->register(); + $wp_actions_registered[2]['callback'](); + + $this->assertCount( 17, $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/get-health', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/list-models', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/duplicate-post', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/export-post', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/update-settings', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/get-meta-fields', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/reorder-posts', $wp_abilities_registered ); + } + + private function createModelMock( string $type ): Model { + return new class( $type ) implements Model { + private string $type; + + public function __construct( string $type ) { + $this->type = $type; + } + + public function setup(): void {} + + public function get_name(): string { + return $this->type; + } + + public function get_type(): string { + return $this->type; + } + + /** + * @return array<string, mixed> + */ + public function get_options(): array { + return [ + 'show_in_rest' => true, + 'saltus_rest' => true, + ]; + } + + public function get_args(): array { + return []; + } + }; + } +} + +class ModelerWithModels extends Modeler { + /** @var array<string, Model> */ + private array $models; + + /** + * @param array<string, Model> $models + */ + public function __construct( ModelFactory $model_factory, array $models ) { + parent::__construct( $model_factory ); + $this->models = $models; + } + + /** + * @return array<string, Model> + */ + public function get_models(): array { + return $this->models; + } +} + +class NativeAbilityRegistrar extends AbilityRegistrar { + public function has_native_api(): bool { + return true; + } +} + +class LegacyAbilityRegistrar extends AbilityRegistrar { + public function has_native_api(): bool { + return false; + } +} + +class CoreWithPublicServices extends Core { + public function serviceClasses(): array { + return $this->get_service_classes(); + } +} + +class ContributorFeature implements ToolContributor { + /** + * @return list<ToolInterface> + */ + public function get_mcp_tools( Modeler $modeler, ?ModelRestPolicy $policy = null ): array { + return [ new ContributedTool() ]; + } +} + +class ContributedTool extends RestTool { + public function get_name(): string { + return 'contributed_tool'; + } + + public function get_description(): string { + return 'A tool contributed by its feature'; + } + + /** + * @return array<string, mixed> + */ + public function get_parameters(): array { + return []; + } + + public function build_rest_request( array $args ): ?\WP_REST_Request { + return $this->request( 'GET', '/saltus-framework/v1/contributed-tool' ); + } +} diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/Integration/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/Integration/ContainerIntegrationTest.php b/tests/Integration/ContainerIntegrationTest.php new file mode 100644 index 00000000..b02a42d5 --- /dev/null +++ b/tests/Integration/ContainerIntegrationTest.php @@ -0,0 +1,119 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Integration; + +use Saltus\WP\Framework\Infrastructure\Container\ContainerAssembler; +use Saltus\WP\Framework\Infrastructure\Container\GenericContainer; +use Saltus\WP\Framework\Infrastructure\Container\ServiceContainer; +use Saltus\WP\Framework\Infrastructure\Plugin\Registerable; +use Saltus\WP\Framework\Infrastructure\Service\Conditional; +use Saltus\WP\Framework\Infrastructure\Service\Service; +use Saltus\WP\Framework\Tests\TestCase; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Infrastructure\Container\ContainerAssembler + */ +class ContainerIntegrationTest extends TestCase { + + protected function setUp(): void { + parent::setUp(); + + global $wp_is_admin; + $wp_is_admin = true; + } + + protected function tearDown(): void { + global $wp_is_admin; + $wp_is_admin = false; + + parent::tearDown(); + } + + public function testAssemblerCreatesGenericContainer(): void { + $assembler = new ContainerAssembler(); + $container = $assembler->create( GenericContainer::class ); + + $this->assertInstanceOf( GenericContainer::class, $container ); + } + + public function testAssemblerCreatesServiceContainer(): void { + $assembler = new ContainerAssembler(); + $container = $assembler->create( ServiceContainer::class ); + + $this->assertInstanceOf( ServiceContainer::class, $container ); + } + + public function testAssemblerThrowsForNonExistentClass(): void { + $assembler = new ContainerAssembler(); + + $this->expectException( \InvalidArgumentException::class ); + $assembler->create( 'NonExistentContainerClass' ); + } + + public function testGenericContainerPutAndCount(): void { + $assembler = new ContainerAssembler(); + $container = $assembler->create( GenericContainer::class ); + + $container->put( 'key_a', new GenericContainerTestService() ); + $container->put( 'key_b', new GenericContainerTestService() ); + + $this->assertCount( 2, $container ); + $this->assertTrue( $container->has( 'key_a' ) ); + $this->assertTrue( $container->has( 'key_b' ) ); + } + + public function testServiceContainerRegistersFeatures(): void { + $assembler = new ContainerAssembler(); + $container = $assembler->create( ServiceContainer::class ); + + $container->register( 'simple', SimpleService::class, [] ); + $container->register( 'registerable', RegisterableService::class, [] ); + + $this->assertCount( 2, $container ); + $this->assertInstanceOf( SimpleService::class, $container->get( 'simple' ) ); + $this->assertInstanceOf( RegisterableService::class, $container->get( 'registerable' ) ); + } + + public function testServiceContainerSkipsUnneededConditionalServices(): void { + global $wp_is_admin; + $wp_is_admin = false; + + $assembler = new ContainerAssembler(); + $container = $assembler->create( ServiceContainer::class ); + + $container->register( 'admin_only', ConditionalService::class, [] ); + + $this->assertCount( 0, $container ); + } + + public function testServiceContainerTriggersRegisterOnRegisterableServices(): void { + $assembler = new ContainerAssembler(); + $container = $assembler->create( ServiceContainer::class ); + + $container->register( 'with_register', RegisterableService::class, [] ); + + global $wp_registered_items; + $this->assertContains( 'with_register', $wp_registered_items ); + } +} + +class GenericContainerTestService implements Service { +} + +class SimpleService implements Service { +} + +class RegisterableService implements Service, Registerable { + public function register() { + global $wp_registered_items; + $wp_registered_items[] = 'with_register'; + } +} + +class ConditionalService implements Service, Conditional { + public static function is_needed(): bool { + return \is_admin(); + } +} diff --git a/tests/Integration/FrameworkBootTest.php b/tests/Integration/FrameworkBootTest.php new file mode 100644 index 00000000..4acdbe8d --- /dev/null +++ b/tests/Integration/FrameworkBootTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Integration; + +use Saltus\WP\Framework\Core; +use Saltus\WP\Framework\Features\MCP\MCP; +use Saltus\WP\Framework\Tests\TestCase; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Core + */ +class FrameworkBootTest extends TestCase { + public function testCoreRegistersDefaultServices(): void { + $core = new Core( __DIR__ ); + + $core->register_services(); + + $container = $core->get_container(); + + $this->assertTrue( $container->has( 'mcp' ) ); + $this->assertInstanceOf( MCP::class, $container->get( 'mcp' ) ); + $this->assertGreaterThan( 1, count( $container ) ); + } + + public function testCoreRegistersLifecycleHooksAgainstPluginFile(): void { + global $wp_activation_hooks, $wp_deactivation_hooks; + $wp_activation_hooks = []; + $wp_deactivation_hooks = []; + $plugin_file = __FILE__; + + $core = new Core( __DIR__, $plugin_file ); + $core->register(); + + $this->assertSame( $plugin_file, $wp_activation_hooks[0]['file'] ); + $this->assertSame( $plugin_file, $wp_deactivation_hooks[0]['file'] ); + } + + public function testCoreSkipsLifecycleHooksWhenPluginFileIsDirectory(): void { + global $wp_activation_hooks, $wp_deactivation_hooks; + $wp_activation_hooks = []; + $wp_deactivation_hooks = []; + + $core = new Core( __DIR__ ); + $core->register(); + + $this->assertSame( [], $wp_activation_hooks ); + $this->assertSame( [], $wp_deactivation_hooks ); + } +} diff --git a/tests/Integration/RestRegistrationTest.php b/tests/Integration/RestRegistrationTest.php new file mode 100644 index 00000000..beeb8de4 --- /dev/null +++ b/tests/Integration/RestRegistrationTest.php @@ -0,0 +1,100 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Integration; + +use Saltus\WP\Framework\Core; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\DuplicateController; +use Saltus\WP\Framework\Rest\ExportController; +use Saltus\WP\Framework\Rest\HealthController; +use Saltus\WP\Framework\Rest\MetaController; +use Saltus\WP\Framework\Rest\ModelsController; +use Saltus\WP\Framework\Rest\ReorderController; +use Saltus\WP\Framework\Rest\SettingsController; +use Saltus\WP\Framework\Tests\TestCase; + +require_once dirname( __DIR__ ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Core + * @covers \Saltus\WP\Framework\Rest\ModelRestPolicy + * @covers \Saltus\WP\Framework\Rest\RestRouteDefinition + * @covers \Saltus\WP\Framework\Rest\HealthController + */ +class RestRegistrationTest extends TestCase { + + public function testCoreProducesRouteDefinitionsForAllCapabilities(): void { + $plugin_file = dirname( __DIR__, 2 ) . '/vendor/saltus/framework/saltus-framework.php'; + + $core = new Core( __DIR__, $plugin_file ); + $core->register_services(); + + $modeler = $this->createMock( Modeler::class ); + $modeler->method( 'get_rest_routes' )->willReturn( [] ); + $modeler_prop = new \ReflectionProperty( $core, 'modeler' ); + $modeler_prop->setAccessible( true ); + $modeler_prop->setValue( $core, $modeler ); + + $policy = new ModelRestPolicy( $modeler ); + $reflection = new \ReflectionMethod( $core, 'get_rest_routes' ); + $reflection->setAccessible( true ); + $routes = $reflection->invoke( $core, $policy ); + + $this->assertContainsOnlyInstancesOf( RestRouteDefinition::class, $routes ); + $this->assertGreaterThanOrEqual( 1, count( $routes ) ); + + $capabilities = array_map( + static fn( RestRouteDefinition $route ): string => $route->get_capability(), + $routes + ); + $this->assertContains( ModelRestPolicy::CAPABILITY_HEALTH, $capabilities ); + } + + public function testModelerContributesModelsRestRoute(): void { + $modeler = $this->createMock( Modeler::class ); + $modeler->method( 'get_rest_routes' ) + ->willReturn( [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_MODELS, + new ModelsController( $modeler, new ModelRestPolicy( $modeler ) ), + ), + ] ); + $policy = new ModelRestPolicy( $modeler ); + + $routes = $modeler->get_rest_routes( $modeler, $policy ); + + $this->assertCount( 1, $routes ); + $this->assertSame( ModelRestPolicy::CAPABILITY_MODELS, $routes[0]->get_capability() ); + } + + public function testRestRouteDefinitionRegistersControllerRoutes(): void { + $controller = $this->createMock( HealthController::class ); + $controller->expects( $this->once() )->method( 'register_routes' ); + + $definition = new RestRouteDefinition( 'health', $controller ); + $definition->register_routes(); + } + + public function testRestRouteDefinitionSkipsRegistrationWhenMethodMissing(): void { + $controller = new \stdClass(); + $definition = new RestRouteDefinition( 'health', $controller ); + + $this->assertNull( $definition->register_routes() ); + } + + public function testHealthRouteIsAlwaysIncluded(): void { + $modeler = $this->createMock( Modeler::class ); + $policy = new ModelRestPolicy( $modeler ); + + $this->assertTrue( $policy->has_capability( ModelRestPolicy::CAPABILITY_HEALTH ) ); + } + + public function testHealthControllerImplementsRegisterRoutes(): void { + $controller = new HealthController( '1.0.0' ); + $this->assertTrue( method_exists( $controller, 'register_routes' ) ); + } +} diff --git a/tests/MCP/Abilities/AbilityRegistrarTest.php b/tests/MCP/Abilities/AbilityRegistrarTest.php new file mode 100644 index 00000000..3179472a --- /dev/null +++ b/tests/MCP/Abilities/AbilityRegistrarTest.php @@ -0,0 +1,450 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Abilities; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Features\DragAndDrop\DragAndDrop; +use Saltus\WP\Framework\Features\Duplicate\Duplicate; +use Saltus\WP\Framework\Features\Meta\Meta; +use Saltus\WP\Framework\Features\Settings\Settings; +use Saltus\WP\Framework\Features\SingleExport\SingleExport; +use Saltus\WP\Framework\MCP\Abilities\AbilityDefinitionFactory; +use Saltus\WP\Framework\MCP\Abilities\AbilityRegistrar; +use Saltus\WP\Framework\MCP\Cache\TransientCache; +use Saltus\WP\Framework\MCP\Abilities\AbilityRuntime; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; +use Saltus\WP\Framework\MCP\Tools\ToolContributor; +use Saltus\WP\Framework\MCP\Tools\ToolProvider; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Models\ModelFactory; +use Saltus\WP\Framework\Rest\ModelRestPolicy; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @phpstan-import-type AbilityDefinition from \Saltus\WP\Framework\MCP\Abilities\AbilityDefinitionFactory + */ +/** + * @covers \Saltus\WP\Framework\MCP\Abilities\AbilityRegistrar + */ +class AbilityRegistrarTest extends TestCase { + + protected function setUp(): void { + global $wpdb, $wp_abilities_registered, $wp_options, $wp_rest_request_log, $wp_transients, $wp_current_user_can, $wp_taxonomy_objects, $wp_post_type_objects, $wp_rest_response_override; + + $wp_abilities_registered = []; + $wp_options = []; + $wp_rest_request_log = []; + $wp_transients = []; + $wp_current_user_can = true; + $wp_taxonomy_objects = []; + $wp_post_type_objects = []; + $wp_rest_response_override = null; + if ( ! is_object( $wpdb ) ) { + $wpdb = $this->fakeWpdb(); + } + $wpdb->inserts = []; + $wpdb->queries = []; + } + + public function testRegisterMapsAllMcpToolsToNativeAbilities(): void { + global $wp_abilities_registered; + + $registered = ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $this->assertCount( 17, $registered ); + $this->assertArrayHasKey( 'saltus/get-health', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/list-models', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/list-meta-fields', $wp_abilities_registered ); + $this->assertArrayHasKey( 'saltus/get-meta-fields', $wp_abilities_registered ); + $this->assertSame( 'List Models', $wp_abilities_registered['saltus/list-models']['label'] ); + $this->assertSame( 'list_models', $wp_abilities_registered['saltus/list-models']['meta']['mcp_tool'] ); + $this->assertArrayHasKey( 'input_schema', $wp_abilities_registered['saltus/list-models'] ); + $this->assertArrayHasKey( 'callback', $wp_abilities_registered['saltus/list-models'] ); + $this->assertArrayHasKey( 'execute_callback', $wp_abilities_registered['saltus/list-models'] ); + $this->assertArrayHasKey( 'permission_callback', $wp_abilities_registered['saltus/list-models'] ); + } + + public function testRegisterFiltersRestBackedAbilitiesWhenPolicyIsInjected(): void { + global $wp_abilities_registered; + + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ + 'show_in_rest' => true, + 'saltus_rest' => [ + 'models' => true, + 'meta' => true, + ], + ] + ), + ] + ); + + $registered = ( new AbilityRegistrar( $this->defaultToolProvider( $modeler ), null, new ModelRestPolicy( $modeler ) ) )->register(); + + $this->assertContains( 'saltus/get-health', $registered ); + $this->assertContains( 'saltus/list-models', $registered ); + $this->assertContains( 'saltus/list-meta-fields', $registered ); + $this->assertArrayHasKey( 'saltus/list-posts', $wp_abilities_registered ); + $this->assertArrayNotHasKey( 'saltus/update-settings', $wp_abilities_registered ); + $this->assertArrayNotHasKey( 'saltus/duplicate-post', $wp_abilities_registered ); + } + + public function testPermissionCallbackReusesWordPressCapabilityGate(): void { + global $wp_abilities_registered, $wp_current_user_can; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + $permissionCallback = $wp_abilities_registered['saltus/list-models']['permission_callback']; + + $wp_current_user_can = true; + $this->assertTrue( $permissionCallback() ); + + $wp_current_user_can = false; + $this->assertFalse( $permissionCallback() ); + } + + public function testPermissionCallbackAllowsCustomPostTypeCreateCapability(): void { + global $wp_abilities_registered, $wp_current_user_can, $wp_post_type_objects; + + $cap = new \stdClass(); + $cap->create_posts = 'create_books'; + $wp_post_type_objects['book'] = (object) [ + 'name' => 'book', + 'cap' => $cap, + ]; + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + 'create_books' => true, + ]; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + $permissionCallback = $wp_abilities_registered['saltus/create-post']['permission_callback']; + + $this->assertTrue( $permissionCallback( [ 'post_type' => 'book' ] ) ); + } + + public function testPermissionCallbackRejectsMissingCustomPostTypeCreateCapability(): void { + global $wp_abilities_registered, $wp_current_user_can, $wp_post_type_objects; + + $cap = new \stdClass(); + $cap->create_posts = 'create_books'; + $wp_post_type_objects['book'] = (object) [ + 'name' => 'book', + 'cap' => $cap, + ]; + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + 'create_books' => false, + ]; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + $permissionCallback = $wp_abilities_registered['saltus/create-post']['permission_callback']; + + $this->assertFalse( $permissionCallback( [ 'post_type' => 'book' ] ) ); + } + + public function testPermissionCallbackRejectsInvalidTaxonomyForCreateTerm(): void { + global $wp_abilities_registered, $wp_current_user_can, $wp_taxonomy_objects; + + $wp_taxonomy_objects['missing_taxonomy'] = null; + $wp_current_user_can = [ + 'read' => true, + 'manage_categories' => true, + ]; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + $permissionCallback = $wp_abilities_registered['saltus/create-term']['permission_callback']; + + $this->assertFalse( $permissionCallback( [ 'taxonomy' => 'missing_taxonomy' ] ) ); + } + + public function testPermissionCallbackUsesPostSpecificCapability(): void { + global $wp_abilities_registered, $wp_current_user_can; + + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + 'edit_post:123' => true, + 'edit_post:456' => false, + ]; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + $permissionCallback = $wp_abilities_registered['saltus/update-post']['permission_callback']; + + $this->assertTrue( $permissionCallback( [ 'post_id' => 123 ] ) ); + $this->assertFalse( $permissionCallback( [ 'post_id' => 456 ] ) ); + } + + public function testPermissionCallbackRejectsMutatingPostToolWithoutPostId(): void { + global $wp_abilities_registered, $wp_current_user_can; + + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + ]; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + $permissionCallback = $wp_abilities_registered['saltus/update-post']['permission_callback']; + + $this->assertFalse( $permissionCallback( [] ) ); + } + + public function testCallbackDispatchesThroughRestRequest(): void { + global $wp_abilities_registered, $wp_rest_request_log; + + $runtime = new AbilityRuntime( null, new RateLimiter( 1, 60 ) ); + ( new AbilityRegistrar( $this->defaultToolProvider(), new AbilityDefinitionFactory( $runtime ) ) )->register(); + + $callback = $wp_abilities_registered['saltus/update-settings']['execute_callback']; + $result = $callback( + [ + 'post_type' => 'book', + 'settings' => [ 'featured' => true ], + ] + ); + + $this->assertSame( [ 'ok' => true, 'route' => '/saltus-framework/v1/settings/book' ], $result ); + $this->assertSame( 'PUT', $wp_rest_request_log[0]['method'] ); + $this->assertSame( '/saltus-framework/v1/settings/book', $wp_rest_request_log[0]['route'] ); + $this->assertSame( [ 'featured' => true ], $wp_rest_request_log[0]['params'] ); + } + + public function testListPostsCallbackDispatchesTermFiltersThroughRestRequest(): void { + global $wp_abilities_registered, $wp_rest_request_log, $wp_taxonomy_objects; + + $wp_taxonomy_objects['genre'] = (object) [ + 'name' => 'genre', + 'rest_base' => 'genres', + ]; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $callback = $wp_abilities_registered['saltus/list-posts']['execute_callback']; + $result = $callback( + [ + 'post_type' => 'movie', + 'per_page' => 6, + 'orderby' => 'date', + 'order' => 'desc', + 'terms' => [ + 'genre' => [ 12 ], + ], + ] + ); + + $this->assertSame( [ 'ok' => true, 'route' => '/wp/v2/movie' ], $result ); + $this->assertSame( 'GET', $wp_rest_request_log[0]['method'] ); + $this->assertSame( '/wp/v2/movie', $wp_rest_request_log[0]['route'] ); + $this->assertSame( [ 12 ], $wp_rest_request_log[0]['query']['genres'] ); + $this->assertSame( 6, $wp_rest_request_log[0]['query']['per_page'] ); + } + + public function testListMetaFieldsCallbackDispatchesThroughRestRequest(): void { + global $wp_abilities_registered, $wp_rest_request_log; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $callback = $wp_abilities_registered['saltus/list-meta-fields']['execute_callback']; + $result = $callback(); + + $this->assertSame( [ 'ok' => true, 'route' => '/saltus-framework/v1/meta' ], $result ); + $this->assertSame( 'GET', $wp_rest_request_log[0]['method'] ); + $this->assertSame( '/saltus-framework/v1/meta', $wp_rest_request_log[0]['route'] ); + } + + public function testGetHealthCallbackDispatchesThroughRestRequest(): void { + global $wp_abilities_registered, $wp_rest_request_log; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $callback = $wp_abilities_registered['saltus/get-health']['execute_callback']; + $result = $callback(); + + $this->assertSame( [ 'ok' => true, 'route' => '/saltus-framework/v1/health' ], $result ); + $this->assertSame( 'GET', $wp_rest_request_log[0]['method'] ); + $this->assertSame( '/saltus-framework/v1/health', $wp_rest_request_log[0]['route'] ); + } + + public function testReadCallbacksUseTransientCache(): void { + global $wp_abilities_registered, $wp_rest_request_log; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $callback = $wp_abilities_registered['saltus/list-meta-fields']['execute_callback']; + + $this->assertSame( [ 'ok' => true, 'route' => '/saltus-framework/v1/meta' ], $callback() ); + $this->assertSame( [ 'ok' => true, 'route' => '/saltus-framework/v1/meta' ], $callback() ); + + $this->assertCount( 1, $wp_rest_request_log ); + } + + public function testMutatingCallbacksClearTransientCache(): void { + global $wp_abilities_registered, $wp_options, $wp_rest_request_log; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $wp_abilities_registered['saltus/list-meta-fields']['execute_callback'](); + $this->assertNotEmpty( $wp_options['saltus_mcp_cache_keys'] ?? [] ); + + $wp_abilities_registered['saltus/update-settings']['execute_callback']( + [ + 'post_type' => 'book', + 'settings' => [ 'featured' => true ], + ] + ); + + $this->assertArrayHasKey( 'saltus_mcp_cache_keys', $wp_options ); + $this->assertCount( 2, $wp_rest_request_log ); + } + + public function testCallbacksWriteAuditRecords(): void { + global $wpdb, $wp_abilities_registered; + + ( new AbilityRegistrar( $this->defaultToolProvider() ) )->register(); + + $wp_abilities_registered['saltus/list-meta-fields']['execute_callback'](); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'wp_saltus_mcp_audit', $wpdb->inserts[0]['table'] ); + $this->assertSame( 'list_meta_fields', $wpdb->inserts[0]['data']['ability'] ); + $this->assertSame( 'success', $wpdb->inserts[0]['data']['status'] ); + } + + public function testCallbacksReturnRateLimitError(): void { + global $wp_abilities_registered; + + $runtime = new AbilityRuntime( null, new RateLimiter( 1, 60 ) ); + ( new AbilityRegistrar( $this->defaultToolProvider(), new AbilityDefinitionFactory( $runtime ) ) )->register(); + + $callback = $wp_abilities_registered['saltus/update-settings']['execute_callback']; + $args = [ + 'post_type' => 'book', + 'settings' => [ 'featured' => true ], + ]; + $callback( $args ); + $result = $callback( $args ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rate_limited', $result->get_error_code() ); + } + + public function testRegistrarWithoutInjectedProviderRegistersNoTools(): void { + global $wp_abilities_registered; + + $registered = ( new AbilityRegistrar() )->register(); + + $this->assertSame( [], $registered ); + $this->assertSame( [], $wp_abilities_registered ); + } + + private function defaultToolProvider( ?Modeler $modeler = null ): ToolProvider { + $tool_modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $modeler ??= $tool_modeler; + $provider = new ToolProvider(); + + /** @var list<ToolContributor> $contributors */ + $contributors = [ + $tool_modeler, + new Duplicate(), + new SingleExport(), + new Settings(), + new Meta(), + new DragAndDrop(), + ]; + $policy = new ModelRestPolicy( $modeler ); + + foreach ( $contributors as $contributor ) { + foreach ( $contributor->get_mcp_tools( $modeler, $policy ) as $tool ) { + $provider->register( $tool ); + } + } + + return $provider; + } + + private function fakeWpdb(): object { + return new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string { + return $this->prefix; + } + + /** + * @param array<string, mixed> $data + * @param list<string> $format + */ + public function insert( string $table, array $data, array $format = [] ): bool { + $this->inserts[] = compact( 'table', 'data', 'format' ); + return true; + } + + public function query( string $query ): bool { + $this->queries[] = $query; + return true; + } + + public function prepare( string $query, ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace( '/%[dsf]/', (string) $arg, $query, 1 ); + } + return $query; + } + + public function get_results( string $query, $output = null ) { + return array_reverse( array_map( fn( array $insert ) => $insert['data'], $this->inserts ) ); + } + + public function get_charset_collate(): string { + return ''; + } + }; + } + + /** + * @param array<string, mixed> $options + * @return Model&object{options: array<string, mixed>} + */ + private function createModelMock( array $options ) { + return new class( $options ) implements Model { + /** @var array<string, mixed> */ + public array $options; + + /** + * @param array<string, mixed> $options + */ + public function __construct( array $options ) { + $this->options = $options; + } + + public function setup(): void {} + + public function get_name(): string { + return 'book'; + } + + public function get_type(): string { + return 'post_type'; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return []; + } + }; + } +} diff --git a/tests/MCP/Abilities/AbilityRuntimeTest.php b/tests/MCP/Abilities/AbilityRuntimeTest.php new file mode 100644 index 00000000..9783e12d --- /dev/null +++ b/tests/MCP/Abilities/AbilityRuntimeTest.php @@ -0,0 +1,191 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Abilities; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Abilities\AbilityRuntime; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; +use Saltus\WP\Framework\MCP\Tools\CreatePost; +use Saltus\WP\Framework\MCP\Tools\ListModels; +use Saltus\WP\Framework\MCP\Tools\UpdateSettings; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Abilities\AbilityRuntime + */ +class AbilityRuntimeTest extends TestCase { + + protected function setUp(): void { + global $wpdb, $wp_transients, $wp_options, $wp_rest_request_log, $wp_rest_response_override; + $wp_transients = []; + $wp_options = []; + $wp_rest_request_log = []; + $wp_rest_response_override = null; + if ( ! is_object( $wpdb ) ) { + $wpdb = $this->fakeWpdb(); + } + $wpdb->inserts = []; + $wpdb->queries = []; + } + + public function testExecuteCallsRestDoRequestOnValidTool(): void { + global $wp_rest_request_log; + + $runtime = new AbilityRuntime(); + $tool = new ListModels(); + + $result = $runtime->execute( $tool, [ 'type' => 'post_types' ] ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $wp_rest_request_log ); + $this->assertSame( 'GET', $wp_rest_request_log[0]['method'] ); + $this->assertSame( '/saltus-framework/v1/models', $wp_rest_request_log[0]['route'] ); + } + + public function testExecuteReturnsValidationError(): void { + $runtime = new AbilityRuntime(); + $tool = new CreatePost(); + + $result = $runtime->execute( $tool, [] ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'invalid_params', $result->get_error_code() ); + } + + public function testExecuteReturnsRateLimitError(): void { + $runtime = new AbilityRuntime( null, new RateLimiter( 1, 60 ) ); + $tool = new ListModels(); + + $runtime->execute( $tool, [] ); + $result = $runtime->execute( $tool, [] ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'rate_limited', $result->get_error_code() ); + } + + public function testExecuteWritesAuditRecordOnSuccess(): void { + global $wpdb; + + $runtime = new AbilityRuntime(); + $tool = new ListModels(); + + $runtime->execute( $tool, [] ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'success', $wpdb->inserts[0]['data']['status'] ); + } + + public function testExecuteWritesAuditRecordOnError(): void { + global $wpdb; + + $runtime = new AbilityRuntime(); + $tool = new CreatePost(); + + $runtime->execute( $tool, [] ); + + $this->assertNotEmpty( $wpdb->inserts ); + $this->assertSame( 'validation_error', $wpdb->inserts[0]['data']['status'] ); + } + + public function testExecuteReturnsWpErrorWhenRestDispatchReturnsErrorStatus(): void { + global $wpdb, $wp_rest_response_override; + + $wp_rest_response_override = new \WP_REST_Response( + [ + 'code' => 'model_not_found', + 'message' => 'Model not found.', + ], + 404 + ); + + $runtime = new AbilityRuntime(); + $tool = new ListModels(); + $result = $runtime->execute( $tool, [] ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'model_not_found', $result->get_error_code() ); + $this->assertSame( 'error', $wpdb->inserts[0]['data']['status'] ); + } + + public function testCacheHitSkipsRestRequest(): void { + global $wp_rest_request_log, $wp_transients; + + $runtime = new AbilityRuntime(); + $tool = new ListModels(); + $cache_key = 'saltus_mcp_' . hash( 'sha256', '{"tool":"list_models","args":[],"user":1,"locale":"en_US"}' ); + + $wp_transients = [ + $cache_key => [ + 'value' => [ 'cached' => true ], + 'expires' => 0, + ], + ]; + + $result = $runtime->execute( $tool, [] ); + + $this->assertSame( [ 'cached' => true ], $result ); + $this->assertEmpty( $wp_rest_request_log ); + } + + public function testMutatingToolClearsCache(): void { + $runtime = new AbilityRuntime(); + $read_tool = new ListModels(); + $write_tool = new UpdateSettings(); + + $runtime->execute( $read_tool, [] ); + $this->assertNotEmpty( + $runtime->execute( + $write_tool, + [ + 'post_type' => 'book', + 'settings' => [], + ] + ) + ); + } + + private function fakeWpdb(): object { + return new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string { + return $this->prefix; + } + + /** + * @param array<string, mixed> $data + * @param list<string> $format + */ + public function insert( string $table, array $data, array $format = [] ): bool { + $this->inserts[] = compact( 'table', 'data', 'format' ); + return true; + } + + public function query( string $query ): bool { + $this->queries[] = $query; + return true; + } + + public function prepare( string $query, ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace( '/%[dsf]/', (string) $arg, $query, 1 ); + } + return $query; + } + + public function get_results( string $query, $output = null ) { + return array_reverse( array_map( fn( array $insert ) => $insert['data'], $this->inserts ) ); + } + + public function get_charset_collate(): string { + return ''; + } + }; + } +} diff --git a/tests/MCP/Audit/AuditEntryTest.php b/tests/MCP/Audit/AuditEntryTest.php new file mode 100644 index 00000000..ab744c17 --- /dev/null +++ b/tests/MCP/Audit/AuditEntryTest.php @@ -0,0 +1,86 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Audit; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Audit\AuditEntry; + +/** + * @covers \Saltus\WP\Framework\MCP\Audit\AuditEntry + */ +class AuditEntryTest extends TestCase +{ + public function testConstructorSetsToolNameAndArguments(): void + { + $entry = new AuditEntry('list_models', ['type' => 'all']); + $arr = $entry->to_array(); + + $this->assertSame('list_models', $arr['tool']); + $this->assertSame(['type' => 'all'], $arr['arguments']); + } + + public function testInitialStatusIsStarted(): void + { + $entry = new AuditEntry('get_post', ['id' => 1]); + $arr = $entry->to_array(); + + $this->assertSame('started', $arr['status']); + $this->assertNull($arr['duration_ms']); + } + + public function testCompleteSetsStatusAndDuration(): void + { + $entry = new AuditEntry('create_post', ['title' => 'Test']); + usleep(1000); + $entry->complete('success'); + + $arr = $entry->to_array(); + + $this->assertSame('success', $arr['status']); + $this->assertNotNull($arr['duration_ms']); + $this->assertGreaterThan(0, $arr['duration_ms']); + $this->assertNull($arr['error_code']); + $this->assertNull($arr['error_message']); + } + + public function testCompleteWithError(): void + { + $entry = new AuditEntry('delete_post', ['id' => 99]); + $entry->complete('error', 'rest_forbidden', 'You cannot delete this post'); + + $arr = $entry->to_array(); + + $this->assertSame('error', $arr['status']); + $this->assertSame('rest_forbidden', $arr['error_code']); + $this->assertSame('You cannot delete this post', $arr['error_message']); + } + + public function testTimestampFormat(): void + { + $entry = new AuditEntry('list_posts', []); + $arr = $entry->to_array(); + + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', + $arr['timestamp'] + ); + } + + public function testMultipleCompletesOverwrite(): void + { + $entry = new AuditEntry('test', []); + $entry->complete('error', 'e1', 'first'); + $entry->complete('success'); + + $arr = $entry->to_array(); + $this->assertSame('success', $arr['status']); + $this->assertNull($arr['error_code']); + $this->assertNull($arr['error_message']); + } + + public function testGetDurationBeforeComplete(): void + { + $entry = new AuditEntry('test', []); + $this->assertNull($entry->get_duration()); + } +} diff --git a/tests/MCP/Audit/AuditLoggerTest.php b/tests/MCP/Audit/AuditLoggerTest.php new file mode 100644 index 00000000..3d855ddb --- /dev/null +++ b/tests/MCP/Audit/AuditLoggerTest.php @@ -0,0 +1,207 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Audit; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Audit\AuditEntry; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Audit\AuditLogger + */ +class AuditLoggerTest extends TestCase +{ + protected function setUp(): void + { + global $wpdb; + if ( ! is_object( $wpdb ) ) { + $wpdb = $this->fakeWpdb(); + } + $wpdb->inserts = []; + $wpdb->queries = []; + } + + public function testRecordStoresEntryInAuditTable(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $entry = new AuditEntry('list_models', []); + $entry->complete('success'); + $logger->record($entry); + + $this->assertCount(1, $wpdb->inserts); + $this->assertSame('wp_saltus_mcp_audit', $wpdb->inserts[0]['table']); + $this->assertSame('list_models', $wpdb->inserts[0]['data']['ability']); + $this->assertSame('success', $wpdb->inserts[0]['data']['status']); + } + + public function testRecordDoesNotRunRetentionCleanup(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $entry = new AuditEntry('list_models', []); + $entry->complete('success'); + $logger->record($entry); + + $delete_queries = array_values(array_filter($wpdb->queries, static fn(string $query): bool => strpos($query, 'DELETE FROM') === 0)); + + $this->assertSame([], $delete_queries); + } + + public function testCleanupExpiredEntriesDeletesOnlyOldAuditRows(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $logger->cleanup_expired_entries(); + + $delete_queries = array_values(array_filter($wpdb->queries, static fn(string $query): bool => strpos($query, 'DELETE FROM') === 0)); + + $this->assertCount(1, $delete_queries); + $this->assertStringStartsWith("DELETE FROM wp_saltus_mcp_audit WHERE created_at < '", $delete_queries[0]); + } + + public function testRecordStoresErrors(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $fail = new AuditEntry('bad', []); + $fail->complete('error', 'api_error', 'fail'); + $logger->record($fail); + + $this->assertSame('api_error', $wpdb->inserts[0]['data']['error_code']); + $this->assertSame('fail', $wpdb->inserts[0]['data']['error_message']); + } + + public function testGetRecentEntriesReadsFromTable(): void + { + $logger = new AuditLogger(); + + for ($i = 0; $i < 5; $i++) { + $e = new AuditEntry("tool_{$i}", []); + $e->complete('success'); + $logger->record($e); + } + + $recent = $logger->get_recent_entries(2); + $this->assertNotEmpty($recent); + $this->assertSame('tool_4', $recent[0]['ability']); + } + + public function testInvalidStatusFallsBackToError(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $entry = new AuditEntry('test_tool', []); + $entry->complete('bogus_status'); + $logger->record($entry); + + $this->assertSame('error', $wpdb->inserts[0]['data']['status']); + } + + public function testSanitizesLongAbility(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $entry = new AuditEntry(str_repeat('a', 250), []); + $entry->complete('success'); + $logger->record($entry); + + $this->assertSame(191, strlen($wpdb->inserts[0]['data']['ability'])); + } + + public function testSanitizesLongIdentifier(): void + { + global $wpdb; + + $logger = new AuditLogger(); + + $entry = new AuditEntry('test', [], str_repeat('x', 250)); + $entry->complete('success'); + $logger->record($entry); + + $this->assertSame(191, strlen($wpdb->inserts[0]['data']['identifier'])); + } + + public function testSanitizesLongErrorCode(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $entry = new AuditEntry('test', []); + $entry->complete('error', str_repeat('e', 250)); + $logger->record($entry); + + $this->assertSame(191, strlen($wpdb->inserts[0]['data']['error_code'])); + } + + public function testStripsNullBytes(): void + { + global $wpdb; + + $logger = new AuditLogger(); + $entry = new AuditEntry("bad\x00tool", []); + $entry->complete('success'); + $logger->record($entry); + + $this->assertSame('badtool', $wpdb->inserts[0]['data']['ability']); + } + + private function fakeWpdb(): object + { + return new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string + { + return $this->prefix; + } + + /** + * @param array<string, mixed> $data + * @param list<string> $format + */ + public function insert(string $table, array $data, array $format = []): bool + { + $this->inserts[] = compact('table', 'data', 'format'); + return true; + } + + public function query(string $query): bool + { + $this->queries[] = $query; + return true; + } + + public function prepare(string $query, ...$args): string + { + foreach ($args as $arg) { + $replacement = is_string( $arg ) ? "'" . (string) $arg . "'" : (string) $arg; + $query = preg_replace( '/%[dsf]/', $replacement, $query, 1 ); + } + return $query; + } + + public function get_results(string $query, $output = null) + { + return array_reverse(array_map(fn(array $insert) => $insert['data'], $this->inserts)); + } + + public function get_charset_collate(): string + { + return ''; + } + }; + } +} diff --git a/tests/MCP/Cache/TransientCacheTest.php b/tests/MCP/Cache/TransientCacheTest.php new file mode 100644 index 00000000..0b178cec --- /dev/null +++ b/tests/MCP/Cache/TransientCacheTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Cache; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Cache\TransientCache; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\Cache\TransientCache + */ +class TransientCacheTest extends TestCase { + + protected function setUp(): void { + global $wp_transients, $wp_options; + $wp_transients = []; + $wp_options = []; + } + + public function testGetReturnsNullForMissingKey(): void { + $cache = new TransientCache(); + $this->assertNull( $cache->get( 'nonexistent' ) ); + } + + public function testSetAndGet(): void { + $cache = new TransientCache(); + $cache->set( 'test_key', [ 'data' => 'value' ], 60 ); + $this->assertSame( [ 'data' => 'value' ], $cache->get( 'test_key' ) ); + } + + public function testHas(): void { + $cache = new TransientCache(); + $cache->set( 'present', [ 'x' => 1 ], 60 ); + $this->assertTrue( $cache->has( 'present' ) ); + $this->assertFalse( $cache->has( 'absent' ) ); + } + + public function testDelete(): void { + $cache = new TransientCache(); + $cache->set( 'tmp', [ 'x' => 1 ], 60 ); + $cache->delete( 'tmp' ); + $this->assertNull( $cache->get( 'tmp' ) ); + } + + public function testClearRemovesAllKeys(): void { + $cache = new TransientCache(); + $cache->set( 'a', [ 1 ], 60 ); + $cache->set( 'b', [ 2 ], 60 ); + $cache->clear(); + + $this->assertNull( $cache->get( 'a' ) ); + $this->assertNull( $cache->get( 'b' ) ); + } + + public function testClearRemovesIndexOption(): void { + global $wp_options; + + $cache = new TransientCache(); + $cache->set( 'x', [ 1 ], 60 ); + $this->assertNotEmpty( $wp_options['saltus_mcp_cache_keys'] ?? [] ); + + $cache->clear(); + $this->assertArrayNotHasKey( 'saltus_mcp_cache_keys', $wp_options ); + } +} diff --git a/tests/MCP/RateLimiter/RateLimitResultTest.php b/tests/MCP/RateLimiter/RateLimitResultTest.php new file mode 100644 index 00000000..716211c2 --- /dev/null +++ b/tests/MCP/RateLimiter/RateLimitResultTest.php @@ -0,0 +1,42 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\RateLimiter; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimitResult; + +/** + * @covers \Saltus\WP\Framework\MCP\RateLimiter\RateLimitResult + */ +class RateLimitResultTest extends TestCase +{ + public function testConstructorSetsProperties(): void + { + $result = new RateLimitResult(true, 42, 1000.5, 5); + + $this->assertTrue($result->allowed); + $this->assertSame(42, $result->remaining); + $this->assertSame(1000.5, $result->reset_at); + $this->assertSame(5, $result->retry_after); + } + + public function testAllowedResult(): void + { + $result = new RateLimitResult(true, 59, 1234.0); + + $this->assertTrue($result->allowed); + $this->assertSame(59, $result->remaining); + $this->assertSame(1234.0, $result->reset_at); + $this->assertNull($result->retry_after); + } + + public function testBlockedResult(): void + { + $result = new RateLimitResult(false, 0, 999.0, 30); + + $this->assertFalse($result->allowed); + $this->assertSame(0, $result->remaining); + $this->assertSame(999.0, $result->reset_at); + $this->assertSame(30, $result->retry_after); + } +} diff --git a/tests/MCP/RateLimiter/RateLimiterTest.php b/tests/MCP/RateLimiter/RateLimiterTest.php new file mode 100644 index 00000000..77ff99cd --- /dev/null +++ b/tests/MCP/RateLimiter/RateLimiterTest.php @@ -0,0 +1,101 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\RateLimiter; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimiter; +use Saltus\WP\Framework\MCP\RateLimiter\RateLimitResult; + +require_once dirname( __DIR__, 2 ) . '/Rest/functions.php'; + +/** + * @covers \Saltus\WP\Framework\MCP\RateLimiter\RateLimiter + */ +class RateLimiterTest extends TestCase +{ + protected function setUp(): void + { + global $wp_transients; + $wp_transients = []; + } + + public function testAllowsRequestsUnderLimit(): void + { + $limiter = new RateLimiter(5, 60); + + for ($i = 0; $i < 5; $i++) { + $result = $limiter->check('client1'); + $this->assertTrue($result->allowed); + } + } + + public function testBlocksRequestsOverLimit(): void + { + $limiter = new RateLimiter(3, 60); + + for ($i = 0; $i < 3; $i++) { + $limiter->check('client2'); + } + + $result = $limiter->check('client2'); + $this->assertFalse($result->allowed); + } + + public function testAllowsAfterWindowExpires(): void + { + $limiter = new RateLimiter(1, 1); + + $limiter->check('bob'); + sleep(2); + $result = $limiter->check('bob'); + $this->assertTrue($result->allowed); + } + + public function testDifferentIdentifiersIndependent(): void + { + $limiter = new RateLimiter(2, 60); + + $this->assertTrue($limiter->check('alice')->allowed); + $this->assertTrue($limiter->check('alice')->allowed); + $this->assertFalse($limiter->check('alice')->allowed); + + $this->assertTrue($limiter->check('bob')->allowed); + } + + public function testReturnsRemainingCount(): void + { + $limiter = new RateLimiter(5, 60); + + $result = $limiter->check('user'); + $this->assertSame(4, $result->remaining); + } + + public function testReturnedZeroRemainingWhenBlocked(): void + { + $limiter = new RateLimiter(1, 60); + + $limiter->check('limited'); + $result = $limiter->check('limited'); + + $this->assertSame(0, $result->remaining); + } + + public function testRetryAfterOnBlocked(): void + { + $limiter = new RateLimiter(1, 10); + + $limiter->check('slow'); + $result = $limiter->check('slow'); + + $this->assertNotNull($result->retry_after); + $this->assertGreaterThanOrEqual(1, $result->retry_after); + } + + public function testResetAtIsFutureTimestamp(): void + { + $limiter = new RateLimiter(1, 60); + + $result = $limiter->check('future'); + $this->assertGreaterThan(microtime(true), $result->reset_at); + } +} diff --git a/tests/MCP/Tools/ToolProviderTest.php b/tests/MCP/Tools/ToolProviderTest.php new file mode 100644 index 00000000..c740421b --- /dev/null +++ b/tests/MCP/Tools/ToolProviderTest.php @@ -0,0 +1,65 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Tools; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Tools\ToolProvider; +use Saltus\WP\Framework\MCP\Tools\ToolInterface; + +/** + * @covers \Saltus\WP\Framework\MCP\Tools\ToolProvider + */ +class ToolProviderTest extends TestCase +{ + private ToolProvider $provider; + + protected function setUp(): void + { + $this->provider = new ToolProvider(); + } + + public function testRegisterAndGet(): void + { + $tool = $this->createStub(ToolInterface::class); + $tool->method('get_name')->willReturn('test_tool'); + + $this->provider->register($tool); + $this->assertSame($tool, $this->provider->get('test_tool')); + } + + public function testGetReturnsNullForUnknown(): void + { + $this->assertNull($this->provider->get('nonexistent')); + } + + public function testAllReturnsAllRegistered(): void + { + $tool1 = $this->createStub(ToolInterface::class); + $tool1->method('get_name')->willReturn('tool_a'); + + $tool2 = $this->createStub(ToolInterface::class); + $tool2->method('get_name')->willReturn('tool_b'); + + $this->provider->register($tool1); + $this->provider->register($tool2); + + $all = $this->provider->all(); + $this->assertCount(2, $all); + $this->assertArrayHasKey('tool_a', $all); + $this->assertArrayHasKey('tool_b', $all); + } + + public function testRegisterOverwritesExisting(): void + { + $tool1 = $this->createStub(ToolInterface::class); + $tool1->method('get_name')->willReturn('dup'); + + $tool2 = $this->createStub(ToolInterface::class); + $tool2->method('get_name')->willReturn('dup'); + + $this->provider->register($tool1); + $this->provider->register($tool2); + + $this->assertSame($tool2, $this->provider->get('dup')); + } +} diff --git a/tests/MCP/Validation/ValidatorTest.php b/tests/MCP/Validation/ValidatorTest.php new file mode 100644 index 00000000..74bd0ef1 --- /dev/null +++ b/tests/MCP/Validation/ValidatorTest.php @@ -0,0 +1,138 @@ +<?php + +namespace Saltus\WP\Framework\Tests\MCP\Validation; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Validation\Validator; + +/** + * @covers \Saltus\WP\Framework\MCP\Validation\Validator + */ +class ValidatorTest extends TestCase +{ + public function testValidPasses(): void + { + $schema = [ + 'name' => ['type' => 'string', 'required' => true], + 'age' => ['type' => 'number'], + ]; + $args = ['name' => 'Alice', 'age' => 30]; + $result = Validator::validate($args, $schema); + $this->assertTrue($result['valid']); + $this->assertEmpty($result['errors']); + } + + public function testMissingRequiredField(): void + { + $schema = [ + 'name' => ['type' => 'string', 'required' => true], + ]; + $args = []; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertContains("'name' is required", $result['errors']); + } + + public function testTypeMismatchString(): void + { + $schema = [ + 'title' => ['type' => 'string'], + ]; + $args = ['title' => 42]; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('must be of type string', $result['errors'][0]); + } + + public function testTypeMismatchNumber(): void + { + $schema = [ + 'count' => ['type' => 'number'], + ]; + $args = ['count' => 'not-a-number']; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('must be of type number', $result['errors'][0]); + } + + public function testTypeMismatchBoolean(): void + { + $schema = [ + 'active' => ['type' => 'boolean'], + ]; + $args = ['active' => 'yes']; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('must be of type boolean', $result['errors'][0]); + } + + public function testTypeMismatchObject(): void + { + $schema = [ + 'meta' => ['type' => 'object'], + ]; + $args = ['meta' => 'string-instead']; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('must be of type object', $result['errors'][0]); + } + + public function testValidObjectType(): void + { + $schema = [ + 'meta' => ['type' => 'object'], + ]; + $args = ['meta' => ['key' => 'value']]; + $result = Validator::validate($args, $schema); + $this->assertTrue($result['valid']); + } + + public function testEnumValidation(): void + { + $schema = [ + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending']], + ]; + $args = ['status' => 'draft']; + $result = Validator::validate($args, $schema); + $this->assertTrue($result['valid']); + } + + public function testEnumValidationFails(): void + { + $schema = [ + 'status' => ['type' => 'string', 'enum' => ['draft', 'publish']], + ]; + $args = ['status' => 'trash']; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertStringContainsString('must be one of', $result['errors'][0]); + } + + public function testMultipleErrors(): void + { + $schema = [ + 'name' => ['type' => 'string', 'required' => true], + 'status' => ['type' => 'string', 'enum' => ['active']], + ]; + $args = ['status' => 123]; + $result = Validator::validate($args, $schema); + $this->assertFalse($result['valid']); + $this->assertContains("'name' is required", $result['errors']); + } + + public function testEmptySchema(): void + { + $result = Validator::validate(['anything' => 1], []); + $this->assertTrue($result['valid']); + } + + public function testOptionalFieldOmitted(): void + { + $schema = [ + 'name' => ['type' => 'string'], + 'desc' => ['type' => 'string'], + ]; + $result = Validator::validate(['name' => 'test'], $schema); + $this->assertTrue($result['valid']); + } +} diff --git a/tests/Rest/DuplicateControllerTest.php b/tests/Rest/DuplicateControllerTest.php new file mode 100644 index 00000000..91935480 --- /dev/null +++ b/tests/Rest/DuplicateControllerTest.php @@ -0,0 +1,219 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\DuplicateController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use WP_REST_Request; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\DuplicateController + */ +class DuplicateControllerTest extends TestCase { + private DuplicateController $controller; + + protected function setUp(): void { + global $wp_rest_routes_registered, $wp_current_user_can, $wp_posts, $wp_insert_post_without_storage; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + $wp_posts = []; + $wp_insert_post_without_storage = false; + + $this->controller = new DuplicateController(); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'duplicate', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } + + public function testRegisterRoutes(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertSame( 'saltus-framework/v1', $wp_rest_routes_registered[0]['namespace'] ); + $this->assertStringContainsString( 'duplicate', $wp_rest_routes_registered[0]['route'] ); + } + + public function testCreateItemPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->create_item_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testCreateItemPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->create_item_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testCreateItemPermissionsCheckUsesPostSpecificCapability(): void { + global $wp_current_user_can, $wp_posts; + + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'book', + ] ); + $wp_current_user_can = [ + 'edit_posts' => false, + 'edit_post:42' => true, + ]; + + $result = $this->controller->create_item_permissions_check( new WP_REST_Request( [ 'post_id' => 42 ] ) ); + + $this->assertTrue( $result ); + } + + public function testCreateItemReturnsErrorWhenPostNotFound(): void { + $request = new WP_REST_Request( [ 'post_id' => 999 ] ); + + $result = $this->controller->create_item( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'post_not_found', $result->get_error_code() ); + } + + public function testCreateItemReturnsErrorWhenModelDoesNotEnableDuplicate(): void { + global $wp_posts; + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'book', + 'post_title' => 'Original', + ] ); + + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ + 'show_in_rest' => true, + 'saltus_rest' => [ 'duplicate' => false ], + ] + ), + ] + ); + $this->controller = new DuplicateController( new ModelRestPolicy( $modeler ) ); + + $result = $this->controller->create_item( new WP_REST_Request( [ 'post_id' => 42 ] ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'model_rest_capability_disabled', $result->get_error_code() ); + } + + public function testCreateItemReturnsErrorWhenCannotEditPost(): void { + global $wp_posts, $wp_current_user_can; + $wp_current_user_can = true; + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'post', + 'post_title' => 'Original', + 'post_status' => 'publish', + ] ); + + $request = new WP_REST_Request( [ 'post_id' => 42 ] ); + + $this->controller->create_item( $request ); + + $this->assertTrue( true ); + } + + public function testCreateItemSuccess(): void { + global $wp_posts, $wp_current_user_can; + $wp_current_user_can = true; + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'post', + 'post_title' => 'Original Post', + 'post_status' => 'publish', + ] ); + + $request = new WP_REST_Request( [ 'post_id' => 42 ] ); + $result = $this->controller->create_item( $request ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'post_type', $data ); + $this->assertArrayHasKey( 'post_title', $data ); + $this->assertArrayHasKey( 'post_status', $data ); + $this->assertArrayHasKey( 'edit_link', $data ); + $this->assertSame( 'post', $data['post_type'] ); + } + } + + public function testCreateItemReturnsErrorWhenDuplicatedPostCannotBeRetrieved(): void { + global $wp_posts, $wp_current_user_can, $wp_insert_post_without_storage; + $wp_current_user_can = true; + $wp_insert_post_without_storage = true; + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'post', + 'post_title' => 'Original Post', + 'post_status' => 'publish', + ] ); + + $result = $this->controller->create_item( new WP_REST_Request( [ 'post_id' => 42 ] ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_duplicate_failed', $result->get_error_code() ); + } + + /** + * @param array<string, mixed> $options + * @return Model&object{options: array<string, mixed>} + */ + private function createModelMock( array $options ) { + return new class( $options ) implements Model { + /** @var array<string, mixed> */ + public array $options; + + /** + * @param array<string, mixed> $options + */ + public function __construct( array $options ) { + $this->options = $options; + } + + public function setup(): void {} + + public function get_name(): string { + return 'book'; + } + + public function get_type(): string { + return 'post_type'; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return []; + } + }; + } +} diff --git a/tests/Rest/ExportControllerTest.php b/tests/Rest/ExportControllerTest.php new file mode 100644 index 00000000..364b9850 --- /dev/null +++ b/tests/Rest/ExportControllerTest.php @@ -0,0 +1,170 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\ExportController; +use WP_REST_Request; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\ExportController + */ +class ExportControllerTest extends TestCase { + private ExportController $controller; + + protected function setUp(): void { + global $wpdb, $wp_rest_routes_registered, $wp_current_user_can, $wp_posts; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + $wp_posts = []; + + if ( ! isset( $GLOBALS['wpdb'] ) ) { + $wpdb = new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string { + return $this->prefix; + } + + public function insert( string $table, array $data, array $format = [] ): bool { + $this->inserts[] = compact( 'table', 'data', 'format' ); + return true; + } + + public function query( string $query ): bool { + $this->queries[] = $query; + return true; + } + + public function prepare( string $query, ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace( '/%[dsf]/', (string) $arg, $query, 1 ); + } + return $query; + } + + public function get_results( string $query, $output = null ) { + return array_reverse( array_map( fn( array $insert ) => $insert['data'], $this->inserts ) ); + } + + public function get_charset_collate(): string { + return ''; + } + }; + } + + $this->controller = new ExportController(); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'export', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } + + public function testRegisterRoutes(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertSame( 'saltus-framework/v1', $wp_rest_routes_registered[0]['namespace'] ); + $this->assertStringContainsString( 'export', $wp_rest_routes_registered[0]['route'] ); + $this->assertSame( 'GET', $wp_rest_routes_registered[0]['args']['methods'] ); + } + + public function testGetItemPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testGetItemPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemReturnsErrorWhenPostNotFound(): void { + $request = new WP_REST_Request( [ 'post_id' => 999 ] ); + + $result = $this->controller->get_item( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'post_not_found', $result->get_error_code() ); + } + + public function testGetItemReturnsExportData(): void { + global $wp_posts; + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'post', + 'post_title' => 'Exportable Post', + 'post_content' => 'Selected content', + ] ); + $wp_posts[43] = new \WP_Post( [ + 'ID' => 43, + 'post_type' => 'post', + 'post_title' => 'Other Post', + 'post_content' => 'Other content', + ] ); + + $request = new WP_REST_Request( [ 'post_id' => 42 ] ); + $result = $this->controller->get_item( $request ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertArrayHasKey( 'post_id', $data ); + $this->assertArrayHasKey( 'post_type', $data ); + $this->assertArrayHasKey( 'post_title', $data ); + $this->assertArrayHasKey( 'wxr', $data ); + $this->assertSame( 42, $data['post_id'] ); + $this->assertSame( 'post', $data['post_type'] ); + $this->assertSame( 'Exportable Post', $data['post_title'] ); + $this->assertStringContainsString( 'WXR', $data['wxr'] ); + $this->assertStringContainsString( '<wp:post_id>42</wp:post_id>', $data['wxr'] ); + $this->assertStringContainsString( 'Selected content', $data['wxr'] ); + $this->assertStringNotContainsString( 'Other Post', $data['wxr'] ); + $this->assertStringNotContainsString( 'Other content', $data['wxr'] ); + } + } + + public function testExportPostRemovesWordPressDownloadHeaders(): void { + global $wp_posts; + $wp_posts[42] = new \WP_Post( [ + 'ID' => 42, + 'post_type' => 'post', + 'post_title' => 'Exportable Post', + 'post_content' => 'Selected content', + ] ); + + $exporter = new \Saltus\WP\Framework\Features\SingleExport\SaltusSingleExport( 'post' ); + $result = $exporter->export_post( 42 ); + + $this->assertIsArray( $result ); + $this->assertStringContainsString( '<wp:post_id>42</wp:post_id>', $result['wxr'] ); + $this->assertSame( [], array_values( array_filter( headers_list(), static fn( string $header ): bool => preg_match( '/^Content-(Description|Disposition|Type):/i', $header ) === 1 ) ) ); + } +} diff --git a/tests/Rest/HealthControllerTest.php b/tests/Rest/HealthControllerTest.php new file mode 100644 index 00000000..adca132e --- /dev/null +++ b/tests/Rest/HealthControllerTest.php @@ -0,0 +1,148 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\MCP\Audit\AuditLogger; +use Saltus\WP\Framework\Rest\HealthController; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\HealthController + */ +class HealthControllerTest extends TestCase { + private HealthController $controller; + + protected function setUp(): void { + global $wp_current_user_can, $wp_filter_values, $wp_rest_routes_registered; + + $wp_current_user_can = true; + $wp_filter_values = []; + $wp_rest_routes_registered = []; + + $logger = $this->createStub( AuditLogger::class ); + $logger->method( 'get_recent_entries' )->willReturn( + [ + [ + 'status' => 'success', + 'duration_ms' => 10.0, + ], + [ + 'status' => 'cache_hit', + 'duration_ms' => 4.0, + ], + [ + 'status' => 'error', + 'duration_ms' => 30.0, + ], + [ + 'status' => 'validation_error', + 'duration_ms' => 2.0, + ], + [ + 'status' => 'rate_limited', + 'duration_ms' => 1.0, + ], + ] + ); + + $this->controller = new HealthController( '2.0.0', $logger ); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'health', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + public function testRegisterRoutesRegistersHealthEndpoint(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertSame( 'saltus-framework/v1', $wp_rest_routes_registered[0]['namespace'] ); + $this->assertSame( '/health', $wp_rest_routes_registered[0]['route'] ); + } + + public function testPermissionRequiresEditPosts(): void { + global $wp_current_user_can; + + $wp_current_user_can = false; + + $result = $this->controller->get_item_permissions_check( null ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemReturnsAuditMetrics(): void { + $data = $this->controller->get_item( null )->get_data(); + + $this->assertSame( 'degraded', $data['status'] ); + $this->assertSame( '2.0.0', $data['version'] ); + $this->assertTrue( $data['abilities']['native_api_available'] ); + $this->assertSame( 5, $data['audit']['sample_size'] ); + $this->assertSame( 1, $data['audit']['error_count'] ); + $this->assertSame( 1 / 5, $data['audit']['error_rate'] ); + $this->assertSame( 9.4, $data['audit']['latency_ms']['average'] ); + $this->assertSame( 30.0, $data['audit']['latency_ms']['p95'] ); + $this->assertSame( + [ + 'cache_hit' => 1, + 'error' => 1, + 'rate_limited' => 1, + 'success' => 1, + 'validation_error' => 1, + ], + $data['audit']['statuses'] + ); + } + + public function testClientFailuresDoNotDegradeHealth(): void { + $logger = $this->createStub( AuditLogger::class ); + $logger->method( 'get_recent_entries' )->willReturn( + [ + [ 'status' => 'success' ], + [ 'status' => 'validation_error' ], + [ 'status' => 'rate_limited' ], + ] + ); + + $controller = new HealthController( '2.0.0', $logger ); + $data = $controller->get_item( null )->get_data(); + + $this->assertSame( 'ok', $data['status'] ); + $this->assertSame( 0, $data['audit']['error_count'] ); + $this->assertEqualsWithDelta( 0.0, $data['audit']['error_rate'], 0.0 ); + $this->assertSame( + [ + 'rate_limited' => 1, + 'success' => 1, + 'validation_error' => 1, + ], + $data['audit']['statuses'] + ); + } + + public function testGetItemReportsOkWithoutAuditEntries(): void { + $logger = $this->createStub( AuditLogger::class ); + $logger->method( 'get_recent_entries' )->willReturn( [] ); + + $controller = new HealthController( '2.0.0', $logger ); + $data = $controller->get_item( null )->get_data(); + + $this->assertSame( 'ok', $data['status'] ); + $this->assertSame( 0, $data['audit']['sample_size'] ); + $this->assertSame( 0.0, $data['audit']['error_rate'] ); + $this->assertNull( $data['audit']['latency_ms']['average'] ); + $this->assertNull( $data['audit']['latency_ms']['p95'] ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } +} diff --git a/tests/Rest/MetaControllerTest.php b/tests/Rest/MetaControllerTest.php new file mode 100644 index 00000000..ac9a5ccf --- /dev/null +++ b/tests/Rest/MetaControllerTest.php @@ -0,0 +1,436 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\MetaController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Modeler; +use WP_REST_Request; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\MetaController + */ +class MetaControllerTest extends TestCase { + private MetaController $controller; + private Modeler $modeler; + + protected function setUp(): void { + global $wp_rest_routes_registered, $wp_current_user_can, $wp_post_type_objects; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + $wp_post_type_objects = []; + + $this->modeler = $this->createStub( Modeler::class ); + $this->controller = new MetaController( $this->modeler ); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'meta', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } + + public function testRegisterRoutes(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 2, $wp_rest_routes_registered ); + $this->assertStringContainsString( 'meta', $wp_rest_routes_registered[0]['route'] ); + } + + public function testGetItemsPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->get_items_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testGetItemsPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->get_items_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemsPermissionsCheckUsesPostTypeEditCapability(): void { + global $wp_current_user_can, $wp_post_type_objects; + + $wp_post_type_objects['book'] = $this->postTypeObject( 'book', 'edit_books' ); + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + 'edit_books' => true, + ]; + + $result = $this->controller->get_items_permissions_check( new WP_REST_Request( [ 'post_type' => 'book' ] ) ); + + $this->assertTrue( $result ); + } + + public function testGetItemsPermissionsCheckRejectsMissingPostTypeEditCapability(): void { + global $wp_current_user_can, $wp_post_type_objects; + + $wp_post_type_objects['book'] = $this->postTypeObject( 'book', 'edit_books' ); + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + 'edit_books' => false, + ]; + + $result = $this->controller->get_items_permissions_check( new WP_REST_Request( [ 'post_type' => 'book' ] ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetAllItemsReturnsPostTypeMetaFields(): void { + $book_meta = [ 'isbn' => [ 'type' => 'text' ] ]; + $movie_meta = []; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( 'post_type', $book_meta, 'Book', 'Books' ), + 'genre' => $this->createModelMock( 'taxonomy' ), + 'movie' => $this->createModelMock( 'post_type', $movie_meta, 'Movie', 'Movies' ), + ] + ); + + $result = $this->controller->get_all_items( new WP_REST_Request() ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $data = rest_ensure_response( $result )->get_data(); + if ( is_array( $data ) ) { + $this->assertCount( 2, $data['post_types'] ); + $this->assertSame( 'book', $data['post_types'][0]['post_type'] ); + $this->assertSame( 'Book', $data['post_types'][0]['label_singular'] ); + $this->assertSame( 'Books', $data['post_types'][0]['label_plural'] ); + $this->assertSame( $book_meta, $data['post_types'][0]['meta'] ); + $this->assertSame( 'movie', $data['post_types'][1]['post_type'] ); + $this->assertSame( [], $data['post_types'][1]['meta'] ); + } + } + + public function testGetAllItemsFiltersModelsWhenPolicyIsInjected(): void { + $book_meta = [ 'isbn' => [ 'type' => 'text' ] ]; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + 'post_type', + $book_meta, + 'Book', + 'Books', + [ + 'show_in_rest' => true, + 'saltus_rest' => [ 'meta' => true ], + ] + ), + 'movie' => $this->createModelMock( + 'post_type', + [], + 'Movie', + 'Movies', + [ + 'show_in_rest' => true, + 'saltus_rest' => [ 'meta' => false ], + ] + ), + 'hidden' => $this->createModelMock( + 'post_type', + [], + 'Hidden', + 'Hidden', + [ + 'show_in_rest' => false, + 'saltus_rest' => true, + ] + ), + ] + ); + $this->controller = new MetaController( $this->modeler, new ModelRestPolicy( $this->modeler ) ); + + $result = $this->controller->get_all_items( new WP_REST_Request() ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertCount( 1, $data['post_types'] ); + $this->assertSame( 'book', $data['post_types'][0]['post_type'] ); + } + + public function testGetAllItemsFiltersPostTypesByEditCapability(): void { + global $wp_current_user_can, $wp_post_type_objects; + + $wp_post_type_objects['book'] = $this->postTypeObject( 'book', 'edit_books' ); + $wp_post_type_objects['movie'] = $this->postTypeObject( 'movie', 'edit_movies' ); + $wp_current_user_can = [ + 'read' => true, + 'edit_posts' => false, + 'edit_books' => true, + 'edit_movies' => false, + ]; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( 'post_type', [ 'isbn' => [ 'type' => 'text' ] ], 'Book', 'Books' ), + 'movie' => $this->createModelMock( 'post_type', [ 'rating' => [ 'type' => 'text' ] ], 'Movie', 'Movies' ), + ] + ); + + $result = $this->controller->get_all_items( new WP_REST_Request() ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertCount( 1, $data['post_types'] ); + $this->assertSame( 'book', $data['post_types'][0]['post_type'] ); + } + + public function testGetItemsReturnsErrorWhenModelNotFound(): void { + $this->modeler->method( 'get_models' )->willReturn( [] ); + + $request = new WP_REST_Request( [ 'post_type' => 'nonexistent' ] ); + $result = $this->controller->get_items( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'model_not_found', $result->get_error_code() ); + } + + public function testGetItemsReturnsErrorWhenModelTypeIsNotPostType(): void { + $taxonomy_model = $this->createModelMock( 'taxonomy' ); + $this->modeler->method( 'get_models' )->willReturn( [ 'category' => $taxonomy_model ] ); + + $request = new WP_REST_Request( [ 'post_type' => 'category' ] ); + $result = $this->controller->get_items( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_model_type', $result->get_error_code() ); + } + + public function testGetItemsReturnsEmptyMetaWhenNoMetaDefined(): void { + $model = $this->createModelMock( 'post_type', [] ); + $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); + + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $result = $this->controller->get_items( $request ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $data = rest_ensure_response( $result )->get_data(); + if ( is_array( $data ) ) { + $this->assertSame( 'book', $data['post_type'] ); + $this->assertSame( [], $data['meta'] ); + } + } + + public function testGetItemsReturnsMetaWhenDefined(): void { + $meta_fields = [ + 'author' => [ 'type' => 'text' ], + 'isbn' => [ 'type' => 'text' ], + ]; + $model = $this->createModelMock( 'post_type', $meta_fields ); + $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); + + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $result = $this->controller->get_items( $request ); + + $this->assertNotInstanceOf( WP_Error::class, $result ); + + $data = rest_ensure_response( $result )->get_data(); + if ( is_array( $data ) ) { + $this->assertSame( 'book', $data['post_type'] ); + $this->assertSame( $meta_fields, $data['meta'] ); + } + } + + public function testGetItemsReturnsNormalizedSerializedMetaFieldPaths(): void { + $meta_fields = [ + 'points_info' => [ + 'data_type' => 'serialize', + 'register_rest_api' => true, + 'sections' => [ + [ + 'id' => 'location', + 'title' => 'Location', + 'fields' => [ + 'coordinates' => [ + 'type' => 'fieldset', + 'title' => 'Coordinates', + 'fields' => [ + 'latitude' => [ + 'type' => 'number', + 'title' => 'Latitude', + ], + 'longitude' => [ + 'type' => 'number', + 'title' => 'Longitude', + ], + ], + ], + 'tooltipContent' => [ + 'type' => 'textarea', + 'title' => 'Tooltip', + ], + ], + ], + ], + ], + ]; + + $model = $this->createModelMock( 'post_type', $meta_fields ); + $this->modeler->method( 'get_models' )->willReturn( [ 'point' => $model ] ); + + $result = $this->controller->get_items( new WP_REST_Request( [ 'post_type' => 'point' ] ) ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertSame( $meta_fields, $data['meta'] ); + $this->assertSame( 'points_info', $data['normalized']['rest_meta_keys'][0]['meta_key'] ); + $this->assertTrue( $data['normalized']['rest_meta_keys'][0]['serialized'] ); + $this->assertTrue( $data['normalized']['rest_meta_keys'][0]['writable_rest'] ); + $this->assertSame( 'object', $data['normalized']['rest_meta_keys'][0]['schema']['type'] ); + $this->assertArrayHasKey( 'coordinates', $data['normalized']['rest_meta_keys'][0]['schema']['properties'] ); + + $fields = $this->indexNormalizedFieldsByPath( $data['normalized']['fields'] ); + + $this->assertArrayHasKey( 'points_info.coordinates', $fields ); + $this->assertArrayHasKey( 'points_info.coordinates.latitude', $fields ); + $this->assertArrayHasKey( 'points_info.tooltipContent', $fields ); + $this->assertSame( 'object', $fields['points_info.coordinates']['type'] ); + $this->assertSame( 'number', $fields['points_info.coordinates.latitude']['type'] ); + $this->assertSame( 1, $fields['points_info.coordinates.latitude']['depth'] ); + $this->assertSame( 'points_info', $fields['points_info.coordinates.latitude']['meta_key'] ); + $this->assertSame( 'location', $fields['points_info.coordinates.latitude']['section_id'] ); + } + + public function testGetItemsReturnsNormalizedUnserializedRestMetaKeys(): void { + $meta_fields = [ + 'relationship_point' => [ + 'register_rest_api' => true, + 'fields' => [ + 'globe_id' => [ + 'type' => 'number', + 'title' => 'Globe ID', + ], + 'globe_id_select' => [ + 'type' => 'select', + 'title' => 'Globe', + 'options' => 'get_globes', + ], + ], + ], + ]; + + $model = $this->createModelMock( 'post_type', $meta_fields ); + $this->modeler->method( 'get_models' )->willReturn( [ 'point' => $model ] ); + + $result = $this->controller->get_items( new WP_REST_Request( [ 'post_type' => 'point' ] ) ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + + $rest_meta_keys = array_column( $data['normalized']['rest_meta_keys'], null, 'meta_key' ); + $this->assertArrayHasKey( 'globe_id', $rest_meta_keys ); + $this->assertArrayHasKey( 'globe_id_select', $rest_meta_keys ); + $this->assertFalse( $rest_meta_keys['globe_id']['serialized'] ); + $this->assertSame( 'number', $rest_meta_keys['globe_id']['schema']['type'] ); + $this->assertSame( 'array', $rest_meta_keys['globe_id_select']['schema']['type'] ); + + $fields = $this->indexNormalizedFieldsByPath( $data['normalized']['fields'] ); + + $this->assertSame( 'globe_id', $fields['globe_id']['path'] ); + $this->assertSame( 'globe_id', $fields['globe_id']['meta_key'] ); + $this->assertFalse( $fields['globe_id']['serialized'] ); + $this->assertTrue( $fields['globe_id']['writable_rest'] ); + $this->assertSame( 'relationship_point', $fields['globe_id']['metabox_id'] ); + } + + /** + * @param list<array<string, mixed>> $fields + * @return array<string, array<string, mixed>> + */ + private function indexNormalizedFieldsByPath( array $fields ): array { + $indexed = []; + + foreach ( $fields as $field ) { + $indexed[ $field['path'] ] = $field; + } + + return $indexed; + } + + private function postTypeObject( string $post_type, string $edit_capability ): \stdClass { + $cap = new \stdClass(); + $cap->edit_posts = $edit_capability; + + return (object) [ + 'name' => $post_type, + 'cap' => $cap, + ]; + } + + /** + * @return \Saltus\WP\Framework\Models\Model&object{args: array<string, mixed>} + */ + private function createModelMock( string $type, ?array $meta = null, string $label_singular = '', string $label_plural = '', array $options = [] ) { + $args = []; + + if ( $meta !== null ) { + $args['meta'] = $meta; + } + if ( $label_singular !== '' ) { + $args['label_singular'] = $label_singular; + } + if ( $label_plural !== '' ) { + $args['label_plural'] = $label_plural; + } + + return new class( $type, $args, $options ) implements \Saltus\WP\Framework\Models\Model { + /** @var array<string, mixed> */ + public array $args; + /** @var array<string, mixed> */ + public array $options; + private string $type; + + /** + * @param array<string, mixed> $args + * @param array<string, mixed> $options + */ + public function __construct( string $type, array $args, array $options ) { + $this->type = $type; + $this->args = $args; + $this->options = $options; + } + + public function setup(): void {} + + public function get_name(): string { + return ''; + } + + public function get_type(): string { + return $this->type; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return $this->args; + } + }; + } +} diff --git a/tests/Rest/ModelsControllerTest.php b/tests/Rest/ModelsControllerTest.php new file mode 100644 index 00000000..882a19a0 --- /dev/null +++ b/tests/Rest/ModelsControllerTest.php @@ -0,0 +1,375 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\ModelsController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Config\NoFile; +use Saltus\WP\Framework\Models\Model; +use Saltus\WP\Framework\Models\Taxonomy; +use WP_REST_Request; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\ModelsController + */ +class ModelsControllerTest extends TestCase { + private ModelsController $controller; + private Modeler $modeler; + + protected function setUp(): void { + global $wp_rest_routes_registered, $wp_current_user_can, $wp_post_type_objects, $wp_taxonomy_objects; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + $wp_post_type_objects = []; + $wp_taxonomy_objects = []; + + $this->modeler = $this->createStub( Modeler::class ); + $this->controller = new ModelsController( $this->modeler ); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'models', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } + + public function testRegisterRoutesRegistersTwoRoutes(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 2, $wp_rest_routes_registered ); + } + + public function testGetItemsPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->get_items_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testGetItemsPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->get_items_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemPermissionsCheckUsesPostTypeEditCapability(): void { + global $wp_current_user_can, $wp_post_type_objects; + + $wp_post_type_objects['book'] = $this->postTypeObject( 'book', 'edit_books' ); + $wp_current_user_can = [ + 'edit_posts' => false, + 'edit_books' => true, + ]; + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( 'post_type', 'Books', 'Books', 'book' ), + ] + ); + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request( [ 'post_type' => 'book' ] ) ); + + $this->assertTrue( $result ); + } + + public function testGetItemsFiltersModelsByObjectCapability(): void { + global $wp_current_user_can, $wp_post_type_objects; + + $wp_post_type_objects['book'] = $this->postTypeObject( 'book', 'edit_books' ); + $wp_post_type_objects['movie'] = $this->postTypeObject( 'movie', 'edit_movies' ); + $wp_current_user_can = [ + 'edit_posts' => false, + 'edit_books' => true, + 'edit_movies' => false, + ]; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( 'post_type', 'Books', 'Books', 'book' ), + 'movie' => $this->createModelMock( 'post_type', 'Movies', 'Movies', 'movie' ), + ] + ); + + $result = $this->controller->get_items( new WP_REST_Request() ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertCount( 1, $data ); + $this->assertSame( 'book', $data[0]['name'] ); + } + + public function testGetItemPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testGetItemPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemsReturnsEmptyArrayWhenNoModels(): void { + $this->modeler->method( 'get_models' )->willReturn( [] ); + + $result = $this->controller->get_items( new WP_REST_Request() ); + + $data = rest_ensure_response( $result )->get_data(); + $this->assertSame( [], $data ); + } + + public function testGetItemsReturnsPreparedModels(): void { + $model1 = $this->createModelMock( 'post_type', 'Books', 'Books', 'book', 'post_type', [ 'public' => true, 'show_in_rest' => true ] ); + $model2 = $this->createModelMock( 'taxonomy', 'Categories', 'Categories', 'category', 'taxonomy', [ 'public' => true, 'show_in_rest' => true ] ); + + $this->modeler->method( 'get_models' )->willReturn( [ + 'book' => $model1, + 'category' => $model2, + ] ); + + $result = $this->controller->get_items( new WP_REST_Request() ); + + $data = rest_ensure_response( $result )->get_data(); + $this->assertIsArray( $data ); + $this->assertCount( 2, $data ); + } + + public function testGetItemsFiltersModelsWhenPolicyIsInjected(): void { + $model1 = $this->createModelMock( + 'post_type', + 'Books', + 'Books', + 'book', + 'post_type', + [ + 'public' => true, + 'show_in_rest' => true, + 'saltus_rest' => [ 'models' => true ], + ] + ); + $model2 = $this->createModelMock( + 'post_type', + 'Movies', + 'Movies', + 'movie', + 'post_type', + [ + 'public' => true, + 'show_in_rest' => true, + 'saltus_rest' => [ 'models' => false ], + ] + ); + $model3 = $this->createModelMock( + 'post_type', + 'Hidden', + 'Hidden', + 'hidden', + 'post_type', + [ + 'public' => true, + 'show_in_rest' => false, + 'saltus_rest' => true, + ] + ); + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $model1, + 'movie' => $model2, + 'hidden' => $model3, + ] + ); + $this->controller = new ModelsController( $this->modeler, new ModelRestPolicy( $this->modeler ) ); + + $result = $this->controller->get_items( new WP_REST_Request() ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertCount( 1, $data ); + $this->assertSame( 'book', $data[0]['name'] ); + } + + public function testGetItemReturnsErrorWhenModelNotFound(): void { + $this->modeler->method( 'get_models' )->willReturn( [] ); + + $request = new WP_REST_Request( [ 'post_type' => 'nonexistent' ] ); + $result = $this->controller->get_item( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'model_not_found', $result->get_error_code() ); + } + + public function testGetItemReturnsPreparedModel(): void { + $args = [ 'public' => true, 'show_in_rest' => true ]; + $model = $this->createModelMock( 'post_type', 'Books', 'Books', 'book', 'post_type', $args, 'Featured book model', true); + $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); + + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $result = $this->controller->get_item( $request ); + + $data = rest_ensure_response( $result )->get_data(); + $this->assertIsArray( $data ); + $this->assertSame( 'book', $data['name'] ); + $this->assertSame( 'post_type', $data['type'] ); + $this->assertSame( 'Books', $data['label_singular'] ); + $this->assertSame( 'Books', $data['label_plural'] ); + } + + public function testGetItemDoesNotAccessPrivateFallbackProperties(): void { + $model = new class implements Model { + private string $name = 'private-book'; + + public function setup(): void {} + + public function get_name(): string { + return 'book'; + } + + public function get_type(): string { + return 'post_type'; + } + + public function get_options(): array { + return []; + } + + public function get_args(): array { + return []; + } + }; + + $this->modeler->method( 'get_models' )->willReturn( [ 'book' => $model ] ); + + $result = $this->controller->get_item( new WP_REST_Request( [ 'post_type' => 'book' ] ) ); + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertSame( '', $data['name'] ); + } + + public function testGetItemReturnsTaxonomyMetadata(): void { + $taxonomy = new Taxonomy( + new NoFile( + [ + 'type' => 'category', + 'name' => 'genre', + 'associations' => [ 'movie', 'book' ], + 'options' => [ + 'rest_base' => 'genres', + ], + ] + ) + ); + $taxonomy->setup(); + + $this->modeler->method( 'get_models' )->willReturn( [ 'genre' => $taxonomy ] ); + + $request = new WP_REST_Request( [ 'post_type' => 'genre' ] ); + $result = $this->controller->get_item( $request ); + + $data = rest_ensure_response( $result )->get_data(); + $this->assertSame( 'genre', $data['name'] ); + $this->assertSame( 'taxonomy', $data['type'] ); + $this->assertSame( 'genres', $data['rest_base'] ); + $this->assertSame( [ 'movie', 'book' ], $data['associations'] ); + $this->assertTrue( $data['hierarchical'] ); + } + + /** + * @return Model&object{name: string, one: string, many: string, type: string, description: string, featured_image: bool, options: array<string, mixed>} + */ + private function createModelMock( + string $type, + string $one = '', + string $many = '', + string $name = '', + string $getType = 'post_type', + array $options = [], + string $description = '', + bool $featuredImage = true + ) { + return new class( $type, $one, $many, $name, $getType, $options, $description, $featuredImage ) implements Model { + public string $type; + public string $one; + public string $many; + public string $name; + public string $description; + public bool $featured_image; + /** @var array<string, mixed> */ + public array $options; + private string $getType; + + /** + * @param array<string, mixed> $options + */ + public function __construct( + string $type, + string $one, + string $many, + string $name, + string $getType, + array $options, + string $description, + bool $featuredImage + ) { + $this->type = $type; + $this->one = $one; + $this->many = $many; + $this->name = $name; + $this->getType = $getType; + $this->options = $options; + $this->description = $description; + $this->featured_image = $featuredImage; + } + + public function setup(): void {} + + public function get_name(): string { + return $this->name; + } + + public function get_type(): string { + return $this->getType; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return []; + } + }; + } + + private function postTypeObject( string $post_type, string $edit_capability ): \stdClass { + $cap = new \stdClass(); + $cap->edit_posts = $edit_capability; + + return (object) [ + 'name' => $post_type, + 'cap' => $cap, + ]; + } +} diff --git a/tests/Rest/ReorderControllerTest.php b/tests/Rest/ReorderControllerTest.php new file mode 100644 index 00000000..cdb65481 --- /dev/null +++ b/tests/Rest/ReorderControllerTest.php @@ -0,0 +1,235 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\ReorderController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use WP_REST_Request; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\ReorderController + */ +class ReorderControllerTest extends TestCase { + private ReorderController $controller; + + protected function setUp(): void { + global $wp_rest_routes_registered, $wp_current_user_can, $wp_posts; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + $wp_posts = []; + + $this->controller = new ReorderController(); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'reorder', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } + + public function testRegisterRoutes(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertStringContainsString( 'reorder', $wp_rest_routes_registered[0]['route'] ); + $this->assertSame( 'POST', $wp_rest_routes_registered[0]['args']['methods'] ); + } + + public function testCreateItemPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->create_item_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testCreateItemPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->create_item_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testCreateItemPermissionsCheckAllowsAnyEditableRequestedPost(): void { + global $wp_current_user_can, $wp_posts; + + $wp_posts[1] = new \WP_Post( [ 'ID' => 1, 'post_type' => 'book' ] ); + $wp_posts[2] = new \WP_Post( [ 'ID' => 2, 'post_type' => 'movie' ] ); + $wp_current_user_can = [ + 'edit_posts' => false, + 'edit_post:1' => false, + 'edit_post:2' => true, + ]; + + $result = $this->controller->create_item_permissions_check( + new WP_REST_Request( + [ + 'items' => [ + [ 'id' => 1, 'menu_order' => 1 ], + [ 'id' => 2, 'menu_order' => 2 ], + ], + ] + ) + ); + + $this->assertTrue( $result ); + } + + public function testCreateItemReturnsErrorWhenNoItemsProvided(): void { + $request = new WP_REST_Request( [] ); + $result = $this->controller->create_item( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_empty_data', $result->get_error_code() ); + } + + public function testCreateItemReturnsErrorWhenItemsIsEmpty(): void { + $request = new WP_REST_Request( [ 'items' => [] ] ); + $result = $this->controller->create_item( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_empty_data', $result->get_error_code() ); + } + + public function testCreateItemSkipsNonExistentPosts(): void { + global $wp_posts; + $wp_posts[1] = new \WP_Post( [ 'ID' => 1, 'menu_order' => 0 ] ); + + $request = new WP_REST_Request( [ + 'items' => [ + [ 'id' => 1, 'menu_order' => 2 ], + [ 'id' => 999, 'menu_order' => 1 ], + ], + ] ); + $result = $this->controller->create_item( $request ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertArrayHasKey( 'results', $data ); + $this->assertCount( 2, $data['results'] ); + $this->assertSame( 2, $data['total'] ); + $this->assertSame( 1, $data['updated'] ); + $this->assertSame( 'updated', $data['results'][0]['status'] ); + $this->assertSame( 'skipped', $data['results'][1]['status'] ); + $this->assertSame( 'Post not found', $data['results'][1]['reason'] ); + } + } + + public function testCreateItemSkipsPostsWhoseModelDoesNotEnableReorder(): void { + global $wp_posts; + $wp_posts[1] = new \WP_Post( [ 'ID' => 1, 'post_type' => 'book', 'menu_order' => 0 ] ); + + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ + 'show_in_rest' => true, + 'saltus_rest' => [ 'reorder' => false ], + ] + ), + ] + ); + $this->controller = new ReorderController( new ModelRestPolicy( $modeler ) ); + + $result = $this->controller->create_item( + new WP_REST_Request( + [ + 'items' => [ + [ 'id' => 1, 'menu_order' => 2 ], + ], + ] + ) + ); + + $data = rest_ensure_response( $result )->get_data(); + + $this->assertIsArray( $data ); + $this->assertSame( 0, $data['updated'] ); + $this->assertSame( 'skipped', $data['results'][0]['status'] ); + $this->assertSame( 'Reorder is not enabled for this post type', $data['results'][0]['reason'] ); + $this->assertSame( 0, $wp_posts[1]->menu_order ); + } + + public function testCreateItemUpdatesMenuOrder(): void { + global $wp_posts; + $wp_posts[1] = new \WP_Post( [ 'ID' => 1, 'menu_order' => 0 ] ); + $wp_posts[2] = new \WP_Post( [ 'ID' => 2, 'menu_order' => 0 ] ); + $wp_posts[3] = new \WP_Post( [ 'ID' => 3, 'menu_order' => 0 ] ); + + $request = new WP_REST_Request( [ + 'items' => [ + [ 'id' => 1, 'menu_order' => 1 ], + [ 'id' => 2, 'menu_order' => 2 ], + [ 'id' => 3, 'menu_order' => 3 ], + ], + ] ); + $result = $this->controller->create_item( $request ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertSame( 3, $data['total'] ); + $this->assertSame( 3, $data['updated'] ); + $this->assertSame( 'updated', $data['results'][0]['status'] ); + } + + $this->assertSame( 1, $wp_posts[1]->menu_order ); + $this->assertSame( 2, $wp_posts[2]->menu_order ); + $this->assertSame( 3, $wp_posts[3]->menu_order ); + } + + /** + * @param array<string, mixed> $options + * @return Model&object{options: array<string, mixed>} + */ + private function createModelMock( array $options ) { + return new class( $options ) implements Model { + /** @var array<string, mixed> */ + public array $options; + + /** + * @param array<string, mixed> $options + */ + public function __construct( array $options ) { + $this->options = $options; + } + + public function setup(): void {} + + public function get_name(): string { + return 'book'; + } + + public function get_type(): string { + return 'post_type'; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return []; + } + }; + } +} diff --git a/tests/Rest/RestServerTest.php b/tests/Rest/RestServerTest.php new file mode 100644 index 00000000..0c29046d --- /dev/null +++ b/tests/Rest/RestServerTest.php @@ -0,0 +1,208 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\DuplicateController; +use Saltus\WP\Framework\Rest\ExportController; +use Saltus\WP\Framework\Rest\HealthController; +use Saltus\WP\Framework\Rest\MetaController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Rest\ModelsController; +use Saltus\WP\Framework\Rest\ReorderController; +use Saltus\WP\Framework\Rest\RestRouteDefinition; +use Saltus\WP\Framework\Rest\RestServer; +use Saltus\WP\Framework\Rest\SettingsController; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\RestServer + */ +class RestServerTest extends TestCase { + private Modeler $modeler; + + protected function setUp(): void { + global $wp_rest_routes_registered; + $wp_rest_routes_registered = []; + + $this->modeler = $this->createStub( Modeler::class ); + } + + public function testRegisterRoutesRegistersAllControllerRoutes(): void { + global $wp_rest_routes_registered; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + 'post_type', + [ + 'show_in_rest' => true, + 'saltus_rest' => true, + ] + ), + ] + ); + + $this->createServer()->register_routes(); + + $routes = array_map( + fn( $r ) => $r['route'], + $wp_rest_routes_registered + ); + + $expectedPatterns = [ 'health', 'models', 'duplicate', 'export', 'settings', 'meta', 'reorder' ]; + + foreach ( $expectedPatterns as $pattern ) { + $found = false; + foreach ( $routes as $route ) { + if ( str_contains( $route, $pattern ) ) { + $found = true; + break; + } + } + $this->assertTrue( $found, "Route containing '{$pattern}' was not registered." ); + } + } + + public function testRegisterRoutesRegistersMoreThanOneRoute(): void { + global $wp_rest_routes_registered; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + 'post_type', + [ + 'show_in_rest' => true, + 'saltus_rest' => [ + 'models' => true, + 'settings' => true, + ], + ] + ), + ] + ); + + $this->createServer()->register_routes(); + + $this->assertGreaterThan( 1, count( $wp_rest_routes_registered ) ); + } + + public function testRegisterRoutesRegistersOnlyHealthWithoutOptIn(): void { + global $wp_rest_routes_registered; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( 'post_type', [ 'show_in_rest' => true ] ), + ] + ); + + $this->createServer()->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertSame( '/health', $wp_rest_routes_registered[0]['route'] ); + } + + public function testRegisterRoutesRespectsShowInRestFalse(): void { + global $wp_rest_routes_registered; + + $this->modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + 'post_type', + [ + 'show_in_rest' => false, + 'saltus_rest' => true, + ] + ), + ] + ); + + $this->createServer()->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertSame( '/health', $wp_rest_routes_registered[0]['route'] ); + } + + /** + * @return Model&object{options: array<string, mixed>} + */ + private function createModelMock( string $type, array $options ) { + return new class( $type, $options ) implements Model { + private string $type; + /** @var array<string, mixed> */ + public array $options; + + /** + * @param array<string, mixed> $options + */ + public function __construct( string $type, array $options ) { + $this->type = $type; + $this->options = $options; + } + + public function setup(): void {} + + public function get_name(): string { + return ''; + } + + public function get_type(): string { + return $this->type; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return []; + } + }; + } + + private function createServer(): RestServer { + $policy = new ModelRestPolicy( $this->modeler ); + + return new RestServer( + $policy, + [ + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_HEALTH, + new HealthController( '2.0.0' ) + ), + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_MODELS, + new ModelsController( $this->modeler, $policy ) + ), + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_DUPLICATE, + new DuplicateController( $policy ), + 'post_type' + ), + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_EXPORT, + new ExportController( $policy ), + 'post_type' + ), + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_SETTINGS, + new SettingsController( $policy ), + 'post_type' + ), + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_META, + new MetaController( $this->modeler, $policy ), + 'post_type' + ), + new RestRouteDefinition( + ModelRestPolicy::CAPABILITY_REORDER, + new ReorderController( $policy ), + 'post_type' + ), + ] + ); + } +} diff --git a/tests/Rest/SettingsControllerTest.php b/tests/Rest/SettingsControllerTest.php new file mode 100644 index 00000000..339265fe --- /dev/null +++ b/tests/Rest/SettingsControllerTest.php @@ -0,0 +1,311 @@ +<?php + +namespace Saltus\WP\Framework\Tests\Rest; + +use PHPUnit\Framework\TestCase; +use Saltus\WP\Framework\Rest\SettingsController; +use Saltus\WP\Framework\Rest\ModelRestPolicy; +use Saltus\WP\Framework\Modeler; +use Saltus\WP\Framework\Models\Model; +use WP_REST_Request; +use WP_Error; + +require_once __DIR__ . '/functions.php'; + +/** + * @covers \Saltus\WP\Framework\Rest\SettingsController + */ +class SettingsControllerTest extends TestCase { + private SettingsController $controller; + + protected function setUp(): void { + global $wp_rest_routes_registered, $wp_current_user_can, $wp_options, $wp_post_type_objects; + $wp_rest_routes_registered = []; + $wp_current_user_can = true; + $wp_options = []; + $wp_post_type_objects = []; + + $this->controller = new SettingsController(); + } + + public function testConstructorSetsNamespaceAndRestBase(): void { + $this->assertSame( 'saltus-framework/v1', $this->getProtectedProperty( $this->controller, 'namespace' ) ); + $this->assertSame( 'settings', $this->getProtectedProperty( $this->controller, 'rest_base' ) ); + } + + private function getProtectedProperty( object $object, string $property ) { + $reflection = new \ReflectionProperty( $object, $property ); + $reflection->setAccessible( true ); + return $reflection->getValue( $object ); + } + + public function testRegisterRoutes(): void { + global $wp_rest_routes_registered; + + $this->controller->register_routes(); + + $this->assertCount( 1, $wp_rest_routes_registered ); + $this->assertStringContainsString( 'settings', $wp_rest_routes_registered[0]['route'] ); + } + + public function testGetItemPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testGetItemPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemPermissionsCheckUsesPostTypeEditCapability(): void { + global $wp_current_user_can, $wp_post_type_objects; + + $wp_post_type_objects['book'] = $this->postTypeObject( 'book', 'edit_books' ); + $wp_current_user_can = [ + 'edit_posts' => false, + 'edit_books' => true, + ]; + + $result = $this->controller->get_item_permissions_check( new WP_REST_Request( [ 'post_type' => 'book' ] ) ); + + $this->assertTrue( $result ); + } + + public function testUpdateItemPermissionsCheckReturnsTrueWhenAuthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = true; + + $result = $this->controller->update_item_permissions_check( new WP_REST_Request() ); + $this->assertTrue( $result ); + } + + public function testUpdateItemPermissionsCheckReturnsErrorWhenUnauthorized(): void { + global $wp_current_user_can; + $wp_current_user_can = false; + + $result = $this->controller->update_item_permissions_check( new WP_REST_Request() ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + public function testGetItemReturnsEmptySettingsByDefault(): void { + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $result = $this->controller->get_item( $request ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertSame( 'book', $data['post_type'] ); + $this->assertSame( [], $data['settings'] ); + } + } + + public function testGetItemReturnsNotFoundWhenModelDoesNotEnableSettings(): void { + $modeler = $this->createStub( Modeler::class ); + $modeler->method( 'get_models' )->willReturn( + [ + 'book' => $this->createModelMock( + [ + 'show_in_rest' => true, + 'saltus_rest' => [ 'settings' => false ], + ] + ), + ] + ); + $this->controller = new SettingsController( new ModelRestPolicy( $modeler ) ); + + $result = $this->controller->get_item( new WP_REST_Request( [ 'post_type' => 'book' ] ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'model_not_found', $result->get_error_code() ); + } + + public function testGetItemReturnsSavedSettings(): void { + global $wp_options; + $saved = [ 'display_title' => 'yes', 'show_excerpt' => 'no' ]; + update_option( 'saltus_framework_settings_book', $saved ); + + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $result = $this->controller->get_item( $request ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertSame( 'book', $data['post_type'] ); + $this->assertSame( $saved, $data['settings'] ); + } + } + + public function testUpdateItemReturnsErrorWhenNoSettingsProvided(): void { + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $request->set_json_params( [] ); + $result = $this->controller->update_item( $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'rest_empty_data', $result->get_error_code() ); + } + + public function testUpdateItemSavesAndReturnsSettings(): void { + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $request->set_json_params( [ 'display_title' => 'yes', 'show_excerpt' => 'no' ] ); + $result = $this->controller->update_item( $request ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $this->assertSame( 'book', $data['post_type'] ); + $this->assertSame( 'updated', $data['status'] ); + $this->assertArrayHasKey( 'settings', $data ); + } + + global $wp_options; + $this->assertArrayHasKey( 'saltus_framework_settings_book', $wp_options ); + } + + public function testUpdateItemSanitizesKeys(): void { + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $request->set_json_params( [ 'Display-Title' => 'yes' ] ); + $result = $this->controller->update_item( $request ); + + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + if ( is_array( $data ) ) { + $settings = $data['settings']; + $this->assertArrayHasKey( 'Display-Title', $settings ); + } + } + + public function testUpdateItemPreservesStructuredSettings(): void { + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $request->set_json_params( + [ + 'enabled' => true, + 'count' => 3, + 'group' => [ + 'Display Title' => ' yes ', + 'items' => [ + [ + 'label' => ' First ', + ], + ], + ], + ] + ); + + $result = $this->controller->update_item( $request ); + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertSame( + [ + 'enabled' => true, + 'count' => 3, + 'group' => [ + 'DisplayTitle' => 'yes', + 'items' => [ + [ + 'label' => 'First', + ], + ], + ], + ], + $data['settings'] + ); + } + + public function testUpdateItemSanitizesObjectsWithoutCastingFatal(): void { + $request = new WP_REST_Request( [ 'post_type' => 'book' ] ); + $request->set_json_params( + [ + 'group' => [ + 'object_value' => new \stdClass(), + ], + ] + ); + + $result = $this->controller->update_item( $request ); + $response = rest_ensure_response( $result ); + $data = $response->get_data(); + + $this->assertIsArray( $data ); + $this->assertSame( + [ + 'group' => [ + 'object_value' => '', + ], + ], + $data['settings'] + ); + } + + public function testGetItemSchema(): void { + $schema = $this->controller->get_item_schema(); + + $this->assertIsArray( $schema ); + $this->assertSame( 'settings', $schema['title'] ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'post_type', $schema['properties'] ); + $this->assertArrayHasKey( 'settings', $schema['properties'] ); + $this->assertTrue( $schema['properties']['post_type']['readonly'] ); + } + + /** + * @param array<string, mixed> $options + * @return Model&object{options: array<string, mixed>} + */ + private function createModelMock( array $options ) { + return new class( $options ) implements Model { + /** @var array<string, mixed> */ + public array $options; + + /** + * @param array<string, mixed> $options + */ + public function __construct( array $options ) { + $this->options = $options; + } + + public function setup(): void {} + + public function get_name(): string { + return 'book'; + } + + public function get_type(): string { + return 'post_type'; + } + + public function get_options(): array { + return $this->options; + } + + public function get_args(): array { + return []; + } + }; + } + + private function postTypeObject( string $post_type, string $edit_capability ): \stdClass { + $cap = new \stdClass(); + $cap->edit_posts = $edit_capability; + + return (object) [ + 'name' => $post_type, + 'cap' => $cap, + ]; + } +} diff --git a/tests/Rest/functions.php b/tests/Rest/functions.php new file mode 100644 index 00000000..0ecc8ab1 --- /dev/null +++ b/tests/Rest/functions.php @@ -0,0 +1,892 @@ +<?php + +/** + * Minimal WordPress function/class stubs for REST controller tests. + * Only loaded when Rest test suite runs. + */ + +if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', '/' ); +} + +if ( ! defined( 'WXR_VERSION' ) ) { + define( 'WXR_VERSION', '1.2' ); +} + +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private array $errors = []; + + public function __construct( string $code = '', string $message = '', array $data = [] ) { + if ( $code !== '' ) { + $this->errors[ $code ] = [ + 'message' => $message, + 'data' => $data, + ]; + } + } + + public function get_error_code(): ?string { + $keys = array_keys( $this->errors ); + return $keys[0] ?? null; + } + + public function get_error_message(): string { + $code = $this->get_error_code(); + return $code ? ( $this->errors[ $code ]['message'] ?? '' ) : ''; + } + + public function get_error_data( string $key = '' ) { + $code = $this->get_error_code(); + return $code ? ( $this->errors[ $code ]['data'] ?? [] ) : []; + } + } +} + +if ( ! class_exists( 'WP_REST_Response' ) ) { + class WP_REST_Response { + private $data; + private int $status; + + public function __construct( $data = [], int $status = 200 ) { + $this->data = $data; + $this->status = $status; + } + + public function get_data() { + return $this->data; + } + + public function get_status(): int { + return $this->status; + } + } +} + +if ( ! class_exists( 'WP_REST_Server' ) ) { + class WP_REST_Server { + public const READABLE = 'GET'; + public const CREATABLE = 'POST'; + public const EDITABLE = 'PUT'; + public const DELETABLE = 'DELETE'; + public const ALLMETHODS = 'GET,POST,PUT,PATCH,DELETE'; + } +} + +if ( ! class_exists( 'WP_REST_Request' ) ) { + class WP_REST_Request { + private array $params = []; + private array $json_params = []; + private string $method = 'GET'; + private string $route = ''; + + public function __construct( $method_or_params = [], string $route = '' ) { + if ( is_string( $method_or_params ) ) { + $this->method = $method_or_params; + $this->route = $route; + return; + } + + $params = $method_or_params; + $this->params = $params; + } + + public function get_param( string $key ) { + return $this->params[ $key ] ?? null; + } + + public function get_params(): array { + return $this->params; + } + + public function set_param( string $key, $value ): void { + $this->params[ $key ] = $value; + } + + public function set_json_params( array $params ): void { + $this->json_params = $params; + } + + public function get_json_params(): array { + return $this->json_params; + } + + public function set_body_params( array $params ): void { + $this->json_params = $params; + } + + public function get_method(): string { + return $this->method; + } + + public function get_route(): string { + return $this->route; + } + } +} + +if ( ! class_exists( 'WP_REST_Controller' ) ) { + class WP_REST_Controller { + protected string $namespace = ''; + protected string $rest_base = ''; + + public function get_endpoint_args_for_item_schema( string $method = 'GET' ): array { + return []; + } + + public function get_item_schema(): array { + return []; + } + } +} + +if ( ! class_exists( 'WP_Post' ) ) { + class WP_Post { + public int $ID = 0; + public string $post_type = 'post'; + public string $post_title = ''; + public string $post_status = 'draft'; + public string $post_content = ''; + public int $post_author = 0; + public string $post_name = ''; + public int $post_parent = 0; + public string $post_excerpt = ''; + public string $post_password = ''; + public string $comment_status = 'open'; + public string $ping_status = 'open'; + public int $menu_order = 0; + public string $to_ping = ''; + + public function __construct( array $properties = [] ) { + foreach ( $properties as $key => $value ) { + if ( property_exists( $this, $key ) ) { + $this->$key = $value; + } + } + } + } +} + +$wp_rest_routes_registered = []; +$wp_abilities_registered = []; +$wp_rest_request_log = []; +$wp_rest_response_override = null; +$wp_current_user_can = true; +$wp_is_admin = false; +$wp_filters_registered = []; +$wp_filter_values = []; +$wp_scripts_enqueued = []; +$wp_styles_enqueued = []; +$wp_scripts_localized = []; +$wp_nonce_valid = true; +$wp_meta_updates = []; +$wp_post_type_objects = []; +$wp_posts = []; +$wp_post_meta = []; +$wp_insert_post_without_storage = false; +$wp_options = []; +$wp_transients = []; +$wp_activation_hooks = []; +$wp_deactivation_hooks = []; +$wp_scheduled_events = []; +$wpdb = new class implements \Saltus\WP\Framework\MCP\Audit\AuditDatabase { + public string $prefix = 'wp_'; + public string $posts = 'wp_posts'; + /** @var list<array<string, mixed>> */ + public array $inserts = []; + /** @var list<string> */ + public array $queries = []; + + public function prefix(): string { + return $this->prefix; + } + + /** + * @param array<string, mixed> $data + * @param list<string> $format + */ + public function insert( string $table, array $data, array $format = [] ): bool { + $this->inserts[] = compact( 'table', 'data', 'format' ); + return true; + } + + public function query( string $query ): bool { + $this->queries[] = $query; + return true; + } + + public function prepare( string $query, ...$args ): string { + foreach ( $args as $arg ) { + $replacement = is_string( $arg ) ? "'" . $arg . "'" : (string) $arg; + $query = preg_replace( '/%[dsf]/', $replacement, $query, 1 ); + } + return $query; + } + + public function get_results( string $query, $output = null ) { + return array_reverse( array_map( fn( array $insert ) => $insert['data'], $this->inserts ) ); + } + + public function get_charset_collate(): string { + return ''; + } +}; + +if ( ! function_exists( 'register_rest_route' ) ) { + function register_rest_route( string $namespace, string $route, array $args = [], bool $override = false ): void { + global $wp_rest_routes_registered; + $wp_rest_routes_registered[] = compact( 'namespace', 'route', 'args', 'override' ); + } +} + +if ( ! function_exists( 'register_activation_hook' ) ) { + function register_activation_hook( string $file, callable $callback ): void { + global $wp_activation_hooks; + $wp_activation_hooks[] = compact( 'file', 'callback' ); + } +} + +if ( ! function_exists( 'register_deactivation_hook' ) ) { + function register_deactivation_hook( string $file, callable $callback ): void { + global $wp_deactivation_hooks; + $wp_deactivation_hooks[] = compact( 'file', 'callback' ); + } +} + +if ( ! function_exists( 'wp_next_scheduled' ) ) { + function wp_next_scheduled( string $hook ) { + global $wp_scheduled_events; + return $wp_scheduled_events[ $hook ]['timestamp'] ?? false; + } +} + +if ( ! function_exists( 'wp_schedule_event' ) ) { + function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): bool { + global $wp_scheduled_events; + $wp_scheduled_events[ $hook ] = compact( 'timestamp', 'recurrence', 'hook' ); + return true; + } +} + +if ( ! function_exists( 'wp_unschedule_event' ) ) { + function wp_unschedule_event( int $timestamp, string $hook ): bool { + global $wp_scheduled_events; + if ( isset( $wp_scheduled_events[ $hook ] ) && $wp_scheduled_events[ $hook ]['timestamp'] === $timestamp ) { + unset( $wp_scheduled_events[ $hook ] ); + } + return true; + } +} + +if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { + function wp_clear_scheduled_hook( string $hook ): void { + global $wp_scheduled_events; + unset( $wp_scheduled_events[ $hook ] ); + } +} + +if ( ! function_exists( 'flush_rewrite_rules' ) ) { + function flush_rewrite_rules( bool $hard = true ): void {} +} + +if ( ! function_exists( 'wp_register_ability' ) ) { + function wp_register_ability( string $name, array $args ): void { + global $wp_abilities_registered; + $wp_abilities_registered[ $name ] = $args; + } +} + +if ( ! function_exists( 'rest_do_request' ) ) { + function rest_do_request( WP_REST_Request $request ): WP_REST_Response { + global $wp_rest_request_log, $wp_rest_response_override; + $wp_rest_request_log[] = [ + 'method' => $request->get_method(), + 'route' => $request->get_route(), + 'params' => $request->get_json_params(), + 'query' => $request->get_params(), + ]; + if ( $wp_rest_response_override instanceof WP_REST_Response ) { + return $wp_rest_response_override; + } + return new WP_REST_Response( [ 'ok' => true, 'route' => $request->get_route() ] ); + } +} + +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $capability, ...$args ): bool { + global $wp_current_user_can; + if ( is_bool( $wp_current_user_can ) ) { + return $wp_current_user_can; + } + if ( is_array( $wp_current_user_can ) ) { + $key = $capability; + if ( $args !== [] ) { + $key .= ':' . implode( ':', array_map( 'strval', $args ) ); + } + + if ( array_key_exists( $key, $wp_current_user_can ) ) { + return (bool) $wp_current_user_can[ $key ]; + } + + if ( array_key_exists( $capability, $wp_current_user_can ) ) { + return (bool) $wp_current_user_can[ $capability ]; + } + + return false; + } + return true; + } +} + +if ( ! function_exists( 'get_post' ) ) { + function get_post( ?int $post_id = null ): ?WP_Post { + global $wp_posts; + if ( $post_id === null || $post_id === 0 ) { + return null; + } + return $wp_posts[ $post_id ] ?? null; + } +} + +if ( ! function_exists( 'rest_ensure_response' ) ) { + function rest_ensure_response( $value ) { + if ( $value instanceof WP_REST_Response || $value instanceof WP_Error ) { + return $value; + } + return new WP_REST_Response( $value ); + } +} + +if ( ! function_exists( 'admin_url' ) ) { + function admin_url( string $path = '', string $scheme = 'admin' ): string { + return 'http://example.com/wp-admin/' . ltrim( $path, '/' ); + } +} + +if ( ! function_exists( '__' ) ) { + function __( string $text, string $domain = 'default' ): string { + return $text; + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; + } +} + +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $tag, $value, ...$args ) { + global $wp_filters_registered, $wp_filter_values; + $wp_filters_registered = is_array( $wp_filters_registered ) ? $wp_filters_registered : []; + $wp_filter_values = is_array( $wp_filter_values ) ? $wp_filter_values : []; + if ( array_key_exists( $tag, $wp_filter_values ) ) { + $filter = $wp_filter_values[ $tag ]; + return is_callable( $filter ) ? $filter( $value, ...$args ) : $filter; + } + foreach ( $wp_filters_registered[ $tag ] ?? [] as $filter ) { + $value = $filter['callback']( $value, ...array_slice( $args, 0, $filter['accepted_args'] - 1 ) ); + } + return $value; + } +} + +if ( ! function_exists( 'do_action' ) ) { + function do_action( string $tag, ...$args ): void {} +} + +if ( ! function_exists( 'add_action' ) ) { + function add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { + global $wp_actions_registered; + $wp_actions_registered[] = compact( 'hook_name', 'callback', 'priority', 'accepted_args' ); + } +} + +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { + global $wp_filters_registered; + $wp_filters_registered = is_array( $wp_filters_registered ) ? $wp_filters_registered : []; + $wp_filters_registered[ $hook_name ][] = compact( 'hook_name', 'callback', 'priority', 'accepted_args' ); + } +} + +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( string $hook_name, callable $callback, int $priority = 10 ): bool { + global $wp_filters_registered; + if ( ! isset( $wp_filters_registered[ $hook_name ] ) ) { + return false; + } + foreach ( $wp_filters_registered[ $hook_name ] as $key => $filter ) { + if ( $filter['callback'] === $callback && $filter['priority'] === $priority ) { + unset( $wp_filters_registered[ $hook_name ][ $key ] ); + return true; + } + } + return false; + } +} + +if ( ! function_exists( 'has_filter' ) ) { + function has_filter( string $tag ): bool { + global $wp_filters_registered, $wp_filter_values; + $wp_filters_registered = is_array( $wp_filters_registered ) ? $wp_filters_registered : []; + $wp_filter_values = is_array( $wp_filter_values ) ? $wp_filter_values : []; + return ! empty( $wp_filters_registered[ $tag ] ) || array_key_exists( $tag, $wp_filter_values ); + } +} + +if ( ! function_exists( 'is_admin' ) ) { + function is_admin(): bool { + global $wp_is_admin; + return (bool) $wp_is_admin; + } +} + +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $option, $default = false ) { + global $wp_options; + return $wp_options[ $option ] ?? $default; + } +} + +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $option, $value, $autoload = null ): bool { + global $wp_options; + $wp_options[ $option ] = $value; + return true; + } +} + +if ( ! function_exists( 'delete_option' ) ) { + function delete_option( string $option ): bool { + global $wp_options; + unset( $wp_options[ $option ] ); + return true; + } +} + +if ( ! function_exists( 'get_transient' ) ) { + function get_transient( string $transient ) { + global $wp_transients; + $value = $wp_transients[ $transient ] ?? null; + if ( ! is_array( $value ) ) { + return false; + } + if ( $value['expires'] !== 0 && microtime( true ) >= $value['expires'] ) { + unset( $wp_transients[ $transient ] ); + return false; + } + return $value['value']; + } +} + +if ( ! function_exists( 'set_transient' ) ) { + function set_transient( string $transient, $value, int $expiration = 0 ): bool { + global $wp_transients; + $wp_transients[ $transient ] = [ + 'value' => $value, + 'expires' => $expiration > 0 ? microtime( true ) + $expiration : 0, + ]; + return true; + } +} + +if ( ! function_exists( 'delete_transient' ) ) { + function delete_transient( string $transient ): bool { + global $wp_transients; + unset( $wp_transients[ $transient ] ); + return true; + } +} + +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $value, int $flags = 0, int $depth = 512 ) { + return json_encode( $value, $flags, $depth ); + } +} + +if ( ! function_exists( 'get_locale' ) ) { + function get_locale(): string { + return 'en_US'; + } +} + +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( string $key ): string { + return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( $key ) ); + } +} + +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( string $str ): string { + return trim( $str ); + } +} + +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $value ) { + if ( is_string( $value ) ) { + return stripslashes( $value ); + } + return $value; + } +} + +if ( ! function_exists( 'wp_update_post' ) ) { + function wp_update_post( array $post_data, bool $wp_error = false ) { + global $wp_posts; + $id = $post_data['ID'] ?? 0; + if ( ! isset( $wp_posts[ $id ] ) ) { + return $wp_error ? new WP_Error( 'not_found', 'Post not found.' ) : 0; + } + foreach ( $post_data as $key => $value ) { + if ( $key !== 'ID' && property_exists( 'WP_Post', $key ) ) { + $wp_posts[ $id ]->$key = $value; + } + } + return $id; + } +} + +if ( ! function_exists( 'export_wp' ) ) { + function export_wp( array $args = [] ): void { + $wpdb = $GLOBALS['wpdb']; + $wp_posts = $GLOBALS['wp_posts'] ?? []; + + $args = apply_filters( 'export_args', $args ); + + $start_date = $args['start_date'] ?? false; + $end_date = $args['end_date'] ?? false; + + if ( $start_date && $end_date ) { + $start_date_str = date( 'Y-m-d', strtotime( $start_date ) ); + $end_date_str = date( 'Y-m-d', strtotime( '+1 month', strtotime( $start_date ) ) ); + } else { + $start_date_str = '1970-01-01'; + $end_date_str = '1971-01-01'; + } + + $post_type = $args['content'] ?? 'post'; + if ( $post_type === 'all' ) { + $post_type = 'post'; + } + + $sql = $wpdb->prepare( + "SELECT ID FROM {$wpdb->posts} WHERE {$wpdb->posts}.post_type = %s AND {$wpdb->posts}.post_status != 'auto-draft' AND {$wpdb->posts}.post_date >= %s AND {$wpdb->posts}.post_date < %s", + $post_type, + $start_date_str, + $end_date_str + ); + + $wp_query = new \WP_Query( [ + 'post_type' => $post_type, + 'post_status' => 'any', + 'date_query' => [ + [ + 'after' => $start_date_str, + 'before' => $end_date_str, + 'inclusive' => true, + ], + ], + ] ); + $sql = apply_filters( 'posts_request', $sql, $wp_query ); + + $post_id = 0; + if ( preg_match( '/=\s*(\d+)\s*$/', $sql, $matches ) ) { + $post_id = (int) $matches[1]; + } + + echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; + echo '<!-- WXR export -->' . "\n"; + echo '<rss version="2.0"><channel>' . "\n"; + echo '<wp:wxr_version>1.2</wp:wxr_version>' . "\n"; + + if ( $post_id && isset( $wp_posts[ $post_id ] ) ) { + $post = $wp_posts[ $post_id ]; + echo '<item>' . "\n"; + echo '<title>' . esc_html( $post->post_title ) . '' . "\n"; + echo 'post_content . ']]>' . "\n"; + echo 'post_excerpt . ']]>' . "\n"; + echo '' . (int) $post->ID . '' . "\n"; + echo '' . esc_html( $post->post_type ) . '' . "\n"; + echo '' . esc_html( $post->post_status ) . '' . "\n"; + echo '' . "\n"; + } + + echo '' . "\n"; + } +} + +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id(): int { + return 1; + } +} + +if ( ! function_exists( 'wp_insert_post' ) ) { + function wp_insert_post( array $args, bool $wp_error = false ) { + global $wp_posts, $wp_insert_post_without_storage; + $new_id = count( $wp_posts ) + 100; + $post = new WP_Post( $args ); + $post->ID = $new_id; + if ( $wp_insert_post_without_storage ) { + return $new_id; + } + $wp_posts[ $new_id ] = $post; + return $new_id; + } +} + +if ( ! function_exists( 'register_taxonomy' ) ) { + function register_taxonomy( string $taxonomy, $object_type, array $args = [] ): void { + global $wp_taxonomies_registered; + $wp_taxonomies_registered[ $taxonomy ] = compact( 'taxonomy', 'object_type', 'args' ); + } +} + +if ( ! function_exists( 'register_taxonomy_for_object_type' ) ) { + function register_taxonomy_for_object_type( string $taxonomy, string $object_type ): bool { + return true; + } +} + +if ( ! function_exists( 'get_object_taxonomies' ) ) { + function get_object_taxonomies( $object, string $output = 'names' ): array { + return []; + } +} + +if ( ! function_exists( 'get_post_meta' ) ) { + function get_post_meta( int $post_id, string $key = '', bool $single = false ) { + global $wp_post_meta; + if ( $key === '' ) { + return $wp_post_meta[ $post_id ] ?? []; + } + if ( ! array_key_exists( $post_id, $wp_post_meta ) || ! array_key_exists( $key, $wp_post_meta[ $post_id ] ) ) { + return $single ? '' : []; + } + if ( $single ) { + return is_array( $wp_post_meta[ $post_id ][ $key ] ) ? ( $wp_post_meta[ $post_id ][ $key ][0] ?? '' ) : $wp_post_meta[ $post_id ][ $key ]; + } + if ( is_array( $wp_post_meta[ $post_id ][ $key ] ) ) { + return $wp_post_meta[ $post_id ][ $key ]; + } + return [ $wp_post_meta[ $post_id ][ $key ] ]; + } +} + +if ( ! function_exists( 'update_post_meta' ) ) { + function update_post_meta( int $post_id, string $meta_key, $meta_value, $prev_value = '' ): bool { + global $wp_meta_updates, $wp_post_meta; + $wp_meta_updates[] = compact( 'post_id', 'meta_key', 'meta_value', 'prev_value' ); + $wp_post_meta[ $post_id ][ $meta_key ] = [ $meta_value ]; + return true; + } +} + +if ( ! function_exists( 'get_post_type' ) ) { + function get_post_type( ?int $post_id = null ) { + global $wp_posts; + if ( $post_id && isset( $wp_posts[ $post_id ] ) ) { + return $wp_posts[ $post_id ]->post_type; + } + return false; + } +} + +if ( ! function_exists( 'get_post_type_object' ) ) { + function get_post_type_object( string $post_type ): ?stdClass { + global $wp_post_type_objects; + if ( isset( $wp_post_type_objects[ $post_type ] ) ) { + return $wp_post_type_objects[ $post_type ]; + } + + $cap = new stdClass(); + $cap->edit_posts = 'edit_posts'; + $cap->create_posts = 'edit_posts'; + return (object) [ + 'name' => $post_type, + 'cap' => $cap, + ]; + } +} + +if ( ! function_exists( 'get_taxonomy' ) ) { + function get_taxonomy( string $taxonomy ): ?stdClass { + global $wp_taxonomy_objects; + if ( array_key_exists( $taxonomy, $wp_taxonomy_objects ) ) { + return $wp_taxonomy_objects[ $taxonomy ] instanceof stdClass ? $wp_taxonomy_objects[ $taxonomy ] : null; + } + + return (object) [ + 'name' => $taxonomy, + 'rest_base' => $taxonomy, + ]; + } +} + +if ( ! function_exists( 'trailingslashit' ) ) { + function trailingslashit( string $path ): string { + return rtrim( $path, '/\\' ) . '/'; + } +} + +if ( ! function_exists( 'plugins_url' ) ) { + function plugins_url( string $path = '', string $plugin = '' ): string { + return 'http://example.com/wp-content/plugins/' . ltrim( $path, '/' ); + } +} + +if ( ! function_exists( 'wp_safe_redirect' ) ) { + function wp_safe_redirect( string $location, int $status = 302 ): void { + // no-op + } +} + +if ( ! function_exists( 'add_query_arg' ) ) { + function add_query_arg( $key, $value = false, string $url = '' ): string { + if ( is_array( $key ) ) { + return $url . '?' . http_build_query( $key ); + } + return $url . '?' . $key . '=' . $value; + } +} + +if ( ! class_exists( 'WP_Query' ) ) { + class WP_Query { + public array $query = []; + private array $vars = []; + + public function __construct( array $query = [] ) { + $this->query = $query; + $this->vars = $query; + } + + public function get( string $key ) { + return $this->vars[ $key ] ?? null; + } + + public function set( string $key, $value ): void { + $this->vars[ $key ] = $value; + } + } +} + +if ( ! class_exists( 'WP_Term' ) ) { + class WP_Term { + public int $term_id = 0; + public string $name = ''; + public int $count = 0; + + public function __construct( array $properties = [] ) { + foreach ( $properties as $key => $value ) { + $this->$key = $value; + } + } + } +} + +if ( ! function_exists( 'wp_verify_nonce' ) ) { + function wp_verify_nonce( $nonce, string $action = '' ) { + global $wp_nonce_valid; + return $wp_nonce_valid; + } +} + +if ( ! function_exists( 'wp_create_nonce' ) ) { + function wp_create_nonce( string $action = '-1' ): string { + return 'nonce-' . $action; + } +} + +if ( ! function_exists( 'wp_nonce_url' ) ) { + function wp_nonce_url( string $actionurl, string $action = '-1', string $name = '_wpnonce' ): string { + return add_query_arg( $name, wp_create_nonce( $action ), $actionurl ); + } +} + +if ( ! function_exists( 'wp_nonce_field' ) ) { + function wp_nonce_field( string $action = '-1', string $name = '_wpnonce', bool $referer = true, bool $display = true ): string { + $field = ''; + if ( $display ) { + echo $field; + } + return $field; + } +} + +if ( ! function_exists( 'esc_attr' ) ) { + function esc_attr( $text ): string { + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $text ): string { + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); + } +} + +if ( ! function_exists( 'esc_js' ) ) { + function esc_js( $text ): string { + return addslashes( (string) $text ); + } +} + +if ( ! function_exists( 'esc_url' ) ) { + function esc_url( $url ): string { + return (string) $url; + } +} + +if ( ! function_exists( 'esc_html__' ) ) { + function esc_html__( string $text, string $domain = 'default' ): string { + return $text; + } +} + +if ( ! function_exists( 'wp_die' ) ) { + function wp_die( $message = '', $title = '', $args = [] ): void { + throw new \RuntimeException( is_scalar( $message ) ? (string) $message : 'wp_die called' ); + } +} + +if ( ! function_exists( 'wp_enqueue_script' ) ) { + function wp_enqueue_script( string $handle, string $src = '', array $deps = [], $ver = false, bool $in_footer = false ): void { + global $wp_scripts_enqueued; + $wp_scripts_enqueued[] = compact( 'handle', 'src', 'deps', 'ver', 'in_footer' ); + } +} + +if ( ! function_exists( 'wp_enqueue_style' ) ) { + function wp_enqueue_style( string $handle, string $src = '', array $deps = [], $ver = false, string $media = 'all' ): void { + global $wp_styles_enqueued; + $wp_styles_enqueued[] = compact( 'handle', 'src', 'deps', 'ver', 'media' ); + } +} + +if ( ! function_exists( 'wp_localize_script' ) ) { + function wp_localize_script( string $handle, string $object_name, array $l10n ): bool { + global $wp_scripts_localized; + $wp_scripts_localized[] = compact( 'handle', 'object_name', 'l10n' ); + return true; + } +} + +if ( ! function_exists( 'absint' ) ) { + function absint( $maybeint ): int { + return abs( (int) $maybeint ); + } +} + +if ( ! function_exists( 'wp_get_object_terms' ) ) { + function wp_get_object_terms( int $object_id, $taxonomies, array $args = [] ) { + return []; + } +} + +if ( ! function_exists( 'wp_set_object_terms' ) ) { + function wp_set_object_terms( int $object_id, $terms, string $taxonomy, bool $append = false ) { + return []; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..b5ccff56 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,8 @@ +register( 'dependent', GenericContainerDependentService::class, [ 'configured' ] ); + + $service = $container->get( 'dependent' ); + + $this->assertInstanceOf( GenericContainerDependentService::class, $service ); + $this->assertSame( 'configured', $service->value() ); + } + + public function testRegisterRejectsUnknownClass(): void { + $container = new GenericContainer(); + + $this->expectException( FailedToMakeInstance::class ); + $this->expectExceptionCode( FailedToMakeInstance::UNREFLECTABLE_CLASS ); + + $container->register( 'missing', 'Saltus\\WP\\Framework\\Tests\\MissingService' ); + } + + public function testRegisterRejectsNonServiceObjects(): void { + $container = new GenericContainer(); + + $this->expectException( Invalid::class ); + + $container->register( 'invalid', GenericContainerInvalidService::class ); + } +} + +class GenericContainerDependentService implements Service { + private string $value; + + public function __construct( string $value ) { + $this->value = $value; + } + + public function value(): string { + return $this->value; + } +} + +class GenericContainerInvalidService { +} diff --git a/tests/Unit/Infrastructure/Container/SimpleContainerTest.php b/tests/Unit/Infrastructure/Container/SimpleContainerTest.php new file mode 100644 index 00000000..13070262 --- /dev/null +++ b/tests/Unit/Infrastructure/Container/SimpleContainerTest.php @@ -0,0 +1,35 @@ +put( 'example', $service ); + + $this->assertTrue( $container->has( 'example' ) ); + $this->assertSame( $service, $container->get( 'example' ) ); + } + + public function testGetThrowsForMissingService(): void { + $container = new SimpleContainer(); + + $this->expectException( Invalid::class ); + $this->expectExceptionMessage( 'missing' ); + + $container->get( 'missing' ); + } +} + +class SimpleContainerTestService implements Service { +} diff --git a/tests/Unit/Infrastructure/Services/Assets/AssetDataTest.php b/tests/Unit/Infrastructure/Services/Assets/AssetDataTest.php new file mode 100644 index 00000000..5b10ae05 --- /dev/null +++ b/tests/Unit/Infrastructure/Services/Assets/AssetDataTest.php @@ -0,0 +1,38 @@ + 'abc123', + 'rest' => '/wp-json/saltus-framework/v1', + ] + ); + + $this->assertSame( 'assets/js/admin.js', $data->get_source() ); + $this->assertSame( 'SaltusAdmin', $data->get_identifier() ); + $this->assertSame( + [ + 'nonce' => 'abc123', + 'rest' => '/wp-json/saltus-framework/v1', + ], + $data->get_data() + ); + } + + public function testDataDefaultsToEmptyArray(): void { + $data = new AssetData( 'assets/js/admin.js', 'SaltusAdmin' ); + + $this->assertSame( [], $data->get_data() ); + } +} diff --git a/tests/Unit/ModelerLegacyTest.php b/tests/Unit/ModelerLegacyTest.php new file mode 100644 index 00000000..4904e567 --- /dev/null +++ b/tests/Unit/ModelerLegacyTest.php @@ -0,0 +1,216 @@ +tmp_dir = sys_get_temp_dir() . '/saltus-modeler-test-' . str_replace( '.', '', uniqid( '', true ) ); + mkdir( $this->tmp_dir, 0777, true ); + $this->resetWordPressFilters(); + } + + protected function tearDown(): void { + $this->removeDirectory( $this->tmp_dir ); + $this->resetWordPressFilters(); + parent::tearDown(); + } + + public function testInitUsesCurrentModelPathFilterAfterDeprecatedFilter(): void { + global $wp_filter_values; + + $deprecated_path = $this->tmp_dir . '/deprecated-models'; + $current_path = $this->tmp_dir . '/current-models'; + mkdir( $deprecated_path, 0777, true ); + mkdir( $current_path, 0777, true ); + file_put_contents( $current_path . '/model.json', '{"type":"post_type","name":"book"}' ); + + $wp_filter_values['saltus_models_path'] = $deprecated_path; + $wp_filter_values['saltus/framework/models/path'] = $current_path; + $created_names = []; + $modeler = new ExposedModeler( $this->factoryRecordingNames( $created_names ) ); + + $modeler->init( $this->tmp_dir ); + + $this->assertSame( [ 'book' ], $created_names ); + $this->assertArrayHasKey( 'book', $modeler->get_models() ); + } + + public function testLoadProcessesFilesInFilenameOrder(): void { + $models_path = $this->tmp_dir . '/models'; + mkdir( $models_path, 0777, true ); + file_put_contents( $models_path . '/b.json', '{"type":"post_type","name":"book"}' ); + file_put_contents( $models_path . '/a.json', '{"type":"post_type","name":"album"}' ); + file_put_contents( $models_path . '/ignored.txt', '{"type":"post_type","name":"ignored"}' ); + + $created_names = []; + $modeler = new ExposedModeler( $this->factoryRecordingNames( $created_names ) ); + + $modeler->loadFromPath( $models_path ); + + $this->assertSame( [ 'album', 'book' ], $created_names ); + $this->assertSame( [ 'album', 'book' ], array_keys( $modeler->get_models() ) ); + } + + public function testLoadProcessesMultipleModelsFromOneConfigFile(): void { + $models_path = $this->tmp_dir . '/models'; + mkdir( $models_path, 0777, true ); + file_put_contents( + $models_path . '/models.json', + '[{"type":"post_type","name":"book"},{"type":"taxonomy","name":"genre"}]' + ); + + $created_names = []; + $modeler = new ExposedModeler( $this->factoryRecordingNames( $created_names ) ); + + $modeler->loadFromPath( $models_path ); + + $this->assertSame( [ 'book', 'genre' ], $created_names ); + $this->assertSame( [ 'book', 'genre' ], array_keys( $modeler->get_models() ) ); + } + + public function testLoadProcessesCurrentExtraModelsFilterArray(): void { + global $wp_filter_values; + + $wp_filter_values['saltus/framework/models/extra_models'] = [ + [ + 'type' => 'post_type', + 'name' => 'book', + ], + [ + 'type' => 'taxonomy', + 'name' => 'genre', + ], + ]; + + $created_names = []; + $modeler = new ExposedModeler( $this->factoryRecordingNames( $created_names ) ); + + $modeler->loadFromPath( $this->tmp_dir . '/missing-models' ); + + $this->assertSame( [ 'book', 'genre' ], $created_names ); + $this->assertSame( [ 'book', 'genre' ], array_keys( $modeler->get_models() ) ); + } + + public function testContributesModelRestRouteAndMcpTools(): void { + $modeler = new Modeler( $this->createStub( ModelFactory::class ) ); + $policy = new ModelRestPolicy( $modeler ); + + $routes = $modeler->get_rest_routes( $modeler, $policy ); + $tools = $modeler->get_mcp_tools( $modeler, $policy ); + + $this->assertCount( 1, $routes ); + $this->assertSame( ModelRestPolicy::CAPABILITY_MODELS, $routes[0]->get_capability() ); + $this->assertNull( $routes[0]->get_model_type() ); + $this->assertStringContainsString( ModelsController::class, $this->describeRouteController( $routes[0] ) ); + $this->assertContainsOnlyInstancesOf( \Saltus\WP\Framework\MCP\Tools\ToolInterface::class, $tools ); + $tool_classes = array_map( static fn( object $tool ): string => get_class( $tool ), $tools ); + $this->assertContains( GetHealth::class, $tool_classes ); + $this->assertContains( ListModels::class, $tool_classes ); + $this->assertContains( CreatePost::class, $tool_classes ); + } + + /** + * @param list $created_names + */ + private function factoryRecordingNames( array &$created_names ): ModelFactory { + $factory = $this->getMockBuilder( ModelFactory::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'create' ] ) + ->getMock(); + + $factory->expects( $this->atLeastOnce() )->method( 'create' )->willReturnCallback( + function ( AbstractConfig $config ) use ( &$created_names ): ?Model { + $name = (string) $config->get( 'name' ); + $created_names[] = $name; + + return new NamedModel( $name, (string) $config->get( 'type' ) ); + } + ); + + return $factory; + } + + private function describeRouteController( object $route ): string { + $reflection = new \ReflectionClass( $route ); + $property = $reflection->getProperty( 'controller' ); + $property->setAccessible( true ); + + return get_class( $property->getValue( $route ) ); + } + + private function resetWordPressFilters(): void { + global $wp_filters_registered, $wp_filter_values; + $wp_filters_registered = []; + $wp_filter_values = []; + } + + private function removeDirectory( string $directory ): void { + if ( ! is_dir( $directory ) ) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $path ) { + $path->isDir() ? rmdir( $path->getPathname() ) : unlink( $path->getPathname() ); + } + rmdir( $directory ); + } +} + +class ExposedModeler extends Modeler { + public function loadFromPath( string $path ): void { + $this->load( $path ); + } +} + +class NamedModel implements Model { + private string $name; + private string $type; + + public function __construct( string $name, string $type ) { + $this->name = $name; + $this->type = $type; + } + + public function setup(): void {} + + public function get_name(): string { + return $this->name; + } + + public function get_type(): string { + return $this->type; + } + + public function get_options(): array { + return []; + } + + public function get_args(): array { + return []; + } +} diff --git a/tests/Unit/ModelerTest.php b/tests/Unit/ModelerTest.php new file mode 100644 index 00000000..34b56acd --- /dev/null +++ b/tests/Unit/ModelerTest.php @@ -0,0 +1,73 @@ +add( $model ); + } )->call( $modeler ); + } + + public function testGetModelsReturnsEmptyArrayInitially(): void { + $factory = $this->createStub( ModelFactory::class ); + $modeler = new Modeler( $factory ); + + $this->assertSame( [], $modeler->get_models() ); + } + + public function testAddStoresModelKeyedByName(): void { + $factory = $this->createStub( ModelFactory::class ); + $modeler = new Modeler( $factory ); + + $model = $this->createStub( Model::class ); + $model->method( 'get_name' )->willReturn( 'movie' ); + + $this->callAdd( $modeler, $model ); + + $models = $modeler->get_models(); + $this->assertArrayHasKey( 'movie', $models ); + $this->assertSame( $model, $models['movie'] ); + } + + public function testAddStoresMultipleModels(): void { + $factory = $this->createStub( ModelFactory::class ); + $modeler = new Modeler( $factory ); + + $movie = $this->createStub( Model::class ); + $movie->method( 'get_name' )->willReturn( 'movie' ); + + $book = $this->createStub( Model::class ); + $book->method( 'get_name' )->willReturn( 'book' ); + + $this->callAdd( $modeler, $movie ); + $this->callAdd( $modeler, $book ); + + $this->assertCount( 2, $modeler->get_models() ); + } + + public function testAddWithSameNameOverwrites(): void { + $factory = $this->createStub( ModelFactory::class ); + $modeler = new Modeler( $factory ); + + $first = $this->createStub( Model::class ); + $first->method( 'get_name' )->willReturn( 'movie' ); + + $second = $this->createStub( Model::class ); + $second->method( 'get_name' )->willReturn( 'movie' ); + + $this->callAdd( $modeler, $first ); + $this->callAdd( $modeler, $second ); + + $this->assertCount( 1, $modeler->get_models() ); + $this->assertSame( $second, $modeler->get_models()['movie'] ); + } +} diff --git a/tests/Unit/Models/BaseModelTest.php b/tests/Unit/Models/BaseModelTest.php new file mode 100644 index 00000000..83291fb4 --- /dev/null +++ b/tests/Unit/Models/BaseModelTest.php @@ -0,0 +1,20 @@ + 'movie', 'type' => 'post_type' ] ) ); + + $this->assertSame( 'movie', $model->get_name() ); + } +} diff --git a/tests/Unit/Models/Config/NoFileTest.php b/tests/Unit/Models/Config/NoFileTest.php new file mode 100644 index 00000000..91793b2e --- /dev/null +++ b/tests/Unit/Models/Config/NoFileTest.php @@ -0,0 +1,35 @@ + 'post-type', + 'settings' => [ + 'labels' => [ + 'name' => 'Books', + ], + ], + ] + ); + + $this->assertTrue( $config->has( 'type' ) ); + $this->assertSame( 'post-type', $config->get( 'type' ) ); + $this->assertSame( 'Books', $config->get( 'settings.labels.name' ) ); + } + + public function testMissingValueReturnsDefault(): void { + $config = new NoFile( [] ); + + $this->assertFalse( $config->has( 'missing' ) ); + $this->assertSame( 'fallback', $config->get( 'missing', 'fallback' ) ); + } +} diff --git a/tests/Unit/Models/ModelFactoryTest.php b/tests/Unit/Models/ModelFactoryTest.php new file mode 100644 index 00000000..24d9b3ae --- /dev/null +++ b/tests/Unit/Models/ModelFactoryTest.php @@ -0,0 +1,25 @@ +assertNull( $factory->create( new NoFile( [] ) ) ); + } + + public function testCreateReturnsNullForUnknownType(): void { + $factory = new ModelFactory( new SimpleContainer(), [] ); + + $this->assertNull( $factory->create( new NoFile( [ 'type' => 'unknown' ] ) ) ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..6c8c4f51 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ +