From 4d3992747251a9660fd557ea24ef19fa68591408 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 29 May 2026 16:11:24 -0300 Subject: [PATCH 1/5] Update star history section for dark and light themes --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 +

From 33ccd0a0df7293c2eb0bf5994983cdb9d2e1fbd6 Mon Sep 17 00:00:00 2001 From: sebastian-works <60854302+sebastian-works@users.noreply.github.com> Date: Sun, 31 May 2026 00:43:01 +0200 Subject: [PATCH 2/5] fix: update symfony packages to 8.1.0 for security advisories (CVE-2026-45067 et al.) --- composer.lock | 264 ++++++++++++++++++++++++++------------------------ 1 file changed, 140 insertions(+), 124 deletions(-) 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", From 2f047f2f7e93da6580af5c06ae3d6a108832a433 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 2 Jun 2026 08:45:11 -0300 Subject: [PATCH 3/5] Fix workspace name-only update rejected by required brand fields The Workspace and Brand settings tabs both submit to the same updateSettings endpoint, but the Workspace tab sends only the name. UpdateWorkspaceRequest marked brand_font and image_style as required, so the name-only request failed validation silently (the form only renders the name error). Mark both brand fields as sometimes|required so a name-only update succeeds while the Brand tab still validates them. Closes #74 --- .../App/Workspace/UpdateWorkspaceRequest.php | 4 ++-- tests/Feature/WorkspaceControllerTest.php | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) 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/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'), [ From ba98f01530e5d3782cc975f3f42f73ba3c5b6b3c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 2 Jun 2026 09:12:14 -0300 Subject: [PATCH 4/5] Fix calendar showing 'no content' for every post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar read each post's caption from post_platforms[0].content, but post_platforms has no content column — the caption lives on posts.content. As a result every cell fell back to the 'no content' placeholder. Read post.content instead (matching the list view) and drop the phantom content field from the PostPlatform interface. Also surface the caption in the month view (it previously showed only the time and platform icons), add horizontal padding to the month post list so the card border is not clipped inside the overflow-y scroll container, and standardise the hover effect across day/week/month views to a shadow-only highlight (no lift). Closes #70 --- resources/js/pages/posts/Calendar.vue | 19 ++++++++++++------- tests/Feature/PostControllerTest.php | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) 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') }} +

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')); From cca7893e0f0caafffb807210def3ef396203be8c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 4 Jun 2026 17:08:57 -0300 Subject: [PATCH 5/5] Stop persisting instagram_carousel as a content type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instagram carousels were stored as content_type=instagram_carousel, which the publisher's match() did not handle — publishing failed with "Unsupported Instagram content type: instagram_carousel" for any post created via API, MCP, or template (the AI flow worked only thanks to an inline band-aid that rewrote carousel to feed before saving). A carousel is just an Instagram feed post with multiple images: the editor, preview, and publisher already treat a multi-image feed as a carousel. So instagram_carousel is a generation format, not a stored content type. Remove it from the ContentType enum entirely; it now lives only as an AI generation-format string (wizard card + slide structure + carousel templates are untouched), and posts always persist as instagram_feed. - ContentType: drop the InstagramCarousel case; InstagramFeed maxMediaCount 1 -> 10 - StreamPostCreation: resolvedContentType() maps carousel -> feed; band-aid removed - StartPostCreationRequest: accept instagram_carousel as a generation format - Frontend: carousel becomes a wizard-local AiFormat; UI/UX unchanged - API/MCP now reject instagram_carousel as a content_type (Rule::in no longer lists it) --- app/Enums/PostPlatform/ContentType.php | 18 ++-- .../App/Ai/StartPostCreationRequest.php | 5 +- app/Jobs/Ai/StreamPostCreation.php | 26 ++++-- .../components/posts/create/AiPostWizard.vue | 15 +-- resources/js/enums/content-type.ts | 1 - tests/Feature/Ai/PostAiCreateTest.php | 20 ++++ tests/Feature/Ai/StreamPostCreationTest.php | 91 +++++++++++++++++++ tests/Feature/Api/PostApiTest.php | 33 +++++++ tests/Feature/Mcp/PostToolTest.php | 34 +++++++ tests/Feature/UpdatePostRequestTest.php | 16 ++++ tests/Unit/Enums/ContentTypeTest.php | 7 +- 11 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 tests/Feature/Ai/StreamPostCreationTest.php 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/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/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/tests/Feature/Ai/PostAiCreateTest.php b/tests/Feature/Ai/PostAiCreateTest.php index 99003add..297da2d7 100644 --- a/tests/Feature/Ai/PostAiCreateTest.php +++ b/tests/Feature/Ai/PostAiCreateTest.php @@ -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(); 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/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/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);