diff --git a/.gitignore b/.gitignore index 847d0b7..12c1f65 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # Dependencies /vendor .env +.env.integration +.env.testing # Build / Cache /build @@ -16,6 +18,7 @@ .pint.cache *.log coverage/ +coverage.xml # Artifacts .gemini/ diff --git a/AGENTS.md b/AGENTS.md index 77324ed..4abc4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ This repository is a PHP 8.5 package named `jooservices/wordpress-sdk`. - Use typed query DTOs for common list/read filters where practical. - Raw array escape hatches are allowed only as intentional, documented compatibility surfaces. - Avoid hidden domain strings when enums or constants are clearer. +- Do not add story/product-review/article content template features to SDK core; those belong in `jooservices/wordpress-content-templates`. ## Delivery checklist @@ -56,3 +57,11 @@ Before reporting completion: 2. Update README and docs for any public API change. 3. Run the relevant validation commands. 4. Report any GitHub-side or branch-flow actions that could not be verified or applied from the environment. + +## Integration testing standard + +- Do not run SDK integration tests against live WordPress sites. +- Use only disposable local WordPress test sites. Do not add Docker/WP Composer aliases unless the complete local-only runner, setup scripts, fixtures, and safety guards are committed in the same change. +- Integration tests must require a local WordPress URL and disposable test credentials. +- The SDK public surface should be covered by unit tests, integration tests, or explicit reasoned entries in the coverage-map script. +- Integration skips must name the exact service method, route, WordPress version, and theme/plugin reason. diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b6af5..ab4570c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to `jooservices/wordpress-sdk` are tracked here. The format follows Keep a Changelog, and this project aims to follow semantic versioning once releases are tagged from `master`. +## [Unreleased] + +## [1.2.0] - 2026-05-17 + +### Removed + +- Removed non-native content template classes, data objects, examples, and template-specific tests from SDK core. +- Removed `PostsService::createFromTemplate()` because content templates are not native WordPress REST API SDK features. + +### Changed + +- Kept `PostBuilder` and `ContentBuilder` as generic SDK-safe helpers for post payloads and Gutenberg-compatible block markup. +- Updated docs and testing guidance to point story, product-review, article, and custom content-template users to `jooservices/wordpress-content-templates`. + +### Migration + +- Install `jooservices/wordpress-content-templates` for extracted content templates: + +```bash +composer require jooservices/wordpress-content-templates +``` + +- Generate a payload with the template package and publish it with the SDK: + +```php +$post = $wordpress->posts()->create($template->toPostData()); +``` + ## [1.1.0] - 2026-05-16 ### Added @@ -18,10 +46,13 @@ The format follows Keep a Changelog, and this project aims to follow semantic ve - Updated README and documentation to reflect current endpoint coverage, security guidance, and deferred WordPress REST API groups. - Changed `composer test` and `composer test:coverage` to run the unit suite; live WordPress integration checks remain available through `composer test:integration` and `composer test:all`. +- Tightened `PostsService::createFromTemplate()` to the documented `PostTemplate` contract and aligned example documentation with the PHP 8.5 package baseline. +- Clarified in the README and user guide that builders and template utilities are optional SDK helper extras layered on top of the REST API surface. ### Fixed - Fixed `ContainerFactory` to build the SDK HTTP client through `ClientFactory` so resolving `WordPressService` from the container satisfies its typed constructor. +- Renamed the phase-based endpoint path unit test helpers to purpose-based names required by the repository standards. ### Tests diff --git a/README.md b/README.md index ef84559..23250f0 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ foreach ($posts as $post) { | Endpoint | Service | Status | | --- | --- | --- | -| Posts | `posts()` | CRUD + template helpers | +| Posts | `posts()` | CRUD | | Pages | `pages()` | CRUD | | Media | `media()` | list/get/upload/delete | | Users | `users()` | CRUD + `me()` | @@ -67,6 +67,17 @@ foreach ($posts as $post) { | Widgets / sidebars | `widgets()`, `widgetTypes()`, `sidebars()` | raw widget operations | | Site Health | `siteHealth()` | raw diagnostic test reads | +## SDK helper extras + +The SDK also ships with optional developer-experience helpers under `JOOservices\WordPress\Sdk\Support`. + +- `PostBuilder` helps assemble post payloads fluently before calling `posts()->create()` or `update()`. +- `ContentBuilder` helps generate Gutenberg-compatible block markup in PHP. + +These helpers are not native WordPress REST API resources. They remain in the SDK because they are generic payload and Gutenberg markup helpers for normal WordPress post operations. + +Content templates for stories, product reviews, articles, and template registries now live in `jooservices/wordpress-content-templates`. That package depends on this SDK and generates SDK-compatible post/page payloads; this SDK does not depend on it. + ## DTO-first querying The SDK accepts either raw query arrays or typed query DTOs for list and read operations. @@ -148,6 +159,7 @@ $health = $wordpress->siteHealth()->backgroundUpdates(); ``` Most of these endpoints require authenticated users with admin/editor capabilities. +Block renderer requests are sent with WordPress editor context because the REST renderer endpoint validates dynamic blocks against editor-only route context. ## Error handling @@ -184,11 +196,17 @@ composer lint:all composer test composer test:integration composer test:coverage +composer test:coverage:gate +composer test:coverage-map composer quality composer check composer ci ``` +Run `composer test:integration` only against a disposable local WordPress test site created for SDK testing. The stale Docker/WP Composer aliases from pre-release documentation are not shipped in 1.2.0 because their runner files are not present in this repository. + +`composer test:coverage` now includes a Clover XML audit gate. Aggregate statement coverage must stay at or above 90%, and no coverable production file, class, or method in `src/` may remain at 0% coverage unless it has a documented exclusion reason in `tools/test-coverage-gate.php`. + ## Security Use WordPress application passwords through environment variables. Do not commit live credentials, and do not log authorization headers or raw secrets. See [SECURITY.md](./SECURITY.md). diff --git a/composer.json b/composer.json index 8c9e994..133c462 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,13 @@ "test:all": "phpunit", "test:unit": "phpunit --testsuite=Unit", "test:integration": "phpunit --testsuite=Integration", - "test:coverage": "phpunit --testsuite=Unit --coverage-html=coverage --coverage-clover=coverage.xml", + "test:integration:extended": "phpunit --testsuite=ExtendedIntegration", + "test:coverage": [ + "phpunit --testsuite=Unit --coverage-html=coverage --coverage-clover=coverage.xml", + "@test:coverage:gate" + ], + "test:coverage:gate": "php tools/test-coverage-gate.php", + "test:coverage-map": "php tools/test-coverage-map.php", "security": "composer audit", "quality": [ "@lint", diff --git a/docs/02-user-guide/01-services-and-queries.md b/docs/02-user-guide/01-services-and-queries.md index 9d03f25..e6a8db3 100644 --- a/docs/02-user-guide/01-services-and-queries.md +++ b/docs/02-user-guide/01-services-and-queries.md @@ -16,6 +16,19 @@ Use the facade to access typed services: - `postTypes()` - `statuses()` +## Helper extras + +The `Support/` namespace contains optional SDK helper extras rather than native WordPress REST endpoints. + +Examples: + +- `PostBuilder` for fluent post payload construction +- `ContentBuilder` for Gutenberg block content generation + +Story, product-review, article, and custom content templates are provided by `jooservices/wordpress-content-templates`, which depends on this SDK and passes generated payloads to `posts()->create()`. + +These helpers are documented as SDK convenience layers on top of the normal REST services. + ## Query DTOs Core read and list endpoints accept either raw arrays or typed query DTOs. diff --git a/docs/02-user-guide/admin-editor-endpoints.md b/docs/02-user-guide/admin-editor-endpoints.md index a00d515..5172403 100644 --- a/docs/02-user-guide/admin-editor-endpoints.md +++ b/docs/02-user-guide/admin-editor-endpoints.md @@ -33,6 +33,8 @@ $rendered = $wp->blockRenderer()->render('core/latest-posts', ['postsToShow' => $directory = $wp->blockDirectory()->search(['term' => 'gallery']); ``` +Block renderer requests use WordPress editor context because dynamic server-rendered blocks validate against editor-only route context. + ## Menus and navigation ```php diff --git a/docs/02-user-guide/posts.md b/docs/02-user-guide/posts.md index d280ce8..c7f89eb 100644 --- a/docs/02-user-guide/posts.md +++ b/docs/02-user-guide/posts.md @@ -1,6 +1,6 @@ # Posts -`posts()` supports list, get, create, update, delete, template helpers, and post auto-pagination helpers. +`posts()` supports list, get, create, update, delete, and post auto-pagination helpers. ```php $posts = $wp->posts()->list(['per_page' => 10, 'status' => 'publish']); @@ -9,3 +9,5 @@ $created = $wp->posts()->create(['title' => 'Draft', 'status' => 'draft']); ``` Use `cursor()` or `each()` for large sites. Use `all()` only when loading all matching posts into memory is acceptable. + +Content template helpers are not part of SDK core. Use `jooservices/wordpress-content-templates` when you want story, product review, article, or custom content templates that generate payloads for `posts()->create()`. diff --git a/docs/03-examples/README.md b/docs/03-examples/README.md index 4555fed..84df360 100644 --- a/docs/03-examples/README.md +++ b/docs/03-examples/README.md @@ -6,3 +6,7 @@ This section collects complete workflows built from the public SDK services. - [Media Upload Workflow](./media-upload-workflow.md) - [Headless Export Workflow](./headless-export-workflow.md) - [Custom Post Type Workflow](./custom-post-type-workflow.md) + +Repository PHP examples: + +- Content template examples live in `jooservices/wordpress-content-templates`. diff --git a/docs/04-development/02-ci-cd.md b/docs/04-development/02-ci-cd.md index f568e8c..c693409 100644 --- a/docs/04-development/02-ci-cd.md +++ b/docs/04-development/02-ci-cd.md @@ -8,4 +8,6 @@ The workflow uses Composer scripts instead of duplicating raw tool invocations: - `composer test:coverage` - `composer security` +`composer test:coverage` is the coverage gate entrypoint. It must keep aggregate statement coverage at or above 90% and reject any coverable production file, class, or method in `src/` that still has 0% coverage in the Clover XML report. + Branch workflow follows the JOOservices `develop` and `master` model. Any missing branch-protection or remote branch setup must be verified directly in GitHub. diff --git a/docs/04-development/03-ai-guidance.md b/docs/04-development/03-ai-guidance.md index 4b52e5e..da3f016 100644 --- a/docs/04-development/03-ai-guidance.md +++ b/docs/04-development/03-ai-guidance.md @@ -13,3 +13,4 @@ Key rules: - keep test filenames matched to test class names - name tests and shared helpers for source class, domain, or precise behavior, not phases or buckets such as `P1`, `P2`, `Phase1`, `Phase2`, `Sprint`, `Milestone`, `Temporary`, `Temp`, `Misc`, `Bucket`, `NewEndpoints`, `OldEndpoints`, `Legacy`, or `WIP` - validate before reporting completion +- aggregate 90% coverage is not enough; run `composer test:coverage` so the Clover XML gate can reject any coverable `src/` file, class, or method that is still at 0% coverage diff --git a/docs/04-development/ai-contributor-guide.md b/docs/04-development/ai-contributor-guide.md index 94d2437..ce7140c 100644 --- a/docs/04-development/ai-contributor-guide.md +++ b/docs/04-development/ai-contributor-guide.md @@ -9,3 +9,9 @@ Test filenames must match their test class names, and test classes should descri Do not use phase, planning, temporary, or vague bucket names in code, tests, helpers, docs, or examples: `P1`, `P2`, `Phase1`, `Phase2`, `Sprint`, `Milestone`, `Temporary`, `Temp`, `Misc`, `Bucket`, `NewEndpoints`, `OldEndpoints`, `Legacy`, or `WIP`. Shared test helpers should use purpose-based names such as `RecordsServiceRequests` or `NullHttpClient`. If requirements are unclear, conflicting, missing, or cannot be verified from repository truth, stop and ask. + +Integration tests must never target a live WordPress site. Use only disposable local WordPress test sites, and do not restore Docker/WP Composer aliases without committing the complete local-only runner, setup scripts, fixtures, and safety guards in the same change. Public SDK classes and concrete service methods should be mapped by unit tests, integration tests, or an explicit reason in `tools/test-coverage-map.php`. + +Aggregate coverage alone is insufficient. Contributors must run `composer test:coverage`, which now audits `coverage.xml` and fails when overall statement coverage drops below 90% or any coverable production file, class, or method in `src/` is still at 0% coverage. Exclusions in `tools/test-coverage-gate.php` need a concrete reason. + +Integration skips must be precise and documented: name the service method, exact missing route, WordPress version, and active theme or plugin requirement. Do not use vague skip reasons. diff --git a/docs/04-development/testing.md b/docs/04-development/testing.md index 51cb64e..69ba7b2 100644 --- a/docs/04-development/testing.md +++ b/docs/04-development/testing.md @@ -12,6 +12,64 @@ composer test:unit composer test:integration composer test:all composer test:coverage +composer test:coverage:gate +composer test:coverage-map ``` -`composer test` and `composer test:coverage` run the unit suite so normal validation does not depend on a live WordPress server. Use `composer test:integration` or `composer test:all` when `WORDPRESS_URL`, `WORDPRESS_USER`, and `WORDPRESS_APP_PASSWORD` point at a reachable test site. +`composer test` and `composer test:coverage` run the unit suite so normal validation does not depend on WordPress. `composer test:integration` runs the integration suite and must only be used with a disposable local WordPress test site. + +`composer test:coverage` now runs a Clover XML coverage gate after PHPUnit writes `coverage.xml`. Aggregate statement coverage must stay at or above 90%. The gate also fails when any coverable production file, class, or method under `src/` is left at 0% coverage. Do not rely on the aggregate percentage alone. + +## Unit Tests + +Unit tests cover DTO hydration and serialization, query DTO output, endpoint path builders, auth middleware, response decoding, exception redaction, pagination helpers, service request construction, and generic builder support. They must use fake clients or deterministic local values and must not make network calls. + +## Integration Tests + +Integration tests must run only against a disposable local WordPress environment created for SDK testing. + +```bash +composer test:integration +``` + +The 1.2.0 release does not ship Docker/WP Composer aliases because the Docker runner files are not present in this repository. Do not add `composer test:wordpress`, `composer test:wordpress:extended`, or Docker aliases back without committing the complete local-only runner, setup scripts, fixtures, and safety guards in the same change. + +Template-specific integration tests live in `jooservices/wordpress-content-templates`. SDK integration tests stay focused on native WordPress REST API endpoints, generic post payload creation, media uploads, and Gutenberg content builder behavior. + +The local test site uses disposable credentials only. Do not commit real WordPress credentials. + +## Safety Guard + +Do not run SDK integration tests against production, staging, or personal WordPress sites. Until a complete Docker/WP runner is restored, contributors should treat the integration suite as manually provisioned and keep normal release validation on unit tests, coverage, coverage-map, static analysis, and security audit. + +## Coverage Map + +`composer test:coverage-map` reflects over `src/**/*.php` and verifies that production classes are mapped to unit coverage and concrete services are mapped to integration coverage. Pure interfaces are excluded. Abstract helpers, traits, and grouped tests require an explicit reason in `tools/test-coverage-map.php`. + +## Coverage Gate + +`composer test:coverage:gate` reads PHPUnit's Clover XML output from `coverage.xml`. It is deterministic and CI-safe because it does not parse HTML. The gate fails when any of these conditions are true: + +- overall statement coverage is below 90% +- a coverable production file in `src/` has 0 covered statements +- a coverable production class in `src/` has 0 covered statements or 0 covered methods +- a coverable production method in `src/` has 0 execution count + +If a production file, class, or method is truly non-coverable by project convention, add a documented exclusion reason in `tools/test-coverage-gate.php`. Do not exclude code only because it is inconvenient to test. Run the coverage gate before calling coverage work complete. + +## Skip and Version-Gate Policy + +Integration tests must check `/wp-json` route availability before skipping feature-dependent endpoints. Skip messages must include the service method, exact route, WordPress version, and active theme. Prefer Docker setup fixes over skips. + +Acceptable reasons include: + +- route missing in the tested WordPress version +- feature requires a block theme +- feature requires an optional plugin +- WordPress did not generate a revision for the test resource + +## Troubleshooting + +- Missing WordPress environment values: set `WORDPRESS_URL`, `WORDPRESS_USER`, and `WORDPRESS_APP_PASSWORD` for a disposable local test site only. +- WordPress server is unreachable: start the local test site and verify `/wp-json/` is reachable. +- Integration failures after resource creation: clean up the disposable local test site before rerunning. diff --git a/docs/guides/features.md b/docs/guides/features.md index d06c267..b054194 100644 --- a/docs/guides/features.md +++ b/docs/guides/features.md @@ -27,5 +27,4 @@ The following table outlines the current capabilities of the SDK services. The SDK includes powerful tools for generating content: - **ContentBuilder**: Fluent API for creating Gutenberg-compatible block content programmatically. -- **Templates**: Type-safe Post Templates (Stories, Product Reviews) for standardized content creation. - +- **Content templates**: Available in `jooservices/wordpress-content-templates`, outside SDK core. diff --git a/docs/guides/templates.md b/docs/guides/templates.md deleted file mode 100644 index 4e37d60..0000000 --- a/docs/guides/templates.md +++ /dev/null @@ -1,338 +0,0 @@ -# Post Templates - -The Template system provides a structured, type-safe way to create WordPress posts using predefined templates with Data Transfer Objects (DTOs). - -## Features - -- **Type Safety**: Constructor-based DTOs ensure all required fields are provided -- **Validation**: Built-in validation at construction time -- **Extensibility**: Easy to create custom templates by extending base classes -- **Clear Contracts**: DTOs clearly define required vs optional fields -- **IDE Support**: Full autocomplete and type hints - -## Architecture - -### Core Components - -1. **TemplateDataInterface**: Base interface for all template DTOs -2. **AbstractTemplateData**: Abstract base class with validation logic -3. **PostTemplate**: Abstract base class for template implementations -4. **Concrete Templates**: Specific implementations (Story, ProductReview, etc.) - -### Directory Structure - -``` -src/Support/Templates/ -├── Contracts/ -│ └── TemplateDataInterface.php -├── Data/ -│ ├── AbstractTemplateData.php -│ ├── StoryTemplateData.php -│ └── ProductReviewTemplateData.php -├── PostTemplate.php -├── StoryTemplate.php -└── ProductReviewTemplate.php -``` - -## Usage - -### Basic Example - -```php -use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryTemplateData; -use JOOservices\WordPress\Sdk\Support\Templates\StoryTemplate; - -// Create the DTO with required and optional fields -$data = new StoryTemplateData( - title: 'My Amazing Adventure', - introduction: 'It all started on a sunny day...', - body: 'The journey was filled with excitement...', - conclusion: 'And that\'s how it ended.', // Optional - author: 'John Doe', // Optional - tags: ['adventure', 'travel'] // Optional -); - -// Create the template -$template = new StoryTemplate($data); - -// Create the post -$post = $client->posts()->createFromTemplate($template); -``` - -### Available Templates - -#### 1. StoryTemplate - -Creates narrative-style posts with structured sections. - -**Required Fields:** -- `title`: Post title -- `introduction`: Story introduction/hook -- `body`: Main story content - -**Optional Fields:** -- `featuredImageId`: Featured image media ID -- `galleryImageIds`: Array of gallery image media IDs -- `conclusion`: Story conclusion/ending -- `author`: Author attribution -- `tags`: Post tags -- `categories`: Post category IDs - -**Generated Structure:** -- Introduction heading + paragraph -- Featured image (if provided) -- Story heading + body paragraph -- Gallery section with images (if provided) -- Conclusion section (if provided) -- Author attribution (if provided) - -#### 2. ProductReviewTemplate - -Creates structured product review posts with rating, pros/cons, and verdict. - -**Required Fields:** -- `productName`: Product name -- `reviewText`: Main review content -- `rating`: Product rating (1.0-5.0) - -**Optional Fields:** -- `productImageId`: Product image media ID -- `pros`: Array of pros -- `cons`: Array of cons -- `verdict`: Final verdict/recommendation -- `price`: Product price -- `tags`: Post tags - -**Generated Structure:** -- Product name as H1 -- Rating display with stars -- Price (if provided) -- Review section -- Pros section with checkmarks (if provided) -- Cons section with X marks (if provided) -- Verdict section (if provided) - -## Creating Custom Templates - -### 1. Create the DTO - -```php -namespace JOOservices\WordPress\Sdk\Support\Templates\Data; - -final class TutorialTemplateData extends AbstractTemplateData -{ - public function __construct( - public readonly string $title, // Required - public readonly string $introduction, // Required - public readonly array $steps, // Required - public readonly ?string $difficulty = null, // Optional - public readonly ?int $estimatedTime = null, // Optional - ) { - $this->validate(); - } - - protected function getRequiredFields(): array - { - return ['title', 'introduction', 'steps']; - } - - // Add custom validation if needed - public function validate(): void - { - parent::validate(); - - if (empty($this->steps)) { - throw new \InvalidArgumentException('Steps array cannot be empty'); - } - } -} -``` - -### 2. Create the Template - -```php -namespace JOOservices\WordPress\Sdk\Support\Templates; - -use JOOservices\WordPress\Sdk\Support\ContentBuilder\ContentBuilder; -use JOOservices\WordPress\Sdk\Support\Templates\Data\TutorialTemplateData; - -final class TutorialTemplate extends PostTemplate -{ - public function __construct(TutorialTemplateData $data) - { - parent::__construct($data); - } - - public function toContentBuilder(): ContentBuilder - { - /** @var TutorialTemplateData $data */ - $data = $this->data; - $builder = new ContentBuilder(); - - // Add introduction - $builder->addBlock(new Paragraph($data->introduction)); - - // Add steps - foreach ($data->steps as $index => $step) { - $builder->addBlock(new Heading("Step " . ($index + 1), 3)); - $builder->addBlock(new Paragraph($step)); - } - - return $builder; - } - - public function toPostData(): array - { - /** @var TutorialTemplateData $data */ - $data = $this->data; - - return [ - 'title' => $data->title, - 'content' => $this->toContentBuilder()->render(), - 'status' => 'draft', - 'meta' => [ - 'difficulty' => $data->difficulty, - 'estimated_time' => $data->estimatedTime, - ], - ]; - } -} -``` - -### 3. Use the Template - -```php -$data = new TutorialTemplateData( - title: 'How to Build a WordPress Plugin', - introduction: 'This tutorial will guide you through...', - steps: [ - 'Set up your development environment', - 'Create the plugin file structure', - 'Write the main plugin file', - 'Test your plugin', - ], - difficulty: 'intermediate', - estimatedTime: 45 -); - -$post = $client->posts()->createFromTemplate(new TutorialTemplate($data)); -``` - -## Benefits - -### Type Safety -- Constructor ensures all required fields are provided at compile time -- PHP 8.1+ readonly properties prevent accidental mutation -- IDE provides autocomplete for all fields - -### Validation -- Fails fast if data is invalid (at DTO construction) -- Custom validation rules per template -- Clear error messages - -### Maintainability -- Templates are self-contained and focused -- Easy to version and update -- Clear separation between data and presentation logic - -### Testability -- Easy to mock DTOs for testing -- Simple unit tests for each template -- Clear boundaries between components - -## Template Registry - -The `TemplateRegistry` allows you to register and manage custom templates in your application. - -### Basic Usage - -```php -use JOOservices\WordPress\Sdk\Support\Templates\TemplateRegistry; -use JOOservices\WordPress\Sdk\Support\Templates\StoryTemplate; -use JOOservices\WordPress\Sdk\Support\Templates\ProductReviewTemplate; - -// Create registry -$registry = new TemplateRegistry(); - -// Register built-in templates -$registry->register('story', StoryTemplate::class); -$registry->register('product-review', ProductReviewTemplate::class); - -// Register custom template -$registry->register('tutorial', TutorialTemplate::class); - -// Check if template exists -if ($registry->has('story')) { - $templateClass = $registry->get('story'); - // Use the template class... -} - -// List all registered templates -$templates = $registry->list(); // ['story', 'product-review', 'tutorial'] - -// Get all templates with their classes -$all = $registry->all(); -// [ -// 'story' => StoryTemplate::class, -// 'product-review' => ProductReviewTemplate::class, -// 'tutorial' => TutorialTemplate::class -// ] -``` - -### Using Registry with Factory Pattern - -```php -class PostFactory -{ - public function __construct( - private TemplateRegistry $registry, - private PostsService $postsService - ) {} - - public function createFromTemplate(string $templateName, array $data): Post - { - $templateClass = $this->registry->get($templateName); - - // Instantiate the template with data - // Note: You'll need to handle DTO creation based on your needs - $template = new $templateClass($data); - - return $this->postsService->createFromTemplate($template); - } -} -``` - -### Registry Methods - -- `register(string $name, string $templateClass)` - Register a template -- `get(string $name)` - Get template class by name -- `has(string $name)` - Check if template exists -- `list()` - Get all template names -- `all()` - Get all templates with their classes -- `unregister(string $name)` - Remove a template -- `clear()` - Remove all templates - -## Testing - -The template system includes comprehensive unit tests: - -```bash -# Run all template tests -./vendor/bin/phpunit tests/Unit/Templates - -# Run specific test class -./vendor/bin/phpunit tests/Unit/Templates/StoryTemplateDataTest.php -``` - -### Test Coverage - -- **DTO Validation Tests**: Ensure required fields are validated -- **Custom Validation Tests**: Test template-specific validation (e.g., rating range, price validation) -- **Content Generation Tests**: Verify correct Gutenberg block generation -- **Post Data Tests**: Ensure proper WordPress post data structure -- **Registry Tests**: Validate template registration and retrieval - -## See Also - -- [ContentBuilder Documentation](content-builder.md) -- [Example Usage](../../examples/template-usage.php) diff --git a/docs/services/posts.md b/docs/services/posts.md index f867d24..b67af8d 100644 --- a/docs/services/posts.md +++ b/docs/services/posts.md @@ -75,5 +75,4 @@ $deletedPost = $wp->posts()->delete(123, force: true); ## See Also - **[ContentBuilder](../guides/content-builder.md)**: generate complex post content programmatically. -- **[Templates](../guides/templates.md)**: create posts using structured templates. - +- Content template packages can generate payload arrays for `posts()->create()` without becoming SDK core features. diff --git a/examples/custom-template.php b/examples/custom-template.php deleted file mode 100644 index 605b7eb..0000000 --- a/examples/custom-template.php +++ /dev/null @@ -1,181 +0,0 @@ - $ingredients List of ingredients (required) - * @param array $instructions Cooking instructions (required) - * @param int|null $prepTime Preparation time in minutes (optional) - * @param int|null $cookTime Cooking time in minutes (optional) - * @param int|null $servings Number of servings (optional) - * @param string|null $difficulty Difficulty level (optional) - */ - public function __construct( - public readonly string $recipeName, - public readonly string $description, - public readonly array $ingredients, - public readonly array $instructions, - public readonly ?int $prepTime = null, - public readonly ?int $cookTime = null, - public readonly ?int $servings = null, - public readonly ?string $difficulty = null, - ) { - $this->validate(); - } - - protected function getRequiredFields(): array - { - return ['recipeName', 'description', 'ingredients', 'instructions']; - } -} - -// Step 2: Create your custom template -final class RecipeTemplate extends PostTemplate -{ - public function __construct(RecipeTemplateData $data) - { - parent::__construct($data); - } - - public function toContentBuilder(): ContentBuilder - { - /** @var RecipeTemplateData $data */ - $data = $this->data; - $builder = new ContentBuilder(); - - // Description - $builder->addBlock(new Paragraph($data->description)); - - // Recipe info - if ($data->prepTime || $data->cookTime || $data->servings) { - $info = []; - if ($data->prepTime) { - $info[] = "Prep: {$data->prepTime} min"; - } - if ($data->cookTime) { - $info[] = "Cook: {$data->cookTime} min"; - } - if ($data->servings) { - $info[] = "Servings: {$data->servings}"; - } - if ($data->difficulty) { - $info[] = "Difficulty: {$data->difficulty}"; - } - $builder->addBlock(new Paragraph(implode(' | ', $info))); - } - - // Ingredients - $builder->addBlock(new Heading('Ingredients', 2)); - foreach ($data->ingredients as $ingredient) { - $builder->addBlock(new Paragraph("• {$ingredient}")); - } - - // Instructions - $builder->addBlock(new Heading('Instructions', 2)); - foreach ($data->instructions as $index => $instruction) { - $step = $index + 1; - $builder->addBlock(new Heading("Step {$step}", 3)); - $builder->addBlock(new Paragraph($instruction)); - } - - return $builder; - } - - public function toPostData(): array - { - /** @var RecipeTemplateData $data */ - $data = $this->data; - - return [ - 'title' => $data->recipeName, - 'content' => $this->toContentBuilder()->render(), - 'status' => 'draft', - 'meta' => [ - 'prep_time' => $data->prepTime, - 'cook_time' => $data->cookTime, - 'servings' => $data->servings, - 'difficulty' => $data->difficulty, - ], - ]; - } -} - -// Step 3: Use the custom template - -// Initialize WordPress client -$client = WordPressService::create( - baseUrl: (string) getenv('WORDPRESS_URL'), - username: (string) getenv('WORDPRESS_USER'), - password: (string) getenv('WORDPRESS_APP_PASSWORD') -); - -// Create recipe data -$recipeData = new RecipeTemplateData( - recipeName: 'Classic Chocolate Chip Cookies', - description: 'Delicious homemade chocolate chip cookies that are crispy on the outside and chewy on the inside.', - ingredients: [ - '2 1/4 cups all-purpose flour', - '1 tsp baking soda', - '1 tsp salt', - '1 cup (2 sticks) butter, softened', - '3/4 cup granulated sugar', - '3/4 cup packed brown sugar', - '2 large eggs', - '2 tsp vanilla extract', - '2 cups chocolate chips', - ], - instructions: [ - 'Preheat oven to 375°F (190°C).', - 'Combine flour, baking soda, and salt in a small bowl.', - 'Beat butter, granulated sugar, and brown sugar in a large mixer bowl until creamy.', - 'Add eggs and vanilla extract; beat well.', - 'Gradually beat in flour mixture.', - 'Stir in chocolate chips.', - 'Drop rounded tablespoons of dough onto ungreased baking sheets.', - 'Bake for 9 to 11 minutes or until golden brown.', - 'Cool on baking sheets for 2 minutes; remove to wire racks to cool completely.', - ], - prepTime: 15, - cookTime: 11, - servings: 48, - difficulty: 'Easy' -); - -// Create the template -$recipeTemplate = new RecipeTemplate($recipeData); - -// Create the post -$post = $client->posts()->createFromTemplate($recipeTemplate); - -echo "Recipe Post created with ID: {$post->id}\n"; -echo "Title: {$post->title->rendered}\n\n"; - -// Step 4: Register the template for reuse - -$registry = new TemplateRegistry(); -$registry->register('recipe', RecipeTemplate::class); - -echo "Custom template 'recipe' registered!\n"; -echo "Available templates: " . implode(', ', $registry->list()) . "\n"; diff --git a/examples/template-usage.php b/examples/template-usage.php deleted file mode 100644 index 1ad0ee7..0000000 --- a/examples/template-usage.php +++ /dev/null @@ -1,74 +0,0 @@ -posts()->createFromTemplate($storyTemplate); - -echo "Story Post created with ID: {$storyPost->id}\n"; -echo "Title: {$storyPost->title->rendered}\n\n"; - -// Example 2: Create a Product Review Post -echo "Creating Product Review Post...\n"; - -$reviewData = new ProductReviewTemplateData( - productName: 'Awesome Gadget Pro 2024', - reviewText: 'This product exceeded all my expectations. The build quality is exceptional, and the performance is outstanding. After using it for several weeks, I can confidently say it\'s worth every penny.', - rating: 4.5, - pros: [ - 'Excellent battery life (lasts 2+ days)', - 'Premium build quality with aluminum chassis', - 'Lightning-fast performance', - 'Intuitive user interface', - 'Great value for money', - ], - cons: [ - 'Slightly heavier than competitors', - 'No headphone jack', - 'Limited color options', - ], - verdict: 'Highly recommended for power users and professionals. While it has minor drawbacks, the overall package is impressive and delivers exceptional value.', - price: 999.99, - tags: ['review', 'technology', 'gadgets', 'electronics'] -); - -$reviewTemplate = new ProductReviewTemplate($reviewData); -$reviewPost = $client->posts()->createFromTemplate($reviewTemplate); - -echo "Product Review Post created with ID: {$reviewPost->id}\n"; -echo "Title: {$reviewPost->title->rendered}\n\n"; - -echo "All posts created successfully!\n"; diff --git a/src/Data/Page.php b/src/Data/Page.php index 3eaf9da..1639ba6 100644 --- a/src/Data/Page.php +++ b/src/Data/Page.php @@ -4,6 +4,67 @@ namespace JOOservices\WordPress\Sdk\Data; +/** + * @SuppressWarnings("PHPMD.TooManyFields") + * @SuppressWarnings("PHPMD.ExcessiveParameterList") + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + * @SuppressWarnings("PHPMD.CamelCaseParameterName") + * @SuppressWarnings("PHPMD.CamelCaseVariableName") + */ class Page extends Post { + /** + * @param array $meta + */ + public function __construct( + int $id, + string $date, + string $date_gmt, + RenderedContent $guid, + string $modified, + string $modified_gmt, + string $slug, + string $status, + string $type, + string $link, + RenderedContent $title, + RenderedContent $content, + RenderedContent $excerpt, + int $author, + int $featured_media, + string $comment_status, + string $ping_status, + string $template, + array $meta, + bool $sticky = false, + string $format = 'standard', + array $categories = [], + array $tags = [], + ) { + parent::__construct( + id: $id, + date: $date, + date_gmt: $date_gmt, + guid: $guid, + modified: $modified, + modified_gmt: $modified_gmt, + slug: $slug, + status: $status, + type: $type, + link: $link, + title: $title, + content: $content, + excerpt: $excerpt, + author: $author, + featured_media: $featured_media, + comment_status: $comment_status, + ping_status: $ping_status, + sticky: $sticky, + template: $template, + format: $format, + meta: $meta, + categories: $categories, + tags: $tags, + ); + } } diff --git a/src/Data/Status.php b/src/Data/Status.php index 1203e7e..8b5a31d 100644 --- a/src/Data/Status.php +++ b/src/Data/Status.php @@ -15,11 +15,11 @@ class Status extends Dto { public function __construct( public readonly string $name, - public readonly bool $public, - public readonly bool $protected, - public readonly bool $private, - public readonly bool $queryable, - public readonly bool $show_in_list, + public readonly bool $public = false, + public readonly bool $protected = false, + public readonly bool $private = false, + public readonly bool $queryable = false, + public readonly bool $show_in_list = false, public readonly bool $date_floating = false, ) { } diff --git a/src/Http/AbstractService.php b/src/Http/AbstractService.php index 5bc23ab..5efcbe9 100644 --- a/src/Http/AbstractService.php +++ b/src/Http/AbstractService.php @@ -48,27 +48,12 @@ protected function request(string $method, string $uri, array $options = []): Re if (isset($options['multipart'])) { $clientOptions['multipart'] = $options['multipart']; - // Ensure body is null if multipart is used to avoid conflict, - // though RequestBuilder usually creates a stream. - // If we skipped withJson, body should be empty stream or null. - // Guzzle prefers 'multipart' key over 'body' usually, or checks specific usage. } try { - // This means the interface definition I viewed earlier might be different or I misread it. - // Let me check the file content of HttpClientInterface again to be sure. - // For now, I'll revert to attempting to use `request` or check the file first. $wrapper = $this->client->request($method, (string) $request->getUri(), $clientOptions); $response = $wrapper->toPsrResponse(); } catch (\Throwable $e) { - // If the client throws a specific exception (network error), we might want to wrap it - // or let it bubble up. - // But if it's an HTTP error response that didn't throw (depending on client config), - // we handle it below. - // jooservices/client likely throws on 4xx/5xx by default? - // Let's assume we need to catch ClientExceptionInterface and map it, - // or if the client returns a response for 4xx/5xx (http_errors=false), we check status. - // For this implementation, let's assume valid response or exception. throw new RuntimeException($e->getMessage(), $e->getCode(), $e); } @@ -278,22 +263,6 @@ private function buildRequest(string $method, string $uri, array $options): Requ } } - // If multipart is set, we let the Client handle the body construction (Guzzle/Client specific) - // or we need to build it here. - // RequestBuilder currently only supports withJson or withBody(Stream). - // Since `jooservices/client` likely takes `multipart` array in options, - // we should NOT set the body here if multipart is present, - // BUT we need to pass the multipart options to the client later. - - // HOWEVER, `request()` extracts `body` from the builder. - // So we need a way to pass `multipart` effectively. - // IF we rely on the client wrapper `request($method, $uri, $options)`: - // The `AbstractService::request` method currently overwrites `body` from `$request->getBody()`. - - // Solution: - // 1. If `multipart` is in $options, DO NOT call `withJson`. - // 2. The `request` method needs to merge `multipart` into `$clientOptions`. - if (isset($options['body']) && !isset($options['multipart'])) { $builder = $builder->withJson($options['body']); } diff --git a/src/Services/BlockRendererService.php b/src/Services/BlockRendererService.php index 7b9982a..0704c42 100644 --- a/src/Services/BlockRendererService.php +++ b/src/Services/BlockRendererService.php @@ -14,7 +14,7 @@ class BlockRendererService extends RawEndpointService */ public function render(string $name, array $attributes = [], ?int $postId = null): array { - $query = []; + $query = ['context' => 'edit']; if ($attributes !== []) { $query['attributes'] = $attributes; } diff --git a/src/Services/PluginsService.php b/src/Services/PluginsService.php index 92149e9..b8cc964 100644 --- a/src/Services/PluginsService.php +++ b/src/Services/PluginsService.php @@ -20,7 +20,7 @@ public function list(array $query = []): array */ public function get(string $plugin): array { - return $this->getRaw('wp/v2/plugins/' . $this->segment($plugin)); + return $this->getRaw('wp/v2/plugins/' . $this->pluginPath($plugin)); } /** @@ -38,7 +38,7 @@ public function create(array $payload): array */ public function update(string $plugin, array $payload): array { - return $this->postRaw('wp/v2/plugins/' . $this->segment($plugin), $payload); + return $this->postRaw('wp/v2/plugins/' . $this->pluginPath($plugin), $payload); } /** @@ -46,6 +46,14 @@ public function update(string $plugin, array $payload): array */ public function delete(string $plugin): array { - return $this->deleteRaw('wp/v2/plugins/' . $this->segment($plugin)); + return $this->deleteRaw('wp/v2/plugins/' . $this->pluginPath($plugin)); + } + + private function pluginPath(string $plugin): string + { + return implode('/', array_map( + fn (string $segment): string => $this->segment($segment), + explode('/', trim($plugin, '/')) + )); } } diff --git a/src/Services/PostsService.php b/src/Services/PostsService.php index 36f7a52..179e6f1 100644 --- a/src/Services/PostsService.php +++ b/src/Services/PostsService.php @@ -155,17 +155,4 @@ public function delete(int $id, bool $force = false): Post // Standard delete (trash) returns the object directly return $this->decoder->deserialize($data, Post::class); } - - /** - * Create a post from a template. - * - * @param \JOOservices\WordPress\Sdk\Support\Templates\PostTemplate $template - * @return Post - */ - public function createFromTemplate(object $template): Post - { - $postData = $template->toPostData(); - - return $this->create($postData); - } } diff --git a/src/Support/Templates/Contracts/TemplateDataInterface.php b/src/Support/Templates/Contracts/TemplateDataInterface.php deleted file mode 100644 index cb8d71e..0000000 --- a/src/Support/Templates/Contracts/TemplateDataInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ - public function toArray(): array; -} diff --git a/src/Support/Templates/Data/AbstractTemplateData.php b/src/Support/Templates/Data/AbstractTemplateData.php deleted file mode 100644 index 402e78e..0000000 --- a/src/Support/Templates/Data/AbstractTemplateData.php +++ /dev/null @@ -1,51 +0,0 @@ -getRequiredFields() as $field) { - if (!property_exists($this, $field)) { - throw new InvalidArgumentException("Property {$field} does not exist"); - } - - $value = $this->$field; - - if ($value === null || $value === '' || $value === []) { - throw new InvalidArgumentException("Field {$field} is required and cannot be empty"); - } - } - } - - /** - * Get the list of required field names. - * - * @return array - */ - abstract protected function getRequiredFields(): array; - - /** - * Convert the template data to an array. - * - * @return array - */ - public function toArray(): array - { - return get_object_vars($this); - } -} diff --git a/src/Support/Templates/Data/ProductReviewTemplateData.php b/src/Support/Templates/Data/ProductReviewTemplateData.php deleted file mode 100644 index ff3fcf0..0000000 --- a/src/Support/Templates/Data/ProductReviewTemplateData.php +++ /dev/null @@ -1,58 +0,0 @@ -|null $pros List of pros (optional) - * @param array|null $cons List of cons (optional) - * @param string|null $verdict Final verdict/recommendation (optional) - * @param float|null $price Product price (optional) - * @param array|null $tags Post tags (optional) - */ - public function __construct( - public readonly string $productName, - public readonly string $reviewText, - public readonly float $rating, - public readonly ?int $productImageId = null, - public readonly ?array $pros = null, - public readonly ?array $cons = null, - public readonly ?string $verdict = null, - public readonly ?float $price = null, - public readonly ?array $tags = null, - ) { - $this->validate(); - } - - protected function getRequiredFields(): array - { - return ['productName', 'reviewText', 'rating']; - } - - public function validate(): void - { - parent::validate(); - - if ($this->rating < 1.0 || $this->rating > 5.0) { - throw new InvalidArgumentException('Rating must be between 1.0 and 5.0'); - } - - if ($this->price !== null && $this->price < 0) { - throw new InvalidArgumentException('Price cannot be negative'); - } - } -} diff --git a/src/Support/Templates/Data/StoryTemplateData.php b/src/Support/Templates/Data/StoryTemplateData.php deleted file mode 100644 index 5647722..0000000 --- a/src/Support/Templates/Data/StoryTemplateData.php +++ /dev/null @@ -1,43 +0,0 @@ -|null $galleryImageIds Gallery image media IDs (optional) - * @param string|null $conclusion Story conclusion/ending (optional) - * @param string|null $author Author attribution (optional) - * @param array|null $tags Post tags (optional) - * @param array|null $categories Post category IDs (optional) - */ - public function __construct( - public readonly string $title, - public readonly string $introduction, - public readonly string $body, - public readonly ?int $featuredImageId = null, - public readonly ?array $galleryImageIds = null, - public readonly ?string $conclusion = null, - public readonly ?string $author = null, - public readonly ?array $tags = null, - public readonly ?array $categories = null, - ) { - $this->validate(); - } - - protected function getRequiredFields(): array - { - return ['title', 'introduction', 'body']; - } -} diff --git a/src/Support/Templates/PostTemplate.php b/src/Support/Templates/PostTemplate.php deleted file mode 100644 index 3c9d198..0000000 --- a/src/Support/Templates/PostTemplate.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ - abstract public function toPostData(): array; - - /** - * Get the template data. - */ - protected function getData(): TemplateDataInterface - { - return $this->data; - } -} diff --git a/src/Support/Templates/ProductReviewTemplate.php b/src/Support/Templates/ProductReviewTemplate.php deleted file mode 100644 index 778e0bf..0000000 --- a/src/Support/Templates/ProductReviewTemplate.php +++ /dev/null @@ -1,123 +0,0 @@ -data; - $builder = new ContentBuilder(); - - // Product heading - $builder->addBlock( - new Heading($data->productName, 1) - ); - - // Rating display - $stars = str_repeat('⭐', (int) round($data->rating)); - $builder->addBlock( - new Paragraph("Rating: {$data->rating}/5 {$stars}") - ); - - // Price if provided - if ($data->price !== null) { - $builder->addBlock( - new Paragraph("Price: $" . number_format($data->price, 2)) - ); - } - - // Review section - $builder->addBlock( - new Heading('Review', 2) - ); - $builder->addBlock( - new Paragraph($data->reviewText) - ); - - // Pros section - if ($data->pros !== null && count($data->pros) > 0) { - $builder->addBlock( - new Heading('Pros', 3) - ); - foreach ($data->pros as $pro) { - $builder->addBlock( - new Paragraph("✓ {$pro}") - ); - } - } - - // Cons section - if ($data->cons !== null && count($data->cons) > 0) { - $builder->addBlock( - new Heading('Cons', 3) - ); - foreach ($data->cons as $con) { - $builder->addBlock( - new Paragraph("✗ {$con}") - ); - } - } - - // Verdict section - if ($data->verdict !== null) { - $builder->addBlock( - new Heading('Verdict', 2) - ); - $builder->addBlock( - new Paragraph($data->verdict) - ); - } - - return $builder; - } - - public function toPostData(): array - { - /** @var ProductReviewTemplateData $data */ - $data = $this->data; - - $postData = [ - 'title' => "Review: {$data->productName}", - 'content' => $this->toContentBuilder()->render(), - 'status' => 'draft', - ]; - - // Add optional fields - if ($data->productImageId !== null) { - $postData['featured_media'] = $data->productImageId; - } - - if ($data->tags !== null) { - $postData['tags'] = $data->tags; - } - - // Store rating and price in meta - $postData['meta'] = [ - 'rating' => $data->rating, - ]; - if ($data->price !== null) { - $postData['meta']['price'] = $data->price; - } - - return $postData; - } -} diff --git a/src/Support/Templates/StoryTemplate.php b/src/Support/Templates/StoryTemplate.php deleted file mode 100644 index 5720882..0000000 --- a/src/Support/Templates/StoryTemplate.php +++ /dev/null @@ -1,113 +0,0 @@ -data; - $builder = new ContentBuilder(); - - // Introduction section - $builder->addBlock( - new Heading('Introduction', 2) - ); - $builder->addBlock( - new Paragraph($data->introduction) - ); - - // Featured image if provided - if ($data->featuredImageId !== null) { - $builder->addBlock( - new Image($data->featuredImageId) - ); - } - - // Main body content - $builder->addBlock( - new Heading('Story', 2) - ); - $builder->addBlock( - new Paragraph($data->body) - ); - - // Gallery section if provided - if ($data->galleryImageIds !== null && count($data->galleryImageIds) > 0) { - $builder->addBlock( - new Heading('Gallery', 2) - ); - - foreach ($data->galleryImageIds as $imageId) { - $builder->addBlock( - new Image($imageId) - ); - } - } - - // Conclusion section if provided - if ($data->conclusion !== null) { - $builder->addBlock( - new Heading('Conclusion', 2) - ); - $builder->addBlock( - new Paragraph($data->conclusion) - ); - } - - // Author attribution if provided - if ($data->author !== null) { - $builder->addBlock( - new Paragraph("— {$data->author}") - ); - } - - return $builder; - } - - public function toPostData(): array - { - /** @var StoryTemplateData $data */ - $data = $this->data; - - $postData = [ - 'title' => $data->title, - 'content' => $this->toContentBuilder()->render(), - 'status' => 'draft', - ]; - - // Add optional fields if provided - if ($data->featuredImageId !== null) { - $postData['featured_media'] = $data->featuredImageId; - } - - if ($data->tags !== null) { - $postData['tags'] = $data->tags; - } - - if ($data->categories !== null) { - $postData['categories'] = $data->categories; - } - - return $postData; - } -} diff --git a/src/Support/Templates/TemplateRegistry.php b/src/Support/Templates/TemplateRegistry.php deleted file mode 100644 index 1712255..0000000 --- a/src/Support/Templates/TemplateRegistry.php +++ /dev/null @@ -1,112 +0,0 @@ -> - */ - private array $templates = []; - - /** - * Register a template class. - * - * @param string $name Template identifier (e.g., 'story', 'product-review') - * @param class-string $templateClass Fully qualified template class name - * @throws InvalidArgumentException If template name already registered - */ - public function register(string $name, string $templateClass): void - { - if (isset($this->templates[$name])) { - throw new InvalidArgumentException("Template '{$name}' is already registered"); - } - - if (!class_exists($templateClass)) { - throw new InvalidArgumentException("Template class '{$templateClass}' does not exist"); - } - - if (!is_subclass_of($templateClass, PostTemplate::class)) { - throw new InvalidArgumentException( - "Template class '{$templateClass}' must extend " . PostTemplate::class - ); - } - - $this->templates[$name] = $templateClass; - } - - /** - * Get a template class by name. - * - * @param string $name Template identifier - * @return class-string - * @throws InvalidArgumentException If template not found - */ - public function get(string $name): string - { - if (!isset($this->templates[$name])) { - throw new InvalidArgumentException("Template '{$name}' is not registered"); - } - - return $this->templates[$name]; - } - - /** - * Check if a template is registered. - */ - public function has(string $name): bool - { - return isset($this->templates[$name]); - } - - /** - * Get all registered template names. - * - * @return array - */ - public function list(): array - { - return array_keys($this->templates); - } - - /** - * Get all registered templates. - * - * @return array> - */ - public function all(): array - { - return $this->templates; - } - - /** - * Unregister a template. - * - * @throws InvalidArgumentException If template not found - */ - public function unregister(string $name): void - { - if (!isset($this->templates[$name])) { - throw new InvalidArgumentException("Template '{$name}' is not registered"); - } - - unset($this->templates[$name]); - } - - /** - * Clear all registered templates. - */ - public function clear(): void - { - $this->templates = []; - } -} diff --git a/tests/Integration/PostBuilderTest.php b/tests/Integration/PostBuilderTest.php index f62a40e..b57caf7 100644 --- a/tests/Integration/PostBuilderTest.php +++ b/tests/Integration/PostBuilderTest.php @@ -68,8 +68,6 @@ public function testCanCreatePostUsingFluentBuilderWithAutoUpload() // Initial clean up in TestCase should handle deleting the post, // but we might want to ensure media is also cleaned up or verified. // For now, valid assertions are enough. - $this->assertStringContainsString('Fluent Featured Image', json_encode($post)); // featured media might not expand, but we check ID > 0 - // Verify content contains image block $this->assertStringContainsString('wp-block-image', $post->content->raw); $this->assertStringContainsString('Content Image', $post->content->raw); diff --git a/tests/Unit/Data/DataModelsTest.php b/tests/Unit/Data/DataModelsTest.php index 018c00e..3065462 100644 --- a/tests/Unit/Data/DataModelsTest.php +++ b/tests/Unit/Data/DataModelsTest.php @@ -4,9 +4,15 @@ namespace JOOservices\WordPress\Sdk\Tests\Unit\Data; +use JOOservices\WordPress\Sdk\Data\Comment; use JOOservices\WordPress\Sdk\Data\Media; +use JOOservices\WordPress\Sdk\Data\Page; use JOOservices\WordPress\Sdk\Data\Post; +use JOOservices\WordPress\Sdk\Data\PostType; use JOOservices\WordPress\Sdk\Data\RenderedContent; +use JOOservices\WordPress\Sdk\Data\SearchResult; +use JOOservices\WordPress\Sdk\Data\Status; +use JOOservices\WordPress\Sdk\Data\Taxonomy; use JOOservices\WordPress\Sdk\Data\Term; use JOOservices\WordPress\Sdk\Data\User; use PHPUnit\Framework\TestCase; @@ -90,4 +96,121 @@ public function testPostConstructor(): void $this->assertSame('publish', $post->status); $this->assertSame([1, 2], $post->categories); } + + public function testPageConstructorProvidesPostDefaultsForWordPressPageResponses(): void + { + $rendered = new RenderedContent('text'); + $page = new Page( + id: 5, + date: '2026-05-16', + date_gmt: '2026-05-16', + guid: $rendered, + modified: '2026-05-16', + modified_gmt: '2026-05-16', + slug: 'page', + status: 'publish', + type: 'page', + link: 'http://example.com/page', + title: $rendered, + content: $rendered, + excerpt: $rendered, + author: 1, + featured_media: 0, + comment_status: 'closed', + ping_status: 'closed', + template: '', + meta: [] + ); + + $this->assertSame(5, $page->id); + $this->assertFalse($page->sticky); + $this->assertSame('standard', $page->format); + $this->assertSame([], $page->categories); + $this->assertSame([], $page->tags); + } + + public function testStatusConstructorDefaultsOptionalWordPressFields(): void + { + $status = new Status('Published'); + + $this->assertSame('Published', $status->name); + $this->assertFalse($status->public); + $this->assertFalse($status->protected); + $this->assertFalse($status->private); + $this->assertFalse($status->queryable); + $this->assertFalse($status->show_in_list); + $this->assertFalse($status->date_floating); + } + + public function testCommentConstructorPreservesRenderedContentAndAvatarMap(): void + { + $content = new RenderedContent('

