From 05745605701a68fda18bb0dc6422f65e6f52fb4c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 19:52:38 -0300 Subject: [PATCH] feat(ai-templates): ContentStyle enum as the typed source for the 3 styles --- app/Ai/Templates/AiContentTemplate.php | 4 ++ app/Ai/Templates/AiTemplateRegistry.php | 15 ++++-- app/Ai/Templates/ImageCardTemplate.php | 16 +++++-- app/Ai/Templates/TweetCardImageTemplate.php | 16 +++++-- app/Ai/Templates/TweetCardTemplate.php | 16 +++++-- app/Enums/Ai/ContentStyle.php | 48 +++++++++++++++++++ .../App/Ai/StartPostCreationRequest.php | 14 ++---- .../Ai/Templates/AiTemplateRegistryTest.php | 19 ++++++++ .../Ai/Templates/TemplateContractTest.php | 4 +- .../Templates/TweetCardImageTemplateTest.php | 4 +- .../Ai/Templates/TweetCardTemplateTest.php | 4 +- tests/Unit/Enums/ContentStyleTest.php | 40 ++++++++++++++++ 12 files changed, 170 insertions(+), 30 deletions(-) create mode 100644 app/Enums/Ai/ContentStyle.php create mode 100644 tests/Unit/Enums/ContentStyleTest.php diff --git a/app/Ai/Templates/AiContentTemplate.php b/app/Ai/Templates/AiContentTemplate.php index 55eaa832..d88dba23 100644 --- a/app/Ai/Templates/AiContentTemplate.php +++ b/app/Ai/Templates/AiContentTemplate.php @@ -4,10 +4,14 @@ namespace App\Ai\Templates; +use App\Enums\Ai\ContentStyle; use Illuminate\Contracts\JsonSchema\JsonSchema; interface AiContentTemplate { + /** The typed enum case for this style. */ + public function style(): ContentStyle; + /** Stable key used in the request + registry, e.g. 'image_card'. */ public function key(): string; diff --git a/app/Ai/Templates/AiTemplateRegistry.php b/app/Ai/Templates/AiTemplateRegistry.php index 05222a9d..3c4e27dd 100644 --- a/app/Ai/Templates/AiTemplateRegistry.php +++ b/app/Ai/Templates/AiTemplateRegistry.php @@ -4,6 +4,7 @@ namespace App\Ai\Templates; +use App\Enums\Ai\ContentStyle; use InvalidArgumentException; class AiTemplateRegistry @@ -30,15 +31,23 @@ public function keys(): array return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all()); } - public function find(string $key): AiContentTemplate + /** @return array */ + public function styles(): array { + return ContentStyle::cases(); + } + + public function find(ContentStyle|string $style): AiContentTemplate + { + $value = $style instanceof ContentStyle ? $style->value : $style; + foreach ($this->all() as $template) { - if ($template->key() === $key) { + if ($template->key() === $value) { return $template; } } - throw new InvalidArgumentException("Unknown AI content template: {$key}"); + throw new InvalidArgumentException("Unknown AI content template: {$value}"); } public function default(): AiContentTemplate diff --git a/app/Ai/Templates/ImageCardTemplate.php b/app/Ai/Templates/ImageCardTemplate.php index cb2b3901..55692658 100644 --- a/app/Ai/Templates/ImageCardTemplate.php +++ b/app/Ai/Templates/ImageCardTemplate.php @@ -5,6 +5,7 @@ namespace App\Ai\Templates; use App\Ai\Templates\Concerns\ResolvesContentType; +use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; use App\Services\Image\PostImagePipeline; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -13,29 +14,34 @@ class ImageCardTemplate implements AiContentTemplate { use ResolvesContentType; + public function style(): ContentStyle + { + return ContentStyle::ImageCard; + } + public function key(): string { - return 'image_card'; + return $this->style()->value; } public function name(): string { - return 'posts.ai.templates.image_card.name'; + return $this->style()->label(); } public function description(): string { - return 'posts.ai.templates.image_card.description'; + return $this->style()->description(); } public function previewAsset(): string { - return '/images/ai-templates/image-card.png'; + return $this->style()->previewAsset(); } public function needsAccount(): bool { - return false; + return $this->style()->needsAccount(); } public function supportedFormats(): array diff --git a/app/Ai/Templates/TweetCardImageTemplate.php b/app/Ai/Templates/TweetCardImageTemplate.php index 84271c95..ed15aa8c 100644 --- a/app/Ai/Templates/TweetCardImageTemplate.php +++ b/app/Ai/Templates/TweetCardImageTemplate.php @@ -4,35 +4,41 @@ namespace App\Ai\Templates; +use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; use App\Services\Image\PostImagePipeline; use Illuminate\Contracts\JsonSchema\JsonSchema; class TweetCardImageTemplate implements AiContentTemplate { + public function style(): ContentStyle + { + return ContentStyle::TweetCardImage; + } + public function key(): string { - return 'tweet_card_image'; + return $this->style()->value; } public function name(): string { - return 'posts.ai.templates.tweet_card_image.name'; + return $this->style()->label(); } public function description(): string { - return 'posts.ai.templates.tweet_card_image.description'; + return $this->style()->description(); } public function previewAsset(): string { - return '/images/ai-templates/tweet-card-image.png'; + return $this->style()->previewAsset(); } public function needsAccount(): bool { - return true; + return $this->style()->needsAccount(); } /** @return array */ diff --git a/app/Ai/Templates/TweetCardTemplate.php b/app/Ai/Templates/TweetCardTemplate.php index ded38467..5f944daa 100644 --- a/app/Ai/Templates/TweetCardTemplate.php +++ b/app/Ai/Templates/TweetCardTemplate.php @@ -4,35 +4,41 @@ namespace App\Ai\Templates; +use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; use App\Services\Image\PostImagePipeline; use Illuminate\Contracts\JsonSchema\JsonSchema; class TweetCardTemplate implements AiContentTemplate { + public function style(): ContentStyle + { + return ContentStyle::TweetCard; + } + public function key(): string { - return 'tweet_card'; + return $this->style()->value; } public function name(): string { - return 'posts.ai.templates.tweet_card.name'; + return $this->style()->label(); } public function description(): string { - return 'posts.ai.templates.tweet_card.description'; + return $this->style()->description(); } public function previewAsset(): string { - return '/images/ai-templates/tweet-card.png'; + return $this->style()->previewAsset(); } public function needsAccount(): bool { - return true; + return $this->style()->needsAccount(); } /** @return array */ diff --git a/app/Enums/Ai/ContentStyle.php b/app/Enums/Ai/ContentStyle.php new file mode 100644 index 00000000..038f7dba --- /dev/null +++ b/app/Enums/Ai/ContentStyle.php @@ -0,0 +1,48 @@ +value}.name"; + } + + /** i18n key for the style's description. */ + public function description(): string + { + return "posts.ai.templates.{$this->value}.description"; + } + + /** Whether this style requires a connected social account. */ + public function needsAccount(): bool + { + return match ($this) { + self::ImageCard => false, + self::TweetCard, self::TweetCardImage => true, + }; + } + + /** Public path to the picker preview thumbnail. */ + public function previewAsset(): string + { + return match ($this) { + self::ImageCard => '/images/ai-templates/image-card.png', + self::TweetCard => '/images/ai-templates/tweet-card.png', + self::TweetCardImage => '/images/ai-templates/tweet-card-image.png', + }; + } + + public static function default(): self + { + return self::ImageCard; + } +} diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 258ad4a5..2d24a4fa 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -4,7 +4,7 @@ namespace App\Http\Requests\App\Ai; -use App\Ai\Templates\AiTemplateRegistry; +use App\Enums\Ai\ContentStyle; use App\Enums\PostPlatform\ContentType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -35,21 +35,17 @@ public function rules(): array 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], 'prompt' => ['required', 'string', 'max:2000'], 'date' => ['nullable', 'date_format:Y-m-d'], - 'template' => ['sometimes', 'string', Rule::in(app(AiTemplateRegistry::class)->keys())], + 'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)], ]; } public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - $key = (string) $this->input('template', 'image_card'); - $registry = app(AiTemplateRegistry::class); + $style = ContentStyle::tryFrom((string) $this->input('template', ContentStyle::default()->value)) + ?? ContentStyle::default(); - if (! in_array($key, $registry->keys(), true)) { - return; - } - - if ($registry->find($key)->needsAccount() && blank($this->input('social_account_id'))) { + if ($style->needsAccount() && blank($this->input('social_account_id'))) { $validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account'])); } }); diff --git a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php index 7a123a46..b0119f59 100644 --- a/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php +++ b/tests/Feature/Ai/Templates/AiTemplateRegistryTest.php @@ -5,6 +5,8 @@ use App\Ai\Templates\AiTemplateRegistry; use App\Ai\Templates\ImageCardTemplate; use App\Ai\Templates\TweetCardImageTemplate; +use App\Ai\Templates\TweetCardTemplate; +use App\Enums\Ai\ContentStyle; test('registry resolves keys and defaults to image_card', function () { $registry = app(AiTemplateRegistry::class); @@ -14,6 +16,23 @@ ->and($registry->default()->key())->toBe('image_card'); }); +test('registry find accepts a ContentStyle enum', function () { + $registry = app(AiTemplateRegistry::class); + expect($registry->find(ContentStyle::TweetCard))->toBeInstanceOf(TweetCardTemplate::class) + ->and($registry->find(ContentStyle::ImageCard))->toBeInstanceOf(ImageCardTemplate::class) + ->and($registry->find(ContentStyle::TweetCardImage))->toBeInstanceOf(TweetCardImageTemplate::class); +}); + +test('registry find accepts a string key', function () { + $registry = app(AiTemplateRegistry::class); + expect($registry->find('tweet_card'))->toBeInstanceOf(TweetCardTemplate::class); +}); + +test('registry styles returns all ContentStyle cases', function () { + $registry = app(AiTemplateRegistry::class); + expect($registry->styles())->toBe(ContentStyle::cases()); +}); + test('registry throws on unknown key', function () { expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class); }); diff --git a/tests/Feature/Ai/Templates/TemplateContractTest.php b/tests/Feature/Ai/Templates/TemplateContractTest.php index b2c0c927..15c4a372 100644 --- a/tests/Feature/Ai/Templates/TemplateContractTest.php +++ b/tests/Feature/Ai/Templates/TemplateContractTest.php @@ -4,6 +4,7 @@ use App\Ai\Templates\ImageCardTemplate; use App\Ai\Templates\TemplateContext; +use App\Enums\Ai\ContentStyle; use App\Models\Workspace; use Illuminate\JsonSchema\JsonSchemaTypeFactory; @@ -12,7 +13,8 @@ $workspace = Workspace::factory()->create(); $context = new TemplateContext($workspace, null, 'instagram_feed', 0, false); - expect($t->key())->toBe('image_card') + expect($t->style())->toBe(ContentStyle::ImageCard) + ->and($t->key())->toBe('image_card') ->and($t->needsAccount())->toBeFalse() ->and($t->generatorFormat())->toBe('single') ->and($t->promptView($context))->toBe('prompts.post_content.generator') diff --git a/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php b/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php index 3d1a42ee..1dc7a47c 100644 --- a/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php +++ b/tests/Feature/Ai/Templates/TweetCardImageTemplateTest.php @@ -4,6 +4,7 @@ use App\Ai\Templates\TemplateContext; use App\Ai\Templates\TweetCardImageTemplate; +use App\Enums\Ai\ContentStyle; use App\Models\Workspace; use Illuminate\JsonSchema\JsonSchemaTypeFactory; @@ -13,7 +14,8 @@ $singleContext = new TemplateContext($workspace, null, 'x_post', 1, false); $carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true); - expect($t->key())->toBe('tweet_card_image') + expect($t->style())->toBe(ContentStyle::TweetCardImage) + ->and($t->key())->toBe('tweet_card_image') ->and($t->needsAccount())->toBeTrue() ->and($t->generatorFormat())->toBe('tweet_card') ->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card_image') diff --git a/tests/Feature/Ai/Templates/TweetCardTemplateTest.php b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php index 325b87cc..35c1771a 100644 --- a/tests/Feature/Ai/Templates/TweetCardTemplateTest.php +++ b/tests/Feature/Ai/Templates/TweetCardTemplateTest.php @@ -4,6 +4,7 @@ use App\Ai\Templates\TemplateContext; use App\Ai\Templates\TweetCardTemplate; +use App\Enums\Ai\ContentStyle; use App\Models\Workspace; use Illuminate\JsonSchema\JsonSchemaTypeFactory; @@ -13,7 +14,8 @@ $singleContext = new TemplateContext($workspace, null, 'x_post', 1, false); $carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true); - expect($t->key())->toBe('tweet_card') + expect($t->style())->toBe(ContentStyle::TweetCard) + ->and($t->key())->toBe('tweet_card') ->and($t->needsAccount())->toBeTrue() ->and($t->generatorFormat())->toBe('tweet_card') ->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card') diff --git a/tests/Unit/Enums/ContentStyleTest.php b/tests/Unit/Enums/ContentStyleTest.php new file mode 100644 index 00000000..77f209a2 --- /dev/null +++ b/tests/Unit/Enums/ContentStyleTest.php @@ -0,0 +1,40 @@ +toHaveCount(3); + expect(ContentStyle::ImageCard->value)->toBe('image_card'); + expect(ContentStyle::TweetCard->value)->toBe('tweet_card'); + expect(ContentStyle::TweetCardImage->value)->toBe('tweet_card_image'); +}); + +test('default returns ImageCard', function () { + expect(ContentStyle::default())->toBe(ContentStyle::ImageCard); +}); + +test('needsAccount is false only for ImageCard', function () { + expect(ContentStyle::ImageCard->needsAccount())->toBeFalse(); + expect(ContentStyle::TweetCard->needsAccount())->toBeTrue(); + expect(ContentStyle::TweetCardImage->needsAccount())->toBeTrue(); +}); + +test('previewAsset returns the correct public path for each case', function () { + expect(ContentStyle::ImageCard->previewAsset())->toBe('/images/ai-templates/image-card.png'); + expect(ContentStyle::TweetCard->previewAsset())->toBe('/images/ai-templates/tweet-card.png'); + expect(ContentStyle::TweetCardImage->previewAsset())->toBe('/images/ai-templates/tweet-card-image.png'); +}); + +test('label returns the expected i18n key format', function () { + expect(ContentStyle::ImageCard->label())->toBe('posts.ai.templates.image_card.name'); + expect(ContentStyle::TweetCard->label())->toBe('posts.ai.templates.tweet_card.name'); + expect(ContentStyle::TweetCardImage->label())->toBe('posts.ai.templates.tweet_card_image.name'); +}); + +test('description returns the expected i18n key format', function () { + expect(ContentStyle::ImageCard->description())->toBe('posts.ai.templates.image_card.description'); + expect(ContentStyle::TweetCard->description())->toBe('posts.ai.templates.tweet_card.description'); + expect(ContentStyle::TweetCardImage->description())->toBe('posts.ai.templates.tweet_card_image.description'); +});