Merge branch 'main' into fix/tiktok-app-review

This commit is contained in:
Paulo Castellano 2026-06-10 13:02:25 -03:00 committed by GitHub
commit cbb73487f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 440 additions and 163 deletions

View file

@ -120,7 +120,9 @@ ## License
## Star History
<p align="center">
<a href="https://www.star-history.com/?repos=trypostit%2Ftrypost&type=date&legend=top-left">
<img src="https://api.star-history.com/chart?repos=trypostit/trypost&type=date&legend=top-left" alt="Star History Chart" width="600">
</a>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=trypostit/trypost&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=trypostit/trypost&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=trypostit/trypost&type=date&legend=top-left" />
</picture>
</p>

View file

@ -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,

View file

@ -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'],

View file

@ -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'],
];
}

View file

@ -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();
});

264
composer.lock generated
View file

@ -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",

View file

@ -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<ContentTypeValue | null>(null);
const selectedFormat = ref<AiFormat | null>(null);
const selectedAccountId = ref<string | null>(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;

View file

@ -1,6 +1,5 @@
export const ContentType = {
InstagramFeed: 'instagram_feed',
InstagramCarousel: 'instagram_carousel',
InstagramStory: 'instagram_story',
InstagramReel: 'instagram_reel',
LinkedInPost: 'linkedin_post',

View file

@ -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 => {
<div v-if="dayPosts.length > 0" class="space-y-3">
<Link v-for="post in dayPosts" :key="post.id" :href="getPostUrl(post)" class="block">
<div
class="rounded-xl border-2 border-foreground p-4 shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
class="rounded-xl border-2 border-foreground p-4 shadow-2xs transition-all hover:shadow-md"
:class="getStatusColor(post.status)"
>
<div class="flex items-start justify-between gap-3">
@ -334,7 +334,7 @@ const formatTime = (scheduledAt: string): string => {
<!-- Content Preview -->
<p class="line-clamp-2 text-sm font-medium text-foreground/80">
{{ post.post_platforms[0]?.content || $t('calendar.no_content') }}
{{ post.content?.trim() || $t('calendar.no_content') }}
</p>
</div>
</div>
@ -383,7 +383,7 @@ const formatTime = (scheduledAt: string): string => {
<!-- Posts -->
<Link v-for="post in getPostsForDay(day)" :key="post.id" :href="getPostUrl(post)" class="block">
<div
class="rounded-lg border-2 border-foreground p-2 text-sm shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-sm"
class="rounded-lg border-2 border-foreground p-2 text-sm shadow-2xs transition-all hover:shadow-sm"
:class="getStatusColor(post.status)"
>
<!-- Time -->
@ -418,7 +418,7 @@ const formatTime = (scheduledAt: string): string => {
<!-- Content Preview -->
<p class="line-clamp-2 text-xs font-medium text-foreground/80">
{{ post.post_platforms[0]?.content || $t('calendar.no_content') }}
{{ post.content?.trim() || $t('calendar.no_content') }}
</p>
</div>
</Link>
@ -479,12 +479,13 @@ const formatTime = (scheduledAt: string): string => {
</div>
<!-- Posts -->
<div class="min-h-0 flex-1 space-y-1 overflow-y-auto">
<div class="min-h-0 flex-1 space-y-1 overflow-y-auto px-1">
<Link v-for="post in getPostsForDay(day).slice(0, 3)" :key="post.id" :href="getPostUrl(post)" class="block">
<div
class="flex items-center justify-between gap-1.5 rounded-md border-2 border-foreground px-2 py-1 text-xs shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-sm"
class="flex flex-col gap-1 rounded-md border-2 border-foreground px-2 py-1 text-xs shadow-2xs transition-all hover:shadow-sm"
:class="getStatusColor(post.status)"
>
<div class="flex items-center justify-between gap-1.5">
<span class="shrink-0 font-bold">{{ formatTime(post.scheduled_at) }}</span>
<div class="flex shrink-0 -space-x-1">
<TooltipProvider
@ -513,6 +514,10 @@ const formatTime = (scheduledAt: string): string => {
+{{ post.post_platforms.length - 3 }}
</span>
</div>
</div>
<p class="line-clamp-1 font-medium text-foreground/80">
{{ post.content?.trim() || $t('calendar.no_content') }}
</p>
</div>
</Link>
<div

View file

@ -50,6 +50,26 @@
->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();

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\PostPlatform\ContentType;
use App\Enums\UserWorkspace\Role;
use App\Jobs\Ai\StreamPostCreation;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Ai\Image;
beforeEach(function () {
Bus::fake();
Storage::fake();
Image::fake();
$this->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);
});

View file

@ -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([

View file

@ -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)

View file

@ -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'));

View file

@ -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');
});

View file

@ -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'), [

View file

@ -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);