From 1242cc7384152ab47bd5c5daedaf1e6357304ebf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 28 Jun 2026 20:43:09 -0300 Subject: [PATCH] refactor(posts): centralize inline media validation in PostMediaRules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The media.* rules were duplicated across the web update request and both API requests (and diverged: web requires hosted id+path and tracks source; the API accepts a bare external url it downloads). Pull them into one App\Support\PostMediaRules::rules(hosted:) — same pattern as PostPlatformMetaRules — parameterized by contract, so there's a single place to add a media key and the validated()-strips-unlisted-keys footgun can't drift between entry points. Behavior is unchanged (each ruleset is reproduced exactly). Web store keeps its loose 'media' => array (no item rules) and is left out on purpose — adding strict rules there would change the web create contract. --- .../Requests/Api/Post/StorePostRequest.php | 11 +---- .../Requests/Api/Post/UpdatePostRequest.php | 11 +---- .../Requests/App/Post/UpdatePostRequest.php | 14 +----- app/Support/PostMediaRules.php | 47 +++++++++++++++++++ tests/Unit/Support/PostMediaRulesTest.php | 38 +++++++++++++++ 5 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 app/Support/PostMediaRules.php create mode 100644 tests/Unit/Support/PostMediaRulesTest.php diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 10c35c39..00b98f24 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -9,6 +9,7 @@ use App\Models\SocialAccount; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPlatform; +use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; @@ -35,15 +36,7 @@ public function rules(): array [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms($workspaceId))] ), ], - 'media' => ['sometimes', 'array'], - 'media.*.id' => ['sometimes', 'nullable', 'string'], - 'media.*.path' => ['sometimes', 'nullable', 'string', 'max:500'], - 'media.*.url' => ['required', 'string', 'max:2048', 'url:http,https'], - 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], - 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], - 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], - 'media.*.size' => ['sometimes', 'nullable', 'integer'], - 'media.*.meta' => ['sometimes', 'nullable', 'array'], + ...PostMediaRules::rules(hosted: false), 'platforms' => ['required', 'array', 'min:1'], 'platforms.*.social_account_id' => [ 'required', diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index a6bf68ea..ccd89e56 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -12,6 +12,7 @@ use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; use App\Rules\ContentTypeMatchesPostPlatform; +use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; @@ -44,15 +45,7 @@ public function rules(): array [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())] ), ], - 'media' => ['sometimes', 'array'], - 'media.*.id' => ['sometimes', 'nullable', 'string'], - 'media.*.path' => ['sometimes', 'nullable', 'string', 'max:500'], - 'media.*.url' => ['required', 'string', 'max:2048', 'url:http,https'], - 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], - 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], - 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], - 'media.*.size' => ['sometimes', 'nullable', 'integer'], - 'media.*.meta' => ['sometimes', 'nullable', 'array'], + ...PostMediaRules::rules(hosted: false), 'platforms' => ['sometimes', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], 'platforms.*.content_type' => [ diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index c246ff57..18d7f539 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -4,12 +4,12 @@ namespace App\Http\Requests\App\Post; -use App\Enums\Media\Source; use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; +use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; @@ -42,17 +42,7 @@ public function rules(): array [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())] ), ], - 'media' => ['sometimes', 'array'], - 'media.*.id' => ['required', 'string'], - 'media.*.path' => ['required', 'string', 'max:500'], - 'media.*.url' => ['required', 'string', 'max:2048'], - 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], - 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], - 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], - 'media.*.size' => ['sometimes', 'nullable', 'integer'], - 'media.*.meta' => ['sometimes', 'nullable', 'array'], - 'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))], - 'media.*.source_meta' => ['sometimes', 'nullable', 'array'], + ...PostMediaRules::rules(hosted: true), 'scheduled_at' => [ 'sometimes', 'nullable', diff --git a/app/Support/PostMediaRules.php b/app/Support/PostMediaRules.php new file mode 100644 index 00000000..ca9ca763 --- /dev/null +++ b/app/Support/PostMediaRules.php @@ -0,0 +1,47 @@ + + */ + public static function rules(bool $hosted): array + { + $rules = [ + 'media' => ['sometimes', 'array'], + 'media.*.id' => $hosted ? ['required', 'string'] : ['sometimes', 'nullable', 'string'], + 'media.*.path' => $hosted ? ['required', 'string', 'max:500'] : ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.url' => $hosted + ? ['required', 'string', 'max:2048'] + : ['required', 'string', 'max:2048', 'url:http,https'], + 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], + 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.size' => ['sometimes', 'nullable', 'integer'], + 'media.*.meta' => ['sometimes', 'nullable', 'array'], + ]; + + if ($hosted) { + $rules['media.*.source'] = ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))]; + $rules['media.*.source_meta'] = ['sometimes', 'nullable', 'array']; + } + + return $rules; + } +} diff --git a/tests/Unit/Support/PostMediaRulesTest.php b/tests/Unit/Support/PostMediaRulesTest.php new file mode 100644 index 00000000..beb02baa --- /dev/null +++ b/tests/Unit/Support/PostMediaRulesTest.php @@ -0,0 +1,38 @@ +toContain('required') + ->and($rules['media.*.path'])->toContain('required') + ->and($rules)->toHaveKey('media.*.source') + ->and($rules)->toHaveKey('media.*.source_meta'); +}); + +test('api media rules accept a bare external url and omit source', function () { + $rules = PostMediaRules::rules(hosted: false); + + expect($rules['media.*.id'])->toContain('nullable') + ->and($rules['media.*.url'])->toContain('url:http,https') + ->and($rules)->not->toHaveKey('media.*.source') + ->and($rules)->not->toHaveKey('media.*.source_meta'); +}); + +test('both variants keep the shared item keys so validated() preserves them', function () { + foreach ([true, false] as $hosted) { + expect(PostMediaRules::rules(hosted: $hosted))->toHaveKeys([ + 'media.*.id', + 'media.*.path', + 'media.*.url', + 'media.*.type', + 'media.*.mime_type', + 'media.*.original_filename', + 'media.*.size', + 'media.*.meta', + ]); + } +});