From e73468a06fca0ae15eb2f6cb1a899d60cd126efc Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sun, 17 May 2026 06:28:15 +0700 Subject: [PATCH 1/2] refactor: extract content templates from SDK core --- AGENTS.md | 1 + CHANGELOG.md | 26 ++ README.md | 7 +- docs/02-user-guide/01-services-and-queries.md | 3 +- docs/02-user-guide/posts.md | 2 +- docs/03-examples/README.md | 3 +- docs/04-development/testing.md | 5 +- docs/guides/features.md | 3 +- docs/guides/templates.md | 390 ------------------ docs/services/posts.md | 3 +- examples/custom-template.php | 181 -------- examples/template-usage.php | 126 ------ src/Services/PostsService.php | 13 - .../Contracts/TemplateDataInterface.php | 25 -- .../Templates/Data/AbstractTemplateData.php | 51 --- .../Data/ProductReviewTemplateData.php | 58 --- .../Data/StoryChapterTemplateData.php | 46 --- .../Templates/Data/StoryTemplateData.php | 57 --- src/Support/Templates/PostTemplate.php | 41 -- .../Templates/ProductReviewTemplate.php | 123 ------ src/Support/Templates/StoryTemplate.php | 202 --------- src/Support/Templates/TemplateRegistry.php | 112 ----- .../StoryTemplateExtendedIntegrationTest.php | 126 ------ .../TemplateWorkflowIntegrationTest.php | 274 ------------ tests/Unit/Services/PostsServiceTest.php | 21 - .../Templates/AbstractTemplateDataTest.php | 67 --- tests/Unit/Templates/PostTemplateTest.php | 57 --- .../ProductReviewTemplateDataTest.php | 128 ------ .../Templates/ProductReviewTemplateTest.php | 134 ------ .../StoryChapterTemplateDataTest.php | 55 --- .../Unit/Templates/StoryTemplateDataTest.php | 112 ----- tests/Unit/Templates/StoryTemplateTest.php | 182 -------- tests/Unit/Templates/TemplateRegistryTest.php | 128 ------ tools/test-coverage-map.php | 52 ++- 34 files changed, 81 insertions(+), 2733 deletions(-) delete mode 100644 docs/guides/templates.md delete mode 100644 examples/custom-template.php delete mode 100644 examples/template-usage.php delete mode 100644 src/Support/Templates/Contracts/TemplateDataInterface.php delete mode 100644 src/Support/Templates/Data/AbstractTemplateData.php delete mode 100644 src/Support/Templates/Data/ProductReviewTemplateData.php delete mode 100644 src/Support/Templates/Data/StoryChapterTemplateData.php delete mode 100644 src/Support/Templates/Data/StoryTemplateData.php delete mode 100644 src/Support/Templates/PostTemplate.php delete mode 100644 src/Support/Templates/ProductReviewTemplate.php delete mode 100644 src/Support/Templates/StoryTemplate.php delete mode 100644 src/Support/Templates/TemplateRegistry.php delete mode 100644 tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php delete mode 100644 tests/Integration/Support/Templates/TemplateWorkflowIntegrationTest.php delete mode 100644 tests/Unit/Templates/AbstractTemplateDataTest.php delete mode 100644 tests/Unit/Templates/PostTemplateTest.php delete mode 100644 tests/Unit/Templates/ProductReviewTemplateDataTest.php delete mode 100644 tests/Unit/Templates/ProductReviewTemplateTest.php delete mode 100644 tests/Unit/Templates/StoryChapterTemplateDataTest.php delete mode 100644 tests/Unit/Templates/StoryTemplateDataTest.php delete mode 100644 tests/Unit/Templates/StoryTemplateTest.php delete mode 100644 tests/Unit/Templates/TemplateRegistryTest.php diff --git a/AGENTS.md b/AGENTS.md index 22a161f..b617cec 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a365be..bf43be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,32 @@ The format follows Keep a Changelog, and this project aims to follow semantic ve - Updated testing docs, README development commands, and AI contributor guidance to require Docker-only WordPress integration tests. +## [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 diff --git a/README.md b/README.md index 0815f2c..7232c6a 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,10 @@ The SDK also ships with optional developer-experience helpers under `JOOservices - `PostBuilder` helps assemble post payloads fluently before calling `posts()->create()` or `update()`. - `ContentBuilder` helps generate Gutenberg-compatible block markup in PHP. -- `PostTemplate`, concrete templates, and `createFromTemplate()` are template helpers built on top of normal post creation. -These helpers are not native WordPress REST API resources. They are kept as SDK extras because they reduce repetitive WordPress payload-building while staying generic to SDK users. +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. The Docker integration suite verifies these helpers against a real local WordPress REST API. `composer test:wordpress` starts Docker services, installs WordPress with WP-CLI, creates test auth, uploads local media fixtures, and runs the main integration tests. Optional WordPress capabilities that need extra plugins, block registration, or block-theme state live in the independent `composer test:wordpress:extended` suite. No manual WordPress setup is required; use `composer test:wordpress:reset` to wipe the disposable database and uploads volume. @@ -208,7 +209,7 @@ composer check composer ci ``` -Run `composer test:wordpress` for the one-command Docker/WP-CLI WordPress integration flow. Run `composer test:wordpress:extended` after it, or by itself, for optional capabilities such as deterministic plugin reads, dynamic block rendering, global styles, navigation/template routes, site health reads, and the chaptered story template workflow with uploaded media. See [Testing](./docs/04-development/testing.md) for details, safety guards, reset commands, and route skip policy. +Run `composer test:wordpress` for the one-command Docker/WP-CLI WordPress integration flow. Run `composer test:wordpress:extended` after it, or by itself, for optional capabilities such as deterministic plugin reads, dynamic block rendering, global styles, navigation/template routes, and site health reads. See [Testing](./docs/04-development/testing.md) for details, safety guards, reset commands, and route skip policy. `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`. diff --git a/docs/02-user-guide/01-services-and-queries.md b/docs/02-user-guide/01-services-and-queries.md index 285d070..e6a8db3 100644 --- a/docs/02-user-guide/01-services-and-queries.md +++ b/docs/02-user-guide/01-services-and-queries.md @@ -24,7 +24,8 @@ Examples: - `PostBuilder` for fluent post payload construction - `ContentBuilder` for Gutenberg block content generation -- template helpers such as `PostTemplate` and `posts()->createFromTemplate()` + +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. diff --git a/docs/02-user-guide/posts.md b/docs/02-user-guide/posts.md index f987723..c7f89eb 100644 --- a/docs/02-user-guide/posts.md +++ b/docs/02-user-guide/posts.md @@ -10,4 +10,4 @@ $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. -Optional template helpers such as `posts()->createFromTemplate()` are SDK extras layered on top of normal post creation rather than separate WordPress REST endpoints. +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 14d9e04..84df360 100644 --- a/docs/03-examples/README.md +++ b/docs/03-examples/README.md @@ -9,5 +9,4 @@ This section collects complete workflows built from the public SDK services. Repository PHP examples: -- [Template usage example](../../examples/template-usage.php) -- [Custom template example](../../examples/custom-template.php) +- Content template examples live in `jooservices/wordpress-content-templates`. diff --git a/docs/04-development/testing.md b/docs/04-development/testing.md index 740c448..eff6dc4 100644 --- a/docs/04-development/testing.md +++ b/docs/04-development/testing.md @@ -26,7 +26,7 @@ composer test:coverage-map ## 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 content/template support. They must use fake clients or deterministic local values and must not make network calls. +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. ## Docker Integration Tests @@ -40,7 +40,7 @@ The Docker runner starts MariaDB, WordPress, and WP-CLI, waits for HTTP readines `composer test:integration:docker` is kept as an equivalent compatibility alias. -Template integration tests are part of this command. They create real draft posts through `posts()->createFromTemplate()`, upload local media fixtures through `media()->upload()`, verify fetched WordPress content, and clean up posts/media/terms after each test. Coverage includes the chaptered `StoryTemplate` story `The Lanterns of Grey Harbor`, a product review template, and a registered custom template. +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. ## Extended Docker Integration Tests @@ -59,7 +59,6 @@ The extended suite covers: - plugin endpoint reads against `sdk-test-plugin/sdk-test-plugin` - block renderer requests for `jooservices/test-dynamic-block` - global styles, navigation, templates, template parts, site health, themes, block types, widget types, and sidebars as independently checked raw routes -- a chaptered `StoryTemplate` draft post for `The Lanterns of Grey Harbor` with categories, tags, uploaded cover media, featured media, chapter headings, body paragraphs, and quote assertions Global styles and other optional routes may still be absent in a specific WordPress/theme combination. Each optional route test checks only its own route and skips with the WordPress version, active theme, and active plugin list; one missing route does not skip unrelated extended tests. 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 64460b6..0000000 --- a/docs/guides/templates.md +++ /dev/null @@ -1,390 +0,0 @@ -# Post Templates - -The Template system is an SDK developer-experience extra under `JOOservices\WordPress\Sdk\Support`. -It creates normal WordPress post payloads and sends them through `posts()->create()`; it is not a native WordPress REST API resource or CMS layer. - -## 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 -│ ├── StoryChapterTemplateData.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\Data\StoryChapterTemplateData; -use JOOservices\WordPress\Sdk\Support\Templates\StoryTemplate; - -// Create the DTO with required and optional fields -$data = new StoryTemplateData( - title: 'The Lanterns of Grey Harbor', - introduction: 'Fog rolled over Grey Harbor just before the ferry bells stopped.', - body: 'A serialized mystery in three chapters.', - subtitle: 'A serialized mystery in three chapters', - genre: 'Fiction, Mystery, Serialized Story', - featuredImageId: $media->id, - featuredImageUrl: $media->source_url, - featuredImageAlt: 'Lanterns glowing along a misty harbor pier', - chapters: [ - new StoryChapterTemplateData( - number: 1, - title: 'The Light Beneath the Pier', - openingParagraph: 'Mira tucked the lantern under her coat.', - paragraphs: [ - 'The pier boards flexed with every wave, but one pale light kept shining from below.', - ], - quote: 'Tell the tide I kept my promise.' - ), - ], - conclusion: 'The last lantern was an invitation to remember.', - author: 'Mira Vale', - excerpt: 'Mira Vale begins a quiet mystery beneath the lights of Grey Harbor.', - tags: [$novelTagId, $mysteryTagId], - categories: [$fictionCategoryId] -); - -// 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:** -- `subtitle`: Story subtitle or short summary -- `genre`: Genre/category label rendered in the story byline -- `featuredImageId`: Featured image media ID -- `featuredImageUrl`: Uploaded media source URL for the image block -- `featuredImageAlt`: Image alt text for the image block -- `galleryImageIds`: Array of gallery image media IDs -- `chapters`: Array of `StoryChapterTemplateData` -- `conclusion`: Story conclusion/ending -- `author`: Author attribution -- `excerpt`: Post excerpt -- `tags`: Post tag IDs -- `categories`: Post category IDs - -**Generated Structure:** -- Subtitle and byline when provided -- Introduction heading + paragraph -- Featured image (if provided) -- Story notes/body paragraph -- Contents/chapter navigation when chapters are provided -- Chapter headings, opening paragraphs, content paragraphs, and optional quote blocks -- Gallery section with images (if provided) -- Conclusion section (if provided) -- Author attribution (if provided) - -`StoryChapterTemplateData` requires `number`, `title`, `openingParagraph`, and at least one paragraph in `paragraphs`. - -#### 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 tag IDs - -**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.5 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 -// ] -``` - -## Docker Integration Coverage - -The Docker WordPress suite exercises template helpers against a real local WordPress REST API instead of only asserting generated arrays or strings. - -```bash -composer test:wordpress -``` - -The runner starts MariaDB and WordPress, waits for readiness, installs WordPress through WP-CLI when needed, activates the SDK test marker plugin, creates local users, generates an Application Password, writes `.env.integration`, and runs the PHPUnit integration suite. No manual WordPress install, manual `.env` edits, or manual media uploads are required. - -Template integration coverage includes: - -- a chaptered `StoryTemplate` post for `The Lanterns of Grey Harbor` with three chapters, categories, tags, excerpt, featured media, and an image block referencing uploaded local media -- a `ProductReviewTemplate` post with realistic review copy, pros, cons, verdict, tags, and featured product media -- a registered custom template defined in the integration test namespace that creates a real WordPress post with a media block - -To wipe the disposable WordPress database and uploads volume: - -```bash -docker/wordpress/scripts/run-integration-tests.sh reset -``` - -### Using Registry with Factory Pattern - -```php -class PostFactory -{ - public function __construct( - private TemplateRegistry $registry, - private PostsService $postsService - ) {} - - public function createFromTemplate(string $templateName, TemplateDataInterface $data): Post - { - $templateClass = $this->registry->get($templateName); - - $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 658cf0d..0000000 --- a/examples/template-usage.php +++ /dev/null @@ -1,126 +0,0 @@ -media()->upload($mediaPath, [ - 'title' => 'Template example media', - 'alt_text' => 'Lanterns glowing along a misty harbor pier', - ]) - : null; - -$tagIds = array_values(array_filter(array_map( - static fn (string $id): int => (int) trim($id), - explode(',', (string) getenv('WORDPRESS_TEMPLATE_TAG_IDS')) -))); -$categoryIds = array_values(array_filter(array_map( - static fn (string $id): int => (int) trim($id), - explode(',', (string) getenv('WORDPRESS_TEMPLATE_CATEGORY_IDS')) -))); - -// Example 1: Create a Story Post -echo "Creating Story Post...\n"; - -$storyData = new StoryTemplateData( - title: 'The Lanterns of Grey Harbor', - introduction: 'Fog rolled over Grey Harbor just before the ferry bells stopped, leaving Mira Vale alone with a blue lantern and a locked pier gate.', - body: 'A serialized mystery in three chapters, following a quiet archivist as she traces a vanished letter through the old harbor district.', - subtitle: 'A serialized mystery in three chapters', - genre: 'Fiction, Mystery, Serialized Story', - featuredImageId: $media?->id, - featuredImageUrl: $media?->source_url, - featuredImageAlt: $media !== null ? 'Lanterns glowing along a misty harbor pier' : null, - chapters: [ - new StoryChapterTemplateData( - number: 1, - title: 'The Light Beneath the Pier', - openingParagraph: 'Mira tucked the lantern under her coat and listened for footsteps above the tide line.', - paragraphs: [ - 'The pier boards flexed with every wave, but one pale light kept shining from below, steady as a held breath.', - 'When she knelt beside the last piling, she found a brass key tied to the lantern handle with blue thread.', - ], - quote: 'Tell the tide I kept my promise.' - ), - new StoryChapterTemplateData( - number: 2, - title: 'A Letter Without a Name', - openingParagraph: 'By morning, the key fit the archive drawer that no one in town hall admitted existed.', - paragraphs: [ - 'Inside waited a letter written on ferry stationery, unsigned except for a smear of lamp soot near the fold.', - 'Mira read it twice before noticing the margin marks formed a map of streets missing from modern plans.', - ], - ), - new StoryChapterTemplateData( - number: 3, - title: 'The House That Remembered', - openingParagraph: 'The map ended at a narrow house behind the chandlery, where every window faced the harbor.', - paragraphs: [ - 'Dust lifted from the floorboards as Mira crossed the parlor toward a wall of framed tide charts.', - 'Behind the newest chart, she found a photograph of three children holding lanterns.', - ], - ), - ], - conclusion: 'The last lantern was not a warning after all, but an invitation to remember.', - author: 'Mira Vale', - excerpt: 'Mira Vale begins a three-chapter mystery beneath the lanterns of Grey Harbor.', - tags: $tagIds !== [] ? $tagIds : null, - categories: $categoryIds !== [] ? $categoryIds : null -); - -$storyTemplate = new StoryTemplate($storyData); -$storyPost = $client->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: 'Atlas Field Notebook', - reviewText: 'The notebook opens flat on a crowded desk, holds ink cleanly, and keeps project notes readable after a week of daily travel.', - rating: 4.5, - productImageId: $media?->id, - pros: [ - 'Thick paper handles fountain pen ink', - 'Compact grid pages make sketches easy', - ], - cons: [ - 'Elastic band feels tight at first', - 'Back pocket is narrow for folded maps', - ], - verdict: 'A dependable field notebook for writers who mix outlines, sketches, and review notes in one place.', - price: 18.50, - tags: $tagIds !== [] ? $tagIds : null -); - -$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/Services/PostsService.php b/src/Services/PostsService.php index e71881a..179e6f1 100644 --- a/src/Services/PostsService.php +++ b/src/Services/PostsService.php @@ -15,7 +15,6 @@ use JOOservices\WordPress\Sdk\Http\AbstractService; use JOOservices\WordPress\Sdk\Pagination\PaginatedCollection; use JOOservices\WordPress\Sdk\Support\PostBuilder; -use JOOservices\WordPress\Sdk\Support\Templates\PostTemplate; /** * Service for managing WordPress posts. @@ -156,16 +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. - * - * @return Post - */ - public function createFromTemplate(PostTemplate $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/StoryChapterTemplateData.php b/src/Support/Templates/Data/StoryChapterTemplateData.php deleted file mode 100644 index 2aef672..0000000 --- a/src/Support/Templates/Data/StoryChapterTemplateData.php +++ /dev/null @@ -1,46 +0,0 @@ - $paragraphs - */ - public function __construct( - public readonly int $number, - public readonly string $title, - public readonly string $openingParagraph, - public readonly array $paragraphs, - public readonly ?string $quote = null, - ) { - $this->validate(); - } - - protected function getRequiredFields(): array - { - return ['number', 'title', 'openingParagraph', 'paragraphs']; - } - - public function validate(): void - { - parent::validate(); - - if ($this->number < 1) { - throw new InvalidArgumentException('Chapter number must be greater than zero'); - } - - foreach ($this->paragraphs as $paragraph) { - if ($paragraph === '') { - throw new InvalidArgumentException('Chapter paragraphs cannot contain empty values'); - } - } - } -} diff --git a/src/Support/Templates/Data/StoryTemplateData.php b/src/Support/Templates/Data/StoryTemplateData.php deleted file mode 100644 index 0c7fadc..0000000 --- a/src/Support/Templates/Data/StoryTemplateData.php +++ /dev/null @@ -1,57 +0,0 @@ -|null $galleryImageIds Gallery image media IDs (optional) - * @param array|null $chapters Chapter data (optional) - * @param string|null $conclusion Story conclusion/ending (optional) - * @param string|null $author Author attribution (optional) - * @param string|null $excerpt Post excerpt (optional) - * @param array|null $tags Post tag IDs (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 ?string $subtitle = null, - public readonly ?string $genre = null, - public readonly ?int $featuredImageId = null, - public readonly ?string $featuredImageUrl = null, - public readonly ?string $featuredImageAlt = null, - public readonly ?array $galleryImageIds = null, - public readonly ?array $chapters = null, - public readonly ?string $conclusion = null, - public readonly ?string $author = null, - public readonly ?string $excerpt = 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 5ee74b7..0000000 --- a/src/Support/Templates/StoryTemplate.php +++ /dev/null @@ -1,202 +0,0 @@ -data; - $builder = new ContentBuilder(); - - $this->addStoryHeader($builder, $data); - $this->addIntroduction($builder, $data); - $this->addFeaturedImage($builder, $data); - $this->addStoryBody($builder, $data); - $this->addGallery($builder, $data); - $this->addConclusion($builder, $data); - $this->addAuthorAttribution($builder, $data); - - return $builder; - } - - private function addStoryHeader(ContentBuilder $builder, StoryTemplateData $data): void - { - if ($data->subtitle !== null) { - $builder->addBlock( - new Paragraph($data->subtitle) - ); - } - - if ($data->author !== null || $data->genre !== null) { - $byline = trim(implode(' | ', array_filter([ - $data->author !== null ? "By {$data->author}" : null, - $data->genre, - ]))); - - $builder->addBlock( - new Paragraph($byline) - ); - } - } - - private function addIntroduction(ContentBuilder $builder, StoryTemplateData $data): void - { - $builder->addBlock( - new Heading('Introduction', 2) - ); - $builder->addBlock( - new Paragraph($data->introduction) - ); - } - - private function addFeaturedImage(ContentBuilder $builder, StoryTemplateData $data): void - { - if ($data->featuredImageId !== null) { - $builder->addBlock( - new Image( - $data->featuredImageId, - $data->featuredImageUrl ?? '', - $data->featuredImageAlt ?? '' - ) - ); - } - } - - private function addStoryBody(ContentBuilder $builder, StoryTemplateData $data): void - { - if ($data->chapters !== null && count($data->chapters) > 0) { - $this->addChapteredStoryContent($builder, $data->body, $data->chapters); - - return; - } - - $builder->addBlock( - new Heading('Story', 2) - ); - $builder->addBlock( - new Paragraph($data->body) - ); - } - - private function addGallery(ContentBuilder $builder, StoryTemplateData $data): void - { - if ($data->galleryImageIds === null || count($data->galleryImageIds) === 0) { - return; - } - - $builder->addBlock( - new Heading('Gallery', 2) - ); - - foreach ($data->galleryImageIds as $imageId) { - $builder->addBlock( - new Image($imageId) - ); - } - } - - private function addConclusion(ContentBuilder $builder, StoryTemplateData $data): void - { - if ($data->conclusion !== null) { - $builder->addBlock( - new Heading('Conclusion', 2) - ); - $builder->addBlock( - new Paragraph($data->conclusion) - ); - } - } - - private function addAuthorAttribution(ContentBuilder $builder, StoryTemplateData $data): void - { - if ($data->author !== null) { - $builder->addBlock( - new Paragraph("— {$data->author}") - ); - } - } - - /** - * @param array $chapters - */ - private function addChapteredStoryContent(ContentBuilder $builder, string $body, array $chapters): void - { - $builder->addBlock(new Heading('Story Notes', 2)); - $builder->addBlock(new Paragraph($body)); - $builder->addBlock(new Heading('Contents', 2)); - - foreach ($chapters as $chapter) { - $builder->addBlock(new Paragraph("Chapter {$chapter->number}: {$chapter->title}")); - } - - foreach ($chapters as $chapter) { - $builder->addBlock( - new Heading("Chapter {$chapter->number}: {$chapter->title}", 2, [ - 'anchor' => 'chapter-' . $chapter->number, - ]) - ); - $builder->addBlock(new Paragraph($chapter->openingParagraph)); - - foreach ($chapter->paragraphs as $paragraph) { - $builder->addBlock(new Paragraph($paragraph)); - } - - if ($chapter->quote !== null) { - $builder->addBlock(new Quote($chapter->quote)); - } - } - } - - 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->excerpt !== null) { - $postData['excerpt'] = $data->excerpt; - } - - 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/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php b/tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php deleted file mode 100644 index cda5e5a..0000000 --- a/tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php +++ /dev/null @@ -1,126 +0,0 @@ -wordpress()->media()->upload($this->mediaFixturePath(), [ - 'title' => '[SDK Extended] Grey Harbor cover', - 'alt_text' => 'Lanterns glowing along a misty harbor pier', - ]); - $this->createdResources['media'][] = $media->id; - - $categories = [ - $this->createTestCategory(['name' => '[SDK Extended] Fiction'])->id, - $this->createTestCategory(['name' => '[SDK Extended] Mystery'])->id, - $this->createTestCategory(['name' => '[SDK Extended] Serialized Story'])->id, - ]; - $tags = [ - $this->createTestTag(['name' => '[SDK Extended] novel'])->id, - $this->createTestTag(['name' => '[SDK Extended] serialized-fiction'])->id, - $this->createTestTag(['name' => '[SDK Extended] mystery'])->id, - $this->createTestTag(['name' => '[SDK Extended] chaptered-story'])->id, - ]; - - $template = new StoryTemplate(new StoryTemplateData( - title: 'The Lanterns of Grey Harbor', - introduction: 'Fog rolled through Grey Harbor as archivist Mira Vale found a blue lantern burning below the shuttered ferry pier.', - body: 'A serialized mystery in three chapters, tracing a hidden family record through tide charts, locked drawers, and a house that refuses to forget.', - subtitle: 'A serialized mystery in three chapters', - genre: 'Fiction, Mystery, Serialized Story', - featuredImageId: $media->id, - featuredImageUrl: $media->source_url, - featuredImageAlt: 'Lanterns glowing along a misty harbor pier', - chapters: $this->chapters(), - conclusion: 'When the final lantern went dark, Mira understood the harbor had been preserving a name, not concealing a crime.', - author: 'Mira Vale', - excerpt: 'A chaptered mystery follows Mira Vale beneath the lanterns of Grey Harbor.', - tags: $tags, - categories: $categories, - )); - - $post = $this->wordpress()->posts()->createFromTemplate($template); - $this->createdResources['posts'][] = $post->id; - $fetched = $this->wordpress()->posts()->get($post->id); - $content = $this->postContent($fetched); - - $this->assertGreaterThan(0, $post->id); - $this->assertSame('draft', $post->status); - $this->assertSame($media->id, $post->featured_media); - $this->assertSame($media->id, $this->wordpress()->media()->get($media->id)->id); - $this->assertStringContainsString('The Lanterns of Grey Harbor', $post->title->rendered); - $this->assertStringContainsString('A chaptered mystery follows Mira Vale', $post->excerpt->rendered); - $this->assertContains($categories[0], $post->categories); - $this->assertContains($categories[1], $post->categories); - $this->assertContains($categories[2], $post->categories); - $this->assertContains($tags[0], $post->tags); - $this->assertContains($tags[1], $post->tags); - $this->assertContains($tags[2], $post->tags); - $this->assertContains($tags[3], $post->tags); - $this->assertStringContainsString('Chapter 1: The Light Beneath the Pier', $content); - $this->assertStringContainsString('Chapter 2: A Letter Without a Name', $content); - $this->assertStringContainsString('Chapter 3: The House That Remembered', $content); - $this->assertStringContainsString('Tell the tide I kept my promise.', $content); - $this->assertStringContainsString('wp-image-' . $media->id, $content); - $this->assertStringContainsString($media->source_url, $content); - } - - /** - * @return array - */ - private function chapters(): array - { - return [ - new StoryChapterTemplateData( - number: 1, - title: 'The Light Beneath the Pier', - openingParagraph: 'Mira tucked the lantern under her coat and listened for footsteps above the tide line.', - paragraphs: [ - 'The pier boards flexed with every wave, but one pale light kept shining from below, steady as a held breath.', - 'When she knelt beside the last piling, she found a brass key tied to the lantern handle with blue thread.', - ], - quote: 'Tell the tide I kept my promise.' - ), - new StoryChapterTemplateData( - number: 2, - title: 'A Letter Without a Name', - openingParagraph: 'By morning, the key fit the archive drawer that no one in town hall admitted existed.', - paragraphs: [ - 'Inside waited a letter written on ferry stationery, unsigned except for a smear of lamp soot near the fold.', - 'Mira read it twice before noticing the margin marks formed a map of streets erased from modern plans.', - ], - quote: 'Some houses keep their own address.' - ), - new StoryChapterTemplateData( - number: 3, - title: 'The House That Remembered', - openingParagraph: 'The map ended at a narrow house behind the chandlery, where every window faced the harbor.', - paragraphs: [ - 'Dust lifted from the floorboards in small silver clouds as Mira crossed the parlor toward framed tide charts.', - 'Behind the newest chart, she found a photograph of three children holding lanterns, one face carefully scratched away.', - ], - quote: 'A town forgets only when someone teaches it how.' - ), - ]; - } - - private function mediaFixturePath(): string - { - return dirname(__DIR__, 4) . '/docker/wordpress/fixtures/media/test-image.png'; - } - - private function postContent(Post $post): string - { - return $post->content->raw ?? $post->content->rendered; - } -} diff --git a/tests/Integration/Support/Templates/TemplateWorkflowIntegrationTest.php b/tests/Integration/Support/Templates/TemplateWorkflowIntegrationTest.php deleted file mode 100644 index 40206ac..0000000 --- a/tests/Integration/Support/Templates/TemplateWorkflowIntegrationTest.php +++ /dev/null @@ -1,274 +0,0 @@ -wordpress()->media()->upload($this->mediaFixturePath(), [ - 'title' => '[SDK Integration] Grey Harbor cover', - 'alt_text' => 'Lanterns glowing along a misty harbor pier', - ]); - $this->createdResources['media'][] = $media->id; - - $categories = [ - $this->createTestCategory(['name' => '[SDK Integration] Fiction'])->id, - $this->createTestCategory(['name' => '[SDK Integration] Mystery'])->id, - $this->createTestCategory(['name' => '[SDK Integration] Serialized Story'])->id, - ]; - $tags = [ - $this->createTestTag(['name' => '[SDK Integration] novel'])->id, - $this->createTestTag(['name' => '[SDK Integration] serialized-fiction'])->id, - $this->createTestTag(['name' => '[SDK Integration] mystery'])->id, - $this->createTestTag(['name' => '[SDK Integration] chaptered-story'])->id, - ]; - - $template = new StoryTemplate(new StoryTemplateData( - title: '[SDK Integration] The Lanterns of Grey Harbor', - introduction: 'Fog rolled over Grey Harbor just before the ferry bells stopped, leaving Mira Vale alone with a blue lantern and a locked pier gate.', - body: 'A serialized mystery in three chapters, following a quiet archivist as she traces a vanished letter through the old harbor district.', - subtitle: 'A serialized mystery in three chapters', - genre: 'Fiction, Mystery, Serialized Story', - featuredImageId: $media->id, - featuredImageUrl: $media->source_url, - featuredImageAlt: 'Lanterns glowing along a misty harbor pier', - chapters: $this->greyHarborChapters(), - conclusion: 'The last lantern was not a warning after all, but an invitation to remember who had been left out of the town record.', - author: 'Mira Vale', - excerpt: 'Mira Vale begins a three-chapter mystery beneath the lanterns of Grey Harbor.', - tags: $tags, - categories: $categories, - )); - - $post = $this->wordpress()->posts()->createFromTemplate($template); - $this->createdResources['posts'][] = $post->id; - $fetched = $this->wordpress()->posts()->get($post->id); - - $this->assertGreaterThan(0, $post->id); - $this->assertSame('draft', $post->status); - $this->assertStringContainsString('The Lanterns of Grey Harbor', $post->title->rendered); - $this->assertStringContainsString('Mira Vale begins a three-chapter mystery', $post->excerpt->rendered); - $this->assertSame($media->id, $post->featured_media); - $this->assertSame($media->id, $this->wordpress()->media()->get($media->id)->id); - $this->assertContains($categories[0], $post->categories); - $this->assertContains($categories[1], $post->categories); - $this->assertContains($tags[0], $post->tags); - $this->assertContains($tags[3], $post->tags); - - $content = $this->postContent($fetched); - $this->assertStringContainsString('Contents', $content); - $this->assertStringContainsString('Chapter 1: The Light Beneath the Pier', $content); - $this->assertStringContainsString('Chapter 2: A Letter Without a Name', $content); - $this->assertStringContainsString('Chapter 3: The House That Remembered', $content); - $this->assertStringContainsString('Mira tucked the lantern under her coat', $content); - $this->assertStringContainsString('Tell the tide I kept my promise.', $content); - $this->assertStringContainsString('wp-image-' . $media->id, $content); - $this->assertStringContainsString($media->source_url, $content); - } - - public function testProductReviewTemplateCreatesDraftWithFeaturedProductMedia(): void - { - $media = $this->wordpress()->media()->upload($this->mediaFixturePath(), [ - 'title' => '[SDK Integration] Atlas Field Notebook image', - 'alt_text' => 'Notebook beside a brass reading lamp', - ]); - $this->createdResources['media'][] = $media->id; - $tags = [ - $this->createTestTag(['name' => '[SDK Integration] field-notes'])->id, - $this->createTestTag(['name' => '[SDK Integration] desk-tools'])->id, - ]; - - $template = new ProductReviewTemplate(new ProductReviewTemplateData( - productName: '[SDK Integration] Atlas Field Notebook', - reviewText: 'The notebook opens flat on a crowded desk, holds ink cleanly, and keeps project notes readable after a week of daily travel.', - rating: 4.5, - productImageId: $media->id, - pros: ['Thick paper handles fountain pen ink', 'Compact grid pages make sketches easy'], - cons: ['Elastic band feels tight at first', 'Back pocket is narrow for folded maps'], - verdict: 'A dependable field notebook for writers who mix outlines, sketches, and review notes in one place.', - price: 18.50, - tags: $tags, - )); - - $post = $this->wordpress()->posts()->createFromTemplate($template); - $this->createdResources['posts'][] = $post->id; - $fetched = $this->wordpress()->posts()->get($post->id); - - $this->assertGreaterThan(0, $post->id); - $this->assertSame('draft', $post->status); - $this->assertStringContainsString('Review: [SDK Integration] Atlas Field Notebook', $post->title->rendered); - $this->assertSame($media->id, $post->featured_media); - $this->assertContains($tags[0], $post->tags); - $this->assertStringContainsString('Rating: 4.5/5', $this->postContent($fetched)); - $this->assertStringContainsString('Thick paper handles fountain pen ink', $this->postContent($fetched)); - $this->assertStringContainsString('A dependable field notebook', $this->postContent($fetched)); - $this->assertSame($media->id, $this->wordpress()->media()->get($media->id)->id); - } - - public function testCustomTemplateCanBeRegisteredAndCreatePostWithMediaBlock(): void - { - $media = $this->wordpress()->media()->upload($this->mediaFixturePath(), [ - 'title' => '[SDK Integration] Reading guide image', - 'alt_text' => 'Marked pages in a reading notebook', - ]); - $this->createdResources['media'][] = $media->id; - - $registry = new TemplateRegistry(); - $registry->register('reading-guide', ReadingGuideTemplate::class); - $templateClass = $registry->get('reading-guide'); - - $template = new $templateClass(new ReadingGuideTemplateData( - title: '[SDK Integration] Harbor Mystery Reading Guide', - summary: 'A compact editorial guide for discussing setting, recurring clues, and chapter pacing in a serialized mystery.', - sectionTitle: 'Discussion Notes', - notes: [ - 'Track each lantern sighting beside the character who notices it.', - 'Compare the ferry schedule with the timing of the anonymous letter.', - ], - mediaId: $media->id, - mediaUrl: $media->source_url, - mediaAlt: 'Marked pages in a reading notebook', - )); - - $post = $this->wordpress()->posts()->createFromTemplate($template); - $this->createdResources['posts'][] = $post->id; - $fetched = $this->wordpress()->posts()->get($post->id); - - $this->assertTrue($registry->has('reading-guide')); - $this->assertGreaterThan(0, $post->id); - $this->assertSame('draft', $post->status); - $this->assertStringContainsString('Harbor Mystery Reading Guide', $post->title->rendered); - $this->assertStringContainsString('Discussion Notes', $this->postContent($fetched)); - $this->assertStringContainsString('Track each lantern sighting', $this->postContent($fetched)); - $this->assertStringContainsString('wp-image-' . $media->id, $this->postContent($fetched)); - $this->assertStringContainsString($media->source_url, $this->postContent($fetched)); - } - - /** - * @return array - */ - private function greyHarborChapters(): array - { - return [ - new StoryChapterTemplateData( - number: 1, - title: 'The Light Beneath the Pier', - openingParagraph: 'Mira tucked the lantern under her coat and listened for footsteps above the tide line.', - paragraphs: [ - 'The pier boards flexed with every wave, but one pale light kept shining from below, steady as a held breath.', - 'When she knelt beside the last piling, she found a brass key tied to the lantern handle with blue thread.', - ], - quote: 'Tell the tide I kept my promise.' - ), - new StoryChapterTemplateData( - number: 2, - title: 'A Letter Without a Name', - openingParagraph: 'By morning, the key fit the archive drawer that no one in town hall admitted existed.', - paragraphs: [ - 'Inside waited a letter written on ferry stationery, unsigned except for a smear of lamp soot near the fold.', - 'Mira read it twice before noticing the margin marks formed a map of streets that had vanished from modern plans.', - ], - quote: 'Some houses keep their own address.' - ), - new StoryChapterTemplateData( - number: 3, - title: 'The House That Remembered', - openingParagraph: 'The map ended at a narrow house behind the chandlery, where every window faced the harbor.', - paragraphs: [ - 'Dust lifted from the floorboards in small silver clouds as Mira crossed the parlor toward a wall of framed tide charts.', - 'Behind the newest chart, she found a photograph of three children holding lanterns, one face carefully scratched away.', - ], - quote: 'A town forgets only when someone teaches it how.' - ), - ]; - } - - private function mediaFixturePath(): string - { - return dirname(__DIR__, 4) . '/docker/wordpress/fixtures/media/test-image.png'; - } - - private function postContent(Post $post): string - { - return $post->content->raw ?? $post->content->rendered; - } -} - -final class ReadingGuideTemplateData extends AbstractTemplateData -{ - /** - * @param array $notes - */ - public function __construct( - public readonly string $title, - public readonly string $summary, - public readonly string $sectionTitle, - public readonly array $notes, - public readonly int $mediaId, - public readonly string $mediaUrl, - public readonly string $mediaAlt, - ) { - $this->validate(); - } - - protected function getRequiredFields(): array - { - return ['title', 'summary', 'sectionTitle', 'notes', 'mediaId', 'mediaUrl', 'mediaAlt']; - } -} - -final class ReadingGuideTemplate extends PostTemplate -{ - public function __construct(ReadingGuideTemplateData $data) - { - parent::__construct($data); - } - - public function toContentBuilder(): ContentBuilder - { - /** @var ReadingGuideTemplateData $data */ - $data = $this->data; - $builder = new ContentBuilder(); - - $builder->addBlock(new Paragraph($data->summary)); - $builder->addBlock(new Image($data->mediaId, $data->mediaUrl, $data->mediaAlt)); - $builder->addBlock(new Heading($data->sectionTitle, 2)); - - foreach ($data->notes as $note) { - $builder->addBlock(new Paragraph($note)); - } - - return $builder; - } - - public function toPostData(): array - { - /** @var ReadingGuideTemplateData $data */ - $data = $this->data; - - return [ - 'title' => $data->title, - 'content' => $this->toContentBuilder()->render(), - 'status' => 'draft', - 'featured_media' => $data->mediaId, - ]; - } -} 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/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/StoryChapterTemplateDataTest.php b/tests/Unit/Templates/StoryChapterTemplateDataTest.php deleted file mode 100644 index a829bbb..0000000 --- a/tests/Unit/Templates/StoryChapterTemplateDataTest.php +++ /dev/null @@ -1,55 +0,0 @@ -assertSame(1, $chapter->number); - $this->assertSame('The Light Beneath the Pier', $chapter->title); - $this->assertSame('The harbor keeps names better than people do.', $chapter->quote); - } - - public function testRejectsInvalidChapterNumber(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Chapter number must be greater than zero'); - - new StoryChapterTemplateData( - number: 0, - title: 'The Light Beneath the Pier', - openingParagraph: 'The tide left one lantern burning under the boards.', - paragraphs: ['Mara followed the blue reflection past the mooring posts.'] - ); - } - - public function testRejectsEmptyChapterParagraph(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Chapter paragraphs cannot contain empty values'); - - new StoryChapterTemplateData( - number: 1, - title: 'The Light Beneath the Pier', - openingParagraph: 'The tide left one lantern burning under the boards.', - paragraphs: [''] - ); - } -} 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 b8d9a87..0000000 --- a/tests/Unit/Templates/StoryTemplateTest.php +++ /dev/null @@ -1,182 +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); - } - - public function test_generates_chaptered_story_content_with_media_url_and_excerpt(): void - { - $data = new StoryTemplateData( - title: 'The Lanterns of Grey Harbor', - introduction: 'Fog gathered around the ferry lights as Mara Vale stepped onto the pier.', - body: 'A serialized mystery in three chapters.', - subtitle: 'A serialized mystery in three chapters', - genre: 'Mystery', - featuredImageId: 42, - featuredImageUrl: 'http://127.0.0.1:8088/wp-content/uploads/lanterns.png', - featuredImageAlt: 'Painted lanterns over a harbor pier', - chapters: [ - new StoryChapterTemplateData( - number: 1, - title: 'The Light Beneath the Pier', - openingParagraph: 'The first lantern burned blue beneath the lowest plank.', - paragraphs: [ - 'Mara counted the tide bells and found one more note than the harbor clock allowed.', - ], - quote: 'Keep the lantern covered until the fog answers back.' - ), - ], - author: 'Mira Vale', - excerpt: 'Mira Vale begins a quiet mystery beneath the lights of Grey Harbor.' - ); - - $template = new StoryTemplate($data); - $content = $template->toContentBuilder()->render(); - $postData = $template->toPostData(); - - $this->assertSame('Mira Vale begins a quiet mystery beneath the lights of Grey Harbor.', $postData['excerpt']); - $this->assertStringContainsString('A serialized mystery in three chapters', $content); - $this->assertStringContainsString('By Mira Vale', $content); - $this->assertStringContainsString('Mystery', $content); - $this->assertStringContainsString('src="http://127.0.0.1:8088/wp-content/uploads/lanterns.png"', $content); - $this->assertStringContainsString('alt="Painted lanterns over a harbor pier"', $content); - $this->assertStringContainsString('Contents', $content); - $this->assertStringContainsString('Chapter 1: The Light Beneath the Pier', $content); - $this->assertStringContainsString('Mara counted the tide bells', $content); - $this->assertStringContainsString('Keep the lantern covered', $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-map.php b/tools/test-coverage-map.php index ad8a763..539b611 100644 --- a/tools/test-coverage-map.php +++ b/tools/test-coverage-map.php @@ -25,8 +25,6 @@ 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\Templates\PostTemplate::class => 'Abstract template covered by concrete template tests.', - JOOservices\WordPress\Sdk\Support\Templates\Data\AbstractTemplateData::class => 'Abstract template data covered by concrete data tests.', 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.', @@ -56,6 +54,35 @@ 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'))); @@ -111,7 +138,7 @@ } if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Services\\')) { - $integrationTest = $root . '/tests/Integration/Services/' . basename($relative) . 'IntegrationTest.php'; + $integrationTest = integrationTestPathFor($root, $relative); if (is_file($integrationTest) || isset($integrationCoverageAliases[$class])) { $mappedIntegrationServices++; } else { @@ -134,15 +161,23 @@ 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 { - if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\Templates\\')) { - $relative = str_replace('Support/Templates/', 'Templates/', $relative); - } - return $root . '/tests/Unit/' . $relative . 'Test.php'; } @@ -171,9 +206,6 @@ function hasUnitCoverageMapping(string $root, string $relative, string $class): if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\ContentBuilder\\Parser\\')) { $grouped[] = $root . '/tests/Unit/Support/ContentBuilder/BlockParserTest.php'; } - if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\Templates\\Data\\')) { - $grouped[] = $root . '/tests/Unit/Templates/' . basename($relative) . 'Test.php'; - } if ($class === JOOservices\WordPress\Sdk\Http\Middleware\AuthenticationMiddleware::class) { $grouped[] = $root . '/tests/Unit/Http/AuthenticationMiddlewareTest.php'; } From 1380152c78c497ec83f56c8cd8475d764f8215f6 Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sun, 17 May 2026 06:44:02 +0700 Subject: [PATCH 2/2] fix: clear release validation blockers --- AGENTS.md | 6 +- CHANGELOG.md | 14 ---- README.md | 8 +-- composer.json | 17 ----- docs/04-development/ai-contributor-guide.md | 2 +- docs/04-development/testing.md | 75 +++------------------ 6 files changed, 14 insertions(+), 108 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b617cec..4abc4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Before reporting completion: ## Integration testing standard - Do not run SDK integration tests against live WordPress sites. -- Use only the disposable Docker/WP-CLI environment under `docker/wordpress`. -- Integration tests must require `WORDPRESS_ENV=testing`, a local WordPress URL, and the `jooservices_sdk_test_site` marker. -- The SDK public surface should be covered by unit tests, Docker integration tests, or explicit reasoned entries in the coverage-map script. +- 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 bf43be2..48748c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,6 @@ The format follows Keep a Changelog, and this project aims to follow semantic ve ## [Unreleased] -### Added - -- Added a Docker/WP-CLI WordPress integration testing environment with disposable MariaDB and local-only WordPress services. -- Added a test-only WordPress plugin for deterministic custom endpoint CRUD coverage. -- Added localhost, `WORDPRESS_ENV=testing`, and marker-option safety guards for integration tests. -- Added service-oriented Docker integration tests and REST route contract coverage. -- Added `composer test:coverage-map` to map production public surface to unit and integration tests. - -### Changed - -- Updated testing docs, README development commands, and AI contributor guidance to require Docker-only WordPress integration tests. - -## [1.2.0] - 2026-05-17 - ### Removed - Removed non-native content template classes, data objects, examples, and template-specific tests from SDK core. diff --git a/README.md b/README.md index 7232c6a..23250f0 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,6 @@ These helpers are not native WordPress REST API resources. They remain in the SD 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. -The Docker integration suite verifies these helpers against a real local WordPress REST API. `composer test:wordpress` starts Docker services, installs WordPress with WP-CLI, creates test auth, uploads local media fixtures, and runs the main integration tests. Optional WordPress capabilities that need extra plugins, block registration, or block-theme state live in the independent `composer test:wordpress:extended` suite. No manual WordPress setup is required; use `composer test:wordpress:reset` to wipe the disposable database and uploads volume. - ## DTO-first querying The SDK accepts either raw query arrays or typed query DTOs for list and read operations. @@ -197,10 +195,6 @@ composer lint composer lint:all composer test composer test:integration -composer test:wordpress -composer test:wordpress:extended -composer test:wordpress:reset -composer test:integration:docker composer test:coverage composer test:coverage:gate composer test:coverage-map @@ -209,7 +203,7 @@ composer check composer ci ``` -Run `composer test:wordpress` for the one-command Docker/WP-CLI WordPress integration flow. Run `composer test:wordpress:extended` after it, or by itself, for optional capabilities such as deterministic plugin reads, dynamic block rendering, global styles, navigation/template routes, and site health reads. See [Testing](./docs/04-development/testing.md) for details, safety guards, reset commands, and route skip policy. +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`. diff --git a/composer.json b/composer.json index 39d498b..133c462 100644 --- a/composer.json +++ b/composer.json @@ -76,23 +76,6 @@ ], "test:coverage:gate": "php tools/test-coverage-gate.php", "test:coverage-map": "php tools/test-coverage-map.php", - "integration:up": "docker compose -f docker/wordpress/docker-compose.integration.yml up -d db wordpress", - "integration:setup": "docker/wordpress/scripts/setup.sh && docker/wordpress/scripts/seed.sh", - "integration:seed": "docker/wordpress/scripts/seed.sh", - "integration:down": "docker/wordpress/scripts/teardown.sh", - "test:wordpress": [ - "Composer\\Config::disableProcessTimeout", - "docker/wordpress/scripts/run-integration-tests.sh" - ], - "test:wordpress:extended": [ - "Composer\\Config::disableProcessTimeout", - "docker/wordpress/scripts/run-extended-integration-tests.sh" - ], - "test:wordpress:reset": "docker/wordpress/scripts/run-integration-tests.sh reset", - "test:integration:docker": [ - "Composer\\Config::disableProcessTimeout", - "docker/wordpress/scripts/run-integration-tests.sh" - ], "security": "composer audit", "quality": [ "@lint", diff --git a/docs/04-development/ai-contributor-guide.md b/docs/04-development/ai-contributor-guide.md index 46cdd53..ce7140c 100644 --- a/docs/04-development/ai-contributor-guide.md +++ b/docs/04-development/ai-contributor-guide.md @@ -10,7 +10,7 @@ Do not use phase, planning, temporary, or vague bucket names in code, tests, hel 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 the Docker/WP-CLI environment in `docker/wordpress`, require `WORDPRESS_ENV=testing`, require a local WordPress URL, and require the `jooservices_sdk_test_site` marker. Public SDK classes and concrete service methods should be mapped by unit tests, Docker integration tests, or an explicit reason in `tools/test-coverage-map.php`. +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. diff --git a/docs/04-development/testing.md b/docs/04-development/testing.md index eff6dc4..69ba7b2 100644 --- a/docs/04-development/testing.md +++ b/docs/04-development/testing.md @@ -10,17 +10,13 @@ Shared helpers should use purpose-based names. Do not use phase, planning, tempo composer test composer test:unit composer test:integration -composer test:wordpress -composer test:wordpress:extended -composer test:wordpress:reset -composer test:integration:docker 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 WordPress. `composer test:wordpress` runs the main Docker integration suite. `composer test:wordpress:extended` runs optional WordPress capabilities independently after, or instead of, the main integration command. +`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. @@ -28,72 +24,23 @@ composer test:coverage-map 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. -## Docker Integration Tests +## Integration Tests -Integration tests must run only against the disposable Docker WordPress environment in `docker/wordpress`. +Integration tests must run only against a disposable local WordPress environment created for SDK testing. ```bash -composer test:wordpress -``` - -The Docker runner starts MariaDB, WordPress, and WP-CLI, waits for HTTP readiness, installs WordPress with WP-CLI, activates the test endpoint plugin, creates local test users, generates a WordPress Application Password, seeds deterministic `sdk_test_` data, writes `.env.integration`, runs `composer test:integration`, and removes containers and volumes with `docker compose down -v`. - -`composer test:integration:docker` is kept as an equivalent compatibility alias. - -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. - -## Extended Docker Integration Tests - -Optional WordPress capabilities are covered by a separate one-command suite: - -```bash -composer test:wordpress:extended -``` - -The extended runner starts the same disposable MariaDB, WordPress, and WP-CLI services, installs WordPress if needed, seeds the normal test marker and content, activates a local fixture plugin from `tests/Fixtures/wordpress-plugins/sdk-test-plugin`, activates a bundled block theme when available, flushes rewrite rules, writes extended test environment flags, prints route diagnostics, runs only the `ExtendedIntegration` PHPUnit suite, and then removes containers and volumes unless `WORDPRESS_TEST_KEEP_CONTAINERS=1` is set. - -The fixture plugin is deterministic and local. It has a valid plugin header and registers `jooservices/test-dynamic-block`, whose render callback returns HTML containing `sdk-test-dynamic-block` and the requested message. It has no external API key, network dependency, or production side effect. - -The extended suite covers: - -- plugin endpoint reads against `sdk-test-plugin/sdk-test-plugin` -- block renderer requests for `jooservices/test-dynamic-block` -- global styles, navigation, templates, template parts, site health, themes, block types, widget types, and sidebars as independently checked raw routes - -Global styles and other optional routes may still be absent in a specific WordPress/theme combination. Each optional route test checks only its own route and skips with the WordPress version, active theme, and active plugin list; one missing route does not skip unrelated extended tests. - -Use the lifecycle commands when debugging: - -```bash -composer integration:up -composer integration:setup -composer integration:seed composer test:integration -composer integration:down ``` -The runner also supports direct maintenance commands: +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. -```bash -docker/wordpress/scripts/run-integration-tests.sh reset -docker/wordpress/scripts/run-integration-tests.sh stop -docker/wordpress/scripts/run-extended-integration-tests.sh reset -docker/wordpress/scripts/run-extended-integration-tests.sh stop -WORDPRESS_TEST_KEEP_CONTAINERS=1 composer test:wordpress -WORDPRESS_TEST_KEEP_CONTAINERS=1 composer test:wordpress:extended -``` +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 -Integration tests refuse to run unless all of these are true: - -- `WORDPRESS_ENV=testing` -- `WORDPRESS_URL` points to `localhost`, `127.0.0.1`, or the Docker service host `wordpress` -- the authenticated test plugin marker route confirms the WordPress option `jooservices_sdk_test_site = 1` - -This prevents destructive SDK tests from running against production, staging, or personal WordPress sites. +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 @@ -123,10 +70,6 @@ Acceptable reasons include: ## Troubleshooting -- Port `8088` already in use: stop the conflicting process or override the compose file locally before running tests. -- Docker not running: start Docker Desktop or the Docker daemon, then rerun `composer test:wordpress`. -- DB not ready: rerun the command; the compose healthcheck waits for MariaDB initialization. -- WordPress install failed: run `docker/wordpress/scripts/run-integration-tests.sh reset` and rerun. -- Extended provisioning failed: run `composer test:wordpress:reset`, then rerun `composer test:wordpress:extended`. -- WP-CLI failed: inspect the printed failing `wp` step; no global host WP-CLI is required. -- Permissions: ensure Docker can mount the repository and the scripts are executable. +- 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.