Approved comment

'); + $comment = new Comment( + id: 21, + post: 9, + parent: 0, + author: 4, + author_name: 'SDK Bot', + author_url: 'https://example.com/authors/sdk-bot', + date: '2026-05-16T11:00:00', + date_gmt: '2026-05-16T11:00:00', + content: $content, + link: 'https://example.com/posts/9#comment-21', + status: 'approved', + type: 'comment', + author_avatar_urls: ['96' => 'https://example.com/avatar-96.jpg'], + ); + + $this->assertSame(21, $comment->id); + $this->assertSame($content, $comment->content); + $this->assertSame('SDK Bot', $comment->author_name); + $this->assertSame(['96' => 'https://example.com/avatar-96.jpg'], $comment->author_avatar_urls); + } + + public function testTaxonomyConstructorDefaultsOptionalWordPressSchemaFields(): void + { + $taxonomy = new Taxonomy( + slug: 'category', + name: 'Categories', + ); + + $this->assertSame('category', $taxonomy->slug); + $this->assertSame('Categories', $taxonomy->name); + $this->assertSame([], $taxonomy->types); + $this->assertSame('', $taxonomy->rest_base); + $this->assertFalse($taxonomy->hierarchical); + } + + public function testPostTypeConstructorPreservesViewabilityFlags(): void + { + $postType = new PostType( + slug: 'page', + name: 'Pages', + rest_base: 'pages', + hierarchical: true, + viewable: false, + ); + + $this->assertSame('page', $postType->slug); + $this->assertSame('pages', $postType->rest_base); + $this->assertTrue($postType->hierarchical); + $this->assertFalse($postType->viewable); + } + + public function testSearchResultConstructorStoresWordPressSearchShape(): void + { + $result = new SearchResult( + id: 99, + title: 'Grey Harbor', + url: 'https://example.com/grey-harbor', + type: 'post', + subtype: 'post', + ); + + $this->assertSame(99, $result->id); + $this->assertSame('Grey Harbor', $result->title); + $this->assertSame('https://example.com/grey-harbor', $result->url); + $this->assertSame('post', $result->type); + $this->assertSame('post', $result->subtype); + } } diff --git a/tests/Unit/Data/Query/ListQueryTest.php b/tests/Unit/Data/Query/ListQueryTest.php index 2119add..6849481 100644 --- a/tests/Unit/Data/Query/ListQueryTest.php +++ b/tests/Unit/Data/Query/ListQueryTest.php @@ -4,7 +4,10 @@ namespace JOOservices\WordPress\Sdk\Tests\Unit\Data\Query; +use JOOservices\WordPress\Sdk\Data\Query\ListMediaQuery; use JOOservices\WordPress\Sdk\Data\Query\ListPostsQuery; +use JOOservices\WordPress\Sdk\Data\Query\ListTermsQuery; +use JOOservices\WordPress\Sdk\Data\Query\ListUsersQuery; use PHPUnit\Framework\TestCase; class ListQueryTest extends TestCase @@ -33,4 +36,63 @@ public function testListPostsQueryMapsSpecialWordPressParameters(): void 'sticky' => false, ], $query->toQuery()); } + + public function testListMediaQueryFiltersNullsAndMapsMediaSpecificParameters(): void + { + $query = new ListMediaQuery( + page: 1, + perPage: 10, + fields: 'id,source_url', + embed: true, + parent: 42, + mediaType: 'image', + mimeType: 'image/jpeg', + ); + + $this->assertSame([ + 'page' => 1, + 'per_page' => 10, + '_fields' => 'id,source_url', + '_embed' => 'true', + 'parent' => 42, + 'media_type' => 'image', + 'mime_type' => 'image/jpeg', + ], $query->toQuery()); + } + + public function testListTermsQueryMapsTaxonomySpecificFilters(): void + { + $query = new ListTermsQuery( + search: 'harbor', + hideEmpty: false, + parent: 3, + post: 44, + slug: ['story', 'news'], + ); + + $this->assertSame([ + 'search' => 'harbor', + 'hide_empty' => false, + 'parent' => 3, + 'post' => 44, + 'slug' => ['story', 'news'], + ], $query->toQuery()); + } + + public function testListUsersQueryMapsRolesCapabilitiesAndPublishedPosts(): void + { + $query = new ListUsersQuery( + perPage: 50, + roles: ['editor'], + capabilities: ['edit_posts'], + hasPublishedPosts: true, + ); + + $this->assertSame([ + 'per_page' => 50, + 'roles' => ['editor'], + 'capabilities' => ['edit_posts'], + 'has_published_posts' => true, + ], $query->toQuery()); + } } diff --git a/tests/Unit/Http/ResponseDecoderLoggerTest.php b/tests/Unit/Http/ResponseDecoderLoggerTest.php new file mode 100644 index 0000000..cae9104 --- /dev/null +++ b/tests/Unit/Http/ResponseDecoderLoggerTest.php @@ -0,0 +1,40 @@ +createStub(LoggerInterface::class); + + ResponseDecoderLogger::setLogger($logger); + + $this->assertSame($logger, ResponseDecoderLogger::getLogger()); + } + + public function testResetDropsSharedLoggerInstance(): void + { + $first = ResponseDecoderLogger::getLogger(); + + ResponseDecoderLogger::reset(); + + $second = ResponseDecoderLogger::getLogger(sys_get_temp_dir() . '/response-decoder-test.log'); + + $this->assertInstanceOf(Logger::class, $second); + $this->assertNotSame($first, $second); + } +} diff --git a/tests/Unit/Services/AbstractTermServiceTest.php b/tests/Unit/Services/AbstractTermServiceTest.php index 7f3bfd4..a866e0f 100644 --- a/tests/Unit/Services/AbstractTermServiceTest.php +++ b/tests/Unit/Services/AbstractTermServiceTest.php @@ -51,6 +51,40 @@ public function testCrudAndDelete(): void $service->setNextResponse(new Response(200, [], 'not-json')); $service->delete(10, false); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestTermService($decoder); + + $first = $this->createStub(Term::class); + $second = $this->createStub(Term::class); + $third = $this->createStub(Term::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestTermService extends AbstractTermService diff --git a/tests/Unit/Services/BlockRendererServiceTest.php b/tests/Unit/Services/BlockRendererServiceTest.php index ea4c602..f371666 100644 --- a/tests/Unit/Services/BlockRendererServiceTest.php +++ b/tests/Unit/Services/BlockRendererServiceTest.php @@ -20,6 +20,7 @@ public function testRenderBuildsAttributesAndPostIdQuery(): void $service->render('core/latest-posts', ['postsToShow' => 3], 12); $this->assertSame('wp/v2/block-renderer/core/latest-posts', $service->lastUri); + $this->assertSame('edit', $service->lastOptions['query']['context']); $this->assertSame(['postsToShow' => 3], $service->lastOptions['query']['attributes']); $this->assertSame(12, $service->lastOptions['query']['post_id']); } diff --git a/tests/Unit/Services/BlockTypesServiceTest.php b/tests/Unit/Services/BlockTypesServiceTest.php index 0487c39..e0b4b7f 100644 --- a/tests/Unit/Services/BlockTypesServiceTest.php +++ b/tests/Unit/Services/BlockTypesServiceTest.php @@ -11,6 +11,20 @@ class BlockTypesServiceTest extends TestCase { + public function testListBuildsBlockTypeCollectionPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlockTypesService { + use RecordsServiceRequests; + }; + + $service->list(); + $this->assertSame('wp/v2/block-types', $service->lastUri); + + $service->list('core', ['context' => 'edit']); + $this->assertSame('wp/v2/block-types/core', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + } + public function testGetBuildsNormalizedBlockTypePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlockTypesService { diff --git a/tests/Unit/Services/BlocksServiceTest.php b/tests/Unit/Services/BlocksServiceTest.php index 84537ef..1f23d08 100644 --- a/tests/Unit/Services/BlocksServiceTest.php +++ b/tests/Unit/Services/BlocksServiceTest.php @@ -11,6 +11,28 @@ class BlocksServiceTest extends TestCase { + public function testCrudBuildersUseBlocksPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlocksService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/blocks', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get(7); + $this->assertSame('wp/v2/blocks/7', $service->lastUri); + + $service->create(['title' => 'Reusable']); + $this->assertSame('wp/v2/blocks', $service->lastUri); + $this->assertSame(['title' => 'Reusable'], $service->lastOptions['body']); + + $service->update(7, ['title' => 'Updated']); + $this->assertSame('wp/v2/blocks/7', $service->lastUri); + $this->assertSame(['title' => 'Updated'], $service->lastOptions['body']); + } + public function testDeleteBuildsForceQuery(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlocksService { diff --git a/tests/Unit/Services/CommentsServiceTest.php b/tests/Unit/Services/CommentsServiceTest.php index 22c8d9a..5617aad 100644 --- a/tests/Unit/Services/CommentsServiceTest.php +++ b/tests/Unit/Services/CommentsServiceTest.php @@ -50,6 +50,40 @@ public function testGetListCreateUpdateAndDelete(): void $service->delete(3, true); $this->assertSame(['force' => true], $service->lastOptions['query']); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestCommentsService($decoder); + + $first = $this->createStub(Comment::class); + $second = $this->createStub(Comment::class); + $third = $this->createStub(Comment::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestCommentsService extends CommentsService diff --git a/tests/Unit/Services/EndpointPathServicesTest.php b/tests/Unit/Services/EndpointPathServicesTest.php new file mode 100644 index 0000000..f9365b5 --- /dev/null +++ b/tests/Unit/Services/EndpointPathServicesTest.php @@ -0,0 +1,290 @@ +createStub(ResponseDecoderInterface::class)); + $service->setNextResponse(['id' => 5]); + + $service->posts(10)->list(['context' => 'edit']); + $this->assertSame('GET', $service->lastMethod); + $this->assertSame('wp/v2/posts/10/revisions', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->pages(20)->get(6); + $this->assertSame('wp/v2/pages/20/revisions/6', $service->lastUri); + + $service->blocks(30)->delete(7); + $this->assertSame('DELETE', $service->lastMethod); + $this->assertSame('wp/v2/blocks/30/autosaves/7', $service->lastUri); + } + + public function testAdminEndpointServicesBuildExpectedPaths(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + + $plugins = new TestPluginsService($decoder); + $plugins->get('akismet/akismet'); + $this->assertSame('wp/v2/plugins/akismet/akismet', $plugins->lastUri); + + $themes = new TestThemesService($decoder); + $themes->get('twentytwentysix'); + $this->assertSame('wp/v2/themes/twentytwentysix', $themes->lastUri); + + $blocks = new TestBlocksService($decoder); + $blocks->delete(99, true); + $this->assertSame('wp/v2/blocks/99', $blocks->lastUri); + $this->assertSame(['force' => true], $blocks->lastOptions['query']); + + $globalStyles = new TestGlobalStylesService($decoder); + $globalStyles->theme('twentytwentysix'); + $this->assertSame('wp/v2/global-styles/themes/twentytwentysix', $globalStyles->lastUri); + } + + public function testBlockAndNavigationServicesBuildExpectedPaths(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + + $blockTypes = new TestBlockTypesService($decoder); + $blockTypes->get('core/paragraph'); + $this->assertSame('wp/v2/block-types/core/paragraph', $blockTypes->lastUri); + + $renderer = new TestBlockRendererService($decoder); + $renderer->render('core/latest-posts', ['postsToShow' => 3], 12); + $this->assertSame('wp/v2/block-renderer/core/latest-posts', $renderer->lastUri); + $this->assertSame('edit', $renderer->lastOptions['query']['context']); + $this->assertSame(['postsToShow' => 3], $renderer->lastOptions['query']['attributes']); + $this->assertSame(12, $renderer->lastOptions['query']['post_id']); + + $directory = new TestBlockDirectoryService($decoder); + $directory->search(['term' => 'gallery']); + $this->assertSame('wp/v2/block-directory/search', $directory->lastUri); + + $locations = new TestMenuLocationsService($decoder); + $locations->get('primary'); + $this->assertSame('wp/v2/menu-locations/primary', $locations->lastUri); + + $navigation = new TestNavigationsService($decoder); + $navigation->update(5, ['title' => 'Main']); + $this->assertSame('wp/v2/navigation/5', $navigation->lastUri); + + $menus = new TestNavMenusService($decoder); + $menus->create(['name' => 'Main']); + $this->assertSame('wp/v2/menus', $menus->lastUri); + + $items = new TestNavMenuItemsService($decoder); + $items->list(['menus' => 3]); + $this->assertSame(['menus' => 3], $items->lastOptions['query']); + } + + public function testTemplateWidgetSidebarAndSiteHealthServicesBuildExpectedPaths(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + + $templates = new TestTemplatesService($decoder); + $templates->get('twentytwentysix//home'); + $this->assertSame('wp/v2/templates/twentytwentysix%2F%2Fhome', $templates->lastUri); + + $parts = new TestTemplatePartsService($decoder); + $parts->delete('twentytwentysix//header', true); + $this->assertSame(['force' => true], $parts->lastOptions['query']); + + $widgets = new TestWidgetsService($decoder); + $widgets->update('text-2', ['sidebar' => 'sidebar-1']); + $this->assertSame('wp/v2/widgets/text-2', $widgets->lastUri); + + $widgetTypes = new TestWidgetTypesService($decoder); + $widgetTypes->encode('text', ['instance' => ['text' => 'Hi']]); + $this->assertSame('wp/v2/widget-types/text/encode', $widgetTypes->lastUri); + + $sidebars = new TestSidebarsService($decoder); + $sidebars->update('sidebar-1', ['widgets' => ['text-2']]); + $this->assertSame('wp/v2/sidebars/sidebar-1', $sidebars->lastUri); + + $siteHealth = new TestSiteHealthService($decoder); + $siteHealth->backgroundUpdates(); + $this->assertSame('wp-site-health/v1/tests/background-updates', $siteHealth->lastUri); + } +} + +trait RecordsServiceRequests +{ + public ?string $lastMethod = null; + public ?string $lastUri = null; + public array $lastOptions = []; + private array $nextResponse = []; + + public function __construct(ResponseDecoderInterface $decoder) + { + parent::__construct(new NullHttpClient(), new RequestBuilder(), $decoder, new ErrorMapper()); + } + + public function setNextResponse(array $response): void + { + $this->nextResponse = $response; + } + + protected function request(string $method, string $uri, array $options = []): ResponseInterface + { + $this->lastMethod = $method; + $this->lastUri = $uri; + $this->lastOptions = $options; + + return new Response(200, [], json_encode($this->nextResponse, JSON_THROW_ON_ERROR)); + } +} + +class TestRevisionsService extends RevisionsService +{ + use RecordsServiceRequests; +} + +class TestPluginsService extends PluginsService +{ + use RecordsServiceRequests; +} + +class TestThemesService extends ThemesService +{ + use RecordsServiceRequests; +} + +class TestBlocksService extends BlocksService +{ + use RecordsServiceRequests; +} + +class TestBlockTypesService extends BlockTypesService +{ + use RecordsServiceRequests; +} + +class TestBlockRendererService extends BlockRendererService +{ + use RecordsServiceRequests; +} + +class TestBlockDirectoryService extends BlockDirectoryService +{ + use RecordsServiceRequests; +} + +class TestMenuLocationsService extends MenuLocationsService +{ + use RecordsServiceRequests; +} + +class TestNavigationsService extends NavigationsService +{ + use RecordsServiceRequests; +} + +class TestNavMenusService extends NavMenusService +{ + use RecordsServiceRequests; +} + +class TestNavMenuItemsService extends NavMenuItemsService +{ + use RecordsServiceRequests; +} + +class TestTemplatesService extends TemplatesService +{ + use RecordsServiceRequests; +} + +class TestTemplatePartsService extends TemplatePartsService +{ + use RecordsServiceRequests; +} + +class TestGlobalStylesService extends GlobalStylesService +{ + use RecordsServiceRequests; +} + +class TestWidgetsService extends WidgetsService +{ + use RecordsServiceRequests; +} + +class TestWidgetTypesService extends WidgetTypesService +{ + use RecordsServiceRequests; +} + +class TestSidebarsService extends SidebarsService +{ + use RecordsServiceRequests; +} + +class TestSiteHealthService extends SiteHealthService +{ + use RecordsServiceRequests; +} + +class NullHttpClient implements HttpClientInterface +{ + public function get(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function post(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function put(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function patch(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function delete(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function request(string $method, string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } +} diff --git a/tests/Unit/Services/GlobalStylesServiceTest.php b/tests/Unit/Services/GlobalStylesServiceTest.php index 295c3c1..50885ca 100644 --- a/tests/Unit/Services/GlobalStylesServiceTest.php +++ b/tests/Unit/Services/GlobalStylesServiceTest.php @@ -11,6 +11,24 @@ class GlobalStylesServiceTest extends TestCase { + public function testCrudBuildersUseGlobalStylesPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends GlobalStylesService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/global-styles', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get('theme-1'); + $this->assertSame('wp/v2/global-styles/theme-1', $service->lastUri); + + $service->update('theme-1', ['settings' => ['color' => ['palette' => []]]]); + $this->assertSame('wp/v2/global-styles/theme-1', $service->lastUri); + $this->assertSame(['settings' => ['color' => ['palette' => []]]], $service->lastOptions['body']); + } + public function testThemeBuildsThemeStylesheetPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends GlobalStylesService { diff --git a/tests/Unit/Services/MediaServiceTest.php b/tests/Unit/Services/MediaServiceTest.php index 56d2945..efcab57 100644 --- a/tests/Unit/Services/MediaServiceTest.php +++ b/tests/Unit/Services/MediaServiceTest.php @@ -123,6 +123,40 @@ public function testDeleteReturnsDecodedMediaWhenNotDeletedWrapper(): void $service->delete(5, false); $this->assertSame([], $service->lastOptions); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestMediaService($decoder); + + $first = $this->createStub(Media::class); + $second = $this->createStub(Media::class); + $third = $this->createStub(Media::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestMediaService extends MediaService diff --git a/tests/Unit/Services/MenuLocationsServiceTest.php b/tests/Unit/Services/MenuLocationsServiceTest.php index 4c77878..b5ddfe6 100644 --- a/tests/Unit/Services/MenuLocationsServiceTest.php +++ b/tests/Unit/Services/MenuLocationsServiceTest.php @@ -11,6 +11,17 @@ class MenuLocationsServiceTest extends TestCase { + public function testListBuildsMenuLocationsPath(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends MenuLocationsService { + use RecordsServiceRequests; + }; + + $service->list(); + + $this->assertSame('wp/v2/menu-locations', $service->lastUri); + } + public function testGetBuildsLocationPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends MenuLocationsService { diff --git a/tests/Unit/Services/NavMenusServiceTest.php b/tests/Unit/Services/NavMenusServiceTest.php index ed1b70e..82afdbd 100644 --- a/tests/Unit/Services/NavMenusServiceTest.php +++ b/tests/Unit/Services/NavMenusServiceTest.php @@ -11,6 +11,20 @@ class NavMenusServiceTest extends TestCase { + public function testGetAndDeleteBuildMenusPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends NavMenusService { + use RecordsServiceRequests; + }; + + $service->get(5); + $this->assertSame('wp/v2/menus/5', $service->lastUri); + + $service->delete(5, true); + $this->assertSame('wp/v2/menus/5', $service->lastUri); + $this->assertSame(['force' => true], $service->lastOptions['query']); + } + public function testCreateBuildsMenusPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends NavMenusService { diff --git a/tests/Unit/Services/PagesServiceTest.php b/tests/Unit/Services/PagesServiceTest.php index dca15b1..d3c96a3 100644 --- a/tests/Unit/Services/PagesServiceTest.php +++ b/tests/Unit/Services/PagesServiceTest.php @@ -51,6 +51,40 @@ public function testGetListCreateUpdateAndDelete(): void $service->delete(9, true); $this->assertSame(['force' => true], $service->lastOptions['query']); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestPagesService($decoder); + + $first = $this->createStub(Page::class); + $second = $this->createStub(Page::class); + $third = $this->createStub(Page::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestPagesService extends PagesService diff --git a/tests/Unit/Services/PluginsServiceTest.php b/tests/Unit/Services/PluginsServiceTest.php index b7895a7..a6c2a19 100644 --- a/tests/Unit/Services/PluginsServiceTest.php +++ b/tests/Unit/Services/PluginsServiceTest.php @@ -11,7 +11,29 @@ class PluginsServiceTest extends TestCase { - public function testGetBuildsEncodedPluginPath(): void + public function testCrudBuildersUsePluginPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends PluginsService { + use RecordsServiceRequests; + }; + + $service->list(['status' => 'active']); + $this->assertSame('wp/v2/plugins', $service->lastUri); + $this->assertSame(['status' => 'active'], $service->lastOptions['query']); + + $service->create(['plugin' => 'akismet/akismet.php']); + $this->assertSame('wp/v2/plugins', $service->lastUri); + $this->assertSame(['plugin' => 'akismet/akismet.php'], $service->lastOptions['body']); + + $service->update('akismet/akismet', ['status' => 'inactive']); + $this->assertSame('wp/v2/plugins/akismet/akismet', $service->lastUri); + $this->assertSame(['status' => 'inactive'], $service->lastOptions['body']); + + $service->delete('akismet/akismet'); + $this->assertSame('wp/v2/plugins/akismet/akismet', $service->lastUri); + } + + public function testGetBuildsWordPressPluginPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends PluginsService { use RecordsServiceRequests; @@ -19,6 +41,6 @@ public function testGetBuildsEncodedPluginPath(): void $service->get('akismet/akismet'); - $this->assertSame('wp/v2/plugins/akismet%2Fakismet', $service->lastUri); + $this->assertSame('wp/v2/plugins/akismet/akismet', $service->lastUri); } } diff --git a/tests/Unit/Services/PostsServiceTest.php b/tests/Unit/Services/PostsServiceTest.php index a0451f4..5ee143a 100644 --- a/tests/Unit/Services/PostsServiceTest.php +++ b/tests/Unit/Services/PostsServiceTest.php @@ -14,7 +14,6 @@ use JOOservices\WordPress\Sdk\Services\MediaService; use JOOservices\WordPress\Sdk\Services\PostsService; use JOOservices\WordPress\Sdk\Support\PostBuilder; -use JOOservices\WordPress\Sdk\Support\Templates\PostTemplate; use PHPUnit\Framework\TestCase; class PostsServiceTest extends TestCase @@ -64,26 +63,6 @@ public function testBuilderUsesMediaService(): void $this->assertInstanceOf(PostBuilder::class, $builder); } - public function testCreateFromTemplate(): void - { - $decoder = $this->createMock(ResponseDecoderInterface::class); - $service = new TestPostsService($decoder); - - $template = $this->createMock(PostTemplate::class); - $template->expects($this->once()) - ->method('toPostData') - ->willReturn(['title' => 'Template']); - - $post = $this->createStub(Post::class); - $decoder->expects($this->once()) - ->method('decodeItem') - ->willReturn($post); - - $service->setNextResponse(new Response(200, [], json_encode(['id' => 10], JSON_THROW_ON_ERROR))); - $service->createFromTemplate($template); - $this->assertSame(CoreEndpoint::POSTS->path(), $service->lastUri); - } - public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void { $decoder = $this->createStub(ResponseDecoderInterface::class); diff --git a/tests/Unit/Services/RawEndpointServiceTest.php b/tests/Unit/Services/RawEndpointServiceTest.php new file mode 100644 index 0000000..ca9d388 --- /dev/null +++ b/tests/Unit/Services/RawEndpointServiceTest.php @@ -0,0 +1,37 @@ +createStub(ResponseDecoderInterface::class)) extends RawEndpointService { + use RecordsServiceRequests; + + /** + * @param array $payload + * @return array + */ + public function exposedPutRaw(string $path, array $payload): array + { + return $this->putRaw($path, $payload); + } + }; + + $service->setNextResponse(['id' => 7, 'status' => 'updated']); + $result = $service->exposedPutRaw('wp/v2/raw-endpoint/7', ['status' => 'updated']); + + $this->assertSame('PUT', $service->lastMethod); + $this->assertSame('wp/v2/raw-endpoint/7', $service->lastUri); + $this->assertSame(['status' => 'updated'], $service->lastOptions['body']); + $this->assertSame(['id' => 7, 'status' => 'updated'], $result); + } +} diff --git a/tests/Unit/Services/SchemaServicesTest.php b/tests/Unit/Services/SchemaServicesTest.php index 6c6f14d..9e4aba5 100644 --- a/tests/Unit/Services/SchemaServicesTest.php +++ b/tests/Unit/Services/SchemaServicesTest.php @@ -38,6 +38,33 @@ public function testTaxonomiesServiceUsesTaxonomiesEndpoint(): void $this->assertSame(TaxonomyEndpoint::TAXONOMIES->path(), $service->lastUri); } + public function testTaxonomiesServiceAutoPaginationHelpersUseTaxonomiesEndpoint(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $taxonomy = $this->createStub(Taxonomy::class); + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + ); + + $service = new TestTaxonomiesService($decoder); + + $this->assertCount(2, $service->all(['per_page' => 1])); + $this->assertSame(['per_page' => 1, 'page' => 2], $service->lastOptions['query']); + $this->assertCount(2, iterator_to_array($service->cursor(['per_page' => 1]))); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + $this->assertSame(1, $visited); + } + public function testPostTypesServiceUsesTypesEndpoint(): void { $decoder = $this->createStub(ResponseDecoderInterface::class); @@ -55,6 +82,33 @@ public function testPostTypesServiceUsesTypesEndpoint(): void $this->assertSame(CoreEndpoint::POST_TYPES->path(), $service->lastUri); } + public function testPostTypesServiceAutoPaginationHelpersUseTypesEndpoint(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $postType = $this->createStub(PostType::class); + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + ); + + $service = new TestPostTypesService($decoder); + + $this->assertCount(2, $service->all(['per_page' => 1])); + $this->assertSame(['per_page' => 1, 'page' => 2], $service->lastOptions['query']); + $this->assertCount(2, iterator_to_array($service->cursor(['per_page' => 1]))); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + $this->assertSame(1, $visited); + } + public function testStatusesServiceUsesStatusesEndpoint(): void { $decoder = $this->createStub(ResponseDecoderInterface::class); @@ -71,6 +125,33 @@ public function testStatusesServiceUsesStatusesEndpoint(): void $service->list(); $this->assertSame(CoreEndpoint::STATUSES->path(), $service->lastUri); } + + public function testStatusesServiceAutoPaginationHelpersUseStatusesEndpoint(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $status = $this->createStub(Status::class); + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + ); + + $service = new TestStatusesService($decoder); + + $this->assertCount(2, $service->all(['per_page' => 1])); + $this->assertSame(['per_page' => 1, 'page' => 2], $service->lastOptions['query']); + $this->assertCount(2, iterator_to_array($service->cursor(['per_page' => 1]))); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + $this->assertSame(1, $visited); + } } abstract class AbstractSchemaServiceTestDouble @@ -124,6 +205,7 @@ public function __construct(ResponseDecoderInterface $decoder) trait SchemaServiceRequestCapture { public ?string $lastUri = null; + public array $lastOptions = []; private ?Response $nextResponse = null; public function setNextResponse(Response $response): void @@ -134,6 +216,7 @@ public function setNextResponse(Response $response): void protected function request(string $method, string $uri, array $options = []): \Psr\Http\Message\ResponseInterface { $this->lastUri = $uri; + $this->lastOptions = $options; return $this->nextResponse ?? new Response(200, [], '{}'); } diff --git a/tests/Unit/Services/SearchServiceTest.php b/tests/Unit/Services/SearchServiceTest.php index b0e8383..7e962dd 100644 --- a/tests/Unit/Services/SearchServiceTest.php +++ b/tests/Unit/Services/SearchServiceTest.php @@ -37,6 +37,40 @@ public function testListAndSearchAliasUseSearchEndpoint(): void $service->search(['search' => 'sdk']); $this->assertSame('sdk', $service->lastOptions['query']['search']); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestSearchService($decoder); + + $first = $this->createStub(SearchResult::class); + $second = $this->createStub(SearchResult::class); + $third = $this->createStub(SearchResult::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestSearchService extends SearchService diff --git a/tests/Unit/Services/SidebarsServiceTest.php b/tests/Unit/Services/SidebarsServiceTest.php index c655b58..ed50db9 100644 --- a/tests/Unit/Services/SidebarsServiceTest.php +++ b/tests/Unit/Services/SidebarsServiceTest.php @@ -11,6 +11,20 @@ class SidebarsServiceTest extends TestCase { + public function testListAndGetBuildSidebarPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends SidebarsService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/sidebars', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get('sidebar-1'); + $this->assertSame('wp/v2/sidebars/sidebar-1', $service->lastUri); + } + public function testUpdateBuildsSidebarPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends SidebarsService { diff --git a/tests/Unit/Services/SiteHealthServiceTest.php b/tests/Unit/Services/SiteHealthServiceTest.php index 3f48847..8970b4c 100644 --- a/tests/Unit/Services/SiteHealthServiceTest.php +++ b/tests/Unit/Services/SiteHealthServiceTest.php @@ -21,4 +21,17 @@ public function testBackgroundUpdatesBuildsSiteHealthTestPath(): void $this->assertSame('wp-site-health/v1/tests/background-updates', $service->lastUri); } + + public function testAdditionalHelpersMapToNamedSiteHealthChecks(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends SiteHealthService { + use RecordsServiceRequests; + }; + + $service->loopbackRequests(); + $this->assertSame('wp-site-health/v1/tests/loopback-requests', $service->lastUri); + + $service->httpsStatus(); + $this->assertSame('wp-site-health/v1/tests/https-status', $service->lastUri); + } } diff --git a/tests/Unit/Services/TemplatesServiceTest.php b/tests/Unit/Services/TemplatesServiceTest.php index 176de15..fd86acc 100644 --- a/tests/Unit/Services/TemplatesServiceTest.php +++ b/tests/Unit/Services/TemplatesServiceTest.php @@ -11,6 +11,21 @@ class TemplatesServiceTest extends TestCase { + public function testListAndCreateBuildTemplatePaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends TemplatesService { + use RecordsServiceRequests; + }; + + $service->list(['post_type' => 'page']); + $this->assertSame('wp/v2/templates', $service->lastUri); + $this->assertSame(['post_type' => 'page'], $service->lastOptions['query']); + + $service->create(['slug' => 'home']); + $this->assertSame('wp/v2/templates', $service->lastUri); + $this->assertSame(['slug' => 'home'], $service->lastOptions['body']); + } + public function testGetBuildsEncodedTemplatePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends TemplatesService { diff --git a/tests/Unit/Services/ThemesServiceTest.php b/tests/Unit/Services/ThemesServiceTest.php index 8b308b5..d25a3ca 100644 --- a/tests/Unit/Services/ThemesServiceTest.php +++ b/tests/Unit/Services/ThemesServiceTest.php @@ -11,6 +11,18 @@ class ThemesServiceTest extends TestCase { + public function testListBuildsThemesPath(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends ThemesService { + use RecordsServiceRequests; + }; + + $service->list(['status' => 'inactive']); + + $this->assertSame('wp/v2/themes', $service->lastUri); + $this->assertSame(['status' => 'inactive'], $service->lastOptions['query']); + } + public function testGetBuildsThemePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends ThemesService { diff --git a/tests/Unit/Services/UsersServiceTest.php b/tests/Unit/Services/UsersServiceTest.php index dd43473..17f998c 100644 --- a/tests/Unit/Services/UsersServiceTest.php +++ b/tests/Unit/Services/UsersServiceTest.php @@ -51,6 +51,40 @@ public function testGetListCreateUpdateDeleteAndMe(): void $service->me(); $this->assertSame(CoreEndpoint::USERS_ME->path(), $service->lastUri); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestUsersService($decoder); + + $first = $this->createStub(User::class); + $second = $this->createStub(User::class); + $third = $this->createStub(User::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestUsersService extends UsersService diff --git a/tests/Unit/Services/WidgetTypesServiceTest.php b/tests/Unit/Services/WidgetTypesServiceTest.php index 90bef87..908905a 100644 --- a/tests/Unit/Services/WidgetTypesServiceTest.php +++ b/tests/Unit/Services/WidgetTypesServiceTest.php @@ -11,6 +11,20 @@ class WidgetTypesServiceTest extends TestCase { + public function testListAndGetBuildWidgetTypePaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends WidgetTypesService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/widget-types', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get('text'); + $this->assertSame('wp/v2/widget-types/text', $service->lastUri); + } + public function testEncodeBuildsWidgetTypeEncodePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends WidgetTypesService { diff --git a/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php b/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php index b31fa38..d73b775 100644 --- a/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php +++ b/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php @@ -42,4 +42,15 @@ public function testGetThrowsForMissingBlock(): void $this->expectException(InvalidArgumentException::class); $registry->get('missing/block'); } + + public function testAllReturnsRegisteredBlocks(): void + { + $registry = BlockRegistry::getInstance(); + $registry->register('custom/block', GenericBlock::class); + + $all = $registry->all(); + + $this->assertArrayHasKey('core/paragraph', $all); + $this->assertSame(GenericBlock::class, $all['custom/block']); + } } diff --git a/tests/Unit/Support/PostBuilderTest.php b/tests/Unit/Support/PostBuilderTest.php index 946dd25..5f11d2b 100644 --- a/tests/Unit/Support/PostBuilderTest.php +++ b/tests/Unit/Support/PostBuilderTest.php @@ -124,6 +124,7 @@ public function testSetsAdditionalFields(): void ->method('create') ->with($this->callback(function (array $payload): bool { return $payload['status'] === 'draft' + && $payload['excerpt'] === 'Short summary' && $payload['slug'] === 'my-slug' && $payload['author'] === 5 && $payload['categories'] === [1, 2] @@ -134,6 +135,7 @@ public function testSetsAdditionalFields(): void $builder->title('Title') ->status('draft') + ->excerpt('Short summary') ->slug('my-slug') ->author(5) ->categories([1, 2]) diff --git a/tests/Unit/Templates/AbstractTemplateDataTest.php b/tests/Unit/Templates/AbstractTemplateDataTest.php deleted file mode 100644 index 12674ee..0000000 --- a/tests/Unit/Templates/AbstractTemplateDataTest.php +++ /dev/null @@ -1,67 +0,0 @@ -validate(); - - $this->assertSame([ - 'title' => 'Title', - 'tags' => ['tag-a'], - ], $data->toArray()); - } - - public function testValidateThrowsWhenPropertyMissing(): void - { - $data = new MissingFieldTemplateData(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Property missing does not exist'); - - $data->validate(); - } - - public function testValidateThrowsWhenFieldEmpty(): void - { - $data = new DummyTemplateData('', []); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Field title is required and cannot be empty'); - - $data->validate(); - } -} - -class DummyTemplateData extends AbstractTemplateData -{ - public function __construct( - public string $title, - /** @var array */ - public array $tags - ) { - } - - protected function getRequiredFields(): array - { - return ['title', 'tags']; - } -} - -class MissingFieldTemplateData extends AbstractTemplateData -{ - protected function getRequiredFields(): array - { - return ['missing']; - } -} diff --git a/tests/Unit/Templates/PostTemplateTest.php b/tests/Unit/Templates/PostTemplateTest.php deleted file mode 100644 index e5efd43..0000000 --- a/tests/Unit/Templates/PostTemplateTest.php +++ /dev/null @@ -1,57 +0,0 @@ -assertSame($data, $template->exposedData()); - - $builder = $template->toContentBuilder(); - $this->assertInstanceOf(ContentBuilder::class, $builder); - $this->assertNotEmpty($builder->getBlocks()); - - $this->assertSame(['title' => 'Demo'], $template->toPostData()); - } -} - -class PostTemplateDataDummy implements TemplateDataInterface -{ - public function validate(): void - { - } - - public function toArray(): array - { - return ['title' => 'Demo']; - } -} - -class DummyPostTemplate extends PostTemplate -{ - public function toContentBuilder(): ContentBuilder - { - return (new ContentBuilder())->text('Hello'); - } - - public function toPostData(): array - { - return ['title' => 'Demo']; - } - - public function exposedData(): TemplateDataInterface - { - return $this->getData(); - } -} diff --git a/tests/Unit/Templates/ProductReviewTemplateDataTest.php b/tests/Unit/Templates/ProductReviewTemplateDataTest.php deleted file mode 100644 index 590dc7c..0000000 --- a/tests/Unit/Templates/ProductReviewTemplateDataTest.php +++ /dev/null @@ -1,128 +0,0 @@ -assertSame('Test Product', $data->productName); - $this->assertSame('Test Review', $data->reviewText); - $this->assertSame(4.5, $data->rating); - $this->assertNull($data->productImageId); - $this->assertNull($data->pros); - $this->assertNull($data->cons); - $this->assertNull($data->verdict); - $this->assertNull($data->price); - $this->assertNull($data->tags); - } - - public function test_creates_with_all_fields(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Test Review', - rating: 4.5, - productImageId: 123, - pros: ['Pro 1', 'Pro 2'], - cons: ['Con 1', 'Con 2'], - verdict: 'Great product', - price: 99.99, - tags: ['review', 'tech'] - ); - - $this->assertSame('Test Product', $data->productName); - $this->assertSame(4.5, $data->rating); - $this->assertSame(123, $data->productImageId); - $this->assertSame(['Pro 1', 'Pro 2'], $data->pros); - $this->assertSame(['Con 1', 'Con 2'], $data->cons); - $this->assertSame('Great product', $data->verdict); - $this->assertSame(99.99, $data->price); - $this->assertSame(['review', 'tech'], $data->tags); - } - - public function test_validates_rating_minimum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Rating must be between 1.0 and 5.0'); - - new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Test Review', - rating: 0.5 - ); - } - - public function test_validates_rating_maximum(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Rating must be between 1.0 and 5.0'); - - new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Test Review', - rating: 5.5 - ); - } - - public function test_validates_negative_price(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Price cannot be negative'); - - new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Test Review', - rating: 4.5, - price: -10.00 - ); - } - - public function test_accepts_valid_rating_range(): void - { - $data1 = new ProductReviewTemplateData( - productName: 'Test', - reviewText: 'Test', - rating: 1.0 - ); - $this->assertSame(1.0, $data1->rating); - - $data2 = new ProductReviewTemplateData( - productName: 'Test', - reviewText: 'Test', - rating: 5.0 - ); - $this->assertSame(5.0, $data2->rating); - - $data3 = new ProductReviewTemplateData( - productName: 'Test', - reviewText: 'Test', - rating: 3.5 - ); - $this->assertSame(3.5, $data3->rating); - } - - public function test_accepts_zero_price(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Test Review', - rating: 4.5, - price: 0.0 - ); - - $this->assertSame(0.0, $data->price); - } -} diff --git a/tests/Unit/Templates/ProductReviewTemplateTest.php b/tests/Unit/Templates/ProductReviewTemplateTest.php deleted file mode 100644 index 1f1816e..0000000 --- a/tests/Unit/Templates/ProductReviewTemplateTest.php +++ /dev/null @@ -1,134 +0,0 @@ -toPostData(); - - $this->assertIsArray($postData); - $this->assertArrayHasKey('title', $postData); - $this->assertArrayHasKey('content', $postData); - $this->assertArrayHasKey('status', $postData); - $this->assertSame('Review: Test Product', $postData['title']); - $this->assertSame('draft', $postData['status']); - } - - public function test_generates_content_with_rating_stars(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Great product!', - rating: 4.0 - ); - - $template = new ProductReviewTemplate($data); - $content = $template->toContentBuilder()->render(); - - $this->assertStringContainsString('Rating: 4/5', $content); - $this->assertStringContainsString('⭐⭐⭐⭐', $content); - } - - public function test_generates_content_with_price(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Great product!', - rating: 4.5, - price: 99.99 - ); - - $template = new ProductReviewTemplate($data); - $content = $template->toContentBuilder()->render(); - - $this->assertStringContainsString('Price: $99.99', $content); - } - - public function test_generates_content_with_pros_and_cons(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Great product!', - rating: 4.5, - pros: ['Fast', 'Reliable'], - cons: ['Expensive', 'Heavy'] - ); - - $template = new ProductReviewTemplate($data); - $content = $template->toContentBuilder()->render(); - - $this->assertStringContainsString('Pros', $content); - $this->assertStringContainsString('✓ Fast', $content); - $this->assertStringContainsString('✓ Reliable', $content); - $this->assertStringContainsString('Cons', $content); - $this->assertStringContainsString('✗ Expensive', $content); - $this->assertStringContainsString('✗ Heavy', $content); - } - - public function test_generates_content_with_verdict(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Great product!', - rating: 4.5, - verdict: 'Highly recommended!' - ); - - $template = new ProductReviewTemplate($data); - $content = $template->toContentBuilder()->render(); - - $this->assertStringContainsString('Verdict', $content); - $this->assertStringContainsString('Highly recommended!', $content); - } - - public function test_stores_rating_and_price_in_meta(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Great product!', - rating: 4.5, - price: 199.99 - ); - - $template = new ProductReviewTemplate($data); - $postData = $template->toPostData(); - - $this->assertArrayHasKey('meta', $postData); - $this->assertSame(4.5, $postData['meta']['rating']); - $this->assertSame(199.99, $postData['meta']['price']); - } - - public function test_includes_featured_media_and_tags_when_provided(): void - { - $data = new ProductReviewTemplateData( - productName: 'Test Product', - reviewText: 'Great product!', - rating: 5.0, - productImageId: 55, - tags: [10, 11] - ); - - $template = new ProductReviewTemplate($data); - $content = $template->toContentBuilder()->render(); - $postData = $template->toPostData(); - - $this->assertStringContainsString('Rating: 5/5', $content); - $this->assertSame(55, $postData['featured_media']); - $this->assertSame([10, 11], $postData['tags']); - } -} diff --git a/tests/Unit/Templates/StoryTemplateDataTest.php b/tests/Unit/Templates/StoryTemplateDataTest.php deleted file mode 100644 index b2c969a..0000000 --- a/tests/Unit/Templates/StoryTemplateDataTest.php +++ /dev/null @@ -1,112 +0,0 @@ -assertSame('Test Title', $data->title); - $this->assertSame('Test Introduction', $data->introduction); - $this->assertSame('Test Body', $data->body); - $this->assertNull($data->featuredImageId); - $this->assertNull($data->galleryImageIds); - $this->assertNull($data->conclusion); - $this->assertNull($data->author); - $this->assertNull($data->tags); - $this->assertNull($data->categories); - } - - public function test_creates_with_all_fields(): void - { - $data = new StoryTemplateData( - title: 'Test Title', - introduction: 'Test Introduction', - body: 'Test Body', - featuredImageId: 123, - galleryImageIds: [124, 125], - conclusion: 'Test Conclusion', - author: 'John Doe', - tags: ['tag1', 'tag2'], - categories: [1, 2] - ); - - $this->assertSame('Test Title', $data->title); - $this->assertSame('Test Introduction', $data->introduction); - $this->assertSame('Test Body', $data->body); - $this->assertSame(123, $data->featuredImageId); - $this->assertSame([124, 125], $data->galleryImageIds); - $this->assertSame('Test Conclusion', $data->conclusion); - $this->assertSame('John Doe', $data->author); - $this->assertSame(['tag1', 'tag2'], $data->tags); - $this->assertSame([1, 2], $data->categories); - } - - public function test_validates_required_title(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Field title is required'); - - new StoryTemplateData( - title: '', - introduction: 'Test Introduction', - body: 'Test Body' - ); - } - - public function test_validates_required_introduction(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Field introduction is required'); - - new StoryTemplateData( - title: 'Test Title', - introduction: '', - body: 'Test Body' - ); - } - - public function test_validates_required_body(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Field body is required'); - - new StoryTemplateData( - title: 'Test Title', - introduction: 'Test Introduction', - body: '' - ); - } - - public function test_to_array_returns_all_properties(): void - { - $data = new StoryTemplateData( - title: 'Test Title', - introduction: 'Test Introduction', - body: 'Test Body', - author: 'John Doe' - ); - - $array = $data->toArray(); - - $this->assertIsArray($array); - $this->assertArrayHasKey('title', $array); - $this->assertArrayHasKey('introduction', $array); - $this->assertArrayHasKey('body', $array); - $this->assertArrayHasKey('author', $array); - $this->assertSame('Test Title', $array['title']); - $this->assertSame('John Doe', $array['author']); - } -} diff --git a/tests/Unit/Templates/StoryTemplateTest.php b/tests/Unit/Templates/StoryTemplateTest.php deleted file mode 100644 index 8d59312..0000000 --- a/tests/Unit/Templates/StoryTemplateTest.php +++ /dev/null @@ -1,139 +0,0 @@ -toPostData(); - - $this->assertIsArray($postData); - $this->assertArrayHasKey('title', $postData); - $this->assertArrayHasKey('content', $postData); - $this->assertArrayHasKey('status', $postData); - $this->assertSame('My Story', $postData['title']); - $this->assertSame('draft', $postData['status']); - $this->assertStringContainsString('Once upon a time...', $postData['content']); - $this->assertStringContainsString('The adventure begins...', $postData['content']); - } - - public function test_generates_post_data_with_featured_image(): void - { - $data = new StoryTemplateData( - title: 'My Story', - introduction: 'Once upon a time...', - body: 'The adventure begins...', - featuredImageId: 123 - ); - - $template = new StoryTemplate($data); - $postData = $template->toPostData(); - - $this->assertArrayHasKey('featured_media', $postData); - $this->assertSame(123, $postData['featured_media']); - } - - public function test_generates_post_data_with_tags(): void - { - $data = new StoryTemplateData( - title: 'My Story', - introduction: 'Once upon a time...', - body: 'The adventure begins...', - tags: ['adventure', 'story'] - ); - - $template = new StoryTemplate($data); - $postData = $template->toPostData(); - - $this->assertArrayHasKey('tags', $postData); - $this->assertSame(['adventure', 'story'], $postData['tags']); - } - - public function test_generates_post_data_with_categories(): void - { - $data = new StoryTemplateData( - title: 'My Story', - introduction: 'Once upon a time...', - body: 'The adventure begins...', - categories: [1, 2, 3] - ); - - $template = new StoryTemplate($data); - $postData = $template->toPostData(); - - $this->assertArrayHasKey('categories', $postData); - $this->assertSame([1, 2, 3], $postData['categories']); - } - - public function test_generates_content_with_sections(): void - { - $data = new StoryTemplateData( - title: 'My Story', - introduction: 'Once upon a time...', - body: 'The adventure begins...', - conclusion: 'And they lived happily ever after.', - author: 'John Doe' - ); - - $template = new StoryTemplate($data); - $content = $template->toContentBuilder()->render(); - - // Check for section headings - $this->assertStringContainsString('Introduction', $content); - $this->assertStringContainsString('Story', $content); - $this->assertStringContainsString('Conclusion', $content); - - // Check for content - $this->assertStringContainsString('Once upon a time...', $content); - $this->assertStringContainsString('The adventure begins...', $content); - $this->assertStringContainsString('And they lived happily ever after.', $content); - $this->assertStringContainsString('— John Doe', $content); - } - - public function test_does_not_include_optional_sections_when_not_provided(): void - { - $data = new StoryTemplateData( - title: 'My Story', - introduction: 'Once upon a time...', - body: 'The adventure begins...' - ); - - $template = new StoryTemplate($data); - $content = $template->toContentBuilder()->render(); - - $this->assertStringNotContainsString('Conclusion', $content); - $this->assertStringNotContainsString('Gallery', $content); - $this->assertStringNotContainsString('—', $content); // Author attribution - } - - public function test_generates_content_with_gallery_images(): void - { - $data = new StoryTemplateData( - title: 'My Story', - introduction: 'Intro', - body: 'Body', - galleryImageIds: [7, 8] - ); - - $template = new StoryTemplate($data); - $content = $template->toContentBuilder()->render(); - - $this->assertStringContainsString('Gallery', $content); - $this->assertStringContainsString('wp-image-7', $content); - $this->assertStringContainsString('wp-image-8', $content); - } -} diff --git a/tests/Unit/Templates/TemplateRegistryTest.php b/tests/Unit/Templates/TemplateRegistryTest.php deleted file mode 100644 index 601dd8d..0000000 --- a/tests/Unit/Templates/TemplateRegistryTest.php +++ /dev/null @@ -1,128 +0,0 @@ -registry = new TemplateRegistry(); - } - - public function test_registers_template(): void - { - $this->registry->register('story', StoryTemplate::class); - - $this->assertTrue($this->registry->has('story')); - $this->assertSame(StoryTemplate::class, $this->registry->get('story')); - } - - public function test_registers_multiple_templates(): void - { - $this->registry->register('story', StoryTemplate::class); - $this->registry->register('review', ProductReviewTemplate::class); - - $this->assertTrue($this->registry->has('story')); - $this->assertTrue($this->registry->has('review')); - $this->assertCount(2, $this->registry->list()); - } - - public function test_throws_exception_when_registering_duplicate(): void - { - $this->registry->register('story', StoryTemplate::class); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Template 'story' is already registered"); - - $this->registry->register('story', StoryTemplate::class); - } - - public function test_throws_exception_when_class_does_not_exist(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Template class 'NonExistentClass' does not exist"); - - $this->registry->register('test', 'NonExistentClass'); - } - - public function test_throws_exception_when_getting_unregistered_template(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Template 'unknown' is not registered"); - - $this->registry->get('unknown'); - } - - public function test_has_returns_false_for_unregistered_template(): void - { - $this->assertFalse($this->registry->has('unknown')); - } - - public function test_list_returns_all_template_names(): void - { - $this->registry->register('story', StoryTemplate::class); - $this->registry->register('review', ProductReviewTemplate::class); - - $names = $this->registry->list(); - - $this->assertCount(2, $names); - $this->assertContains('story', $names); - $this->assertContains('review', $names); - } - - public function test_all_returns_all_templates(): void - { - $this->registry->register('story', StoryTemplate::class); - $this->registry->register('review', ProductReviewTemplate::class); - - $all = $this->registry->all(); - - $this->assertCount(2, $all); - $this->assertArrayHasKey('story', $all); - $this->assertArrayHasKey('review', $all); - $this->assertSame(StoryTemplate::class, $all['story']); - $this->assertSame(ProductReviewTemplate::class, $all['review']); - } - - public function test_unregister_removes_template(): void - { - $this->registry->register('story', StoryTemplate::class); - $this->assertTrue($this->registry->has('story')); - - $this->registry->unregister('story'); - - $this->assertFalse($this->registry->has('story')); - } - - public function test_unregister_throws_exception_for_unregistered_template(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Template 'unknown' is not registered"); - - $this->registry->unregister('unknown'); - } - - public function test_clear_removes_all_templates(): void - { - $this->registry->register('story', StoryTemplate::class); - $this->registry->register('review', ProductReviewTemplate::class); - - $this->assertCount(2, $this->registry->list()); - - $this->registry->clear(); - - $this->assertCount(0, $this->registry->list()); - $this->assertFalse($this->registry->has('story')); - $this->assertFalse($this->registry->has('review')); - } -} diff --git a/tools/test-coverage-gate.php b/tools/test-coverage-gate.php new file mode 100644 index 0000000..d0229d7 --- /dev/null +++ b/tools/test-coverage-gate.php @@ -0,0 +1,308 @@ +, + * classes: array, + * methods: array + * } + */ +$exclusions = [ + 'files' => [], + 'classes' => [], + 'methods' => [], +]; + +$coverageFile = dirname(__DIR__) . '/coverage.xml'; + +if (!file_exists($coverageFile)) { + fwrite(STDERR, "Coverage gate failed: coverage.xml not found. Run composer test:coverage first.\n"); + exit(1); +} + +$xml = simplexml_load_file($coverageFile); +if ($xml === false || !isset($xml->project)) { + fwrite(STDERR, "Coverage gate failed: unable to parse coverage.xml.\n"); + exit(1); +} + +$projectMetrics = metricsToArray($xml->project->metrics[0] ?? null); +$overallStatements = $projectMetrics['statements']; +$overallCoveredStatements = $projectMetrics['coveredstatements']; +$overallCoverage = coveragePercent($overallCoveredStatements, $overallStatements); + +$zeroCoveredFiles = []; +$zeroCoveredClasses = []; +$zeroCoveredMethods = []; + +foreach ($xml->project->xpath('.//file') ?: [] as $fileNode) { + $filePath = (string) ($fileNode['name'] ?? ''); + if (!isProductionFile($filePath)) { + continue; + } + + $relativeFile = relativeProductionPath($filePath); + $fileMetrics = metricsToArray($fileNode->metrics[0] ?? null); + + if ($fileMetrics['statements'] > 0 && $fileMetrics['coveredstatements'] === 0 && !array_key_exists($relativeFile, $exclusions['files'])) { + $zeroCoveredFiles[$relativeFile] = describeFileFailure($relativeFile, $fileMetrics); + } + + foreach ($fileNode->class as $classNode) { + $className = (string) ($classNode['name'] ?? ''); + if ($className === '') { + continue; + } + + $classMetrics = metricsToArray($classNode->metrics[0] ?? null); + if (!isCoverableClass($classMetrics) || array_key_exists($className, $exclusions['classes'])) { + continue; + } + + if ( + $classMetrics['coveredstatements'] === 0 + && ($classMetrics['methods'] === 0 || $classMetrics['coveredmethods'] === 0) + ) { + $zeroCoveredClasses[$className] = describeClassFailure($relativeFile, $className, $classMetrics); + } + } + + foreach ($fileNode->line as $lineNode) { + if ((string) ($lineNode['type'] ?? '') !== 'method') { + continue; + } + + $className = resolveMethodClassName($fileNode, (int) ($lineNode['num'] ?? 0)); + if ($className === null) { + continue; + } + + $methodName = (string) ($lineNode['name'] ?? ''); + if ($methodName === '') { + continue; + } + + $methodKey = $className . '::' . $methodName; + if (array_key_exists($methodKey, $exclusions['methods'])) { + continue; + } + + $count = (int) ($lineNode['count'] ?? 0); + if ($count === 0) { + $zeroCoveredMethods[$methodKey] = describeMethodFailure($relativeFile, $className, $methodName, (int) ($lineNode['num'] ?? 0)); + } + } +} + +ksort($zeroCoveredFiles); +ksort($zeroCoveredClasses); +ksort($zeroCoveredMethods); + +$failures = []; +if ($overallCoverage < MINIMUM_STATEMENT_COVERAGE) { + $failures[] = sprintf( + 'Overall statement coverage %.1f%% is below %.1f%% (%d/%d covered statements).', + $overallCoverage, + MINIMUM_STATEMENT_COVERAGE, + $overallCoveredStatements, + $overallStatements, + ); +} + +if ($zeroCoveredFiles !== []) { + $failures[] = renderSection('Files with 0 covered statements', $zeroCoveredFiles); +} + +if ($zeroCoveredClasses !== []) { + $failures[] = renderSection('Classes with 0 coverage', $zeroCoveredClasses); +} + +if ($zeroCoveredMethods !== []) { + $failures[] = renderSection('Methods with 0 coverage', $zeroCoveredMethods); +} + +printf( + "Coverage gate summary: %.1f%% statements (%d/%d), %d zero-covered files, %d zero-covered classes, %d zero-covered methods.\n", + $overallCoverage, + $overallCoveredStatements, + $overallStatements, + count($zeroCoveredFiles), + count($zeroCoveredClasses), + count($zeroCoveredMethods), +); + +if ($exclusions['files'] !== [] || $exclusions['classes'] !== [] || $exclusions['methods'] !== []) { + echo renderExclusions($exclusions); +} + +if ($failures !== []) { + fwrite(STDERR, "Coverage gate failed.\n\n" . implode("\n\n", $failures) . "\n"); + exit(1); +} + +echo "Coverage gate passed.\n"; + +/** + * @return array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} + */ +function metricsToArray(?SimpleXMLElement $metricsNode): array +{ + return [ + 'statements' => (int) ($metricsNode['statements'] ?? 0), + 'coveredstatements' => (int) ($metricsNode['coveredstatements'] ?? 0), + 'methods' => (int) ($metricsNode['methods'] ?? 0), + 'coveredmethods' => (int) ($metricsNode['coveredmethods'] ?? 0), + 'elements' => (int) ($metricsNode['elements'] ?? 0), + 'coveredelements' => (int) ($metricsNode['coveredelements'] ?? 0), + ]; +} + +function coveragePercent(int $covered, int $total): float +{ + return $total > 0 ? ($covered / $total) * 100.0 : 100.0; +} + +function isProductionFile(string $filePath): bool +{ + return str_starts_with($filePath, dirname(__DIR__) . '/src/'); +} + +function relativeProductionPath(string $filePath): string +{ + return 'src/' . substr($filePath, strlen(dirname(__DIR__) . '/src/')); +} + +/** + * @param array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} $metrics + */ +function isCoverableClass(array $metrics): bool +{ + return $metrics['statements'] > 0 || $metrics['methods'] > 0 || $metrics['elements'] > 0; +} + +/** + * @param array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} $metrics + */ +function describeFileFailure(string $relativeFile, array $metrics): string +{ + return sprintf( + '%s has 0 covered statements (%d statements, %d methods).', + $relativeFile, + $metrics['statements'], + $metrics['methods'], + ); +} + +/** + * @param array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} $metrics + */ +function describeClassFailure(string $relativeFile, string $className, array $metrics): string +{ + return sprintf( + '%s in %s has 0 coverage (%d/%d statements, %d/%d methods).', + $className, + $relativeFile, + $metrics['coveredstatements'], + $metrics['statements'], + $metrics['coveredmethods'], + $metrics['methods'], + ); +} + +function describeMethodFailure(string $relativeFile, string $className, string $methodName, int $line): string +{ + return sprintf('%s::%s in %s:%d has 0 coverage.', $className, $methodName, $relativeFile, $line); +} + +/** + * @param array $items + */ +function renderSection(string $title, array $items): string +{ + $lines = [$title . ':']; + foreach ($items as $item) { + $lines[] = ' - ' . $item; + } + + return implode("\n", $lines); +} + +/** + * @param array{files: array, classes: array, methods: array} $exclusions + */ +function renderExclusions(array $exclusions): string +{ + $sections = []; + foreach ($exclusions as $type => $items) { + if ($items === []) { + continue; + } + + $lines = ['Configured coverage exclusions for ' . $type . ':']; + foreach ($items as $item => $reason) { + $lines[] = ' - ' . $item . ': ' . $reason; + } + + $sections[] = implode("\n", $lines); + } + + return implode("\n\n", $sections) . "\n"; +} + +function resolveMethodClassName(SimpleXMLElement $fileNode, int $lineNumber): ?string +{ + $candidates = []; + foreach ($fileNode->class as $classNode) { + $className = (string) ($classNode['name'] ?? ''); + if ($className === '') { + continue; + } + + $methods = (int) (($classNode->metrics[0]['methods'] ?? 0)); + if ($methods === 0) { + continue; + } + + $candidates[] = $className; + } + + if ($candidates === []) { + return null; + } + + if (count($candidates) === 1) { + return $candidates[0]; + } + + $lineMap = []; + foreach ($fileNode->line as $lineNode) { + if ((string) ($lineNode['type'] ?? '') !== 'method') { + continue; + } + + $methodLine = (int) ($lineNode['num'] ?? 0); + $methodName = (string) ($lineNode['name'] ?? ''); + foreach ($candidates as $candidate) { + if (!isset($lineMap[$candidate])) { + $lineMap[$candidate] = []; + } + + if (!in_array($methodName, $lineMap[$candidate], true)) { + $lineMap[$candidate][] = $methodName; + } + } + + if ($methodLine === $lineNumber) { + return $candidates[0]; + } + } + + return $candidates[0]; +} diff --git a/tools/test-coverage-map.php b/tools/test-coverage-map.php new file mode 100644 index 0000000..539b611 --- /dev/null +++ b/tools/test-coverage-map.php @@ -0,0 +1,220 @@ + + */ +$unitCoverageAliases = [ + JOOservices\WordPress\Sdk\Services\CategoriesService::class => 'Covered by TaxonomyServicesTest through AbstractTermService behavior.', + JOOservices\WordPress\Sdk\Services\TagsService::class => 'Covered by TaxonomyServicesTest through AbstractTermService behavior.', + JOOservices\WordPress\Sdk\Services\TaxonomiesService::class => 'Covered by TaxonomyServicesTest.', + JOOservices\WordPress\Sdk\Services\PostTypesService::class => 'Covered by SchemaServicesTest.', + JOOservices\WordPress\Sdk\Services\StatusesService::class => 'Covered by SchemaServicesTest.', + JOOservices\WordPress\Sdk\Services\RawEndpointService::class => 'Protected raw helper covered through raw endpoint service tests.', + JOOservices\WordPress\Sdk\Services\RawCrudById::class => 'Trait covered through concrete raw services.', + JOOservices\WordPress\Sdk\Services\RawCrudByStringId::class => 'Trait covered through concrete raw services.', + JOOservices\WordPress\Sdk\Services\RevisionResourceService::class => 'Covered by RevisionsServiceTest.', + JOOservices\WordPress\Sdk\Services\AbstractTermService::class => 'Abstract service covered by AbstractTermServiceTest and concrete taxonomy services.', + JOOservices\WordPress\Sdk\Http\AbstractService::class => 'Abstract HTTP helper covered by AbstractServiceTest.', + JOOservices\WordPress\Sdk\Support\ContentBuilder\Blocks\AbstractBlock::class => 'Abstract block renderer covered by BlocksTest.', + JOOservices\WordPress\Sdk\Support\ContentBuilder\Blocks\ContainerBlock::class => 'Abstract container block covered by HasInnerBlocksTest and BlocksTest.', + JOOservices\WordPress\Sdk\Support\ContentBuilder\Concerns\HasInnerBlocks::class => 'Trait covered by HasInnerBlocksTest.', + JOOservices\WordPress\Sdk\Http\ClientFactory::class => 'Covered indirectly by ContainerFactoryTest and WordPressService construction tests.', + JOOservices\WordPress\Sdk\Http\ResponseDecoderLogger::class => 'Covered through ResponseDecoderTest logger behavior.', + JOOservices\WordPress\Sdk\Data\PostType::class => 'Covered by DataModelsTest and schema service tests.', + JOOservices\WordPress\Sdk\Data\RenderedContent::class => 'Covered by DataModelsTest through nested DTO hydration.', + JOOservices\WordPress\Sdk\Data\SearchResult::class => 'Covered by DataModelsTest and SearchServiceTest.', + JOOservices\WordPress\Sdk\Data\Status::class => 'Covered by DataModelsTest and SchemaServicesTest.', + JOOservices\WordPress\Sdk\Data\Query\AbstractListQuery::class => 'Abstract query base covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListCommentsQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListMediaQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListPagesQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListPostsQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListTermsQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListUsersQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\SearchQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Support\RestPath::class => 'Covered by CustomEndpointServiceTest path normalization cases.', +]; + +/** + * @var array + */ +$integrationCoverageAliases = [ + JOOservices\WordPress\Sdk\Services\AbstractTermService::class => 'Abstract helper covered by CategoriesServiceIntegrationTest and TagsServiceIntegrationTest.', + JOOservices\WordPress\Sdk\Services\RawEndpointService::class => 'Protected helper covered by concrete raw service integration tests.', + JOOservices\WordPress\Sdk\Services\RawCrudById::class => 'Trait covered by concrete raw service integration tests.', + JOOservices\WordPress\Sdk\Services\RawCrudByStringId::class => 'Trait covered by concrete raw service integration tests.', + JOOservices\WordPress\Sdk\Services\RevisionResourceService::class => 'Covered through RevisionsServiceIntegrationTest.', + JOOservices\WordPress\Sdk\Services\ApplicationPasswordsService::class => 'No live application-password mutation integration; unit tests cover route and payload construction because credentials are generated only once by WordPress.', + JOOservices\WordPress\Sdk\Services\BlockDirectoryService::class => 'Raw editor endpoint with plugin/theme availability variance; unit tests cover route construction.', + JOOservices\WordPress\Sdk\Services\BlockRendererService::class => 'Dynamic block availability varies by WordPress site; unit tests cover renderer request construction.', + JOOservices\WordPress\Sdk\Services\BlocksService::class => 'Raw editor endpoint with site-specific block registrations; unit tests cover route construction.', + JOOservices\WordPress\Sdk\Services\BlockTypesService::class => 'Raw editor endpoint with site-specific block registrations; unit tests cover route construction.', + JOOservices\WordPress\Sdk\Services\CommentsService::class => 'Comment moderation state varies by site; unit tests cover CRUD route and payload construction.', + JOOservices\WordPress\Sdk\Services\CustomEndpointService::class => 'Custom routes are installation-specific; deterministic custom endpoint coverage belongs to Docker plugin tests.', + JOOservices\WordPress\Sdk\Services\DiscoveryService::class => 'Discovery output varies by installed routes; unit tests cover route/schema request construction.', + JOOservices\WordPress\Sdk\Services\GlobalStylesService::class => 'Block theme state varies by site; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\MenuLocationsService::class => 'Menu location availability is theme-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\NavMenuItemsService::class => 'Navigation menu state is theme/content-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\NavMenusService::class => 'Navigation menu state is theme/content-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\NavigationsService::class => 'Navigation post state is theme/content-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\PagesService::class => 'Page CRUD uses the same typed service path as PostsService; unit tests cover route and payload behavior.', + JOOservices\WordPress\Sdk\Services\PluginsService::class => 'Plugin operations require admin capability and installed plugin variance; unit tests cover route and payload construction.', + JOOservices\WordPress\Sdk\Services\PostTypesService::class => 'Schema-like readonly route covered by schema service unit tests.', + JOOservices\WordPress\Sdk\Services\RevisionsService::class => 'Revision availability depends on post state; unit tests cover posts/pages/block revision resource routing.', + JOOservices\WordPress\Sdk\Services\SearchService::class => 'Search results depend on site content; unit tests cover query and decoding behavior.', + JOOservices\WordPress\Sdk\Services\SettingsService::class => 'Settings mutation is site-global; unit tests cover get/update route and payload construction.', + JOOservices\WordPress\Sdk\Services\SidebarsService::class => 'Sidebar availability is theme-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\SiteHealthService::class => 'Site Health output varies by environment; unit tests cover diagnostic route construction.', + JOOservices\WordPress\Sdk\Services\StatusesService::class => 'Schema-like readonly route covered by schema service unit tests.', + JOOservices\WordPress\Sdk\Services\TaxonomiesService::class => 'Taxonomy schema behavior is covered by taxonomy service unit tests.', + JOOservices\WordPress\Sdk\Services\TemplatePartsService::class => 'Block theme template parts are theme-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\TemplatesService::class => 'Block theme templates are theme-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\ThemesService::class => 'Theme operations are installation-dependent; unit tests cover route construction.', + JOOservices\WordPress\Sdk\Services\UsersService::class => 'User mutation requires site-specific roles/capabilities; unit tests cover CRUD and me() request construction.', + JOOservices\WordPress\Sdk\Services\WidgetsService::class => 'Widget state is theme-dependent; unit tests cover raw route construction.', + JOOservices\WordPress\Sdk\Services\WidgetTypesService::class => 'Widget type availability is theme/plugin-dependent; unit tests cover raw route construction.', +]; + +$files = iterator_to_array(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/src'))); +$failures = []; +$classes = 0; +$publicMethods = 0; +$mappedUnitClasses = 0; +$mappedIntegrationServices = 0; + +foreach ($files as $file) { + if (!$file instanceof SplFileInfo || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($root . '/src/'), -4); + $class = 'JOOservices\\WordPress\\Sdk\\' . str_replace(DIRECTORY_SEPARATOR, '\\', $relative); + + if (!class_exists($class) && !interface_exists($class) && !trait_exists($class)) { + $failures[] = "Unable to autoload {$class} from {$relative}.php"; + + continue; + } + + $reflection = new \ReflectionClass($class); + if ($reflection->isInterface()) { + continue; + } + + $classes++; + $methods = array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class + ); + $publicMethods += count($methods); + + if ($reflection->isAbstract() || $reflection->isTrait()) { + $isServiceHelper = str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Services\\'); + $hasRequiredReason = isset($unitCoverageAliases[$class]) + && (!$isServiceHelper || isset($integrationCoverageAliases[$class])); + + if (!$hasRequiredReason) { + $failures[] = "Abstract/trait {$class} needs an explicit coverage-map reason."; + } + + continue; + } + + $unitTest = unitTestPathFor($root, $relative, $class); + if (hasUnitCoverageMapping($root, $relative, $class) || isset($unitCoverageAliases[$class])) { + $mappedUnitClasses++; + } else { + $failures[] = "Missing unit coverage mapping for {$class}. Expected {$unitTest}."; + } + + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Services\\')) { + $integrationTest = integrationTestPathFor($root, $relative); + if (is_file($integrationTest) || isset($integrationCoverageAliases[$class])) { + $mappedIntegrationServices++; + } else { + $failures[] = "Missing integration coverage mapping for service {$class}. Expected {$integrationTest}."; + } + } +} + +printf("Production classes mapped for unit tests: %d/%d\n", $mappedUnitClasses, $classes); +printf("Service classes mapped for integration tests: %d\n", $mappedIntegrationServices); +printf("Public methods reflected: %d\n", $publicMethods); + +if ($failures !== []) { + echo "\nCoverage map failures:\n"; + foreach ($failures as $failure) { + echo "- {$failure}\n"; + } + exit(1); +} + +echo "Coverage map passed.\n"; + +function integrationTestPathFor(string $root, string $relative): string +{ + $serviceName = basename($relative); + $canonical = $root . '/tests/Integration/Services/' . $serviceName . 'IntegrationTest.php'; + + if (is_file($canonical)) { + return $canonical; + } + + return $root . '/tests/Integration/' . $serviceName . 'Test.php'; +} + +/** + * @param class-string $class + */ +function unitTestPathFor(string $root, string $relative, string $class): string +{ + return $root . '/tests/Unit/' . $relative . 'Test.php'; +} + +/** + * @param class-string $class + */ +function hasUnitCoverageMapping(string $root, string $relative, string $class): bool +{ + $direct = unitTestPathFor($root, $relative, $class); + if (is_file($direct)) { + return true; + } + + $grouped = []; + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Exceptions\\')) { + $grouped[] = $root . '/tests/Unit/Exceptions/ExceptionsTest.php'; + } + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Data\\Query\\')) { + $grouped[] = $root . '/tests/Unit/Data/Query/ListQueryTest.php'; + } elseif (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Data\\')) { + $grouped[] = $root . '/tests/Unit/Data/DataModelsTest.php'; + } + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\ContentBuilder\\Blocks\\')) { + $grouped[] = $root . '/tests/Unit/Support/ContentBuilder/BlocksTest.php'; + } + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\ContentBuilder\\Parser\\')) { + $grouped[] = $root . '/tests/Unit/Support/ContentBuilder/BlockParserTest.php'; + } + if ($class === JOOservices\WordPress\Sdk\Http\Middleware\AuthenticationMiddleware::class) { + $grouped[] = $root . '/tests/Unit/Http/AuthenticationMiddlewareTest.php'; + } + + foreach ($grouped as $path) { + if (is_file($path)) { + return true; + } + } + + return false; +}