From 790854add54ac98eaf47a5a27e6f00c68d8a586f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 27 Sep 2026 10:24:13 -0300 Subject: [PATCH 01/21] feat: validate optional YouTube descriptions --- app/Rules/ValidYouTubeDescription.php | 26 ++++++++++++++ app/Support/PostPlatformMetaRules.php | 12 +++++++ app/Support/YouTubeDescription.php | 36 +++++++++++++++++++ lang/ar/posts.php | 9 +++++ lang/de/posts.php | 9 +++++ lang/el/posts.php | 9 +++++ lang/en/posts.php | 9 +++++ lang/es/posts.php | 9 +++++ lang/fr/posts.php | 9 +++++ lang/it/posts.php | 9 +++++ lang/ja/posts.php | 9 +++++ lang/ko/posts.php | 9 +++++ lang/nl/posts.php | 9 +++++ lang/pl/posts.php | 9 +++++ lang/pt-BR/posts.php | 9 +++++ lang/ru/posts.php | 9 +++++ lang/tr/posts.php | 9 +++++ lang/uk/posts.php | 9 +++++ lang/zh/posts.php | 9 +++++ tests/Unit/PostPlatformMetaRulesTest.php | 14 ++++++++ tests/Unit/Support/YouTubeDescriptionTest.php | 32 +++++++++++++++++ 21 files changed, 264 insertions(+) create mode 100644 app/Rules/ValidYouTubeDescription.php create mode 100644 app/Support/YouTubeDescription.php create mode 100644 tests/Unit/Support/YouTubeDescriptionTest.php diff --git a/app/Rules/ValidYouTubeDescription.php b/app/Rules/ValidYouTubeDescription.php new file mode 100644 index 000000000..94819092e --- /dev/null +++ b/app/Rules/ValidYouTubeDescription.php @@ -0,0 +1,26 @@ +translate(); + } + } +} diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index c904ee952..099116015 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -10,6 +10,7 @@ use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; +use App\Rules\ValidYouTubeDescription; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -56,6 +57,9 @@ public static function rules(): array 'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'], 'platforms.*.meta.link' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + // YouTube + 'platforms.*.meta.description' => ['sometimes', 'nullable', 'string', new ValidYouTubeDescription], + // Discord 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], @@ -112,6 +116,7 @@ public static function attributes(): array { return [ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), + 'platforms.*.meta.description' => __('posts.form.youtube.description'), 'platforms.*.meta.link' => __('posts.form.pinterest.link'), 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), @@ -174,6 +179,13 @@ public static function assertStoredPostPublishable(Post $post): void */ public static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { + if ($platform === Platform::YouTube) { + $key = YouTubeDescription::violation(data_get($meta, 'description')); + if ($key !== null) { + return ['description', __($key)]; + } + } + $topicType = TopicType::fromMeta(data_get($meta, 'topic_type')); $ctaAction = CtaAction::fromMeta(data_get($meta, 'call_to_action.action_type')); $needsGoogleBusinessEvent = $platform === Platform::GoogleBusiness && $topicType->requiresEvent(); diff --git a/app/Support/YouTubeDescription.php b/app/Support/YouTubeDescription.php new file mode 100644 index 000000000..96b57b976 --- /dev/null +++ b/app/Support/YouTubeDescription.php @@ -0,0 +1,36 @@ + self::MAX_BYTES) { + return 'posts.form.youtube.description_max'; + } + if (str_contains($description, '<') || str_contains($description, '>')) { + return 'posts.form.youtube.description_invalid'; + } + + return null; + } + + /** @param array|null $meta */ + public static function resolve(?array $meta, ?string $content): string + { + $description = $meta['description'] ?? null; + + return is_string($description) && trim($description) !== '' ? $description : ($content ?? ''); + } +} diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 5bd4cb358..e48b24acf 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'عنوان المستند', 'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك', ], + 'youtube' => [ + 'settings' => 'إعدادات YouTube', + 'posting_to' => 'النشر على', + 'description' => 'الوصف', + 'description_placeholder' => 'وصف الفيديو', + 'description_max' => 'يجب ألا يتجاوز وصف YouTube ‏5000 بايت بترميز UTF-8.', + 'description_invalid' => 'يجب أن يكون وصف YouTube نصًا صالحًا دون < أو >.', + 'description_bytes' => ':used / :limit بايت', + ], 'pinterest' => [ 'settings' => 'إعدادات Pinterest', 'posting_to' => 'النشر إلى', diff --git a/lang/de/posts.php b/lang/de/posts.php index f8ab34c30..0bb69e167 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -144,6 +144,15 @@ 'document_title' => 'Dokumenttitel', 'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt', ], + 'youtube' => [ + 'settings' => 'YouTube-Einstellungen', + 'posting_to' => 'Veröffentlichen auf', + 'description' => 'Beschreibung', + 'description_placeholder' => 'Videobeschreibung', + 'description_max' => 'Die YouTube-Beschreibung darf 5.000 UTF-8-Bytes nicht überschreiten.', + 'description_invalid' => 'Die YouTube-Beschreibung muss gültiger Text ohne < oder > sein.', + 'description_bytes' => ':used / :limit Bytes', + ], 'pinterest' => [ 'settings' => 'Pinterest-Einstellungen', 'posting_to' => 'Veröffentlichen auf', diff --git a/lang/el/posts.php b/lang/el/posts.php index 4da982a4f..a025e13cd 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Τίτλος εγγράφου', 'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας', ], + 'youtube' => [ + 'settings' => 'Ρυθμίσεις YouTube', + 'posting_to' => 'Δημοσίευση σε', + 'description' => 'Περιγραφή', + 'description_placeholder' => 'Περιγραφή βίντεο', + 'description_max' => 'Η περιγραφή YouTube δεν πρέπει να υπερβαίνει τα 5.000 byte UTF-8.', + 'description_invalid' => 'Η περιγραφή YouTube πρέπει να είναι έγκυρο κείμενο χωρίς < ή >.', + 'description_bytes' => ':used / :limit byte', + ], 'pinterest' => [ 'settings' => 'Ρυθμίσεις Pinterest', 'posting_to' => 'Δημοσίευση σε', diff --git a/lang/en/posts.php b/lang/en/posts.php index 12a91b91d..001fc3cae 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Document title', 'document_title_placeholder' => 'Shown on your PDF document post', ], + 'youtube' => [ + 'settings' => 'YouTube settings', + 'posting_to' => 'Posting to', + 'description' => 'Description', + 'description_placeholder' => 'Video description', + 'description_max' => 'The YouTube description must not exceed 5,000 UTF-8 bytes.', + 'description_invalid' => 'The YouTube description must be valid text without < or >.', + 'description_bytes' => ':used / :limit bytes', + ], 'pinterest' => [ 'settings' => 'Pinterest Settings', 'posting_to' => 'Posting to', diff --git a/lang/es/posts.php b/lang/es/posts.php index ce9c2e387..e571391f7 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Título del documento', 'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF', ], + 'youtube' => [ + 'settings' => 'Configuración de YouTube', + 'posting_to' => 'Publicando en', + 'description' => 'Descripción', + 'description_placeholder' => 'Descripción del vídeo', + 'description_max' => 'La descripción de YouTube no puede superar los 5.000 bytes UTF-8.', + 'description_invalid' => 'La descripción de YouTube debe ser texto válido sin < ni >.', + 'description_bytes' => ':used / :limit bytes', + ], 'pinterest' => [ 'settings' => 'Configuración de Pinterest', 'posting_to' => 'Publicando en', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 59b72e357..d39f22c1d 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Titre du document', 'document_title_placeholder' => 'Affiché sur votre publication de document PDF', ], + 'youtube' => [ + 'settings' => 'Paramètres YouTube', + 'posting_to' => 'Publication sur', + 'description' => 'Description', + 'description_placeholder' => 'Description de la vidéo', + 'description_max' => 'La description YouTube ne doit pas dépasser 5 000 octets UTF-8.', + 'description_invalid' => 'La description YouTube doit être un texte valide sans < ni >.', + 'description_bytes' => ':used / :limit octets', + ], 'pinterest' => [ 'settings' => 'Paramètres Pinterest', 'posting_to' => 'Publier sur', diff --git a/lang/it/posts.php b/lang/it/posts.php index c03e75e11..8e837339e 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Titolo del documento', 'document_title_placeholder' => 'Mostrato sul tuo post con documento PDF', ], + 'youtube' => [ + 'settings' => 'Impostazioni YouTube', + 'posting_to' => 'Pubblicazione su', + 'description' => 'Descrizione', + 'description_placeholder' => 'Descrizione del video', + 'description_max' => 'La descrizione YouTube non deve superare 5.000 byte UTF-8.', + 'description_invalid' => 'La descrizione YouTube deve essere testo valido senza < o >.', + 'description_bytes' => ':used / :limit byte', + ], 'pinterest' => [ 'settings' => 'Impostazioni Pinterest', 'posting_to' => 'Pubblicazione su', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index e066e8686..286b7d66d 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'ドキュメントタイトル', 'document_title_placeholder' => 'PDF ドキュメント投稿に表示されます', ], + 'youtube' => [ + 'settings' => 'YouTube設定', + 'posting_to' => '投稿先', + 'description' => '説明', + 'description_placeholder' => '動画の説明', + 'description_max' => 'YouTubeの説明はUTF-8で5,000バイト以内にしてください。', + 'description_invalid' => 'YouTubeの説明には < や > を含まない有効なテキストを入力してください。', + 'description_bytes' => ':used / :limit バイト', + ], 'pinterest' => [ 'settings' => 'Pinterest 設定', 'posting_to' => '投稿先', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 13bfc4421..f47741e10 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -142,6 +142,15 @@ 'document_title' => '문서 제목', 'document_title_placeholder' => 'PDF 문서 게시물에 표시됩니다', ], + 'youtube' => [ + 'settings' => 'YouTube 설정', + 'posting_to' => '게시 대상', + 'description' => '설명', + 'description_placeholder' => '동영상 설명', + 'description_max' => 'YouTube 설명은 UTF-8 기준 5,000바이트를 초과할 수 없습니다.', + 'description_invalid' => 'YouTube 설명은 < 또는 >가 없는 유효한 텍스트여야 합니다.', + 'description_bytes' => ':used / :limit 바이트', + ], 'pinterest' => [ 'settings' => 'Pinterest 설정', 'posting_to' => '게시 대상', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 3338a4118..edaac56bb 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Documenttitel', 'document_title_placeholder' => 'Getoond op je PDF-documentpost', ], + 'youtube' => [ + 'settings' => 'YouTube-instellingen', + 'posting_to' => 'Publiceren op', + 'description' => 'Beschrijving', + 'description_placeholder' => 'Videobeschrijving', + 'description_max' => 'De YouTube-beschrijving mag niet langer zijn dan 5.000 UTF-8-bytes.', + 'description_invalid' => 'De YouTube-beschrijving moet geldige tekst zonder < of > zijn.', + 'description_bytes' => ':used / :limit bytes', + ], 'pinterest' => [ 'settings' => 'Pinterest-instellingen', 'posting_to' => 'Posten naar', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 8deb478fd..ed7aea379 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Tytuł dokumentu', 'document_title_placeholder' => 'Wyświetlany w Twoim poście z dokumentem PDF', ], + 'youtube' => [ + 'settings' => 'Ustawienia YouTube', + 'posting_to' => 'Publikowanie na', + 'description' => 'Opis', + 'description_placeholder' => 'Opis filmu', + 'description_max' => 'Opis YouTube nie może przekraczać 5000 bajtów UTF-8.', + 'description_invalid' => 'Opis YouTube musi być poprawnym tekstem bez < ani >.', + 'description_bytes' => ':used / :limit bajtów', + ], 'pinterest' => [ 'settings' => 'Ustawienia Pinterest', 'posting_to' => 'Publikowanie na', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c6d04ee18..c4cc2b8e9 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Título do documento', 'document_title_placeholder' => 'Aparece no seu post de documento PDF', ], + 'youtube' => [ + 'settings' => 'Configurações do YouTube', + 'posting_to' => 'Publicando em', + 'description' => 'Descrição', + 'description_placeholder' => 'Descrição do vídeo', + 'description_max' => 'A descrição do YouTube não pode ultrapassar 5.000 bytes em UTF-8.', + 'description_invalid' => 'A descrição do YouTube deve ser um texto válido sem < ou >.', + 'description_bytes' => ':used / :limit bytes', + ], 'pinterest' => [ 'settings' => 'Configurações do Pinterest', 'posting_to' => 'Publicando em', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 4d557f7f4..69a47f552 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Название документа', 'document_title_placeholder' => 'Отображается в вашем посте с PDF-документом', ], + 'youtube' => [ + 'settings' => 'Настройки YouTube', + 'posting_to' => 'Публикация в', + 'description' => 'Описание', + 'description_placeholder' => 'Описание видео', + 'description_max' => 'Описание YouTube не должно превышать 5000 байт UTF-8.', + 'description_invalid' => 'Описание YouTube должно быть корректным текстом без < или >.', + 'description_bytes' => ':used / :limit байт', + ], 'pinterest' => [ 'settings' => 'Настройки Pinterest', 'posting_to' => 'Публикация в', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index b47e9c531..961087573 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -144,6 +144,15 @@ 'document_title' => 'Belge başlığı', 'document_title_placeholder' => 'PDF belge gönderinizde gösterilir', ], + 'youtube' => [ + 'settings' => 'YouTube ayarları', + 'posting_to' => 'Yayınlanacak hesap', + 'description' => 'Açıklama', + 'description_placeholder' => 'Video açıklaması', + 'description_max' => 'YouTube açıklaması 5.000 UTF-8 baytını aşmamalıdır.', + 'description_invalid' => 'YouTube açıklaması < veya > içermeyen geçerli bir metin olmalıdır.', + 'description_bytes' => ':used / :limit bayt', + ], 'pinterest' => [ 'settings' => 'Pinterest Ayarları', 'posting_to' => 'Şuraya paylaşılıyor', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 5fc6a6cd2..6d3f80981 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -142,6 +142,15 @@ 'document_title' => 'Назва документа', 'document_title_placeholder' => 'Відображається у вашому пості з PDF-документом', ], + 'youtube' => [ + 'settings' => 'Налаштування YouTube', + 'posting_to' => 'Публікація в', + 'description' => 'Опис', + 'description_placeholder' => 'Опис відео', + 'description_max' => 'Опис YouTube не повинен перевищувати 5000 байтів UTF-8.', + 'description_invalid' => 'Опис YouTube має бути коректним текстом без < або >.', + 'description_bytes' => ':used / :limit байтів', + ], 'pinterest' => [ 'settings' => 'Налаштування Pinterest', 'posting_to' => 'Публікація в', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 271cfeff9..fa1483ab9 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -142,6 +142,15 @@ 'document_title' => '文档标题', 'document_title_placeholder' => '显示在你的 PDF 文档帖子上', ], + 'youtube' => [ + 'settings' => 'YouTube 设置', + 'posting_to' => '发布到', + 'description' => '描述', + 'description_placeholder' => '视频描述', + 'description_max' => 'YouTube 描述不得超过 5,000 个 UTF-8 字节。', + 'description_invalid' => 'YouTube 描述必须是有效文本,且不含 < 或 >。', + 'description_bytes' => ':used / :limit 字节', + ], 'pinterest' => [ 'settings' => 'Pinterest 设置', 'posting_to' => '发布到', diff --git a/tests/Unit/PostPlatformMetaRulesTest.php b/tests/Unit/PostPlatformMetaRulesTest.php index 449eaf630..365a9929b 100644 --- a/tests/Unit/PostPlatformMetaRulesTest.php +++ b/tests/Unit/PostPlatformMetaRulesTest.php @@ -21,12 +21,26 @@ test('custom meta attributes only rename pinterest title and link', function () { expect(PostPlatformMetaRules::attributes())->toBe([ 'platforms.*.meta.title' => __('posts.form.pinterest.title'), + 'platforms.*.meta.description' => __('posts.form.youtube.description'), 'platforms.*.meta.link' => __('posts.form.pinterest.link'), 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), ]); }); +test('shared description validation rejects multibyte overflow', function () { + $validator = Validator::make(['platforms' => [['meta' => ['description' => str_repeat('é', 2501)]]]], PostPlatformMetaRules::rules()); + expect($validator->fails())->toBeTrue() + ->and($validator->errors()->has('platforms.0.meta.description'))->toBeTrue(); +}); + +test('stored youtube description is checked without requiring it for other networks', function () { + expect(PostPlatformMetaRules::requiredMetaViolation(Platform::YouTube, ['description' => str_repeat('a', 5001)])) + ->toBe(['description', __('posts.form.youtube.description_max')]) + ->and(PostPlatformMetaRules::requiredMetaViolation(Platform::YouTube, []))->toBeNull() + ->and(PostPlatformMetaRules::requiredMetaViolation(Platform::Facebook, []))->toBeNull(); +}); + test('shared meta rules still include non-pinterest platform fields', function () { $rules = PostPlatformMetaRules::rules(); diff --git a/tests/Unit/Support/YouTubeDescriptionTest.php b/tests/Unit/Support/YouTubeDescriptionTest.php new file mode 100644 index 000000000..a6052ce40 --- /dev/null +++ b/tests/Unit/Support/YouTubeDescriptionTest.php @@ -0,0 +1,32 @@ +toBe($key); +})->with([ + 'absent' => [null, null], + 'empty' => ['', null], + 'ascii boundary' => [str_repeat('a', 5000), null], + 'ascii overflow' => [str_repeat('a', 5001), 'posts.form.youtube.description_max'], + 'accent boundary' => [str_repeat('é', 2500), null], + 'accent overflow' => [str_repeat('é', 2500).'a', 'posts.form.youtube.description_max'], + 'emoji boundary' => [str_repeat('😀', 1250), null], + 'emoji overflow' => [str_repeat('😀', 1250).'a', 'posts.form.youtube.description_max'], + 'multiline url' => ["Line one\n\nhttps://example.com\n#video", null], + 'opening bracket' => ['a < b', 'posts.form.youtube.description_invalid'], + 'closing bracket' => ['a > b', 'posts.form.youtube.description_invalid'], + 'invalid utf8' => ["\xC3\x28", 'posts.form.youtube.description_invalid'], + 'non string' => [['text'], 'posts.form.youtube.description_invalid'], +]); + +test('youtube description resolves legacy and cleared metadata', function (mixed $description, string $expected) { + expect(YouTubeDescription::resolve(['description' => $description], 'Title'))->toBe($expected); +})->with([ + [null, 'Title'], ['', 'Title'], [" \n ", 'Title'], [['invalid'], 'Title'], ['Custom', 'Custom'], +]); + +test('youtube description resolves absent metadata and content', function () { + expect(YouTubeDescription::resolve(null, 'Title'))->toBe('Title') + ->and(YouTubeDescription::resolve([], null))->toBe(''); +}); From 2fc70edbaaecbb9167987c0b6483a36e7c5bde76 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 27 Sep 2026 10:26:41 -0300 Subject: [PATCH 02/21] feat: expose YouTube descriptions through API and MCP --- .../Requests/Api/Post/UpdatePostRequest.php | 9 +++ .../Requests/App/Post/UpdatePostRequest.php | 9 +++ app/Mcp/Tools/Post/CreatePostTool.php | 2 +- app/Mcp/Tools/Post/UpdatePostTool.php | 10 +++- app/Support/PostPlatformMetaRules.php | 28 +++++++++ tests/Feature/Api/PostApiPlatformMetaTest.php | 56 ++++++++++++++++++ .../Feature/Mcp/PostPlatformMetaToolTest.php | 55 ++++++++++++++++++ tests/Feature/UpdatePostRequestTest.php | 57 +++++++++++++++++++ 8 files changed, 224 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 2796b80f4..9603ffc53 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -91,6 +91,15 @@ public function withValidator(Validator $validator): void $platformsById = $this->resolveSelectedPlatforms(); + foreach (PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate( + $this->route('post'), + $this->has('platforms') ? (array) $this->input('platforms', []) : null, + ) as $key => $message) { + if (! $validator->errors()->has($key)) { + $validator->errors()->add($key, $message); + } + } + PostPlatformMetaRules::addRequiredOnPublishErrors( $validator, $this->input('platforms', []), diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 83647284d..8826b5309 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -92,6 +92,15 @@ public function withValidator(Validator $validator): void ->whereIn('id', $ids) ->pluck('platform', 'id'); + foreach (PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate( + $this->route('post'), + $this->has('platforms') ? (array) $this->input('platforms', []) : null, + ) as $key => $message) { + if (! $validator->errors()->has($key)) { + $validator->errors()->add($key, $message); + } + } + PostPlatformMetaRules::addRequiredOnPublishErrors( $validator, $platforms, diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 0de4bf31a..9c49e4f15 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube Shorts: description (optional plain UTF-8 text, at most 5000 bytes, without < or >). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description.'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index a8b975a43..8eb9bc218 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -78,6 +78,14 @@ public function handle(Request $request): Response|ResponseFactory // media, so a misconfigured post can't be scheduled even without resubmitting // content_type. Mirrors the public API's withValidator check. if ($status === Status::Scheduled->value) { + $descriptionErrors = PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate( + $post, + array_key_exists('platforms', $validated) ? $validated['platforms'] : null, + ); + if ($descriptionErrors !== []) { + throw ValidationException::withMessages($descriptionErrors); + } + $errors = ContentTypeCompatibleWithMedia::errorsFor( ContentTypeCompatibleWithMedia::entriesForUpdate($post, data_get($validated, 'platforms')), (array) ($post->media ?? []), @@ -119,7 +127,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta. YouTube Shorts: description (optional plain UTF-8 text, at most 5000 bytes, without < or >). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 099116015..0607bac05 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -10,6 +10,7 @@ use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; +use App\Models\PostPlatform; use App\Rules\ValidYouTubeDescription; use Illuminate\Support\Str; use Illuminate\Validation\Rule; @@ -171,6 +172,33 @@ public static function assertStoredPostPublishable(Post $post): void } } + /** + * @param array|null $requestPlatforms + * @return array + */ + public static function youtubeDescriptionErrorsForUpdate(Post $post, ?array $requestPlatforms): array + { + $stored = $post->postPlatforms()->get()->keyBy('id'); + $entries = $requestPlatforms ?? $stored->filter(fn (PostPlatform $row): bool => $row->enabled) + ->values()->map(fn (PostPlatform $row): array => ['id' => $row->id])->all(); + $errors = []; + + foreach ($entries as $index => $entry) { + $row = $stored->get(data_get($entry, 'id')); + if ($row === null || $row->platform !== Platform::YouTube) { + continue; + } + $patch = data_get($entry, 'meta'); + $meta = is_array($patch) ? array_merge($row->meta ?? [], $patch) : ($row->meta ?? []); + $key = YouTubeDescription::violation(data_get($meta, 'description')); + if ($key !== null) { + $errors["platforms.{$index}.meta.description"] = __($key); + } + } + + return $errors; + } + /** * The missing required meta field for a platform about to publish, or null when * nothing is missing. Single source of "what each platform requires to publish". diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index 2c92f8144..9b970baca 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -15,6 +15,62 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; +it('youtube description survives create read update omission and clear', function (string $description) { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + $response = $this->withHeaders($this->headers)->postJson(route('api.posts.store'), [ + 'content' => 'Short title', + 'platforms' => [['social_account_id' => $account->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => ['description' => $description]]], + ])->assertCreated(); + $post = Post::findOrFail($response->json('id')); + $platform = $post->postPlatforms()->where('social_account_id', $account->id)->sole(); + expect($platform->meta)->toEqual(['description' => $description]); + $read = $this->withHeaders($this->headers)->getJson(route('api.posts.show', $post))->assertOk(); + expect(collect($read->json('platforms'))->firstWhere('id', $platform->id)['meta']['description'])->toBe($description); + $this->withHeaders($this->headers)->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Draft->value, 'platforms' => [['id' => $platform->id, 'meta' => ['description' => str_repeat('é', 2501)]]], + ])->assertUnprocessable()->assertJsonValidationErrors('platforms.0.meta.description'); + expect(data_get($platform->fresh()->meta, 'description'))->toBe($description); + $this->withHeaders($this->headers)->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Draft->value, 'platforms' => [['id' => $platform->id]], + ])->assertOk(); + expect(data_get($platform->fresh()->meta, 'description'))->toBe($description); + $this->withHeaders($this->headers)->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Draft->value, 'platforms' => [['id' => $platform->id, 'meta' => ['description' => null]]], + ])->assertOk(); + expect(data_get($platform->fresh()->meta, 'description'))->toBeNull(); +})->with(["Full description\nhttps://example.com\n#video", str_repeat('é', 2500)]); + +it('youtube description rejects invalid API create input', function (string $description) { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + $this->withHeaders($this->headers)->postJson(route('api.posts.store'), [ + 'content' => 'Short title', + 'platforms' => [['social_account_id' => $account->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => ['description' => $description]]], + ])->assertUnprocessable()->assertJsonValidationErrors('platforms.0.meta.description'); +})->with([str_repeat('é', 2501), 'a < b', 'a > b']); + +it('youtube description checks effective API metadata on schedule', function (string $patch, bool $allowed) { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'content' => 'Short title', 'status' => PostStatus::Draft, 'media' => [['id' => 'video-1', 'type' => 'video', 'path' => 'medias/video.mp4', 'url' => 'https://example.com/video.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'video.mp4']]]); + $platform = PostPlatform::factory()->youtube()->create(['post_id' => $post->id, 'social_account_id' => $account->id, 'enabled' => true, 'meta' => ['description' => str_repeat('a', 5001)]]); + $data = ['status' => PostStatus::Scheduled->value, 'scheduled_at' => now()->addHour()->toIso8601String()]; + if ($patch !== 'omit') { + $data['platforms'] = [['id' => $platform->id]]; + if ($patch !== 'row') { + $data['platforms'][0]['meta'] = ['description' => $patch === 'clear' ? null : 'Valid description']; + } + } + Queue::fake(); + $response = $this->withHeaders($this->headers)->putJson(route('api.posts.update', $post), $data); + if ($allowed) { + $response->assertOk(); + expect($post->fresh()->status)->toBe(PostStatus::Scheduled); + } else { + $response->assertUnprocessable()->assertJsonValidationErrors('platforms.0.meta.description'); + expect($post->fresh()->status)->toBe(PostStatus::Draft); + Queue::assertNotPushed(PublishPost::class); + } +})->with([['omit', false], ['row', false], ['replace', true], ['clear', true]]); + beforeEach(function () { $result = createApiTestToken(); $this->user = $result['user']; diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 304085886..204072e7f 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -12,6 +12,7 @@ use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\AttachMediaFromUploadTool; use App\Mcp\Tools\Post\CreatePostTool; +use App\Mcp\Tools\Post\GetPostTool; use App\Mcp\Tools\Post\PublishPostTool; use App\Mcp\Tools\Post\UpdatePostTool; use App\Models\Post; @@ -21,6 +22,60 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; +use Illuminate\Testing\Fluent\AssertableJson; + +test('youtube description persists reads retains and clears in MCP', function () { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + TryPostServer::actingAs($this->user)->tool(CreatePostTool::class, [ + 'content' => 'Short title', 'platforms' => [['social_account_id' => $account->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => ['description' => "Full text\nhttps://example.com"]]], + ])->assertOk(); + $platform = PostPlatform::where('social_account_id', $account->id)->sole(); + TryPostServer::actingAs($this->user)->tool(GetPostTool::class, ['post_id' => $platform->post_id]) + ->assertOk()->assertStructuredContent(function (AssertableJson $json) use ($platform) { + $json->etc(); + expect(collect($json->toArray()['platforms'])->firstWhere('id', $platform->id)['meta']['description'])->toBe("Full text\nhttps://example.com"); + }); + TryPostServer::actingAs($this->user)->tool(UpdatePostTool::class, [ + 'post_id' => $platform->post_id, 'platforms' => [['id' => $platform->id, 'meta' => ['description' => str_repeat('é', 2501)]]], + ])->assertHasErrors([__('posts.form.youtube.description_max')]); + TryPostServer::actingAs($this->user)->tool(UpdatePostTool::class, ['post_id' => $platform->post_id, 'platforms' => [['id' => $platform->id]]])->assertOk(); + expect(data_get($platform->fresh()->meta, 'description'))->toBe("Full text\nhttps://example.com"); + TryPostServer::actingAs($this->user)->tool(UpdatePostTool::class, [ + 'post_id' => $platform->post_id, 'platforms' => [['id' => $platform->id, 'meta' => ['description' => null]]], + ])->assertOk(); + expect(data_get($platform->fresh()->meta, 'description'))->toBeNull(); +}); + +test('youtube description rejects invalid MCP create input', function (string $description) { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + TryPostServer::actingAs($this->user)->tool(CreatePostTool::class, [ + 'content' => 'Short title', 'platforms' => [['social_account_id' => $account->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => ['description' => $description]]], + ])->assertHasErrors(); +})->with([str_repeat('é', 2501), 'a < b', 'a > b']); + +test('youtube description checks effective MCP metadata on schedule and publish', function (string $patch, bool $allowed) { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'content' => 'Short title', 'status' => PostStatus::Draft, 'media' => [['id' => 'video-1', 'type' => 'video', 'path' => 'medias/video.mp4', 'url' => 'https://example.com/video.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'video.mp4']]]); + $platform = PostPlatform::factory()->youtube()->create(['post_id' => $post->id, 'social_account_id' => $account->id, 'enabled' => true, 'meta' => ['description' => str_repeat('a', 5001)]]); + Queue::fake(); + TryPostServer::actingAs($this->user)->tool(PublishPostTool::class, ['post_id' => $post->id])->assertHasErrors([__('posts.form.youtube.description_max')]); + Queue::assertNotPushed(PublishPost::class); + $data = ['post_id' => $post->id, 'status' => PostStatus::Scheduled->value, 'scheduled_at' => now()->addHour()->toIso8601String()]; + if ($patch !== 'omit') { + $data['platforms'] = [['id' => $platform->id]]; + if ($patch !== 'row') { + $data['platforms'][0]['meta'] = ['description' => $patch === 'clear' ? null : 'Valid description']; + } + } + $response = TryPostServer::actingAs($this->user)->tool(UpdatePostTool::class, $data); + if ($allowed) { + $response->assertOk(); + expect($post->fresh()->status)->toBe(PostStatus::Scheduled); + } else { + $response->assertHasErrors([__('posts.form.youtube.description_max')]); + expect($post->fresh()->status)->toBe(PostStatus::Draft); + } +})->with([['omit', false], ['row', false], ['replace', true], ['clear', true]]); beforeEach(function () { $this->user = User::factory()->create(); diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php index e83d96505..feecc12cb 100644 --- a/tests/Feature/UpdatePostRequestTest.php +++ b/tests/Feature/UpdatePostRequestTest.php @@ -13,6 +13,63 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Support\PostPlatformMetaRules; + +test('youtube description checks effective web metadata on schedule', function (string $patch, bool $allowed) { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + $this->post->update(['content' => 'Short title', 'status' => Status::Draft, 'media' => $this->mediaPayload]); + $this->postPlatform->update(['enabled' => false]); + $platform = PostPlatform::factory()->youtube()->create(['post_id' => $this->post->id, 'social_account_id' => $account->id, 'enabled' => true, 'meta' => ['description' => str_repeat('a', 5001)]]); + $data = ['status' => Status::Scheduled->value, 'scheduled_at' => now()->addHour()->toIso8601String(), 'media' => $this->mediaPayload]; + if ($patch !== 'omit') { + $data['platforms'] = [['id' => $platform->id, 'content_type' => ContentType::YouTubeShort->value]]; + if ($patch !== 'row') { + $data['platforms'][0]['meta'] = ['description' => $patch === 'clear' ? null : 'Valid description']; + } + } + $response = $this->actingAs($this->user)->put(route('app.posts.update', $this->post), $data); + if ($allowed) { + $response->assertSessionHasNoErrors(); + expect($this->post->fresh()->status)->toBe(Status::Scheduled); + } else { + $response->assertSessionHasErrors('platforms.0.meta.description'); + expect($this->post->fresh()->status)->toBe(Status::Draft); + } +})->with([['omit', false], ['row', false], ['replace', true], ['clear', true]]); + +test('youtube description reports and persists independent selected channel values', function () { + $platforms = collect(range(1, 2))->map(function () { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + + return PostPlatform::factory()->youtube()->create(['post_id' => $this->post->id, 'social_account_id' => $account->id, 'meta' => []]); + }); + $this->actingAs($this->user)->put(route('app.posts.update', $this->post), [ + 'status' => Status::Draft->value, 'platforms' => [ + ['id' => $platforms[0]->id, 'meta' => ['description' => 'First channel']], + ['id' => $platforms[1]->id, 'meta' => ['description' => str_repeat('é', 2501)]], + ], + ])->assertSessionHasErrors('platforms.1.meta.description')->assertSessionDoesntHaveErrors('platforms.0.meta.description'); + $this->actingAs($this->user)->put(route('app.posts.update', $this->post), [ + 'status' => Status::Draft->value, 'content' => 'Short title', 'platforms' => [ + ['id' => $platforms[0]->id, 'meta' => ['description' => 'First channel']], + ['id' => $platforms[1]->id, 'meta' => ['description' => str_repeat('é', 2500)]], + ], + ])->assertSessionHasNoErrors(); + expect(data_get($platforms[0]->fresh()->meta, 'description'))->toBe('First channel') + ->and(data_get($platforms[1]->fresh()->meta, 'description'))->toBe(str_repeat('é', 2500)) + ->and($this->post->fresh()->content)->toBe('Short title'); +}); + +test('youtube description effective validation ignores disabled deselected and foreign rows', function () { + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $this->workspace->id]); + $platform = PostPlatform::factory()->youtube()->create(['post_id' => $this->post->id, 'social_account_id' => $account->id, 'enabled' => false, 'meta' => ['description' => str_repeat('a', 5001)]]); + expect(PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate($this->post, null))->toBe([]) + ->and(PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate($this->post, []))->toBe([]); + $other = Post::factory()->create(); + expect(PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate($other, [['id' => $platform->id]]))->toBe([]) + ->and(PostPlatformMetaRules::youtubeDescriptionErrorsForUpdate($this->post, [['id' => $this->postPlatform->id], ['id' => $platform->id]])) + ->toBe(['platforms.1.meta.description' => __('posts.form.youtube.description_max')]); +}); beforeEach(function () { $this->user = User::factory()->create(); From ebed2963a5e20cba7f09bc9bc685c8b5aa47170b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 27 Sep 2026 10:28:17 -0300 Subject: [PATCH 03/21] feat: publish and preview separate YouTube descriptions --- app/Enums/SocialAccount/Platform.php | 8 +-- app/Services/Post/PostPreviewer.php | 16 ++++- app/Services/Social/YouTubePublisher.php | 30 ++++++--- .../Services/Social/YouTubePublisherTest.php | 64 +++++++++++++++++++ .../Feature/YouTubeDescriptionPreviewTest.php | 25 ++++++++ 5 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/YouTubeDescriptionPreviewTest.php diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index c87258188..eb642fe33 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -164,10 +164,10 @@ public function supportsAltText(): bool * - LinkedIn UGC: 3000 (`commentary` field) * - X standard tweet: 280 (X Premium accepts 25K — ignored, conservative) * - TikTok caption: 2200 - * - YouTube Shorts: title=100, description=5000. We feed `content` to both - * (publisher derives title from the first line via `buildTitle`), and - * Shorts UX only shows ~100 chars before "more" — capping at 100 keeps - * posts appropriate for the format. + * - YouTube Shorts: content supplies the title, capped at 100 characters + * (publisher derives it from the first line via `buildTitle`). Optional + * meta.description is separate plain text, capped at 5000 UTF-8 bytes; + * absent descriptions fall back to content. * - Facebook text status: 10000 (API allows 63206; we cap below * that — 63k-char posts are unrealistic and emoji-heavy content * risks overflowing the TEXT column's 65535-byte ceiling) diff --git a/app/Services/Post/PostPreviewer.php b/app/Services/Post/PostPreviewer.php index ca8343ea2..575b8de8e 100644 --- a/app/Services/Post/PostPreviewer.php +++ b/app/Services/Post/PostPreviewer.php @@ -4,9 +4,11 @@ namespace App\Services\Post; +use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Models\PostPlatform; use App\Services\Social\ContentSanitizer; +use App\Support\YouTubeDescription; use Illuminate\Support\Collection; /** @@ -31,7 +33,9 @@ public function __construct(private readonly ContentSanitizer $sanitizer) {} * sanitized_content: string, * sanitized_length: int, * max_content_length: int, - * truncated: bool + * truncated: bool, + * description?: string, + * description_length_bytes?: int * }> * } */ @@ -59,7 +63,7 @@ private function platformPreviews(Post $post, string $original): Collection $platform = $pp->socialAccount?->platform ?? $pp->platform; $sanitized = $this->sanitizer->sanitize($original, $platform); - return [ + $preview = [ 'post_platform_id' => $pp->id, 'platform' => $platform->value, 'content_type' => $pp->content_type?->value, @@ -68,6 +72,14 @@ private function platformPreviews(Post $post, string $original): Collection 'max_content_length' => $platform->maxContentLength(), 'truncated' => mb_strlen($sanitized) < mb_strlen($original), ]; + + if ($platform === Platform::YouTube) { + $description = YouTubeDescription::resolve($pp->meta, $sanitized); + $preview['description'] = $description; + $preview['description_length_bytes'] = strlen($description); + } + + return $preview; }); } } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index a12b60a36..53a1f6624 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -9,6 +9,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Support\YouTubeDescription; use Google\Client as GoogleClient; use Google\Service\Exception; use Google\Service\YouTube; @@ -31,6 +32,15 @@ public function publish(PostPlatform $postPlatform): array $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $key = YouTubeDescription::violation(data_get($postPlatform->meta, 'description')) + ?? YouTubeDescription::violation(YouTubeDescription::resolve($postPlatform->meta, $content)); + if ($key !== null) { + throw new YouTubePublishException( + userMessage: __($key), + category: ErrorCategory::ContentPolicy, + ); + } + $account = $postPlatform->socialAccount; if ($account->needsProactiveTokenRefresh()) { @@ -58,7 +68,7 @@ public function publish(PostPlatform $postPlatform): array return $this->publishShort($postPlatform, $firstMedia, $account, $content); } - private function createGoogleClient(SocialAccount $account): GoogleClient + protected function createGoogleClient(SocialAccount $account): GoogleClient { $client = new GoogleClient; $client->setClientId(config('services.google.client_id')); @@ -92,9 +102,6 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount ); } - $title = $this->buildTitle($content); - $description = $content; - $tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_'); $handle = null; @@ -127,10 +134,7 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $youtube = new YouTube($client); // Build video metadata - $snippet = new VideoSnippet; - $snippet->setTitle($title); - $snippet->setDescription($description); - $snippet->setCategoryId('22'); + $snippet = $this->buildSnippet($postPlatform, $content); $status = new VideoStatus; $status->setPrivacyStatus('public'); @@ -205,6 +209,16 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount } } + private function buildSnippet(PostPlatform $postPlatform, string $content): VideoSnippet + { + $snippet = new VideoSnippet; + $snippet->setTitle($this->buildTitle($content)); + $snippet->setDescription(YouTubeDescription::resolve($postPlatform->meta, $content)); + $snippet->setCategoryId('22'); + + return $snippet; + } + private function buildTitle(string $content): string { $maxLength = 100; diff --git a/tests/Feature/Services/Social/YouTubePublisherTest.php b/tests/Feature/Services/Social/YouTubePublisherTest.php index eb7a072b2..bbb95ffb7 100644 --- a/tests/Feature/Services/Social/YouTubePublisherTest.php +++ b/tests/Feature/Services/Social/YouTubePublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\YouTubePublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; @@ -11,8 +12,70 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Social\YouTubePublisher; +use Google\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; use Illuminate\Support\Facades\Http; +test('youtube description builds independent upload metadata', function (?string $description, string $expected) { + $this->postPlatform->update(['meta' => ['description' => $description]]); + $snippet = (new ReflectionMethod(YouTubePublisher::class, 'buildSnippet'))->invoke($this->publisher, $this->postPlatform, 'Short title'); + expect($snippet->getTitle())->toBe('Short title #Shorts') + ->and($snippet->getDescription())->toBe($expected) + ->and($snippet->getCategoryId())->toBe('22'); +})->with([ + ["Full text\nhttps://example.com", "Full text\nhttps://example.com"], + [str_repeat('é', 2500), str_repeat('é', 2500)], + [null, 'Short title'], ['', 'Short title'], [" \n ", 'Short title'], +]); + +test('youtube description rejects stored invalid data before network work', function (mixed $description, string $key) { + Http::fake(); + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + $this->postPlatform->update(['meta' => ['description' => $description]]); + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(YouTubePublishException::class, __($key)); + Http::assertNothingSent(); +})->with([ + [str_repeat('é', 2501), 'posts.form.youtube.description_max'], + ['a < b', 'posts.form.youtube.description_invalid'], + ['a > b', 'posts.form.youtube.description_invalid'], + [['invalid'], 'posts.form.youtube.description_invalid'], +]); + +test('youtube description reaches the resumable upload request', function () { + $this->post->update(['content' => 'Short title', 'media' => [[ + 'id' => 'video-1', 'type' => 'video', 'path' => 'medias/video.mp4', 'url' => 'https://example.com/video.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'video.mp4', + ]]]); + $this->postPlatform->update(['meta' => ['description' => "Full text\nhttps://example.com"]]); + Http::fake(['https://example.com/video.mp4' => Http::response(str_repeat('x', 2048), 200)]); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, ['Location' => 'https://upload.example.test/session']), + new Response(200, ['Content-Type' => 'application/json'], '{"id":"short-id"}'), + ])); + $handler->push(Middleware::history($history)); + $client = new Client; + $client->setAccessToken(['access_token' => 'test-token', 'created' => time(), 'expires_in' => 3600]); + $client->setHttpClient(new GuzzleHttp\Client(['handler' => $handler])); + $publisher = new class($client) extends YouTubePublisher + { + public function __construct(private Client $client) {} + + protected function createGoogleClient(SocialAccount $account): Client + { + return $this->client; + } + }; + $result = $publisher->publish($this->postPlatform->fresh()); + $payload = json_decode((string) $history[0]['request']->getBody(), true, flags: JSON_THROW_ON_ERROR); + expect($payload['snippet']['title'])->toBe('Short title #Shorts') + ->and($payload['snippet']['description'])->toBe("Full text\nhttps://example.com") + ->and($result['id'])->toBe('short-id'); +}); + beforeEach(function () { $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); @@ -156,6 +219,7 @@ // expiration handling. Full integration tests should cover the 401 error scenario. test('youtube publisher throws exception with null content', function () { + $this->postPlatform->update(['meta' => ['description' => 'A valid description is not a title']]); $this->post->update([ 'content' => null, 'media' => [ diff --git a/tests/Feature/YouTubeDescriptionPreviewTest.php b/tests/Feature/YouTubeDescriptionPreviewTest.php new file mode 100644 index 000000000..9c69c5a8b --- /dev/null +++ b/tests/Feature/YouTubeDescriptionPreviewTest.php @@ -0,0 +1,25 @@ +create(['content' => 'Title']); + $account = SocialAccount::factory()->youtube()->create(['workspace_id' => $post->workspace_id]); + PostPlatform::factory()->youtube()->create(['post_id' => $post->id, 'social_account_id' => $account->id, 'enabled' => true, 'meta' => ['description' => $description]]); + $preview = app(PostPreviewer::class)->forPost($post->fresh())['platforms'][0]; + expect($preview['description'])->toBe($expected) + ->and($preview['description_length_bytes'])->toBe(strlen($expected)) + ->and($preview['sanitized_content'])->toBe('Title') + ->and($preview['sanitized_length'])->toBe(5) + ->and($preview['max_content_length'])->toBe(100); +})->with([['ação', 'ação'], [null, 'Title'], ['', 'Title'], [['invalid'], 'Title']]); + +test('other networks do not gain youtube description preview fields', function () { + $post = Post::factory()->create(['content' => 'Title']); + PostPlatform::factory()->create(['post_id' => $post->id, 'enabled' => true]); + $preview = app(PostPreviewer::class)->forPost($post->fresh())['platforms'][0]; + expect($preview)->not->toHaveKeys(['description', 'description_length_bytes']); +}); From 46d54b5ea876f42cc3909b36bb8d4f96057ffb39 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 27 Sep 2026 10:35:29 -0300 Subject: [PATCH 04/21] feat: add YouTube description settings to the editor --- .../js/components/ChannelConfigurator.vue | 7 + .../js/components/posts/editor/PreviewTab.vue | 1 + .../posts/editor/YouTubeSettings.vue | 164 ++++++++++++++ .../posts/previews/YouTubePreview.vue | 16 +- resources/js/composables/usePostCompliance.ts | 5 + resources/js/lib/youtubeDescription.ts | 25 +++ tests/Browser/YouTubeDescriptionTest.php | 200 ++++++++++++++++++ .../Support/YouTubeDescriptionParityTest.php | 28 +++ 8 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/posts/editor/YouTubeSettings.vue create mode 100644 resources/js/lib/youtubeDescription.ts create mode 100644 tests/Browser/YouTubeDescriptionTest.php create mode 100644 tests/Unit/Support/YouTubeDescriptionParityTest.php diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 69134079a..1d3ce1469 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -9,6 +9,7 @@ import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue'; +import YouTubeSettings from '@/components/posts/editor/YouTubeSettings.vue'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -172,6 +173,12 @@ const settingsProps = (channel: Channel) => ({ :platform="channel.platform" :media="media" /> + { type="button" class="relative cursor-pointer transition-opacity" :class="activeId === pp.id ? 'opacity-100' : 'opacity-40 hover:opacity-70'" + :data-testid="`preview-platform-${pp.id}`" @click="activeId = pp.id" > +import { IconChevronDown, IconChevronUp } from '@tabler/icons-vue'; +import { computed, ref } from 'vue'; + +import InputError from '@/components/InputError.vue'; +import { Avatar } from '@/components/ui/avatar'; +import { Textarea } from '@/components/ui/textarea'; +import { usePageErrors } from '@/composables/usePageErrors'; +import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { + getYouTubeDescriptionIssue, + YOUTUBE_DESCRIPTION_MAX_BYTES, + youtubeDescriptionBytes, +} from '@/lib/youtubeDescription'; +import { Platform } from '@/types/platform'; + +interface SocialAccount { + id: string; + platform: string; + display_name: string; + username: string; + display_label: string; + avatar_url: string | null; +} + +interface Props { + socialAccount: SocialAccount | null; + platformIndex: number; + meta: Record; + disabled?: boolean; + previewOnly?: boolean; +} + +const props = withDefaults(defineProps(), { + disabled: false, + previewOnly: false, +}); +const emit = defineEmits<{ + 'update:meta': [value: Record]; +}>(); +const open = ref(false); +const description = computed({ + get: () => + typeof props.meta.description === 'string' + ? props.meta.description + : '', + set: (value: string) => + emit('update:meta', { + ...props.meta, + description: value.trim() === '' ? null : value, + }), +}); +const usedBytes = computed(() => youtubeDescriptionBytes(description.value)); +const issueKey = computed(() => + getYouTubeDescriptionIssue(props.meta.description), +); +const errors = usePageErrors(); +const descriptionError = computed( + () => errors.value[`platforms.${props.platformIndex}.meta.description`], +); + + +