From e4bf106618ca93d0241d80810c6d70d0b1df8c12 Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sat, 16 May 2026 16:30:21 +0700 Subject: [PATCH 1/6] feat: add chapter-based story templates --- examples/template-usage.php | 90 ++++-- src/Services/PostsService.php | 4 +- .../Data/StoryChapterTemplateData.php | 46 +++ .../Templates/Data/StoryTemplateData.php | 18 +- src/Support/Templates/StoryTemplate.php | 121 +++++++- .../StoryTemplateExtendedIntegrationTest.php | 126 ++++++++ .../TemplateWorkflowIntegrationTest.php | 274 ++++++++++++++++++ tests/Unit/Support/PostBuilderTest.php | 2 + .../StoryChapterTemplateDataTest.php | 55 ++++ tests/Unit/Templates/StoryTemplateTest.php | 43 +++ 10 files changed, 740 insertions(+), 39 deletions(-) create mode 100644 src/Support/Templates/Data/StoryChapterTemplateData.php create mode 100644 tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php create mode 100644 tests/Integration/Support/Templates/TemplateWorkflowIntegrationTest.php create mode 100644 tests/Unit/Templates/StoryChapterTemplateDataTest.php diff --git a/examples/template-usage.php b/examples/template-usage.php index 1ad0ee7..658cf0d 100644 --- a/examples/template-usage.php +++ b/examples/template-usage.php @@ -11,6 +11,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; use JOOservices\WordPress\Sdk\Support\Templates\Data\ProductReviewTemplateData; +use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryChapterTemplateData; use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryTemplateData; use JOOservices\WordPress\Sdk\Support\Templates\ProductReviewTemplate; use JOOservices\WordPress\Sdk\Support\Templates\StoryTemplate; @@ -23,16 +24,70 @@ password: (string) getenv('WORDPRESS_APP_PASSWORD') ); +$mediaPath = (string) getenv('WORDPRESS_TEMPLATE_MEDIA_PATH'); +$media = $mediaPath !== '' && is_file($mediaPath) + ? $client->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: 'My Amazing Adventure in the Mountains', - introduction: 'It all started on a crisp autumn morning when I decided to embark on a journey that would change my perspective forever.', - body: 'The trail wound through ancient forests, across rushing streams, and up steep rocky paths. Each step brought new challenges and breathtaking views. The summit, when I finally reached it, offered a panorama that words can barely describe.', - conclusion: 'This adventure taught me that the journey itself is often more valuable than the destination.', - author: 'John Doe', - tags: ['adventure', 'travel', 'mountains', 'hiking'] + 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); @@ -45,24 +100,21 @@ echo "Creating Product Review Post...\n"; $reviewData = new ProductReviewTemplateData( - productName: 'Awesome Gadget Pro 2024', - reviewText: 'This product exceeded all my expectations. The build quality is exceptional, and the performance is outstanding. After using it for several weeks, I can confidently say it\'s worth every penny.', + 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: [ - 'Excellent battery life (lasts 2+ days)', - 'Premium build quality with aluminum chassis', - 'Lightning-fast performance', - 'Intuitive user interface', - 'Great value for money', + 'Thick paper handles fountain pen ink', + 'Compact grid pages make sketches easy', ], cons: [ - 'Slightly heavier than competitors', - 'No headphone jack', - 'Limited color options', + 'Elastic band feels tight at first', + 'Back pocket is narrow for folded maps', ], - verdict: 'Highly recommended for power users and professionals. While it has minor drawbacks, the overall package is impressive and delivers exceptional value.', - price: 999.99, - tags: ['review', 'technology', 'gadgets', 'electronics'] + 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); diff --git a/src/Services/PostsService.php b/src/Services/PostsService.php index 36f7a52..e71881a 100644 --- a/src/Services/PostsService.php +++ b/src/Services/PostsService.php @@ -15,6 +15,7 @@ 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. @@ -159,10 +160,9 @@ public function delete(int $id, bool $force = false): Post /** * Create a post from a template. * - * @param \JOOservices\WordPress\Sdk\Support\Templates\PostTemplate $template * @return Post */ - public function createFromTemplate(object $template): Post + public function createFromTemplate(PostTemplate $template): Post { $postData = $template->toPostData(); diff --git a/src/Support/Templates/Data/StoryChapterTemplateData.php b/src/Support/Templates/Data/StoryChapterTemplateData.php new file mode 100644 index 0000000..2aef672 --- /dev/null +++ b/src/Support/Templates/Data/StoryChapterTemplateData.php @@ -0,0 +1,46 @@ + $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 index 5647722..0c7fadc 100644 --- a/src/Support/Templates/Data/StoryTemplateData.php +++ b/src/Support/Templates/Data/StoryTemplateData.php @@ -7,7 +7,9 @@ /** * Data transfer object for Story template. * - * Defines the structure for a story-based post with introduction, body, and optional media. + * Defines the structure for a story-based post with introduction, body, chapters, and optional media. + * + * @SuppressWarnings("PHPMD.ExcessiveParameterList") */ final class StoryTemplateData extends AbstractTemplateData { @@ -15,21 +17,33 @@ final class StoryTemplateData extends AbstractTemplateData * @param string $title Post title (required) * @param string $introduction Story introduction/hook (required) * @param string $body Main story content (required) + * @param string|null $subtitle Story subtitle or short summary (optional) + * @param string|null $genre Story genre label (optional) * @param int|null $featuredImageId Featured image media ID (optional) + * @param string|null $featuredImageUrl Featured image source URL (optional) + * @param string|null $featuredImageAlt Featured image alt text (optional) * @param array|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 array|null $tags Post tags (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, ) { diff --git a/src/Support/Templates/StoryTemplate.php b/src/Support/Templates/StoryTemplate.php index 5720882..5ee74b7 100644 --- a/src/Support/Templates/StoryTemplate.php +++ b/src/Support/Templates/StoryTemplate.php @@ -7,7 +7,9 @@ use JOOservices\WordPress\Sdk\Support\ContentBuilder\Blocks\Core\Heading; use JOOservices\WordPress\Sdk\Support\ContentBuilder\Blocks\Core\Image; use JOOservices\WordPress\Sdk\Support\ContentBuilder\Blocks\Core\Paragraph; +use JOOservices\WordPress\Sdk\Support\ContentBuilder\Blocks\Core\Quote; use JOOservices\WordPress\Sdk\Support\ContentBuilder\ContentBuilder; +use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryChapterTemplateData; use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryTemplateData; /** @@ -28,43 +30,95 @@ public function toContentBuilder(): ContentBuilder $data = $this->data; $builder = new ContentBuilder(); - // Introduction section + $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) ); + } - // Featured image if provided + private function addFeaturedImage(ContentBuilder $builder, StoryTemplateData $data): void + { if ($data->featuredImageId !== null) { $builder->addBlock( - new Image($data->featuredImageId) + 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; + } - // Main body content $builder->addBlock( new Heading('Story', 2) ); $builder->addBlock( new Paragraph($data->body) ); + } - // Gallery section if provided - if ($data->galleryImageIds !== null && count($data->galleryImageIds) > 0) { + 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 Heading('Gallery', 2) + new Image($imageId) ); - - foreach ($data->galleryImageIds as $imageId) { - $builder->addBlock( - new Image($imageId) - ); - } } + } - // Conclusion section if provided + private function addConclusion(ContentBuilder $builder, StoryTemplateData $data): void + { if ($data->conclusion !== null) { $builder->addBlock( new Heading('Conclusion', 2) @@ -73,15 +127,46 @@ public function toContentBuilder(): ContentBuilder new Paragraph($data->conclusion) ); } + } - // Author attribution if provided + private function addAuthorAttribution(ContentBuilder $builder, StoryTemplateData $data): void + { if ($data->author !== null) { $builder->addBlock( new Paragraph("— {$data->author}") ); } + } - return $builder; + /** + * @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 @@ -100,6 +185,10 @@ public function toPostData(): array $postData['featured_media'] = $data->featuredImageId; } + if ($data->excerpt !== null) { + $postData['excerpt'] = $data->excerpt; + } + if ($data->tags !== null) { $postData['tags'] = $data->tags; } diff --git a/tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php b/tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php new file mode 100644 index 0000000..cda5e5a --- /dev/null +++ b/tests/ExtendedIntegration/Support/Templates/StoryTemplateExtendedIntegrationTest.php @@ -0,0 +1,126 @@ +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 new file mode 100644 index 0000000..40206ac --- /dev/null +++ b/tests/Integration/Support/Templates/TemplateWorkflowIntegrationTest.php @@ -0,0 +1,274 @@ +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/Support/PostBuilderTest.php b/tests/Unit/Support/PostBuilderTest.php index 946dd25..5f11d2b 100644 --- a/tests/Unit/Support/PostBuilderTest.php +++ b/tests/Unit/Support/PostBuilderTest.php @@ -124,6 +124,7 @@ public function testSetsAdditionalFields(): void ->method('create') ->with($this->callback(function (array $payload): bool { return $payload['status'] === 'draft' + && $payload['excerpt'] === 'Short summary' && $payload['slug'] === 'my-slug' && $payload['author'] === 5 && $payload['categories'] === [1, 2] @@ -134,6 +135,7 @@ public function testSetsAdditionalFields(): void $builder->title('Title') ->status('draft') + ->excerpt('Short summary') ->slug('my-slug') ->author(5) ->categories([1, 2]) diff --git a/tests/Unit/Templates/StoryChapterTemplateDataTest.php b/tests/Unit/Templates/StoryChapterTemplateDataTest.php new file mode 100644 index 0000000..a829bbb --- /dev/null +++ b/tests/Unit/Templates/StoryChapterTemplateDataTest.php @@ -0,0 +1,55 @@ +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/StoryTemplateTest.php b/tests/Unit/Templates/StoryTemplateTest.php index 8d59312..b8d9a87 100644 --- a/tests/Unit/Templates/StoryTemplateTest.php +++ b/tests/Unit/Templates/StoryTemplateTest.php @@ -4,6 +4,7 @@ namespace JOOservices\WordPress\Sdk\Tests\Unit\Templates; +use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryChapterTemplateData; use JOOservices\WordPress\Sdk\Support\Templates\Data\StoryTemplateData; use JOOservices\WordPress\Sdk\Support\Templates\StoryTemplate; use PHPUnit\Framework\TestCase; @@ -136,4 +137,46 @@ public function test_generates_content_with_gallery_images(): void $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); + } } From bfeb58cf45554a08746ea09d41e780898476f2e4 Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sat, 16 May 2026 16:36:18 +0700 Subject: [PATCH 2/6] chore: add coverage gates and refresh docs --- .gitignore | 3 + AGENTS.md | 8 + CHANGELOG.md | 17 + README.md | 25 +- composer.json | 25 +- docs/02-user-guide/01-services-and-queries.md | 12 + docs/02-user-guide/admin-editor-endpoints.md | 2 + docs/02-user-guide/posts.md | 4 +- docs/03-examples/README.md | 5 + docs/04-development/02-ci-cd.md | 2 + docs/04-development/03-ai-guidance.md | 1 + docs/04-development/ai-contributor-guide.md | 6 + docs/04-development/testing.md | 118 ++++++- docs/guides/templates.md | 84 ++++- tests/Integration/PostBuilderTest.php | 2 - tests/Unit/Http/ResponseDecoderLoggerTest.php | 40 +++ .../ContentBuilder/BlockRegistryTest.php | 11 + tools/test-coverage-gate.php | 308 ++++++++++++++++++ tools/test-coverage-map.php | 188 +++++++++++ 19 files changed, 839 insertions(+), 22 deletions(-) create mode 100644 tests/Unit/Http/ResponseDecoderLoggerTest.php create mode 100644 tools/test-coverage-gate.php create mode 100644 tools/test-coverage-map.php diff --git a/.gitignore b/.gitignore index 847d0b7..12c1f65 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # Dependencies /vendor .env +.env.integration +.env.testing # Build / Cache /build @@ -16,6 +18,7 @@ .pint.cache *.log coverage/ +coverage.xml # Artifacts .gemini/ diff --git a/AGENTS.md b/AGENTS.md index 77324ed..22a161f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,3 +56,11 @@ Before reporting completion: 2. Update README and docs for any public API change. 3. Run the relevant validation commands. 4. Report any GitHub-side or branch-flow actions that could not be verified or applied from the environment. + +## Integration testing standard + +- Do not run SDK integration tests against live WordPress sites. +- Use only 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. +- Integration skips must name the exact service method, route, WordPress version, and theme/plugin reason. diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b6af5..4a365be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to `jooservices/wordpress-sdk` are tracked here. The format follows Keep a Changelog, and this project aims to follow semantic versioning once releases are tagged from `master`. +## [Unreleased] + +### 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.1.0] - 2026-05-16 ### Added @@ -18,10 +32,13 @@ The format follows Keep a Changelog, and this project aims to follow semantic ve - Updated README and documentation to reflect current endpoint coverage, security guidance, and deferred WordPress REST API groups. - Changed `composer test` and `composer test:coverage` to run the unit suite; live WordPress integration checks remain available through `composer test:integration` and `composer test:all`. +- Tightened `PostsService::createFromTemplate()` to the documented `PostTemplate` contract and aligned example documentation with the PHP 8.5 package baseline. +- Clarified in the README and user guide that builders and template utilities are optional SDK helper extras layered on top of the REST API surface. ### Fixed - Fixed `ContainerFactory` to build the SDK HTTP client through `ClientFactory` so resolving `WordPressService` from the container satisfies its typed constructor. +- Renamed the phase-based endpoint path unit test helpers to purpose-based names required by the repository standards. ### Tests diff --git a/README.md b/README.md index ef84559..0815f2c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ foreach ($posts as $post) { | Endpoint | Service | Status | | --- | --- | --- | -| Posts | `posts()` | CRUD + template helpers | +| Posts | `posts()` | CRUD | | Pages | `pages()` | CRUD | | Media | `media()` | list/get/upload/delete | | Users | `users()` | CRUD + `me()` | @@ -67,6 +67,18 @@ foreach ($posts as $post) { | Widgets / sidebars | `widgets()`, `widgetTypes()`, `sidebars()` | raw widget operations | | Site Health | `siteHealth()` | raw diagnostic test reads | +## SDK helper extras + +The SDK also ships with optional developer-experience helpers under `JOOservices\WordPress\Sdk\Support`. + +- `PostBuilder` helps assemble post payloads fluently before calling `posts()->create()` or `update()`. +- `ContentBuilder` helps generate Gutenberg-compatible block markup in PHP. +- `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. + +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. @@ -148,6 +160,7 @@ $health = $wordpress->siteHealth()->backgroundUpdates(); ``` Most of these endpoints require authenticated users with admin/editor capabilities. +Block renderer requests are sent with WordPress editor context because the REST renderer endpoint validates dynamic blocks against editor-only route context. ## Error handling @@ -183,12 +196,22 @@ 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 composer quality 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. + +`composer test:coverage` now includes a Clover XML audit gate. Aggregate statement coverage must stay at or above 90%, and no coverable production file, class, or method in `src/` may remain at 0% coverage unless it has a documented exclusion reason in `tools/test-coverage-gate.php`. + ## Security Use WordPress application passwords through environment variables. Do not commit live credentials, and do not log authorization headers or raw secrets. See [SECURITY.md](./SECURITY.md). diff --git a/composer.json b/composer.json index 8c9e994..39d498b 100644 --- a/composer.json +++ b/composer.json @@ -69,7 +69,30 @@ "test:all": "phpunit", "test:unit": "phpunit --testsuite=Unit", "test:integration": "phpunit --testsuite=Integration", - "test:coverage": "phpunit --testsuite=Unit --coverage-html=coverage --coverage-clover=coverage.xml", + "test:integration:extended": "phpunit --testsuite=ExtendedIntegration", + "test:coverage": [ + "phpunit --testsuite=Unit --coverage-html=coverage --coverage-clover=coverage.xml", + "@test:coverage:gate" + ], + "test:coverage:gate": "php tools/test-coverage-gate.php", + "test:coverage-map": "php tools/test-coverage-map.php", + "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/02-user-guide/01-services-and-queries.md b/docs/02-user-guide/01-services-and-queries.md index 9d03f25..285d070 100644 --- a/docs/02-user-guide/01-services-and-queries.md +++ b/docs/02-user-guide/01-services-and-queries.md @@ -16,6 +16,18 @@ Use the facade to access typed services: - `postTypes()` - `statuses()` +## Helper extras + +The `Support/` namespace contains optional SDK helper extras rather than native WordPress REST endpoints. + +Examples: + +- `PostBuilder` for fluent post payload construction +- `ContentBuilder` for Gutenberg block content generation +- template helpers such as `PostTemplate` and `posts()->createFromTemplate()` + +These helpers are documented as SDK convenience layers on top of the normal REST services. + ## Query DTOs Core read and list endpoints accept either raw arrays or typed query DTOs. diff --git a/docs/02-user-guide/admin-editor-endpoints.md b/docs/02-user-guide/admin-editor-endpoints.md index a00d515..5172403 100644 --- a/docs/02-user-guide/admin-editor-endpoints.md +++ b/docs/02-user-guide/admin-editor-endpoints.md @@ -33,6 +33,8 @@ $rendered = $wp->blockRenderer()->render('core/latest-posts', ['postsToShow' => $directory = $wp->blockDirectory()->search(['term' => 'gallery']); ``` +Block renderer requests use WordPress editor context because dynamic server-rendered blocks validate against editor-only route context. + ## Menus and navigation ```php diff --git a/docs/02-user-guide/posts.md b/docs/02-user-guide/posts.md index d280ce8..f987723 100644 --- a/docs/02-user-guide/posts.md +++ b/docs/02-user-guide/posts.md @@ -1,6 +1,6 @@ # Posts -`posts()` supports list, get, create, update, delete, template helpers, and post auto-pagination helpers. +`posts()` supports list, get, create, update, delete, and post auto-pagination helpers. ```php $posts = $wp->posts()->list(['per_page' => 10, 'status' => 'publish']); @@ -9,3 +9,5 @@ $created = $wp->posts()->create(['title' => 'Draft', 'status' => 'draft']); ``` Use `cursor()` or `each()` for large sites. Use `all()` only when loading all matching posts into memory is acceptable. + +Optional template helpers such as `posts()->createFromTemplate()` are SDK extras layered on top of normal post creation rather than separate WordPress REST endpoints. diff --git a/docs/03-examples/README.md b/docs/03-examples/README.md index 4555fed..14d9e04 100644 --- a/docs/03-examples/README.md +++ b/docs/03-examples/README.md @@ -6,3 +6,8 @@ This section collects complete workflows built from the public SDK services. - [Media Upload Workflow](./media-upload-workflow.md) - [Headless Export Workflow](./headless-export-workflow.md) - [Custom Post Type Workflow](./custom-post-type-workflow.md) + +Repository PHP examples: + +- [Template usage example](../../examples/template-usage.php) +- [Custom template example](../../examples/custom-template.php) diff --git a/docs/04-development/02-ci-cd.md b/docs/04-development/02-ci-cd.md index f568e8c..c693409 100644 --- a/docs/04-development/02-ci-cd.md +++ b/docs/04-development/02-ci-cd.md @@ -8,4 +8,6 @@ The workflow uses Composer scripts instead of duplicating raw tool invocations: - `composer test:coverage` - `composer security` +`composer test:coverage` is the coverage gate entrypoint. It must keep aggregate statement coverage at or above 90% and reject any coverable production file, class, or method in `src/` that still has 0% coverage in the Clover XML report. + Branch workflow follows the JOOservices `develop` and `master` model. Any missing branch-protection or remote branch setup must be verified directly in GitHub. diff --git a/docs/04-development/03-ai-guidance.md b/docs/04-development/03-ai-guidance.md index 4b52e5e..da3f016 100644 --- a/docs/04-development/03-ai-guidance.md +++ b/docs/04-development/03-ai-guidance.md @@ -13,3 +13,4 @@ Key rules: - keep test filenames matched to test class names - name tests and shared helpers for source class, domain, or precise behavior, not phases or buckets such as `P1`, `P2`, `Phase1`, `Phase2`, `Sprint`, `Milestone`, `Temporary`, `Temp`, `Misc`, `Bucket`, `NewEndpoints`, `OldEndpoints`, `Legacy`, or `WIP` - validate before reporting completion +- aggregate 90% coverage is not enough; run `composer test:coverage` so the Clover XML gate can reject any coverable `src/` file, class, or method that is still at 0% coverage diff --git a/docs/04-development/ai-contributor-guide.md b/docs/04-development/ai-contributor-guide.md index 94d2437..46cdd53 100644 --- a/docs/04-development/ai-contributor-guide.md +++ b/docs/04-development/ai-contributor-guide.md @@ -9,3 +9,9 @@ Test filenames must match their test class names, and test classes should descri Do not use phase, planning, temporary, or vague bucket names in code, tests, helpers, docs, or examples: `P1`, `P2`, `Phase1`, `Phase2`, `Sprint`, `Milestone`, `Temporary`, `Temp`, `Misc`, `Bucket`, `NewEndpoints`, `OldEndpoints`, `Legacy`, or `WIP`. Shared test helpers should use purpose-based names such as `RecordsServiceRequests` or `NullHttpClient`. If requirements are unclear, conflicting, missing, or cannot be verified from repository truth, stop and ask. + +Integration tests must never target a live WordPress site. Use only 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`. + +Aggregate coverage alone is insufficient. Contributors must run `composer test:coverage`, which now audits `coverage.xml` and fails when overall statement coverage drops below 90% or any coverable production file, class, or method in `src/` is still at 0% coverage. Exclusions in `tools/test-coverage-gate.php` need a concrete reason. + +Integration skips must be precise and documented: name the service method, exact missing route, WordPress version, and active theme or plugin requirement. Do not use vague skip reasons. diff --git a/docs/04-development/testing.md b/docs/04-development/testing.md index 51cb64e..740c448 100644 --- a/docs/04-development/testing.md +++ b/docs/04-development/testing.md @@ -10,8 +10,124 @@ 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 a live WordPress server. Use `composer test:integration` or `composer test:all` when `WORDPRESS_URL`, `WORDPRESS_USER`, and `WORDPRESS_APP_PASSWORD` point at a reachable test site. +`composer test` and `composer test:coverage` run the unit suite so normal validation does not depend on WordPress. `composer test: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:coverage` now runs a Clover XML coverage gate after PHPUnit writes `coverage.xml`. Aggregate statement coverage must stay at or above 90%. The gate also fails when any coverable production file, class, or method under `src/` is left at 0% coverage. Do not rely on the aggregate percentage alone. + +## Unit Tests + +Unit tests cover DTO hydration and serialization, query DTO output, endpoint path builders, auth middleware, response decoding, exception redaction, pagination helpers, service request construction, and content/template support. They must use fake clients or deterministic local values and must not make network calls. + +## Docker Integration Tests + +Integration tests must run only against the disposable Docker WordPress environment in `docker/wordpress`. + +```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 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. + +## 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 +- 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. + +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: + +```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 +``` + +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. + +## Coverage Map + +`composer test:coverage-map` reflects over `src/**/*.php` and verifies that production classes are mapped to unit coverage and concrete services are mapped to integration coverage. Pure interfaces are excluded. Abstract helpers, traits, and grouped tests require an explicit reason in `tools/test-coverage-map.php`. + +## Coverage Gate + +`composer test:coverage:gate` reads PHPUnit's Clover XML output from `coverage.xml`. It is deterministic and CI-safe because it does not parse HTML. The gate fails when any of these conditions are true: + +- overall statement coverage is below 90% +- a coverable production file in `src/` has 0 covered statements +- a coverable production class in `src/` has 0 covered statements or 0 covered methods +- a coverable production method in `src/` has 0 execution count + +If a production file, class, or method is truly non-coverable by project convention, add a documented exclusion reason in `tools/test-coverage-gate.php`. Do not exclude code only because it is inconvenient to test. Run the coverage gate before calling coverage work complete. + +## Skip and Version-Gate Policy + +Integration tests must check `/wp-json` route availability before skipping feature-dependent endpoints. Skip messages must include the service method, exact route, WordPress version, and active theme. Prefer Docker setup fixes over skips. + +Acceptable reasons include: + +- route missing in the tested WordPress version +- feature requires a block theme +- feature requires an optional plugin +- WordPress did not generate a revision for the test resource + +## Troubleshooting + +- 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. diff --git a/docs/guides/templates.md b/docs/guides/templates.md index 4e37d60..64460b6 100644 --- a/docs/guides/templates.md +++ b/docs/guides/templates.md @@ -1,6 +1,7 @@ # Post Templates -The Template system provides a structured, type-safe way to create WordPress posts using predefined templates with Data Transfer Objects (DTOs). +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 @@ -27,6 +28,7 @@ src/Support/Templates/ │ └── TemplateDataInterface.php ├── Data/ │ ├── AbstractTemplateData.php +│ ├── StoryChapterTemplateData.php │ ├── StoryTemplateData.php │ └── ProductReviewTemplateData.php ├── PostTemplate.php @@ -40,16 +42,35 @@ src/Support/Templates/ ```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: 'My Amazing Adventure', - introduction: 'It all started on a sunny day...', - body: 'The journey was filled with excitement...', - conclusion: 'And that\'s how it ended.', // Optional - author: 'John Doe', // Optional - tags: ['adventure', 'travel'] // Optional + 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 @@ -71,21 +92,32 @@ Creates narrative-style posts with structured sections. - `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 -- `tags`: Post tags +- `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 heading + body paragraph +- 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. @@ -101,7 +133,7 @@ Creates structured product review posts with rating, pros/cons, and verdict. - `cons`: Array of cons - `verdict`: Final verdict/recommendation - `price`: Product price -- `tags`: Post tags +- `tags`: Post tag IDs **Generated Structure:** - Product name as H1 @@ -222,7 +254,7 @@ $post = $client->posts()->createFromTemplate(new TutorialTemplate($data)); ### Type Safety - Constructor ensures all required fields are provided at compile time -- PHP 8.1+ readonly properties prevent accidental mutation +- PHP 8.5 readonly properties prevent accidental mutation - IDE provides autocomplete for all fields ### Validation @@ -279,6 +311,28 @@ $all = $registry->all(); // ] ``` +## 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 @@ -289,14 +343,12 @@ class PostFactory private PostsService $postsService ) {} - public function createFromTemplate(string $templateName, array $data): Post + public function createFromTemplate(string $templateName, TemplateDataInterface $data): Post { $templateClass = $this->registry->get($templateName); - - // Instantiate the template with data - // Note: You'll need to handle DTO creation based on your needs + $template = new $templateClass($data); - + return $this->postsService->createFromTemplate($template); } } diff --git a/tests/Integration/PostBuilderTest.php b/tests/Integration/PostBuilderTest.php index f62a40e..b57caf7 100644 --- a/tests/Integration/PostBuilderTest.php +++ b/tests/Integration/PostBuilderTest.php @@ -68,8 +68,6 @@ public function testCanCreatePostUsingFluentBuilderWithAutoUpload() // Initial clean up in TestCase should handle deleting the post, // but we might want to ensure media is also cleaned up or verified. // For now, valid assertions are enough. - $this->assertStringContainsString('Fluent Featured Image', json_encode($post)); // featured media might not expand, but we check ID > 0 - // Verify content contains image block $this->assertStringContainsString('wp-block-image', $post->content->raw); $this->assertStringContainsString('Content Image', $post->content->raw); diff --git a/tests/Unit/Http/ResponseDecoderLoggerTest.php b/tests/Unit/Http/ResponseDecoderLoggerTest.php new file mode 100644 index 0000000..dae3e53 --- /dev/null +++ b/tests/Unit/Http/ResponseDecoderLoggerTest.php @@ -0,0 +1,40 @@ +createMock(LoggerInterface::class); + + ResponseDecoderLogger::setLogger($logger); + + $this->assertSame($logger, ResponseDecoderLogger::getLogger()); + } + + public function testResetDropsSharedLoggerInstance(): void + { + $first = ResponseDecoderLogger::getLogger(); + + ResponseDecoderLogger::reset(); + + $second = ResponseDecoderLogger::getLogger(sys_get_temp_dir() . '/response-decoder-test.log'); + + $this->assertInstanceOf(Logger::class, $second); + $this->assertNotSame($first, $second); + } +} diff --git a/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php b/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php index b31fa38..d73b775 100644 --- a/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php +++ b/tests/Unit/Support/ContentBuilder/BlockRegistryTest.php @@ -42,4 +42,15 @@ public function testGetThrowsForMissingBlock(): void $this->expectException(InvalidArgumentException::class); $registry->get('missing/block'); } + + public function testAllReturnsRegisteredBlocks(): void + { + $registry = BlockRegistry::getInstance(); + $registry->register('custom/block', GenericBlock::class); + + $all = $registry->all(); + + $this->assertArrayHasKey('core/paragraph', $all); + $this->assertSame(GenericBlock::class, $all['custom/block']); + } } diff --git a/tools/test-coverage-gate.php b/tools/test-coverage-gate.php new file mode 100644 index 0000000..d0229d7 --- /dev/null +++ b/tools/test-coverage-gate.php @@ -0,0 +1,308 @@ +, + * classes: array, + * methods: array + * } + */ +$exclusions = [ + 'files' => [], + 'classes' => [], + 'methods' => [], +]; + +$coverageFile = dirname(__DIR__) . '/coverage.xml'; + +if (!file_exists($coverageFile)) { + fwrite(STDERR, "Coverage gate failed: coverage.xml not found. Run composer test:coverage first.\n"); + exit(1); +} + +$xml = simplexml_load_file($coverageFile); +if ($xml === false || !isset($xml->project)) { + fwrite(STDERR, "Coverage gate failed: unable to parse coverage.xml.\n"); + exit(1); +} + +$projectMetrics = metricsToArray($xml->project->metrics[0] ?? null); +$overallStatements = $projectMetrics['statements']; +$overallCoveredStatements = $projectMetrics['coveredstatements']; +$overallCoverage = coveragePercent($overallCoveredStatements, $overallStatements); + +$zeroCoveredFiles = []; +$zeroCoveredClasses = []; +$zeroCoveredMethods = []; + +foreach ($xml->project->xpath('.//file') ?: [] as $fileNode) { + $filePath = (string) ($fileNode['name'] ?? ''); + if (!isProductionFile($filePath)) { + continue; + } + + $relativeFile = relativeProductionPath($filePath); + $fileMetrics = metricsToArray($fileNode->metrics[0] ?? null); + + if ($fileMetrics['statements'] > 0 && $fileMetrics['coveredstatements'] === 0 && !array_key_exists($relativeFile, $exclusions['files'])) { + $zeroCoveredFiles[$relativeFile] = describeFileFailure($relativeFile, $fileMetrics); + } + + foreach ($fileNode->class as $classNode) { + $className = (string) ($classNode['name'] ?? ''); + if ($className === '') { + continue; + } + + $classMetrics = metricsToArray($classNode->metrics[0] ?? null); + if (!isCoverableClass($classMetrics) || array_key_exists($className, $exclusions['classes'])) { + continue; + } + + if ( + $classMetrics['coveredstatements'] === 0 + && ($classMetrics['methods'] === 0 || $classMetrics['coveredmethods'] === 0) + ) { + $zeroCoveredClasses[$className] = describeClassFailure($relativeFile, $className, $classMetrics); + } + } + + foreach ($fileNode->line as $lineNode) { + if ((string) ($lineNode['type'] ?? '') !== 'method') { + continue; + } + + $className = resolveMethodClassName($fileNode, (int) ($lineNode['num'] ?? 0)); + if ($className === null) { + continue; + } + + $methodName = (string) ($lineNode['name'] ?? ''); + if ($methodName === '') { + continue; + } + + $methodKey = $className . '::' . $methodName; + if (array_key_exists($methodKey, $exclusions['methods'])) { + continue; + } + + $count = (int) ($lineNode['count'] ?? 0); + if ($count === 0) { + $zeroCoveredMethods[$methodKey] = describeMethodFailure($relativeFile, $className, $methodName, (int) ($lineNode['num'] ?? 0)); + } + } +} + +ksort($zeroCoveredFiles); +ksort($zeroCoveredClasses); +ksort($zeroCoveredMethods); + +$failures = []; +if ($overallCoverage < MINIMUM_STATEMENT_COVERAGE) { + $failures[] = sprintf( + 'Overall statement coverage %.1f%% is below %.1f%% (%d/%d covered statements).', + $overallCoverage, + MINIMUM_STATEMENT_COVERAGE, + $overallCoveredStatements, + $overallStatements, + ); +} + +if ($zeroCoveredFiles !== []) { + $failures[] = renderSection('Files with 0 covered statements', $zeroCoveredFiles); +} + +if ($zeroCoveredClasses !== []) { + $failures[] = renderSection('Classes with 0 coverage', $zeroCoveredClasses); +} + +if ($zeroCoveredMethods !== []) { + $failures[] = renderSection('Methods with 0 coverage', $zeroCoveredMethods); +} + +printf( + "Coverage gate summary: %.1f%% statements (%d/%d), %d zero-covered files, %d zero-covered classes, %d zero-covered methods.\n", + $overallCoverage, + $overallCoveredStatements, + $overallStatements, + count($zeroCoveredFiles), + count($zeroCoveredClasses), + count($zeroCoveredMethods), +); + +if ($exclusions['files'] !== [] || $exclusions['classes'] !== [] || $exclusions['methods'] !== []) { + echo renderExclusions($exclusions); +} + +if ($failures !== []) { + fwrite(STDERR, "Coverage gate failed.\n\n" . implode("\n\n", $failures) . "\n"); + exit(1); +} + +echo "Coverage gate passed.\n"; + +/** + * @return array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} + */ +function metricsToArray(?SimpleXMLElement $metricsNode): array +{ + return [ + 'statements' => (int) ($metricsNode['statements'] ?? 0), + 'coveredstatements' => (int) ($metricsNode['coveredstatements'] ?? 0), + 'methods' => (int) ($metricsNode['methods'] ?? 0), + 'coveredmethods' => (int) ($metricsNode['coveredmethods'] ?? 0), + 'elements' => (int) ($metricsNode['elements'] ?? 0), + 'coveredelements' => (int) ($metricsNode['coveredelements'] ?? 0), + ]; +} + +function coveragePercent(int $covered, int $total): float +{ + return $total > 0 ? ($covered / $total) * 100.0 : 100.0; +} + +function isProductionFile(string $filePath): bool +{ + return str_starts_with($filePath, dirname(__DIR__) . '/src/'); +} + +function relativeProductionPath(string $filePath): string +{ + return 'src/' . substr($filePath, strlen(dirname(__DIR__) . '/src/')); +} + +/** + * @param array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} $metrics + */ +function isCoverableClass(array $metrics): bool +{ + return $metrics['statements'] > 0 || $metrics['methods'] > 0 || $metrics['elements'] > 0; +} + +/** + * @param array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} $metrics + */ +function describeFileFailure(string $relativeFile, array $metrics): string +{ + return sprintf( + '%s has 0 covered statements (%d statements, %d methods).', + $relativeFile, + $metrics['statements'], + $metrics['methods'], + ); +} + +/** + * @param array{statements: int, coveredstatements: int, methods: int, coveredmethods: int, elements: int, coveredelements: int} $metrics + */ +function describeClassFailure(string $relativeFile, string $className, array $metrics): string +{ + return sprintf( + '%s in %s has 0 coverage (%d/%d statements, %d/%d methods).', + $className, + $relativeFile, + $metrics['coveredstatements'], + $metrics['statements'], + $metrics['coveredmethods'], + $metrics['methods'], + ); +} + +function describeMethodFailure(string $relativeFile, string $className, string $methodName, int $line): string +{ + return sprintf('%s::%s in %s:%d has 0 coverage.', $className, $methodName, $relativeFile, $line); +} + +/** + * @param array $items + */ +function renderSection(string $title, array $items): string +{ + $lines = [$title . ':']; + foreach ($items as $item) { + $lines[] = ' - ' . $item; + } + + return implode("\n", $lines); +} + +/** + * @param array{files: array, classes: array, methods: array} $exclusions + */ +function renderExclusions(array $exclusions): string +{ + $sections = []; + foreach ($exclusions as $type => $items) { + if ($items === []) { + continue; + } + + $lines = ['Configured coverage exclusions for ' . $type . ':']; + foreach ($items as $item => $reason) { + $lines[] = ' - ' . $item . ': ' . $reason; + } + + $sections[] = implode("\n", $lines); + } + + return implode("\n\n", $sections) . "\n"; +} + +function resolveMethodClassName(SimpleXMLElement $fileNode, int $lineNumber): ?string +{ + $candidates = []; + foreach ($fileNode->class as $classNode) { + $className = (string) ($classNode['name'] ?? ''); + if ($className === '') { + continue; + } + + $methods = (int) (($classNode->metrics[0]['methods'] ?? 0)); + if ($methods === 0) { + continue; + } + + $candidates[] = $className; + } + + if ($candidates === []) { + return null; + } + + if (count($candidates) === 1) { + return $candidates[0]; + } + + $lineMap = []; + foreach ($fileNode->line as $lineNode) { + if ((string) ($lineNode['type'] ?? '') !== 'method') { + continue; + } + + $methodLine = (int) ($lineNode['num'] ?? 0); + $methodName = (string) ($lineNode['name'] ?? ''); + foreach ($candidates as $candidate) { + if (!isset($lineMap[$candidate])) { + $lineMap[$candidate] = []; + } + + if (!in_array($methodName, $lineMap[$candidate], true)) { + $lineMap[$candidate][] = $methodName; + } + } + + if ($methodLine === $lineNumber) { + return $candidates[0]; + } + } + + return $candidates[0]; +} diff --git a/tools/test-coverage-map.php b/tools/test-coverage-map.php new file mode 100644 index 0000000..ad8a763 --- /dev/null +++ b/tools/test-coverage-map.php @@ -0,0 +1,188 @@ + + */ +$unitCoverageAliases = [ + JOOservices\WordPress\Sdk\Services\CategoriesService::class => 'Covered by TaxonomyServicesTest through AbstractTermService behavior.', + JOOservices\WordPress\Sdk\Services\TagsService::class => 'Covered by TaxonomyServicesTest through AbstractTermService behavior.', + JOOservices\WordPress\Sdk\Services\TaxonomiesService::class => 'Covered by TaxonomyServicesTest.', + JOOservices\WordPress\Sdk\Services\PostTypesService::class => 'Covered by SchemaServicesTest.', + JOOservices\WordPress\Sdk\Services\StatusesService::class => 'Covered by SchemaServicesTest.', + JOOservices\WordPress\Sdk\Services\RawEndpointService::class => 'Protected raw helper covered through raw endpoint service tests.', + JOOservices\WordPress\Sdk\Services\RawCrudById::class => 'Trait covered through concrete raw services.', + JOOservices\WordPress\Sdk\Services\RawCrudByStringId::class => 'Trait covered through concrete raw services.', + JOOservices\WordPress\Sdk\Services\RevisionResourceService::class => 'Covered by RevisionsServiceTest.', + JOOservices\WordPress\Sdk\Services\AbstractTermService::class => 'Abstract service covered by AbstractTermServiceTest and concrete taxonomy services.', + JOOservices\WordPress\Sdk\Http\AbstractService::class => 'Abstract HTTP helper covered by AbstractServiceTest.', + JOOservices\WordPress\Sdk\Support\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.', + JOOservices\WordPress\Sdk\Http\ClientFactory::class => 'Covered indirectly by ContainerFactoryTest and WordPressService construction tests.', + JOOservices\WordPress\Sdk\Http\ResponseDecoderLogger::class => 'Covered through ResponseDecoderTest logger behavior.', + JOOservices\WordPress\Sdk\Data\PostType::class => 'Covered by DataModelsTest and schema service tests.', + JOOservices\WordPress\Sdk\Data\RenderedContent::class => 'Covered by DataModelsTest through nested DTO hydration.', + JOOservices\WordPress\Sdk\Data\SearchResult::class => 'Covered by DataModelsTest and SearchServiceTest.', + JOOservices\WordPress\Sdk\Data\Status::class => 'Covered by DataModelsTest and SchemaServicesTest.', + JOOservices\WordPress\Sdk\Data\Query\AbstractListQuery::class => 'Abstract query base covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListCommentsQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListMediaQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListPagesQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListPostsQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListTermsQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\ListUsersQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Data\Query\SearchQuery::class => 'Covered by ListQueryTest.', + JOOservices\WordPress\Sdk\Support\RestPath::class => 'Covered by CustomEndpointServiceTest path normalization cases.', +]; + +/** + * @var array + */ +$integrationCoverageAliases = [ + JOOservices\WordPress\Sdk\Services\AbstractTermService::class => 'Abstract helper covered by CategoriesServiceIntegrationTest and TagsServiceIntegrationTest.', + JOOservices\WordPress\Sdk\Services\RawEndpointService::class => 'Protected helper covered by concrete raw service integration tests.', + JOOservices\WordPress\Sdk\Services\RawCrudById::class => 'Trait covered by concrete raw service integration tests.', + JOOservices\WordPress\Sdk\Services\RawCrudByStringId::class => 'Trait covered by concrete raw service integration tests.', + JOOservices\WordPress\Sdk\Services\RevisionResourceService::class => 'Covered through RevisionsServiceIntegrationTest.', +]; + +$files = iterator_to_array(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/src'))); +$failures = []; +$classes = 0; +$publicMethods = 0; +$mappedUnitClasses = 0; +$mappedIntegrationServices = 0; + +foreach ($files as $file) { + if (!$file instanceof SplFileInfo || $file->getExtension() !== 'php') { + continue; + } + + $relative = substr($file->getPathname(), strlen($root . '/src/'), -4); + $class = 'JOOservices\\WordPress\\Sdk\\' . str_replace(DIRECTORY_SEPARATOR, '\\', $relative); + + if (!class_exists($class) && !interface_exists($class) && !trait_exists($class)) { + $failures[] = "Unable to autoload {$class} from {$relative}.php"; + + continue; + } + + $reflection = new \ReflectionClass($class); + if ($reflection->isInterface()) { + continue; + } + + $classes++; + $methods = array_filter( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC), + static fn (\ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $class + ); + $publicMethods += count($methods); + + if ($reflection->isAbstract() || $reflection->isTrait()) { + $isServiceHelper = str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Services\\'); + $hasRequiredReason = isset($unitCoverageAliases[$class]) + && (!$isServiceHelper || isset($integrationCoverageAliases[$class])); + + if (!$hasRequiredReason) { + $failures[] = "Abstract/trait {$class} needs an explicit coverage-map reason."; + } + + continue; + } + + $unitTest = unitTestPathFor($root, $relative, $class); + if (hasUnitCoverageMapping($root, $relative, $class) || isset($unitCoverageAliases[$class])) { + $mappedUnitClasses++; + } else { + $failures[] = "Missing unit coverage mapping for {$class}. Expected {$unitTest}."; + } + + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Services\\')) { + $integrationTest = $root . '/tests/Integration/Services/' . basename($relative) . 'IntegrationTest.php'; + if (is_file($integrationTest) || isset($integrationCoverageAliases[$class])) { + $mappedIntegrationServices++; + } else { + $failures[] = "Missing integration coverage mapping for service {$class}. Expected {$integrationTest}."; + } + } +} + +printf("Production classes mapped for unit tests: %d/%d\n", $mappedUnitClasses, $classes); +printf("Service classes mapped for integration tests: %d\n", $mappedIntegrationServices); +printf("Public methods reflected: %d\n", $publicMethods); + +if ($failures !== []) { + echo "\nCoverage map failures:\n"; + foreach ($failures as $failure) { + echo "- {$failure}\n"; + } + exit(1); +} + +echo "Coverage map passed.\n"; + +/** + * @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'; +} + +/** + * @param class-string $class + */ +function hasUnitCoverageMapping(string $root, string $relative, string $class): bool +{ + $direct = unitTestPathFor($root, $relative, $class); + if (is_file($direct)) { + return true; + } + + $grouped = []; + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Exceptions\\')) { + $grouped[] = $root . '/tests/Unit/Exceptions/ExceptionsTest.php'; + } + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Data\\Query\\')) { + $grouped[] = $root . '/tests/Unit/Data/Query/ListQueryTest.php'; + } elseif (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Data\\')) { + $grouped[] = $root . '/tests/Unit/Data/DataModelsTest.php'; + } + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\ContentBuilder\\Blocks\\')) { + $grouped[] = $root . '/tests/Unit/Support/ContentBuilder/BlocksTest.php'; + } + if (str_starts_with($class, 'JOOservices\\WordPress\\Sdk\\Support\\ContentBuilder\\Parser\\')) { + $grouped[] = $root . '/tests/Unit/Support/ContentBuilder/BlockParserTest.php'; + } + if (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'; + } + + foreach ($grouped as $path) { + if (is_file($path)) { + return true; + } + } + + return false; +} From 58c641ead2fe9c950e336bc14f5c9575ec2c535b Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sat, 16 May 2026 16:39:23 +0700 Subject: [PATCH 3/6] test: remove logger mock notice --- tests/Unit/Http/ResponseDecoderLoggerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Http/ResponseDecoderLoggerTest.php b/tests/Unit/Http/ResponseDecoderLoggerTest.php index dae3e53..cae9104 100644 --- a/tests/Unit/Http/ResponseDecoderLoggerTest.php +++ b/tests/Unit/Http/ResponseDecoderLoggerTest.php @@ -19,7 +19,7 @@ protected function tearDown(): void public function testSetLoggerOverridesSharedInstance(): void { - $logger = $this->createMock(LoggerInterface::class); + $logger = $this->createStub(LoggerInterface::class); ResponseDecoderLogger::setLogger($logger); From 721d1fe390fe639e0515e7d8c8cca062ecda4817 Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sat, 16 May 2026 20:59:20 +0700 Subject: [PATCH 4/6] fix: align service pagination and endpoint behavior --- src/Data/Page.php | 61 ++++ src/Data/Status.php | 10 +- src/Http/AbstractService.php | 31 -- src/Services/BlockRendererService.php | 2 +- src/Services/PluginsService.php | 14 +- tests/Unit/Data/DataModelsTest.php | 123 ++++++++ tests/Unit/Data/Query/ListQueryTest.php | 62 ++++ .../Unit/Services/AbstractTermServiceTest.php | 34 ++ .../Services/BlockRendererServiceTest.php | 1 + tests/Unit/Services/BlockTypesServiceTest.php | 14 + tests/Unit/Services/BlocksServiceTest.php | 22 ++ tests/Unit/Services/CommentsServiceTest.php | 34 ++ .../Services/EndpointPathServicesTest.php | 290 ++++++++++++++++++ .../Unit/Services/GlobalStylesServiceTest.php | 18 ++ tests/Unit/Services/MediaServiceTest.php | 34 ++ .../Services/MenuLocationsServiceTest.php | 11 + tests/Unit/Services/NavMenusServiceTest.php | 14 + tests/Unit/Services/PagesServiceTest.php | 34 ++ tests/Unit/Services/PluginsServiceTest.php | 26 +- .../Unit/Services/RawEndpointServiceTest.php | 37 +++ tests/Unit/Services/SchemaServicesTest.php | 83 +++++ tests/Unit/Services/SearchServiceTest.php | 34 ++ tests/Unit/Services/SidebarsServiceTest.php | 14 + tests/Unit/Services/SiteHealthServiceTest.php | 13 + tests/Unit/Services/TemplatesServiceTest.php | 15 + tests/Unit/Services/ThemesServiceTest.php | 12 + tests/Unit/Services/UsersServiceTest.php | 34 ++ .../Unit/Services/WidgetTypesServiceTest.php | 14 + 28 files changed, 1049 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/Services/EndpointPathServicesTest.php create mode 100644 tests/Unit/Services/RawEndpointServiceTest.php diff --git a/src/Data/Page.php b/src/Data/Page.php index 3eaf9da..1639ba6 100644 --- a/src/Data/Page.php +++ b/src/Data/Page.php @@ -4,6 +4,67 @@ namespace JOOservices\WordPress\Sdk\Data; +/** + * @SuppressWarnings("PHPMD.TooManyFields") + * @SuppressWarnings("PHPMD.ExcessiveParameterList") + * @SuppressWarnings("PHPMD.BooleanArgumentFlag") + * @SuppressWarnings("PHPMD.CamelCaseParameterName") + * @SuppressWarnings("PHPMD.CamelCaseVariableName") + */ class Page extends Post { + /** + * @param array $meta + */ + public function __construct( + int $id, + string $date, + string $date_gmt, + RenderedContent $guid, + string $modified, + string $modified_gmt, + string $slug, + string $status, + string $type, + string $link, + RenderedContent $title, + RenderedContent $content, + RenderedContent $excerpt, + int $author, + int $featured_media, + string $comment_status, + string $ping_status, + string $template, + array $meta, + bool $sticky = false, + string $format = 'standard', + array $categories = [], + array $tags = [], + ) { + parent::__construct( + id: $id, + date: $date, + date_gmt: $date_gmt, + guid: $guid, + modified: $modified, + modified_gmt: $modified_gmt, + slug: $slug, + status: $status, + type: $type, + link: $link, + title: $title, + content: $content, + excerpt: $excerpt, + author: $author, + featured_media: $featured_media, + comment_status: $comment_status, + ping_status: $ping_status, + sticky: $sticky, + template: $template, + format: $format, + meta: $meta, + categories: $categories, + tags: $tags, + ); + } } diff --git a/src/Data/Status.php b/src/Data/Status.php index 1203e7e..8b5a31d 100644 --- a/src/Data/Status.php +++ b/src/Data/Status.php @@ -15,11 +15,11 @@ class Status extends Dto { public function __construct( public readonly string $name, - public readonly bool $public, - public readonly bool $protected, - public readonly bool $private, - public readonly bool $queryable, - public readonly bool $show_in_list, + public readonly bool $public = false, + public readonly bool $protected = false, + public readonly bool $private = false, + public readonly bool $queryable = false, + public readonly bool $show_in_list = false, public readonly bool $date_floating = false, ) { } diff --git a/src/Http/AbstractService.php b/src/Http/AbstractService.php index 5bc23ab..5efcbe9 100644 --- a/src/Http/AbstractService.php +++ b/src/Http/AbstractService.php @@ -48,27 +48,12 @@ protected function request(string $method, string $uri, array $options = []): Re if (isset($options['multipart'])) { $clientOptions['multipart'] = $options['multipart']; - // Ensure body is null if multipart is used to avoid conflict, - // though RequestBuilder usually creates a stream. - // If we skipped withJson, body should be empty stream or null. - // Guzzle prefers 'multipart' key over 'body' usually, or checks specific usage. } try { - // This means the interface definition I viewed earlier might be different or I misread it. - // Let me check the file content of HttpClientInterface again to be sure. - // For now, I'll revert to attempting to use `request` or check the file first. $wrapper = $this->client->request($method, (string) $request->getUri(), $clientOptions); $response = $wrapper->toPsrResponse(); } catch (\Throwable $e) { - // If the client throws a specific exception (network error), we might want to wrap it - // or let it bubble up. - // But if it's an HTTP error response that didn't throw (depending on client config), - // we handle it below. - // jooservices/client likely throws on 4xx/5xx by default? - // Let's assume we need to catch ClientExceptionInterface and map it, - // or if the client returns a response for 4xx/5xx (http_errors=false), we check status. - // For this implementation, let's assume valid response or exception. throw new RuntimeException($e->getMessage(), $e->getCode(), $e); } @@ -278,22 +263,6 @@ private function buildRequest(string $method, string $uri, array $options): Requ } } - // If multipart is set, we let the Client handle the body construction (Guzzle/Client specific) - // or we need to build it here. - // RequestBuilder currently only supports withJson or withBody(Stream). - // Since `jooservices/client` likely takes `multipart` array in options, - // we should NOT set the body here if multipart is present, - // BUT we need to pass the multipart options to the client later. - - // HOWEVER, `request()` extracts `body` from the builder. - // So we need a way to pass `multipart` effectively. - // IF we rely on the client wrapper `request($method, $uri, $options)`: - // The `AbstractService::request` method currently overwrites `body` from `$request->getBody()`. - - // Solution: - // 1. If `multipart` is in $options, DO NOT call `withJson`. - // 2. The `request` method needs to merge `multipart` into `$clientOptions`. - if (isset($options['body']) && !isset($options['multipart'])) { $builder = $builder->withJson($options['body']); } diff --git a/src/Services/BlockRendererService.php b/src/Services/BlockRendererService.php index 7b9982a..0704c42 100644 --- a/src/Services/BlockRendererService.php +++ b/src/Services/BlockRendererService.php @@ -14,7 +14,7 @@ class BlockRendererService extends RawEndpointService */ public function render(string $name, array $attributes = [], ?int $postId = null): array { - $query = []; + $query = ['context' => 'edit']; if ($attributes !== []) { $query['attributes'] = $attributes; } diff --git a/src/Services/PluginsService.php b/src/Services/PluginsService.php index 92149e9..b8cc964 100644 --- a/src/Services/PluginsService.php +++ b/src/Services/PluginsService.php @@ -20,7 +20,7 @@ public function list(array $query = []): array */ public function get(string $plugin): array { - return $this->getRaw('wp/v2/plugins/' . $this->segment($plugin)); + return $this->getRaw('wp/v2/plugins/' . $this->pluginPath($plugin)); } /** @@ -38,7 +38,7 @@ public function create(array $payload): array */ public function update(string $plugin, array $payload): array { - return $this->postRaw('wp/v2/plugins/' . $this->segment($plugin), $payload); + return $this->postRaw('wp/v2/plugins/' . $this->pluginPath($plugin), $payload); } /** @@ -46,6 +46,14 @@ public function update(string $plugin, array $payload): array */ public function delete(string $plugin): array { - return $this->deleteRaw('wp/v2/plugins/' . $this->segment($plugin)); + return $this->deleteRaw('wp/v2/plugins/' . $this->pluginPath($plugin)); + } + + private function pluginPath(string $plugin): string + { + return implode('/', array_map( + fn (string $segment): string => $this->segment($segment), + explode('/', trim($plugin, '/')) + )); } } diff --git a/tests/Unit/Data/DataModelsTest.php b/tests/Unit/Data/DataModelsTest.php index 018c00e..3065462 100644 --- a/tests/Unit/Data/DataModelsTest.php +++ b/tests/Unit/Data/DataModelsTest.php @@ -4,9 +4,15 @@ namespace JOOservices\WordPress\Sdk\Tests\Unit\Data; +use JOOservices\WordPress\Sdk\Data\Comment; use JOOservices\WordPress\Sdk\Data\Media; +use JOOservices\WordPress\Sdk\Data\Page; use JOOservices\WordPress\Sdk\Data\Post; +use JOOservices\WordPress\Sdk\Data\PostType; use JOOservices\WordPress\Sdk\Data\RenderedContent; +use JOOservices\WordPress\Sdk\Data\SearchResult; +use JOOservices\WordPress\Sdk\Data\Status; +use JOOservices\WordPress\Sdk\Data\Taxonomy; use JOOservices\WordPress\Sdk\Data\Term; use JOOservices\WordPress\Sdk\Data\User; use PHPUnit\Framework\TestCase; @@ -90,4 +96,121 @@ public function testPostConstructor(): void $this->assertSame('publish', $post->status); $this->assertSame([1, 2], $post->categories); } + + public function testPageConstructorProvidesPostDefaultsForWordPressPageResponses(): void + { + $rendered = new RenderedContent('text'); + $page = new Page( + id: 5, + date: '2026-05-16', + date_gmt: '2026-05-16', + guid: $rendered, + modified: '2026-05-16', + modified_gmt: '2026-05-16', + slug: 'page', + status: 'publish', + type: 'page', + link: 'http://example.com/page', + title: $rendered, + content: $rendered, + excerpt: $rendered, + author: 1, + featured_media: 0, + comment_status: 'closed', + ping_status: 'closed', + template: '', + meta: [] + ); + + $this->assertSame(5, $page->id); + $this->assertFalse($page->sticky); + $this->assertSame('standard', $page->format); + $this->assertSame([], $page->categories); + $this->assertSame([], $page->tags); + } + + public function testStatusConstructorDefaultsOptionalWordPressFields(): void + { + $status = new Status('Published'); + + $this->assertSame('Published', $status->name); + $this->assertFalse($status->public); + $this->assertFalse($status->protected); + $this->assertFalse($status->private); + $this->assertFalse($status->queryable); + $this->assertFalse($status->show_in_list); + $this->assertFalse($status->date_floating); + } + + public function testCommentConstructorPreservesRenderedContentAndAvatarMap(): void + { + $content = new RenderedContent('

Approved comment

'); + $comment = new Comment( + id: 21, + post: 9, + parent: 0, + author: 4, + author_name: 'SDK Bot', + author_url: 'https://example.com/authors/sdk-bot', + date: '2026-05-16T11:00:00', + date_gmt: '2026-05-16T11:00:00', + content: $content, + link: 'https://example.com/posts/9#comment-21', + status: 'approved', + type: 'comment', + author_avatar_urls: ['96' => 'https://example.com/avatar-96.jpg'], + ); + + $this->assertSame(21, $comment->id); + $this->assertSame($content, $comment->content); + $this->assertSame('SDK Bot', $comment->author_name); + $this->assertSame(['96' => 'https://example.com/avatar-96.jpg'], $comment->author_avatar_urls); + } + + public function testTaxonomyConstructorDefaultsOptionalWordPressSchemaFields(): void + { + $taxonomy = new Taxonomy( + slug: 'category', + name: 'Categories', + ); + + $this->assertSame('category', $taxonomy->slug); + $this->assertSame('Categories', $taxonomy->name); + $this->assertSame([], $taxonomy->types); + $this->assertSame('', $taxonomy->rest_base); + $this->assertFalse($taxonomy->hierarchical); + } + + public function testPostTypeConstructorPreservesViewabilityFlags(): void + { + $postType = new PostType( + slug: 'page', + name: 'Pages', + rest_base: 'pages', + hierarchical: true, + viewable: false, + ); + + $this->assertSame('page', $postType->slug); + $this->assertSame('pages', $postType->rest_base); + $this->assertTrue($postType->hierarchical); + $this->assertFalse($postType->viewable); + } + + public function testSearchResultConstructorStoresWordPressSearchShape(): void + { + $result = new SearchResult( + id: 99, + title: 'Grey Harbor', + url: 'https://example.com/grey-harbor', + type: 'post', + subtype: 'post', + ); + + $this->assertSame(99, $result->id); + $this->assertSame('Grey Harbor', $result->title); + $this->assertSame('https://example.com/grey-harbor', $result->url); + $this->assertSame('post', $result->type); + $this->assertSame('post', $result->subtype); + } } diff --git a/tests/Unit/Data/Query/ListQueryTest.php b/tests/Unit/Data/Query/ListQueryTest.php index 2119add..6849481 100644 --- a/tests/Unit/Data/Query/ListQueryTest.php +++ b/tests/Unit/Data/Query/ListQueryTest.php @@ -4,7 +4,10 @@ namespace JOOservices\WordPress\Sdk\Tests\Unit\Data\Query; +use JOOservices\WordPress\Sdk\Data\Query\ListMediaQuery; use JOOservices\WordPress\Sdk\Data\Query\ListPostsQuery; +use JOOservices\WordPress\Sdk\Data\Query\ListTermsQuery; +use JOOservices\WordPress\Sdk\Data\Query\ListUsersQuery; use PHPUnit\Framework\TestCase; class ListQueryTest extends TestCase @@ -33,4 +36,63 @@ public function testListPostsQueryMapsSpecialWordPressParameters(): void 'sticky' => false, ], $query->toQuery()); } + + public function testListMediaQueryFiltersNullsAndMapsMediaSpecificParameters(): void + { + $query = new ListMediaQuery( + page: 1, + perPage: 10, + fields: 'id,source_url', + embed: true, + parent: 42, + mediaType: 'image', + mimeType: 'image/jpeg', + ); + + $this->assertSame([ + 'page' => 1, + 'per_page' => 10, + '_fields' => 'id,source_url', + '_embed' => 'true', + 'parent' => 42, + 'media_type' => 'image', + 'mime_type' => 'image/jpeg', + ], $query->toQuery()); + } + + public function testListTermsQueryMapsTaxonomySpecificFilters(): void + { + $query = new ListTermsQuery( + search: 'harbor', + hideEmpty: false, + parent: 3, + post: 44, + slug: ['story', 'news'], + ); + + $this->assertSame([ + 'search' => 'harbor', + 'hide_empty' => false, + 'parent' => 3, + 'post' => 44, + 'slug' => ['story', 'news'], + ], $query->toQuery()); + } + + public function testListUsersQueryMapsRolesCapabilitiesAndPublishedPosts(): void + { + $query = new ListUsersQuery( + perPage: 50, + roles: ['editor'], + capabilities: ['edit_posts'], + hasPublishedPosts: true, + ); + + $this->assertSame([ + 'per_page' => 50, + 'roles' => ['editor'], + 'capabilities' => ['edit_posts'], + 'has_published_posts' => true, + ], $query->toQuery()); + } } diff --git a/tests/Unit/Services/AbstractTermServiceTest.php b/tests/Unit/Services/AbstractTermServiceTest.php index 7f3bfd4..a866e0f 100644 --- a/tests/Unit/Services/AbstractTermServiceTest.php +++ b/tests/Unit/Services/AbstractTermServiceTest.php @@ -51,6 +51,40 @@ public function testCrudAndDelete(): void $service->setNextResponse(new Response(200, [], 'not-json')); $service->delete(10, false); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestTermService($decoder); + + $first = $this->createStub(Term::class); + $second = $this->createStub(Term::class); + $third = $this->createStub(Term::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestTermService extends AbstractTermService diff --git a/tests/Unit/Services/BlockRendererServiceTest.php b/tests/Unit/Services/BlockRendererServiceTest.php index ea4c602..f371666 100644 --- a/tests/Unit/Services/BlockRendererServiceTest.php +++ b/tests/Unit/Services/BlockRendererServiceTest.php @@ -20,6 +20,7 @@ public function testRenderBuildsAttributesAndPostIdQuery(): void $service->render('core/latest-posts', ['postsToShow' => 3], 12); $this->assertSame('wp/v2/block-renderer/core/latest-posts', $service->lastUri); + $this->assertSame('edit', $service->lastOptions['query']['context']); $this->assertSame(['postsToShow' => 3], $service->lastOptions['query']['attributes']); $this->assertSame(12, $service->lastOptions['query']['post_id']); } diff --git a/tests/Unit/Services/BlockTypesServiceTest.php b/tests/Unit/Services/BlockTypesServiceTest.php index 0487c39..e0b4b7f 100644 --- a/tests/Unit/Services/BlockTypesServiceTest.php +++ b/tests/Unit/Services/BlockTypesServiceTest.php @@ -11,6 +11,20 @@ class BlockTypesServiceTest extends TestCase { + public function testListBuildsBlockTypeCollectionPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlockTypesService { + use RecordsServiceRequests; + }; + + $service->list(); + $this->assertSame('wp/v2/block-types', $service->lastUri); + + $service->list('core', ['context' => 'edit']); + $this->assertSame('wp/v2/block-types/core', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + } + public function testGetBuildsNormalizedBlockTypePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlockTypesService { diff --git a/tests/Unit/Services/BlocksServiceTest.php b/tests/Unit/Services/BlocksServiceTest.php index 84537ef..1f23d08 100644 --- a/tests/Unit/Services/BlocksServiceTest.php +++ b/tests/Unit/Services/BlocksServiceTest.php @@ -11,6 +11,28 @@ class BlocksServiceTest extends TestCase { + public function testCrudBuildersUseBlocksPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlocksService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/blocks', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get(7); + $this->assertSame('wp/v2/blocks/7', $service->lastUri); + + $service->create(['title' => 'Reusable']); + $this->assertSame('wp/v2/blocks', $service->lastUri); + $this->assertSame(['title' => 'Reusable'], $service->lastOptions['body']); + + $service->update(7, ['title' => 'Updated']); + $this->assertSame('wp/v2/blocks/7', $service->lastUri); + $this->assertSame(['title' => 'Updated'], $service->lastOptions['body']); + } + public function testDeleteBuildsForceQuery(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends BlocksService { diff --git a/tests/Unit/Services/CommentsServiceTest.php b/tests/Unit/Services/CommentsServiceTest.php index 22c8d9a..5617aad 100644 --- a/tests/Unit/Services/CommentsServiceTest.php +++ b/tests/Unit/Services/CommentsServiceTest.php @@ -50,6 +50,40 @@ public function testGetListCreateUpdateAndDelete(): void $service->delete(3, true); $this->assertSame(['force' => true], $service->lastOptions['query']); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestCommentsService($decoder); + + $first = $this->createStub(Comment::class); + $second = $this->createStub(Comment::class); + $third = $this->createStub(Comment::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestCommentsService extends CommentsService diff --git a/tests/Unit/Services/EndpointPathServicesTest.php b/tests/Unit/Services/EndpointPathServicesTest.php new file mode 100644 index 0000000..f9365b5 --- /dev/null +++ b/tests/Unit/Services/EndpointPathServicesTest.php @@ -0,0 +1,290 @@ +createStub(ResponseDecoderInterface::class)); + $service->setNextResponse(['id' => 5]); + + $service->posts(10)->list(['context' => 'edit']); + $this->assertSame('GET', $service->lastMethod); + $this->assertSame('wp/v2/posts/10/revisions', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->pages(20)->get(6); + $this->assertSame('wp/v2/pages/20/revisions/6', $service->lastUri); + + $service->blocks(30)->delete(7); + $this->assertSame('DELETE', $service->lastMethod); + $this->assertSame('wp/v2/blocks/30/autosaves/7', $service->lastUri); + } + + public function testAdminEndpointServicesBuildExpectedPaths(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + + $plugins = new TestPluginsService($decoder); + $plugins->get('akismet/akismet'); + $this->assertSame('wp/v2/plugins/akismet/akismet', $plugins->lastUri); + + $themes = new TestThemesService($decoder); + $themes->get('twentytwentysix'); + $this->assertSame('wp/v2/themes/twentytwentysix', $themes->lastUri); + + $blocks = new TestBlocksService($decoder); + $blocks->delete(99, true); + $this->assertSame('wp/v2/blocks/99', $blocks->lastUri); + $this->assertSame(['force' => true], $blocks->lastOptions['query']); + + $globalStyles = new TestGlobalStylesService($decoder); + $globalStyles->theme('twentytwentysix'); + $this->assertSame('wp/v2/global-styles/themes/twentytwentysix', $globalStyles->lastUri); + } + + public function testBlockAndNavigationServicesBuildExpectedPaths(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + + $blockTypes = new TestBlockTypesService($decoder); + $blockTypes->get('core/paragraph'); + $this->assertSame('wp/v2/block-types/core/paragraph', $blockTypes->lastUri); + + $renderer = new TestBlockRendererService($decoder); + $renderer->render('core/latest-posts', ['postsToShow' => 3], 12); + $this->assertSame('wp/v2/block-renderer/core/latest-posts', $renderer->lastUri); + $this->assertSame('edit', $renderer->lastOptions['query']['context']); + $this->assertSame(['postsToShow' => 3], $renderer->lastOptions['query']['attributes']); + $this->assertSame(12, $renderer->lastOptions['query']['post_id']); + + $directory = new TestBlockDirectoryService($decoder); + $directory->search(['term' => 'gallery']); + $this->assertSame('wp/v2/block-directory/search', $directory->lastUri); + + $locations = new TestMenuLocationsService($decoder); + $locations->get('primary'); + $this->assertSame('wp/v2/menu-locations/primary', $locations->lastUri); + + $navigation = new TestNavigationsService($decoder); + $navigation->update(5, ['title' => 'Main']); + $this->assertSame('wp/v2/navigation/5', $navigation->lastUri); + + $menus = new TestNavMenusService($decoder); + $menus->create(['name' => 'Main']); + $this->assertSame('wp/v2/menus', $menus->lastUri); + + $items = new TestNavMenuItemsService($decoder); + $items->list(['menus' => 3]); + $this->assertSame(['menus' => 3], $items->lastOptions['query']); + } + + public function testTemplateWidgetSidebarAndSiteHealthServicesBuildExpectedPaths(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + + $templates = new TestTemplatesService($decoder); + $templates->get('twentytwentysix//home'); + $this->assertSame('wp/v2/templates/twentytwentysix%2F%2Fhome', $templates->lastUri); + + $parts = new TestTemplatePartsService($decoder); + $parts->delete('twentytwentysix//header', true); + $this->assertSame(['force' => true], $parts->lastOptions['query']); + + $widgets = new TestWidgetsService($decoder); + $widgets->update('text-2', ['sidebar' => 'sidebar-1']); + $this->assertSame('wp/v2/widgets/text-2', $widgets->lastUri); + + $widgetTypes = new TestWidgetTypesService($decoder); + $widgetTypes->encode('text', ['instance' => ['text' => 'Hi']]); + $this->assertSame('wp/v2/widget-types/text/encode', $widgetTypes->lastUri); + + $sidebars = new TestSidebarsService($decoder); + $sidebars->update('sidebar-1', ['widgets' => ['text-2']]); + $this->assertSame('wp/v2/sidebars/sidebar-1', $sidebars->lastUri); + + $siteHealth = new TestSiteHealthService($decoder); + $siteHealth->backgroundUpdates(); + $this->assertSame('wp-site-health/v1/tests/background-updates', $siteHealth->lastUri); + } +} + +trait RecordsServiceRequests +{ + public ?string $lastMethod = null; + public ?string $lastUri = null; + public array $lastOptions = []; + private array $nextResponse = []; + + public function __construct(ResponseDecoderInterface $decoder) + { + parent::__construct(new NullHttpClient(), new RequestBuilder(), $decoder, new ErrorMapper()); + } + + public function setNextResponse(array $response): void + { + $this->nextResponse = $response; + } + + protected function request(string $method, string $uri, array $options = []): ResponseInterface + { + $this->lastMethod = $method; + $this->lastUri = $uri; + $this->lastOptions = $options; + + return new Response(200, [], json_encode($this->nextResponse, JSON_THROW_ON_ERROR)); + } +} + +class TestRevisionsService extends RevisionsService +{ + use RecordsServiceRequests; +} + +class TestPluginsService extends PluginsService +{ + use RecordsServiceRequests; +} + +class TestThemesService extends ThemesService +{ + use RecordsServiceRequests; +} + +class TestBlocksService extends BlocksService +{ + use RecordsServiceRequests; +} + +class TestBlockTypesService extends BlockTypesService +{ + use RecordsServiceRequests; +} + +class TestBlockRendererService extends BlockRendererService +{ + use RecordsServiceRequests; +} + +class TestBlockDirectoryService extends BlockDirectoryService +{ + use RecordsServiceRequests; +} + +class TestMenuLocationsService extends MenuLocationsService +{ + use RecordsServiceRequests; +} + +class TestNavigationsService extends NavigationsService +{ + use RecordsServiceRequests; +} + +class TestNavMenusService extends NavMenusService +{ + use RecordsServiceRequests; +} + +class TestNavMenuItemsService extends NavMenuItemsService +{ + use RecordsServiceRequests; +} + +class TestTemplatesService extends TemplatesService +{ + use RecordsServiceRequests; +} + +class TestTemplatePartsService extends TemplatePartsService +{ + use RecordsServiceRequests; +} + +class TestGlobalStylesService extends GlobalStylesService +{ + use RecordsServiceRequests; +} + +class TestWidgetsService extends WidgetsService +{ + use RecordsServiceRequests; +} + +class TestWidgetTypesService extends WidgetTypesService +{ + use RecordsServiceRequests; +} + +class TestSidebarsService extends SidebarsService +{ + use RecordsServiceRequests; +} + +class TestSiteHealthService extends SiteHealthService +{ + use RecordsServiceRequests; +} + +class NullHttpClient implements HttpClientInterface +{ + public function get(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function post(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function put(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function patch(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function delete(string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } + + public function request(string $method, string $uri, array $options = []): ResponseWrapperInterface + { + throw new \RuntimeException('Not used'); + } +} diff --git a/tests/Unit/Services/GlobalStylesServiceTest.php b/tests/Unit/Services/GlobalStylesServiceTest.php index 295c3c1..50885ca 100644 --- a/tests/Unit/Services/GlobalStylesServiceTest.php +++ b/tests/Unit/Services/GlobalStylesServiceTest.php @@ -11,6 +11,24 @@ class GlobalStylesServiceTest extends TestCase { + public function testCrudBuildersUseGlobalStylesPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends GlobalStylesService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/global-styles', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get('theme-1'); + $this->assertSame('wp/v2/global-styles/theme-1', $service->lastUri); + + $service->update('theme-1', ['settings' => ['color' => ['palette' => []]]]); + $this->assertSame('wp/v2/global-styles/theme-1', $service->lastUri); + $this->assertSame(['settings' => ['color' => ['palette' => []]]], $service->lastOptions['body']); + } + public function testThemeBuildsThemeStylesheetPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends GlobalStylesService { diff --git a/tests/Unit/Services/MediaServiceTest.php b/tests/Unit/Services/MediaServiceTest.php index 56d2945..efcab57 100644 --- a/tests/Unit/Services/MediaServiceTest.php +++ b/tests/Unit/Services/MediaServiceTest.php @@ -123,6 +123,40 @@ public function testDeleteReturnsDecodedMediaWhenNotDeletedWrapper(): void $service->delete(5, false); $this->assertSame([], $service->lastOptions); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestMediaService($decoder); + + $first = $this->createStub(Media::class); + $second = $this->createStub(Media::class); + $third = $this->createStub(Media::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestMediaService extends MediaService diff --git a/tests/Unit/Services/MenuLocationsServiceTest.php b/tests/Unit/Services/MenuLocationsServiceTest.php index 4c77878..b5ddfe6 100644 --- a/tests/Unit/Services/MenuLocationsServiceTest.php +++ b/tests/Unit/Services/MenuLocationsServiceTest.php @@ -11,6 +11,17 @@ class MenuLocationsServiceTest extends TestCase { + public function testListBuildsMenuLocationsPath(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends MenuLocationsService { + use RecordsServiceRequests; + }; + + $service->list(); + + $this->assertSame('wp/v2/menu-locations', $service->lastUri); + } + public function testGetBuildsLocationPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends MenuLocationsService { diff --git a/tests/Unit/Services/NavMenusServiceTest.php b/tests/Unit/Services/NavMenusServiceTest.php index ed1b70e..82afdbd 100644 --- a/tests/Unit/Services/NavMenusServiceTest.php +++ b/tests/Unit/Services/NavMenusServiceTest.php @@ -11,6 +11,20 @@ class NavMenusServiceTest extends TestCase { + public function testGetAndDeleteBuildMenusPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends NavMenusService { + use RecordsServiceRequests; + }; + + $service->get(5); + $this->assertSame('wp/v2/menus/5', $service->lastUri); + + $service->delete(5, true); + $this->assertSame('wp/v2/menus/5', $service->lastUri); + $this->assertSame(['force' => true], $service->lastOptions['query']); + } + public function testCreateBuildsMenusPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends NavMenusService { diff --git a/tests/Unit/Services/PagesServiceTest.php b/tests/Unit/Services/PagesServiceTest.php index dca15b1..d3c96a3 100644 --- a/tests/Unit/Services/PagesServiceTest.php +++ b/tests/Unit/Services/PagesServiceTest.php @@ -51,6 +51,40 @@ public function testGetListCreateUpdateAndDelete(): void $service->delete(9, true); $this->assertSame(['force' => true], $service->lastOptions['query']); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestPagesService($decoder); + + $first = $this->createStub(Page::class); + $second = $this->createStub(Page::class); + $third = $this->createStub(Page::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestPagesService extends PagesService diff --git a/tests/Unit/Services/PluginsServiceTest.php b/tests/Unit/Services/PluginsServiceTest.php index b7895a7..a6c2a19 100644 --- a/tests/Unit/Services/PluginsServiceTest.php +++ b/tests/Unit/Services/PluginsServiceTest.php @@ -11,7 +11,29 @@ class PluginsServiceTest extends TestCase { - public function testGetBuildsEncodedPluginPath(): void + public function testCrudBuildersUsePluginPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends PluginsService { + use RecordsServiceRequests; + }; + + $service->list(['status' => 'active']); + $this->assertSame('wp/v2/plugins', $service->lastUri); + $this->assertSame(['status' => 'active'], $service->lastOptions['query']); + + $service->create(['plugin' => 'akismet/akismet.php']); + $this->assertSame('wp/v2/plugins', $service->lastUri); + $this->assertSame(['plugin' => 'akismet/akismet.php'], $service->lastOptions['body']); + + $service->update('akismet/akismet', ['status' => 'inactive']); + $this->assertSame('wp/v2/plugins/akismet/akismet', $service->lastUri); + $this->assertSame(['status' => 'inactive'], $service->lastOptions['body']); + + $service->delete('akismet/akismet'); + $this->assertSame('wp/v2/plugins/akismet/akismet', $service->lastUri); + } + + public function testGetBuildsWordPressPluginPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends PluginsService { use RecordsServiceRequests; @@ -19,6 +41,6 @@ public function testGetBuildsEncodedPluginPath(): void $service->get('akismet/akismet'); - $this->assertSame('wp/v2/plugins/akismet%2Fakismet', $service->lastUri); + $this->assertSame('wp/v2/plugins/akismet/akismet', $service->lastUri); } } diff --git a/tests/Unit/Services/RawEndpointServiceTest.php b/tests/Unit/Services/RawEndpointServiceTest.php new file mode 100644 index 0000000..ca9d388 --- /dev/null +++ b/tests/Unit/Services/RawEndpointServiceTest.php @@ -0,0 +1,37 @@ +createStub(ResponseDecoderInterface::class)) extends RawEndpointService { + use RecordsServiceRequests; + + /** + * @param array $payload + * @return array + */ + public function exposedPutRaw(string $path, array $payload): array + { + return $this->putRaw($path, $payload); + } + }; + + $service->setNextResponse(['id' => 7, 'status' => 'updated']); + $result = $service->exposedPutRaw('wp/v2/raw-endpoint/7', ['status' => 'updated']); + + $this->assertSame('PUT', $service->lastMethod); + $this->assertSame('wp/v2/raw-endpoint/7', $service->lastUri); + $this->assertSame(['status' => 'updated'], $service->lastOptions['body']); + $this->assertSame(['id' => 7, 'status' => 'updated'], $result); + } +} diff --git a/tests/Unit/Services/SchemaServicesTest.php b/tests/Unit/Services/SchemaServicesTest.php index 6c6f14d..9e4aba5 100644 --- a/tests/Unit/Services/SchemaServicesTest.php +++ b/tests/Unit/Services/SchemaServicesTest.php @@ -38,6 +38,33 @@ public function testTaxonomiesServiceUsesTaxonomiesEndpoint(): void $this->assertSame(TaxonomyEndpoint::TAXONOMIES->path(), $service->lastUri); } + public function testTaxonomiesServiceAutoPaginationHelpersUseTaxonomiesEndpoint(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $taxonomy = $this->createStub(Taxonomy::class); + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + new PaginatedCollection([$taxonomy], 2, 2), + ); + + $service = new TestTaxonomiesService($decoder); + + $this->assertCount(2, $service->all(['per_page' => 1])); + $this->assertSame(['per_page' => 1, 'page' => 2], $service->lastOptions['query']); + $this->assertCount(2, iterator_to_array($service->cursor(['per_page' => 1]))); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + $this->assertSame(1, $visited); + } + public function testPostTypesServiceUsesTypesEndpoint(): void { $decoder = $this->createStub(ResponseDecoderInterface::class); @@ -55,6 +82,33 @@ public function testPostTypesServiceUsesTypesEndpoint(): void $this->assertSame(CoreEndpoint::POST_TYPES->path(), $service->lastUri); } + public function testPostTypesServiceAutoPaginationHelpersUseTypesEndpoint(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $postType = $this->createStub(PostType::class); + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + new PaginatedCollection([$postType], 2, 2), + ); + + $service = new TestPostTypesService($decoder); + + $this->assertCount(2, $service->all(['per_page' => 1])); + $this->assertSame(['per_page' => 1, 'page' => 2], $service->lastOptions['query']); + $this->assertCount(2, iterator_to_array($service->cursor(['per_page' => 1]))); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + $this->assertSame(1, $visited); + } + public function testStatusesServiceUsesStatusesEndpoint(): void { $decoder = $this->createStub(ResponseDecoderInterface::class); @@ -71,6 +125,33 @@ public function testStatusesServiceUsesStatusesEndpoint(): void $service->list(); $this->assertSame(CoreEndpoint::STATUSES->path(), $service->lastUri); } + + public function testStatusesServiceAutoPaginationHelpersUseStatusesEndpoint(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $status = $this->createStub(Status::class); + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + new PaginatedCollection([$status], 2, 2), + ); + + $service = new TestStatusesService($decoder); + + $this->assertCount(2, $service->all(['per_page' => 1])); + $this->assertSame(['per_page' => 1, 'page' => 2], $service->lastOptions['query']); + $this->assertCount(2, iterator_to_array($service->cursor(['per_page' => 1]))); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + $this->assertSame(1, $visited); + } } abstract class AbstractSchemaServiceTestDouble @@ -124,6 +205,7 @@ public function __construct(ResponseDecoderInterface $decoder) trait SchemaServiceRequestCapture { public ?string $lastUri = null; + public array $lastOptions = []; private ?Response $nextResponse = null; public function setNextResponse(Response $response): void @@ -134,6 +216,7 @@ public function setNextResponse(Response $response): void protected function request(string $method, string $uri, array $options = []): \Psr\Http\Message\ResponseInterface { $this->lastUri = $uri; + $this->lastOptions = $options; return $this->nextResponse ?? new Response(200, [], '{}'); } diff --git a/tests/Unit/Services/SearchServiceTest.php b/tests/Unit/Services/SearchServiceTest.php index b0e8383..7e962dd 100644 --- a/tests/Unit/Services/SearchServiceTest.php +++ b/tests/Unit/Services/SearchServiceTest.php @@ -37,6 +37,40 @@ public function testListAndSearchAliasUseSearchEndpoint(): void $service->search(['search' => 'sdk']); $this->assertSame('sdk', $service->lastOptions['query']['search']); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestSearchService($decoder); + + $first = $this->createStub(SearchResult::class); + $second = $this->createStub(SearchResult::class); + $third = $this->createStub(SearchResult::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestSearchService extends SearchService diff --git a/tests/Unit/Services/SidebarsServiceTest.php b/tests/Unit/Services/SidebarsServiceTest.php index c655b58..ed50db9 100644 --- a/tests/Unit/Services/SidebarsServiceTest.php +++ b/tests/Unit/Services/SidebarsServiceTest.php @@ -11,6 +11,20 @@ class SidebarsServiceTest extends TestCase { + public function testListAndGetBuildSidebarPaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends SidebarsService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/sidebars', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get('sidebar-1'); + $this->assertSame('wp/v2/sidebars/sidebar-1', $service->lastUri); + } + public function testUpdateBuildsSidebarPath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends SidebarsService { diff --git a/tests/Unit/Services/SiteHealthServiceTest.php b/tests/Unit/Services/SiteHealthServiceTest.php index 3f48847..8970b4c 100644 --- a/tests/Unit/Services/SiteHealthServiceTest.php +++ b/tests/Unit/Services/SiteHealthServiceTest.php @@ -21,4 +21,17 @@ public function testBackgroundUpdatesBuildsSiteHealthTestPath(): void $this->assertSame('wp-site-health/v1/tests/background-updates', $service->lastUri); } + + public function testAdditionalHelpersMapToNamedSiteHealthChecks(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends SiteHealthService { + use RecordsServiceRequests; + }; + + $service->loopbackRequests(); + $this->assertSame('wp-site-health/v1/tests/loopback-requests', $service->lastUri); + + $service->httpsStatus(); + $this->assertSame('wp-site-health/v1/tests/https-status', $service->lastUri); + } } diff --git a/tests/Unit/Services/TemplatesServiceTest.php b/tests/Unit/Services/TemplatesServiceTest.php index 176de15..fd86acc 100644 --- a/tests/Unit/Services/TemplatesServiceTest.php +++ b/tests/Unit/Services/TemplatesServiceTest.php @@ -11,6 +11,21 @@ class TemplatesServiceTest extends TestCase { + public function testListAndCreateBuildTemplatePaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends TemplatesService { + use RecordsServiceRequests; + }; + + $service->list(['post_type' => 'page']); + $this->assertSame('wp/v2/templates', $service->lastUri); + $this->assertSame(['post_type' => 'page'], $service->lastOptions['query']); + + $service->create(['slug' => 'home']); + $this->assertSame('wp/v2/templates', $service->lastUri); + $this->assertSame(['slug' => 'home'], $service->lastOptions['body']); + } + public function testGetBuildsEncodedTemplatePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends TemplatesService { diff --git a/tests/Unit/Services/ThemesServiceTest.php b/tests/Unit/Services/ThemesServiceTest.php index 8b308b5..d25a3ca 100644 --- a/tests/Unit/Services/ThemesServiceTest.php +++ b/tests/Unit/Services/ThemesServiceTest.php @@ -11,6 +11,18 @@ class ThemesServiceTest extends TestCase { + public function testListBuildsThemesPath(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends ThemesService { + use RecordsServiceRequests; + }; + + $service->list(['status' => 'inactive']); + + $this->assertSame('wp/v2/themes', $service->lastUri); + $this->assertSame(['status' => 'inactive'], $service->lastOptions['query']); + } + public function testGetBuildsThemePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends ThemesService { diff --git a/tests/Unit/Services/UsersServiceTest.php b/tests/Unit/Services/UsersServiceTest.php index dd43473..17f998c 100644 --- a/tests/Unit/Services/UsersServiceTest.php +++ b/tests/Unit/Services/UsersServiceTest.php @@ -51,6 +51,40 @@ public function testGetListCreateUpdateDeleteAndMe(): void $service->me(); $this->assertSame(CoreEndpoint::USERS_ME->path(), $service->lastUri); } + + public function testAutoPaginationHelpersWalkPagesAndSupportEarlyStop(): void + { + $decoder = $this->createStub(ResponseDecoderInterface::class); + $service = new TestUsersService($decoder); + + $first = $this->createStub(User::class); + $second = $this->createStub(User::class); + $third = $this->createStub(User::class); + + $decoder->method('decodeList')->willReturnOnConsecutiveCalls( + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + new PaginatedCollection([$second, $third], 3, 2), + new PaginatedCollection([$first], 3, 2), + ); + + $all = $service->all(['per_page' => 2]); + $this->assertCount(3, $all); + $this->assertSame(['per_page' => 2, 'page' => 2], $service->lastOptions['query']); + + $cursorItems = iterator_to_array($service->cursor(['per_page' => 2])); + $this->assertCount(3, $cursorItems); + + $visited = 0; + $service->each(function () use (&$visited): bool { + $visited++; + + return false; + }); + + $this->assertSame(1, $visited); + } } class TestUsersService extends UsersService diff --git a/tests/Unit/Services/WidgetTypesServiceTest.php b/tests/Unit/Services/WidgetTypesServiceTest.php index 90bef87..908905a 100644 --- a/tests/Unit/Services/WidgetTypesServiceTest.php +++ b/tests/Unit/Services/WidgetTypesServiceTest.php @@ -11,6 +11,20 @@ class WidgetTypesServiceTest extends TestCase { + public function testListAndGetBuildWidgetTypePaths(): void + { + $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends WidgetTypesService { + use RecordsServiceRequests; + }; + + $service->list(['context' => 'edit']); + $this->assertSame('wp/v2/widget-types', $service->lastUri); + $this->assertSame(['context' => 'edit'], $service->lastOptions['query']); + + $service->get('text'); + $this->assertSame('wp/v2/widget-types/text', $service->lastUri); + } + public function testEncodeBuildsWidgetTypeEncodePath(): void { $service = new class ($this->createStub(ResponseDecoderInterface::class)) extends WidgetTypesService { From 1b230cfcc55901cb01afefdd5d5a684ea102634b Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sun, 17 May 2026 06:45:52 +0700 Subject: [PATCH 5/6] refactor: extract content templates from SDK core (#6) * refactor: extract content templates from SDK core * fix: clear release validation blockers --- AGENTS.md | 7 +- CHANGELOG.md | 26 +- README.md | 11 +- composer.json | 17 - 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/ai-contributor-guide.md | 2 +- docs/04-development/testing.md | 78 +--- 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 ++- 36 files changed, 85 insertions(+), 2831 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..4abc4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ This repository is a PHP 8.5 package named `jooservices/wordpress-sdk`. - Use typed query DTOs for common list/read filters where practical. - Raw array escape hatches are allowed only as intentional, documented compatibility surfaces. - Avoid hidden domain strings when enums or constants are clearer. +- Do not add story/product-review/article content template features to SDK core; those belong in `jooservices/wordpress-content-templates`. ## Delivery checklist @@ -60,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 4a365be..48748c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,29 @@ The format follows Keep a Changelog, and this project aims to follow semantic ve ## [Unreleased] -### Added +### Removed -- 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. +- 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 -- Updated testing docs, README development commands, and AI contributor guidance to require Docker-only WordPress integration tests. +- 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 diff --git a/README.md b/README.md index 0815f2c..23250f0 100644 --- a/README.md +++ b/README.md @@ -73,11 +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. -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. +Content templates for stories, product reviews, articles, and template registries now live in `jooservices/wordpress-content-templates`. That package depends on this SDK and generates SDK-compatible post/page payloads; this SDK does not depend on it. ## DTO-first querying @@ -196,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 @@ -208,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, 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: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/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/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 740c448..69ba7b2 100644 --- a/docs/04-development/testing.md +++ b/docs/04-development/testing.md @@ -10,91 +10,37 @@ 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. ## 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 +## 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 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. - -## 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 -- 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. - -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 @@ -124,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. 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 d5ee3a8377409cacb828ae9292008a343eb07002 Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Sun, 17 May 2026 06:47:38 +0700 Subject: [PATCH 6/6] chore: prepare release 1.2.0 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48748c0..ab4570c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format follows Keep a Changelog, and this project aims to follow semantic ve ## [Unreleased] +## [1.2.0] - 2026-05-17 + ### Removed - Removed non-native content template classes, data objects, examples, and template-specific tests from SDK core.