From 7b986fca9c5145e48254db1b09a26f1d7bf3abae Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 22:01:48 -0300 Subject: [PATCH] Centralize content-type media rules on the ContentType enum. Share the full editor rule set (sizes, durations, accepts, aspect bounds) via Inertia so useMediaRules no longer hardcodes MB/GB math, and expose per-type byte caps on API/MCP content-type listings. Co-authored-by: Cursor --- app/Enums/PostPlatform/ContentType.php | 161 ++++++++++++++- .../Api/PlatformContentTypesResource.php | 3 + .../Tools/Platform/ListContentTypesTool.php | 5 +- resources/js/composables/useMediaRules.ts | 185 ++---------------- resources/js/lib/contentTypeMediaRules.ts | 108 ++++++++-- resources/js/types/index.d.ts | 14 ++ tests/Feature/Api/PlatformApiTest.php | 4 + .../ContentTypeMediaRulesShareTest.php | 3 + tests/Feature/Mcp/PlatformToolTest.php | 4 + tests/Unit/Enums/ContentTypeTest.php | 29 ++- 10 files changed, 317 insertions(+), 199 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 7de5e100..a4e84983 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -207,24 +207,173 @@ public function maxVideoDurationSec(): ?int } /** - * Media-rule fields the Vue editor needs. Shared once via Inertia so the - * frontend does not hardcode the same numbers. + * Minimum attachments required (0 = no floor beyond requiresMedia()). + */ + public function minMediaCount(): int + { + return match ($this) { + self::PinterestCarousel => 2, + self::TikTokPhoto => 1, + default => 0, + }; + } + + /** + * Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG). + */ + public function acceptsGif(): bool + { + return match ($this) { + self::XPost, self::BlueskyPost, self::MastodonPost => true, + default => false, + }; + } + + /** + * Per-type image size cap in bytes. Null when images are not accepted or + * the platform has no tighter editor-side limit than the global media cap. + */ + public function maxImageBytes(): ?int + { + return match ($this) { + self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8), + self::FacebookPost => self::bytesFromMb(4), + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5), + self::PinterestPin, self::PinterestCarousel => self::bytesFromMb(20), + self::XPost => self::bytesFromMb(5), + self::ThreadsPost => self::bytesFromMb(8), + self::MastodonPost => self::bytesFromMb(10), + default => null, + }; + } + + /** + * Per-type video size cap in bytes. Null when videos are not accepted or + * the platform has no tighter editor-side limit. + */ + public function maxVideoBytes(): ?int + { + return match ($this) { + self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100), + self::InstagramReel => self::bytesFromGb(1), + self::FacebookPost => self::bytesFromGb(10), + self::FacebookReel, self::FacebookStory => self::bytesFromGb(1), + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromGb(5), + self::YouTubeShort => self::bytesFromGb(256), + self::PinterestVideoPin => self::bytesFromGb(2), + self::XPost => self::bytesFromMb(512), + self::ThreadsPost => self::bytesFromGb(1), + self::BlueskyPost => self::bytesFromMb(100), + self::MastodonPost => self::bytesFromMb(40), + default => null, + }; + } + + /** + * Per-type PDF size cap in bytes. Null when documents are not accepted. + */ + public function maxDocumentBytes(): ?int + { + return match ($this) { + self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100), + default => null, + }; + } + + /** + * Soft aspect-ratio window used by the Vue cropper / media picker. * - * @return array + * @return array{min: float, max: float}|null + */ + public function aspectRatioBounds(): ?array + { + return match ($this) { + self::InstagramFeed => ['min' => 0.8, 'max' => 1.91], + self::InstagramReel, self::InstagramStory, + self::FacebookReel, self::FacebookStory, + self::YouTubeShort => ['min' => 0.5, 'max' => 0.6], + default => null, + }; + } + + /** + * Whether the editor should auto-fit still images into the story frame. + */ + public function autoFitsImage(): bool + { + return $this === self::InstagramStory; + } + + /** + * Full media-rule payload for the Vue editor (and any other consumer). + * Shared once via Inertia — do not re-hardcode these numbers in JS. + * + * @return array{ + * max_files: int, + * min_files: int|null, + * accept_images: bool, + * accept_videos: bool, + * accept_documents: bool, + * requires_media: bool, + * accepts_gif: bool, + * forbids_mixed_media: bool, + * max_image_bytes: int|null, + * max_video_bytes: int|null, + * max_document_bytes: int|null, + * max_video_duration_sec: int|null, + * aspect_ratio_min: float|null, + * aspect_ratio_max: float|null, + * auto_fits_image: bool + * } + */ + public function mediaRules(): array + { + $bounds = $this->aspectRatioBounds(); + $minFiles = $this->minMediaCount(); + + return [ + 'max_files' => $this->maxMediaCount(), + 'min_files' => $minFiles > 0 ? $minFiles : null, + 'accept_images' => $this->supportsImage(), + 'accept_videos' => $this->supportsVideo(), + 'accept_documents' => $this->supportsDocument(), + 'requires_media' => $this->requiresMedia(), + 'accepts_gif' => $this->acceptsGif(), + 'forbids_mixed_media' => ! $this->supportsMixedMedia(), + 'max_image_bytes' => $this->maxImageBytes(), + 'max_video_bytes' => $this->maxVideoBytes(), + 'max_document_bytes' => $this->maxDocumentBytes(), + 'max_video_duration_sec' => $this->maxVideoDurationSec(), + 'aspect_ratio_min' => $bounds['min'] ?? null, + 'aspect_ratio_max' => $bounds['max'] ?? null, + 'auto_fits_image' => $this->autoFitsImage(), + ]; + } + + /** + * @return array> */ public static function mediaRulesForFrontend(): array { $rules = []; foreach (self::cases() as $type) { - $rules[$type->value] = [ - 'max_video_duration_sec' => $type->maxVideoDurationSec(), - ]; + $rules[$type->value] = $type->mediaRules(); } return $rules; } + private static function bytesFromMb(int $megabytes): int + { + return $megabytes * 1024 * 1024; + } + + private static function bytesFromGb(int $gigabytes): int + { + return $gigabytes * 1024 * 1024 * 1024; + } + public function supportsVideo(): bool { return match ($this) { diff --git a/app/Http/Resources/Api/PlatformContentTypesResource.php b/app/Http/Resources/Api/PlatformContentTypesResource.php index 0d9132cb..428d72e8 100644 --- a/app/Http/Resources/Api/PlatformContentTypesResource.php +++ b/app/Http/Resources/Api/PlatformContentTypesResource.php @@ -43,6 +43,9 @@ public function toArray(Request $request): array 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), 'max_video_duration_sec' => $type->maxVideoDurationSec(), + 'max_image_bytes' => $type->maxImageBytes(), + 'max_video_bytes' => $type->maxVideoBytes(), + 'max_document_bytes' => $type->maxDocumentBytes(), ], array_values(ContentType::forPlatform($platform)), ), diff --git a/app/Mcp/Tools/Platform/ListContentTypesTool.php b/app/Mcp/Tools/Platform/ListContentTypesTool.php index e240f3a2..315bc550 100644 --- a/app/Mcp/Tools/Platform/ListContentTypesTool.php +++ b/app/Mcp/Tools/Platform/ListContentTypesTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] +#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')] class ListContentTypesTool extends Tool { public function handle(Request $request): ResponseFactory @@ -30,6 +30,9 @@ public function handle(Request $request): ResponseFactory 'max_media_count' => $type->maxMediaCount(), 'requires_media' => $type->requiresMedia(), 'max_video_duration_sec' => $type->maxVideoDurationSec(), + 'max_image_bytes' => $type->maxImageBytes(), + 'max_video_bytes' => $type->maxVideoBytes(), + 'max_document_bytes' => $type->maxDocumentBytes(), ], array_values(ContentType::forPlatform($platform)), ); diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 089f71cf..d38b829f 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -1,154 +1,18 @@ import { computed, type Ref, type ComputedRef } from 'vue'; -import { maxVideoDurationSecFor } from '@/lib/contentTypeMediaRules'; +import { + mediaRuleFor, + toMediaRules, + type MediaRules, +} from '@/lib/contentTypeMediaRules'; import { fromMimeType, MediaType } from '@/lib/mediaType'; -export interface MediaRules { - maxFiles: number; - minFiles?: number; - acceptImages: boolean; - acceptVideos: boolean; - acceptDocuments?: boolean; - requiresMedia: boolean; - acceptsGif: boolean; - forbidsMixedMedia?: boolean; - maxImageBytes?: number; - maxVideoBytes?: number; - maxDocumentBytes?: number; - maxVideoDurationSec?: number; - aspectRatioMin?: number; - aspectRatioMax?: number; - autoFitsImage?: boolean; -} - -const MB = 1024 * 1024; -const GB = 1024 * MB; +export type { MediaRules }; /** - * Client-side media constraints. Duration values here are fallbacks when - * Inertia shared contentTypeMediaRules has not synced yet; once synced, - * ContentType::maxVideoDurationSec() wins via withSharedDuration(). + * Fallback only when Inertia once-props have not synced yet (or unknown type). + * Real limits live in App\Enums\PostPlatform\ContentType::mediaRules(). */ -const CONTENT_TYPE_RULES: Record = { - // Instagram - instagram_feed: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - aspectRatioMin: 0.8, aspectRatioMax: 1.91, - }, - instagram_reel: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - instagram_story: { - maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true, - }, - - // Facebook - facebook_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60, - }, - facebook_reel: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 90, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - facebook_story: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 1 * GB, maxVideoDurationSec: 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - - // LinkedIn — one type per account kind; the publish format (single image, - // multi-image, video, or PDF document) is inferred from the attached media. - // Images (up to 10) XOR one video XOR one PDF, never mixed. - linkedin_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, - requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, - }, - linkedin_page_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true, - requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, - }, - - // TikTok - tiktok_video: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - // maxVideoDurationSec is enforced dynamically via creator_info - }, - tiktok_photo: { - maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - }, - - // YouTube - youtube_short: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60, - aspectRatioMin: 0.5, aspectRatioMax: 0.6, - }, - - // Pinterest - pinterest_pin: { - maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 20 * MB, - }, - pinterest_video_pin: { - maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true, - acceptsGif: false, - maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60, - }, - pinterest_carousel: { - maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 20 * MB, - }, - - // X (Twitter) — accepts GIF with animation - x_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, - maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140, - }, - - // Threads - threads_post: { - maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, - }, - - // Bluesky — accepts GIF; tight image size (auto-resized by backend). - // The embed is images XOR video, so the two can't be combined in one post. - bluesky_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, forbidsMixedMedia: true, - maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, - }, - - // Mastodon — accepts GIF - mastodon_post: { - maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, - maxImageBytes: 10 * MB, maxVideoBytes: 40 * MB, - }, -}; - const DEFAULT_RULES: MediaRules = { maxFiles: 10, acceptImages: true, @@ -157,33 +21,18 @@ const DEFAULT_RULES: MediaRules = { acceptsGif: true, }; -const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => { - const shared = maxVideoDurationSecFor(contentType); +export const getMediaRulesForContentType = (contentType: string): MediaRules => { + const shared = mediaRuleFor(contentType); - // Cache not synced yet — keep local hardcoded fallbacks. - if (shared === undefined) { - return rules; + if (!shared) { + return DEFAULT_RULES; } - // Explicitly unlimited / dynamic on the server (e.g. TikTok). - if (shared === null) { - const { maxVideoDurationSec: _ignored, ...rest } = rules; - - return rest; - } - - return { - ...rules, - maxVideoDurationSec: shared, - }; + return toMediaRules(shared); }; export const useMediaRules = (contentType: Ref | ComputedRef) => { - const rules = computed(() => { - const base = CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES; - - return withSharedDuration(contentType.value, base); - }); + const rules = computed(() => getMediaRulesForContentType(contentType.value)); const acceptMimeTypes = computed(() => { const types: string[] = []; @@ -244,9 +93,3 @@ export const useMediaRules = (contentType: Ref | ComputedRef) => getAcceptDescription, }; }; - -export const getMediaRulesForContentType = (contentType: string): MediaRules => { - const base = CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES; - - return withSharedDuration(contentType, base); -}; diff --git a/resources/js/lib/contentTypeMediaRules.ts b/resources/js/lib/contentTypeMediaRules.ts index 8742bd76..dd771f66 100644 --- a/resources/js/lib/contentTypeMediaRules.ts +++ b/resources/js/lib/contentTypeMediaRules.ts @@ -1,7 +1,43 @@ import type { Page } from '@inertiajs/core'; +/** CamelCase shape consumed by the Vue media picker / compliance checks. */ +export type MediaRules = { + maxFiles: number; + minFiles?: number; + acceptImages: boolean; + acceptVideos: boolean; + acceptDocuments?: boolean; + requiresMedia: boolean; + acceptsGif: boolean; + forbidsMixedMedia?: boolean; + maxImageBytes?: number; + maxVideoBytes?: number; + maxDocumentBytes?: number; + maxVideoDurationSec?: number; + aspectRatioMin?: number; + aspectRatioMax?: number; + autoFitsImage?: boolean; +}; + +/** + * Snake_case payload from ContentType::mediaRules() / mediaRulesForFrontend(). + */ export type ContentTypeMediaRule = { + max_files: number; + min_files: number | null; + accept_images: boolean; + accept_videos: boolean; + accept_documents: boolean; + requires_media: boolean; + accepts_gif: boolean; + forbids_mixed_media: boolean; + max_image_bytes: number | null; + max_video_bytes: number | null; + max_document_bytes: number | null; max_video_duration_sec: number | null; + aspect_ratio_min: number | null; + aspect_ratio_max: number | null; + auto_fits_image: boolean; }; type ContentTypeMediaRulesMap = Record; @@ -16,22 +52,58 @@ export const syncContentTypeMediaRules = (page: Page): void => { } }; -/** - * Shared Inertia duration for a content type. - * - `undefined` — cache not synced yet (or unknown type): keep local fallbacks - * - `null` — explicitly unlimited / dynamic (e.g. TikTok) - * - `number` — hard cap in seconds from ContentType::maxVideoDurationSec() - */ -export const maxVideoDurationSecFor = (contentType: string): number | null | undefined => { - if (cachedRules === null) { - return undefined; - } - - const rule = cachedRules[contentType]; - - if (rule === undefined) { - return undefined; - } - - return rule.max_video_duration_sec; +export const mediaRuleFor = (contentType: string): ContentTypeMediaRule | undefined => { + return cachedRules?.[contentType]; +}; + +export const toMediaRules = (rule: ContentTypeMediaRule): MediaRules => { + const mapped: MediaRules = { + maxFiles: rule.max_files, + acceptImages: rule.accept_images, + acceptVideos: rule.accept_videos, + requiresMedia: rule.requires_media, + acceptsGif: rule.accepts_gif, + }; + + if (rule.min_files !== null) { + mapped.minFiles = rule.min_files; + } + + if (rule.accept_documents) { + mapped.acceptDocuments = true; + } + + if (rule.forbids_mixed_media) { + mapped.forbidsMixedMedia = true; + } + + if (rule.max_image_bytes !== null) { + mapped.maxImageBytes = rule.max_image_bytes; + } + + if (rule.max_video_bytes !== null) { + mapped.maxVideoBytes = rule.max_video_bytes; + } + + if (rule.max_document_bytes !== null) { + mapped.maxDocumentBytes = rule.max_document_bytes; + } + + if (rule.max_video_duration_sec !== null) { + mapped.maxVideoDurationSec = rule.max_video_duration_sec; + } + + if (rule.aspect_ratio_min !== null) { + mapped.aspectRatioMin = rule.aspect_ratio_min; + } + + if (rule.aspect_ratio_max !== null) { + mapped.aspectRatioMax = rule.aspect_ratio_max; + } + + if (rule.auto_fits_image) { + mapped.autoFitsImage = true; + } + + return mapped; }; diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 53a18636..97030452 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -63,7 +63,21 @@ export interface NavItem { } export interface ContentTypeMediaRule { + max_files: number; + min_files: number | null; + accept_images: boolean; + accept_videos: boolean; + accept_documents: boolean; + requires_media: boolean; + accepts_gif: boolean; + forbids_mixed_media: boolean; + max_image_bytes: number | null; + max_video_bytes: number | null; + max_document_bytes: number | null; max_video_duration_sec: number | null; + aspect_ratio_min: number | null; + aspect_ratio_max: number | null; + auto_fits_image: boolean; } export interface SharedData { diff --git a/tests/Feature/Api/PlatformApiTest.php b/tests/Feature/Api/PlatformApiTest.php index 954229b7..21435178 100644 --- a/tests/Feature/Api/PlatformApiTest.php +++ b/tests/Feature/Api/PlatformApiTest.php @@ -30,6 +30,9 @@ 'max_media_count', 'requires_media', 'max_video_duration_sec', + 'max_image_bytes', + 'max_video_bytes', + 'max_document_bytes', ], ], ], @@ -41,6 +44,7 @@ $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); }); diff --git a/tests/Feature/ContentTypeMediaRulesShareTest.php b/tests/Feature/ContentTypeMediaRulesShareTest.php index cd78219d..d5a8511c 100644 --- a/tests/Feature/ContentTypeMediaRulesShareTest.php +++ b/tests/Feature/ContentTypeMediaRulesShareTest.php @@ -21,7 +21,10 @@ ->assertInertia(fn (AssertableInertia $page) => $page ->has('contentTypeMediaRules') ->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900) + ->where('contentTypeMediaRules.instagram_reel.max_video_bytes', 1 * 1024 * 1024 * 1024) + ->where('contentTypeMediaRules.instagram_reel.accept_images', false) ->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90) ->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null) + ->where('contentTypeMediaRules.linkedin_post.max_document_bytes', 100 * 1024 * 1024) ); }); diff --git a/tests/Feature/Mcp/PlatformToolTest.php b/tests/Feature/Mcp/PlatformToolTest.php index e8828b20..684c5b24 100644 --- a/tests/Feature/Mcp/PlatformToolTest.php +++ b/tests/Feature/Mcp/PlatformToolTest.php @@ -42,6 +42,9 @@ 'max_media_count', 'requires_media', 'max_video_duration_sec', + 'max_image_bytes', + 'max_video_bytes', + 'max_document_bytes', ]) ) ) @@ -71,6 +74,7 @@ $facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']); expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900); + expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024); expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90); }); }); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 18a9e1ec..9c826076 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -34,13 +34,36 @@ expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull(); }); -test('media rules for frontend expose duration caps keyed by content type', function () { +test('media rules for frontend expose the full editor rule set keyed by content type', function () { $rules = ContentType::mediaRulesForFrontend(); - expect($rules['instagram_reel']['max_video_duration_sec'])->toBe(900); + expect($rules)->toHaveCount(count(ContentType::cases())); + + expect($rules['instagram_reel'])->toMatchArray([ + 'max_files' => 1, + 'accept_images' => false, + 'accept_videos' => true, + 'requires_media' => true, + 'max_video_bytes' => 1 * 1024 * 1024 * 1024, + 'max_video_duration_sec' => 900, + 'aspect_ratio_min' => 0.5, + 'aspect_ratio_max' => 0.6, + ]); + expect($rules['facebook_reel']['max_video_duration_sec'])->toBe(90); expect($rules['tiktok_video']['max_video_duration_sec'])->toBeNull(); - expect($rules)->toHaveCount(count(ContentType::cases())); + expect($rules['linkedin_post']['max_document_bytes'])->toBe(100 * 1024 * 1024); + expect($rules['pinterest_carousel']['min_files'])->toBe(2); + expect($rules['x_post']['accepts_gif'])->toBeTrue(); +}); + +test('media rules reuse enum capability helpers', function () { + $rules = ContentType::InstagramStory->mediaRules(); + + expect($rules['accept_images'])->toBe(ContentType::InstagramStory->supportsImage()) + ->and($rules['accept_videos'])->toBe(ContentType::InstagramStory->supportsVideo()) + ->and($rules['auto_fits_image'])->toBeTrue() + ->and($rules['max_files'])->toBe(ContentType::InstagramStory->maxMediaCount()); }); test('content type maps to correct platform', function () {