diff --git a/README.md b/README.md index 29c0ca18..106e5c40 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ ## License ## Star History

- - Star History Chart - + + + + Star History Chart +

diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 3dc02193..6fdf0e3d 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -10,7 +10,6 @@ enum ContentType: string { // Instagram case InstagramFeed = 'instagram_feed'; - case InstagramCarousel = 'instagram_carousel'; case InstagramReel = 'instagram_reel'; case InstagramStory = 'instagram_story'; @@ -51,11 +50,16 @@ enum ContentType: string // Mastodon case MastodonPost = 'mastodon_post'; + /** + * AI generation format for an Instagram carousel. Not a content type — + * carousel posts are persisted as InstagramFeed. + */ + public const CAROUSEL_FORMAT = 'instagram_carousel'; + public function label(): string { return match ($this) { self::InstagramFeed => 'Feed Post', - self::InstagramCarousel => 'Carousel', self::InstagramReel => 'Reel', self::InstagramStory => 'Story', self::LinkedInPost, self::LinkedInPagePost => 'Post', @@ -84,7 +88,7 @@ public function description(): string public function platform(): SocialPlatform { return match ($this) { - self::InstagramFeed, self::InstagramCarousel, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram, + self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram, self::LinkedInPost, self::LinkedInCarousel => SocialPlatform::LinkedIn, self::LinkedInPagePost, self::LinkedInPageCarousel => SocialPlatform::LinkedInPage, self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook, @@ -109,7 +113,6 @@ public function aiImageDimensions(): array return match ($this) { // Vertical 4:5 (Instagram preferred portrait, Threads mirrors it) self::InstagramFeed, - self::InstagramCarousel, self::ThreadsPost => ['width' => 1080, 'height' => 1350], // Square 1:1 (LinkedIn, X, Facebook, Bluesky, Mastodon) @@ -135,7 +138,7 @@ public function aiImageDimensions(): array public function aspectRatio(): ?string { return match ($this) { - self::InstagramFeed, self::InstagramCarousel => '4:5', + self::InstagramFeed => '4:5', self::InstagramReel, self::InstagramStory => '9:16', self::FacebookReel, self::FacebookStory => '9:16', self::TikTokVideo, self::YouTubeShort => '9:16', @@ -149,8 +152,7 @@ public function aspectRatio(): ?string public function maxMediaCount(): int { return match ($this) { - self::InstagramFeed => 1, - self::InstagramCarousel => 10, + self::InstagramFeed => 10, self::InstagramReel, self::InstagramStory => 1, self::LinkedInPost, self::LinkedInPagePost => 1, self::LinkedInCarousel, self::LinkedInPageCarousel => 20, @@ -172,7 +174,6 @@ public function supportsVideo(): bool { return match ($this) { self::InstagramFeed, self::InstagramReel, self::InstagramStory => true, - self::InstagramCarousel => false, self::LinkedInPost, self::LinkedInPagePost => true, self::LinkedInCarousel, self::LinkedInPageCarousel => false, self::FacebookPost, self::FacebookReel, self::FacebookStory => true, @@ -237,7 +238,6 @@ public static function aiSupported(): array { return [ self::InstagramFeed, - self::InstagramCarousel, self::InstagramStory, self::LinkedInPost, self::LinkedInPagePost, diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php index 2977f7b1..40fe56a4 100644 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ b/app/Http/Requests/App/Ai/StartPostCreationRequest.php @@ -20,11 +20,14 @@ public function authorize(): bool */ public function rules(): array { + $allowedFormats = array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported()); + $allowedFormats[] = ContentType::CAROUSEL_FORMAT; + return [ 'format' => [ 'required', 'string', - Rule::in(array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported())), + Rule::in($allowedFormats), ], 'social_account_id' => ['nullable', 'uuid'], 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], diff --git a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php index 3abeec7f..40514942 100644 --- a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php @@ -29,8 +29,8 @@ public function rules(): array 'brand_color' => $hex, 'background_color' => $hex, 'text_color' => $hex, - 'brand_font' => ['required', 'string', Rule::in(BrandFont::values())], - 'image_style' => ['required', 'string', Rule::in(ImageStyle::values())], + 'brand_font' => ['sometimes', 'required', 'string', Rule::in(BrandFont::values())], + 'image_style' => ['sometimes', 'required', 'string', Rule::in(ImageStyle::values())], 'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'], ]; } diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 780a32a2..1406c11c 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -52,7 +52,7 @@ public function handle(): void $workspace = Workspace::findOrFail($this->workspaceId); $socialAccount = $this->socialAccountId ? SocialAccount::find($this->socialAccountId) : null; - $isCarousel = $this->format === 'instagram_carousel'; + $isCarousel = $this->format === ContentType::CAROUSEL_FORMAT; $agentFormat = $isCarousel ? 'carousel' : 'single'; $slideCount = $isCarousel && $this->imageCount > 0 ? $this->imageCount : 1; @@ -120,13 +120,26 @@ public function handle(): void */ private function dimensionsForFormat(): array { - $type = ContentType::tryFrom($this->format); + $type = $this->resolvedContentType(); return $type ? $type->aiImageDimensions() : ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT]; } + /** + * The stored content type for the requested generation format. The carousel + * generation format is persisted as an Instagram feed post. + */ + private function resolvedContentType(): ?ContentType + { + if ($this->format === ContentType::CAROUSEL_FORMAT) { + return ContentType::InstagramFeed; + } + + return ContentType::tryFrom($this->format); + } + private function humanize(Workspace $workspace, array $structured, string $format): array { try { @@ -277,23 +290,20 @@ private function createPost(Workspace $workspace, string $content, array $media, 'date' => $this->date, ]); - $contentType = ContentType::tryFrom($this->format); + $contentType = $this->resolvedContentType(); if ($contentType && $socialAccount) { $aspectRatio = $this->aspectRatioFor($contentType); - $platformContentType = $contentType === ContentType::InstagramCarousel - ? ContentType::InstagramFeed - : $contentType; $post->postPlatforms() ->where('social_account_id', $socialAccount->id) - ->each(function ($platform) use ($aspectRatio, $platformContentType): void { + ->each(function ($platform) use ($aspectRatio, $contentType): void { $meta = $platform->meta ?? []; if ($aspectRatio !== null) { $meta['aspect_ratio'] = $aspectRatio; } $platform->meta = $meta; - $platform->content_type = $platformContentType->value; + $platform->content_type = $contentType->value; $platform->enabled = true; $platform->save(); }); diff --git a/composer.lock b/composer.lock index 87a52d7c..4f28b729 100644 --- a/composer.lock +++ b/composer.lock @@ -8062,16 +8062,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -8084,7 +8084,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8109,7 +8109,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -8120,29 +8120,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/dom-crawler", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "284ace90732b445b027728b5e0eec6418a17a364" + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364", - "reference": "284ace90732b445b027728b5e0eec6418a17a364", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-mbstring": "^1.0" }, @@ -8175,7 +8179,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v8.0.8" + "source": "https://github.com/symfony/dom-crawler/tree/v8.1.0" }, "funding": [ { @@ -8195,24 +8199,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/error-handler", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", - "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", "symfony/var-dumper": "^7.4|^8.0" @@ -8256,7 +8260,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.0.8" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -8276,24 +8280,25 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -8341,7 +8346,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -8361,20 +8366,20 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -8388,7 +8393,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -8421,7 +8426,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -8432,12 +8437,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/filesystem", @@ -8579,20 +8588,21 @@ }, { "name": "symfony/http-foundation", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", - "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { @@ -8635,7 +8645,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" }, "funding": [ { @@ -8655,34 +8665,38 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1770f6818d83b2fddc12185025b93f39a90cb628" + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628", - "reference": "1770f6818d83b2fddc12185025b93f39a90cb628", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.4|^8.0", "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", "symfony/http-client-contracts": "<2.5", "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", "twig/twig": "<3.21" }, "provide": { @@ -8695,13 +8709,14 @@ "symfony/config": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", "symfony/dom-crawler": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", "symfony/routing": "^7.4|^8.0", "symfony/serializer": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", @@ -8709,7 +8724,7 @@ "symfony/translation-contracts": "^2.5|^3", "symfony/uid": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", "symfony/var-exporter": "^7.4|^8.0", "twig/twig": "^3.21" }, @@ -8739,7 +8754,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.8" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" }, "funding": [ { @@ -8759,25 +8774,25 @@ "type": "tidelift" } ], - "time": "2026-03-31T21:14:05+00:00" + "time": "2026-05-29T08:46:08+00:00" }, { "name": "symfony/mailer", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56" + "reference": "9418d772df3a03a142e3bc06f602adb2b8724877" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ca5f6edaf8780ece814404b58a4482b22b509c56", - "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56", + "url": "https://api.github.com/repos/symfony/mailer/zipball/9418d772df3a03a142e3bc06f602adb2b8724877", + "reference": "9418d772df3a03a142e3bc06f602adb2b8724877", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.4", + "php": ">=8.4.1", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", "symfony/event-dispatcher": "^7.4|^8.0", @@ -8819,7 +8834,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.0.8" + "source": "https://github.com/symfony/mailer/tree/v8.1.0" }, "funding": [ { @@ -8839,24 +8854,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/mime", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333" + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a9fcb293650c054b62a5b406f4e92e7b711ea333", - "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333", + "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -8905,7 +8920,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.9" + "source": "https://github.com/symfony/mime/tree/v8.1.0" }, "funding": [ { @@ -8925,7 +8940,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", @@ -9182,16 +9197,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -9245,7 +9260,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -9265,20 +9280,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -9330,7 +9345,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -9350,20 +9365,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -9415,7 +9430,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -9435,7 +9450,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", @@ -9523,16 +9538,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -9579,7 +9594,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -9599,7 +9614,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", @@ -9683,16 +9698,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -9739,7 +9754,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -9759,7 +9774,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-php86", @@ -10078,20 +10093,20 @@ }, { "name": "symfony/routing", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038" + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", - "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { @@ -10134,7 +10149,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.9" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -10154,20 +10169,20 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -10185,7 +10200,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10221,7 +10236,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -10241,7 +10256,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", @@ -10588,20 +10603,20 @@ }, { "name": "symfony/var-dumper", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", - "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { @@ -10651,7 +10666,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" }, "funding": [ { @@ -10671,31 +10686,32 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/yaml", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -10726,7 +10742,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.8" + "source": "https://github.com/symfony/yaml/tree/v8.1.0" }, "funding": [ { @@ -10746,7 +10762,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", diff --git a/resources/js/components/posts/create/AiPostWizard.vue b/resources/js/components/posts/create/AiPostWizard.vue index 2ea974ba..bcbc6783 100644 --- a/resources/js/components/posts/create/AiPostWizard.vue +++ b/resources/js/components/posts/create/AiPostWizard.vue @@ -42,8 +42,11 @@ const emit = defineEmits<{ cancel: []; }>(); +const CAROUSEL_FORMAT = 'instagram_carousel' as const; +type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT; + // Selections -const selectedFormat = ref(null); +const selectedFormat = ref(null); const selectedAccountId = ref(null); const includeImages = ref(true); const imageCount = ref(2); @@ -59,9 +62,9 @@ const httpStart = useHttp<{ date: string | null; }>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null }); -const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [ +const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [ { value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] }, - { value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] }, + { value: CAROUSEL_FORMAT, platforms: ['instagram', 'instagram-facebook'] }, { value: ContentType.InstagramStory, platforms: ['instagram', 'instagram-facebook'] }, { value: ContentType.LinkedInPost, platforms: ['linkedin'] }, { value: ContentType.LinkedInPagePost, platforms: ['linkedin-page'] }, @@ -95,7 +98,7 @@ const accountsForFormat = computed(() => { return props.socialAccounts.filter((a) => format.platforms.includes(a.platform)); }); -const isCarousel = computed(() => selectedFormat.value === ContentType.InstagramCarousel); +const isCarousel = computed(() => selectedFormat.value === CAROUSEL_FORMAT); const requiresImage = computed(() => selectedFormat.value === ContentType.FacebookPost || selectedFormat.value === ContentType.PinterestPin || @@ -140,11 +143,11 @@ watch(accountsForFormat, (accounts) => { } }); -const selectFormat = (format: ContentTypeValue) => { +const selectFormat = (format: AiFormat) => { selectedFormat.value = format; // Sensible default per format. Picking a format always pre-selects an // image option so the user sees a chip highlighted on arrival. - if (format === ContentType.InstagramCarousel) { + if (format === CAROUSEL_FORMAT) { imageCount.value = 5; } else if (format === ContentType.InstagramFeed) { imageCount.value = 1; diff --git a/resources/js/enums/content-type.ts b/resources/js/enums/content-type.ts index 8daea55f..3d8a22bb 100644 --- a/resources/js/enums/content-type.ts +++ b/resources/js/enums/content-type.ts @@ -1,6 +1,5 @@ export const ContentType = { InstagramFeed: 'instagram_feed', - InstagramCarousel: 'instagram_carousel', InstagramStory: 'instagram_story', InstagramReel: 'instagram_reel', LinkedInPost: 'linkedin_post', diff --git a/resources/js/pages/posts/Calendar.vue b/resources/js/pages/posts/Calendar.vue index 1d93b385..e68e8f6b 100644 --- a/resources/js/pages/posts/Calendar.vue +++ b/resources/js/pages/posts/Calendar.vue @@ -18,7 +18,6 @@ import { PostStatus } from '@/types/post'; interface PostPlatform { id: string; platform: string; - content: string; status: string; social_account: { id: string; @@ -31,6 +30,7 @@ interface PostPlatform { interface Post { id: string; status: string; + content: string | null; scheduled_at: string; post_platforms: PostPlatform[]; } @@ -297,7 +297,7 @@ const formatTime = (scheduledAt: string): string => {
@@ -334,7 +334,7 @@ const formatTime = (scheduledAt: string): string => {

- {{ post.post_platforms[0]?.content || $t('calendar.no_content') }} + {{ post.content?.trim() || $t('calendar.no_content') }}

@@ -383,7 +383,7 @@ const formatTime = (scheduledAt: string): string => {
@@ -418,7 +418,7 @@ const formatTime = (scheduledAt: string): string => {

- {{ post.post_platforms[0]?.content || $t('calendar.no_content') }} + {{ post.content?.trim() || $t('calendar.no_content') }}

@@ -479,12 +479,13 @@ const formatTime = (scheduledAt: string): string => {
-
+
+
{{ formatTime(post.scheduled_at) }}
{ +{{ post.post_platforms.length - 3 }}
+
+

+ {{ post.content?.trim() || $t('calendar.no_content') }} +

assertJsonValidationErrors(['format']); }); +test('start accepts instagram_carousel as a generation format', function () { + Bus::fake(); + + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + ]); + + $this->actingAs($this->user) + ->postJson(route('app.posts.ai.create'), [ + 'prompt' => 'Five tips about productivity', + 'format' => 'instagram_carousel', + 'social_account_id' => $account->id, + 'image_count' => 5, + ]) + ->assertStatus(Response::HTTP_ACCEPTED); + + Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->format === 'instagram_carousel'); +}); + test('start rejects social_account_id from another workspace', function () { Bus::fake(); diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php new file mode 100644 index 00000000..55963bd2 --- /dev/null +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -0,0 +1,91 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->account = SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + ]); +}); + +function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount): void +{ + (new StreamPostCreation( + userId: $account->workspace->user_id, + creationId: (string) Str::uuid(), + workspaceId: $account->workspace_id, + format: $format, + socialAccountId: $account->id, + imageCount: $imageCount, + prompt: 'Five tips about productivity', + ))->handle(); +} + +test('AI carousel generation stores the post as an instagram feed, never instagram_carousel', function () { + // Empty image_keywords make TemplateImageGenerator::render() return null, so + // the storage decision is exercised without touching the image pipeline. + PostContentGenerator::fake([[ + 'caption' => 'Swipe to see the tips', + 'slides' => [ + ['title' => 'Tip 1', 'body' => 'First tip', 'image_keywords' => []], + ['title' => 'Tip 2', 'body' => 'Second tip', 'image_keywords' => []], + ], + ]]); + PostContentHumanizer::fake([[ + 'caption' => 'Swipe to see the tips', + 'slides' => [ + ['title' => 'Tip 1', 'body' => 'First tip'], + ['title' => 'Tip 2', 'body' => 'Second tip'], + ], + ]]); + + runStreamPostCreation('instagram_carousel', $this->account, 2); + + $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); + + expect($platform->content_type)->toBe(ContentType::InstagramFeed); + expect($platform->meta['aspect_ratio'] ?? null)->toBe('4:5'); +}); + +test('AI single feed generation stores the post as an instagram feed', function () { + PostContentGenerator::fake([[ + 'content' => 'A single productivity tip', + 'image_title' => 'Tip', + 'image_body' => 'Do less', + 'image_keywords' => [], + ]]); + PostContentHumanizer::fake([[ + 'content' => 'A single productivity tip', + 'image_title' => 'Tip', + 'image_body' => 'Do less', + ]]); + + runStreamPostCreation('instagram_feed', $this->account, 0); + + $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); + + expect($platform->content_type)->toBe(ContentType::InstagramFeed); +}); diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 592ed299..5c6018a8 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -167,6 +167,39 @@ ->assertOk(); }); +it('rejects creating a post with instagram_carousel — carousel is not a stored content_type', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + +it('rejects updating a post with instagram_carousel — carousel is not a stored content_type', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + $postPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + 'enabled' => true, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'draft', + 'platforms' => [ + ['id' => $postPlatform->id, 'content_type' => 'instagram_carousel'], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + it('cannot update post from another workspace', function () { $otherWorkspace = Workspace::factory()->create(); $otherSocialAccount = SocialAccount::factory()->create([ diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 07bdb5f0..f06b46d7 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -9,7 +9,9 @@ use App\Mcp\Tools\Post\DeletePostTool; use App\Mcp\Tools\Post\GetPostTool; use App\Mcp\Tools\Post\ListPostsTool; +use App\Mcp\Tools\Post\UpdatePostTool; use App\Models\Post; +use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; @@ -177,6 +179,38 @@ $response->assertHasErrors(); }); +test('create post rejects instagram_carousel — carousel is not a stored content_type', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'], + ], + ]); + + $response->assertHasErrors(); +}); + +test('update post rejects instagram_carousel — carousel is not a stored content_type', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'platforms' => [ + ['id' => $platform->id, 'content_type' => 'instagram_carousel'], + ], + ]); + + $response->assertHasErrors(); +}); + test('create post rejects a content_type that does not match the social account platform', function () { // x_post on a LinkedIn account — ContentTypeMatchesPlatform should reject. $response = TryPostServer::actingAs($this->user) diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 628b0f49..a7da0b14 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -176,6 +176,27 @@ ); }); +test('calendar payload exposes post content for rendering', function () { + $scheduledAt = now('UTC')->startOfWeek()->addDays(2)->setTime(12, 0); + + Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Caption visible in the calendar', + 'status' => PostStatus::Scheduled, + 'scheduled_at' => $scheduledAt, + ]); + + $dateKey = $scheduledAt->format('Y-m-d'); + + $response = $this->actingAs($this->user)->get(route('app.calendar')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->where("posts.{$dateKey}.0.content", 'Caption visible in the calendar') + ); +}); + // Create tests test('create requires authentication', function () { $response = $this->get(route('app.posts.create')); diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php index 74b740e3..5bba5d0b 100644 --- a/tests/Feature/UpdatePostRequestTest.php +++ b/tests/Feature/UpdatePostRequestTest.php @@ -525,3 +525,19 @@ expect(data_get($this->post->media, '0.source'))->toBe('ai'); expect(data_get($this->post->media, '0.source_meta.title'))->toBe('Fix ECP typo'); }); + +test('instagram_carousel is rejected as a content_type — carousel is a feed post with multiple images', function () { + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Draft->value, + 'platforms' => [ + [ + 'id' => $this->postPlatform->id, + 'content_type' => 'instagram_carousel', + 'meta' => [], + ], + ], + ]); + + $response->assertSessionHasErrors('platforms.0.content_type'); +}); diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index 1922f0c5..aa0ed4f2 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -232,6 +232,27 @@ expect($this->workspace->refresh()->image_style->value)->toBe('minimalist'); }); +test('update workspace settings updates the name when only the name is submitted', function () { + $this->actingAs($this->user) + ->from(route('app.workspace.settings')) + ->put(route('app.workspace.settings.update'), [ + 'name' => 'Name Only Update', + ]) + ->assertRedirect(route('app.workspace.settings')) + ->assertSessionHasNoErrors(); + + expect($this->workspace->refresh()->name)->toBe('Name Only Update'); +}); + +test('update workspace settings still requires brand_font and image_style when submitted empty', function () { + $this->actingAs($this->user) + ->put(route('app.workspace.settings.update'), [ + 'name' => $this->workspace->name, + 'brand_font' => '', + 'image_style' => '', + ])->assertSessionHasErrors(['brand_font', 'image_style']); +}); + test('update workspace settings rejects unknown image_style values', function () { $this->actingAs($this->user) ->put(route('app.workspace.settings.update'), [ diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index b2a34d3c..7a771504 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -53,9 +53,12 @@ expect(ContentType::XPost->aspectRatio())->toBeNull(); }); +test('instagram_carousel is not a content type — it is an AI generation format only', function () { + expect(ContentType::tryFrom('instagram_carousel'))->toBeNull(); +}); + test('content type has correct max media count', function () { - expect(ContentType::InstagramFeed->maxMediaCount())->toBe(1); - expect(ContentType::InstagramCarousel->maxMediaCount())->toBe(10); + expect(ContentType::InstagramFeed->maxMediaCount())->toBe(10); expect(ContentType::InstagramReel->maxMediaCount())->toBe(1); expect(ContentType::LinkedInCarousel->maxMediaCount())->toBe(20); expect(ContentType::XPost->maxMediaCount())->toBe(4);