From ce6ee0488341aa727bc7c20495ff8f6a14304423 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 10 Jun 2026 15:36:47 -0300 Subject: [PATCH 1/2] Bring post platform meta (aspect ratio) to parity across API and MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #82 made `meta.aspect_ratio` crop Facebook (and already Instagram) feed images at publish time, but the API and MCP surfaces only half-supported it: you couldn't set meta at creation, the value wasn't validated, and responses never returned it. This closes those gaps. - New `AspectRatio` enum is the single source of truth for the allowed ratios (1:1, 4:5, 16:9, original). App/API/MCP requests now validate via `Rule::enum(AspectRatio::class)` — an invalid ratio is rejected everywhere instead of silently center-cropping to square. - API `StorePostRequest` and MCP `CreatePostTool` now accept `platforms.*.meta`; `CreatePost` persists it. MCP create documents `meta` in its schema. - `Api\PostPlatformResource` now exposes `meta`, so API and MCP responses return the aspect_ratio (and other per-platform meta) a client set. --- app/Actions/Post/CreatePost.php | 4 ++ app/Enums/PostPlatform/AspectRatio.php | 13 +++++ .../Requests/Api/Post/StorePostRequest.php | 3 ++ .../Requests/Api/Post/UpdatePostRequest.php | 2 + .../Requests/App/Post/UpdatePostRequest.php | 3 +- .../Resources/Api/PostPlatformResource.php | 1 + app/Mcp/Tools/Post/CreatePostTool.php | 4 ++ app/Mcp/Tools/Post/UpdatePostTool.php | 2 + tests/Feature/Api/PostApiTest.php | 47 +++++++++++++++++++ tests/Feature/Mcp/PostToolTest.php | 47 +++++++++++++++++++ 10 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 app/Enums/PostPlatform/AspectRatio.php diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index bfdb75bd..008ace8a 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -62,6 +62,10 @@ public static function execute(Workspace $workspace, User $user, array $data): P $updates['content_type'] = $contentType; } + if (($meta = data_get($platformData, 'meta')) !== null) { + $updates['meta'] = $meta; + } + $post->postPlatforms() ->where('social_account_id', $accountId) ->update($updates); diff --git a/app/Enums/PostPlatform/AspectRatio.php b/app/Enums/PostPlatform/AspectRatio.php new file mode 100644 index 00000000..52365b4c --- /dev/null +++ b/app/Enums/PostPlatform/AspectRatio.php @@ -0,0 +1,13 @@ + ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], 'scheduled_at' => ['nullable', 'date', 'after:now'], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => [ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 95315781..939e4a5d 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests\Api\Post; use App\Enums\Post\Status; +use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Models\Post; @@ -51,6 +52,7 @@ public function rules(): array new ContentTypeMatchesPostPlatform, ], 'platforms.*.meta' => ['nullable', 'array'], + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], 'scheduled_at' => [ 'nullable', 'date', diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index eaa2d2af..47941085 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -6,6 +6,7 @@ use App\Enums\Media\Source; use App\Enums\Post\Status; +use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Rules\ContentFitsPlatformLimits; @@ -70,7 +71,7 @@ public function rules(): array Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]), ], 'platforms.*.meta' => ['nullable', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::in(['1:1', '4:5', '16:9', 'original'])], + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])], 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], diff --git a/app/Http/Resources/Api/PostPlatformResource.php b/app/Http/Resources/Api/PostPlatformResource.php index 147179e9..3995c87c 100644 --- a/app/Http/Resources/Api/PostPlatformResource.php +++ b/app/Http/Resources/Api/PostPlatformResource.php @@ -18,6 +18,7 @@ public function toArray(Request $request): array 'id' => $this->id, 'platform' => $this->platform?->value, 'content_type' => $this->content_type?->value, + 'meta' => $this->meta, 'status' => $this->status?->value, 'enabled' => $this->enabled, 'platform_url' => $this->platform_url, diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 1709dbc5..35437834 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -5,6 +5,7 @@ namespace App\Mcp\Tools\Post; use App\Actions\Post\CreatePost; +use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Rules\ContentTypeMatchesPlatform; @@ -37,6 +38,8 @@ public function handle(Request $request): ResponseFactory ->where('is_active', true), ], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform], + 'platforms.*.meta' => ['sometimes', 'array'], + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], ]); $post = CreatePost::execute($workspace, $request->user(), $validated); @@ -58,6 +61,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 (e.g. aspect_ratio: 1:1|4:5|16:9|original for Instagram/Facebook feed images).'), ])) ->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 564e633f..c3e26aae 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -7,6 +7,7 @@ use App\Actions\Post\UpdatePost; use App\Enums\Post\Action as PostAction; use App\Enums\Post\Status; +use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Models\Post; @@ -49,6 +50,7 @@ public function handle(Request $request): Response|ResponseFactory ], 'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform], 'platforms.*.meta' => ['sometimes', 'array'], + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], ]); $payload = collect($validated)->except('post_id')->all(); diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 5c6018a8..eee56afd 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -577,3 +577,50 @@ ->assertOk() ->assertJsonStructure(['id', 'status', 'scheduled_at', 'published_at']); }); + +it('creates a post with platform meta (aspect_ratio) and returns it', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']], + ], + ]) + ->assertCreated() + ->assertJsonPath('platforms.0.meta.aspect_ratio', '4:5'); + + $platform = Post::where('workspace_id', $this->workspace->id)->first() + ->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first(); + expect($platform->meta['aspect_ratio'])->toBe('4:5'); +}); + +it('rejects creating a post with an invalid aspect_ratio', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']); +}); + +it('rejects updating a post with an invalid aspect_ratio', 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' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']); +}); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index f06b46d7..c234460d 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -276,3 +276,50 @@ $response->assertHasErrors(); }); + +test('create post persists platform meta (aspect_ratio)', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']], + ], + ]); + + $response->assertOk(); + + $platform = Post::where('workspace_id', $this->workspace->id)->first() + ->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first(); + expect($platform->meta['aspect_ratio'])->toBe('4:5'); +}); + +test('create post rejects an invalid aspect_ratio', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']], + ], + ]); + + $response->assertHasErrors(); +}); + +test('update post rejects an invalid aspect_ratio', 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, 'meta' => ['aspect_ratio' => '3:2']], + ], + ]); + + $response->assertHasErrors(); +}); From efe49c34de065bdb9268d74a93b3a4c831cdaad4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 10 Jun 2026 15:52:41 -0300 Subject: [PATCH 2/2] Make AspectRatio the single source for crop math; fill test gaps Review follow-ups: - AspectRatio::toFloat() now owns the crop ratio math; CropsImageForAspectRatio delegates to it so the enum is the single source of truth for both validation and cropping (no more parallel literal map). - API update controller now reloads postPlatforms before returning, so the update response reflects the persisted platform meta/content_type (was stale). - Tests: AspectRatio enum unit test; API valid-update read-back + 'original' on create; MCP response read-back + valid update. --- app/Enums/PostPlatform/AspectRatio.php | 14 +++++++ app/Http/Controllers/Api/PostController.php | 5 ++- .../Concerns/CropsImageForAspectRatio.php | 7 +--- tests/Feature/Api/PostApiTest.php | 39 +++++++++++++++++++ tests/Feature/Mcp/PostToolTest.php | 33 ++++++++++++++++ tests/Unit/Enums/AspectRatioTest.php | 19 +++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Enums/AspectRatioTest.php diff --git a/app/Enums/PostPlatform/AspectRatio.php b/app/Enums/PostPlatform/AspectRatio.php index 52365b4c..89a34e76 100644 --- a/app/Enums/PostPlatform/AspectRatio.php +++ b/app/Enums/PostPlatform/AspectRatio.php @@ -10,4 +10,18 @@ enum AspectRatio: string case Portrait = '4:5'; case Landscape = '16:9'; case Original = 'original'; + + /** + * Width-to-height ratio used to center-crop an image. `Original` means "no + * crop", so it resolves to a square (1.0) only as a safe fallback — callers + * should bypass cropping entirely for `Original`. + */ + public function toFloat(): float + { + return match ($this) { + self::Portrait => 4 / 5, + self::Landscape => 16 / 9, + self::Square, self::Original => 1.0, + }; + } } diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index fefa3d0e..30be5e48 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -75,7 +75,10 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso ); } - return new PostResource(data_get($result, 'post')); + $updated = data_get($result, 'post'); + $updated->load(['postPlatforms.socialAccount']); + + return new PostResource($updated); } public function destroy(Request $request, Post $post): JsonResponse diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php index f807e62a..ae4cefdb 100644 --- a/app/Services/Social/Concerns/CropsImageForAspectRatio.php +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -4,6 +4,7 @@ namespace App\Services\Social\Concerns; +use App\Enums\PostPlatform\AspectRatio; use App\Exceptions\Social\SocialPublishException; use App\Services\Media\MediaOptimizer; use Illuminate\Support\Facades\Http; @@ -51,11 +52,7 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati protected function aspectRatioToFloat(string $ratio): float { - return match ($ratio) { - '4:5' => 4 / 5, - '16:9' => 16 / 9, - default => 1.0, - }; + return AspectRatio::tryFrom($ratio)?->toFloat() ?? 1.0; } /** diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index eee56afd..5163af93 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -624,3 +624,42 @@ ]) ->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']); }); + +it('accepts a valid aspect_ratio on update and persists it', 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' => 'linkedin_post', 'meta' => ['aspect_ratio' => '16:9']], + ], + ]) + ->assertOk() + ->assertJsonPath('platforms.0.meta.aspect_ratio', '16:9'); + + expect($postPlatform->fresh()->meta['aspect_ratio'])->toBe('16:9'); +}); + +it('accepts the original aspect_ratio (no crop) on create', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => 'original']], + ], + ]) + ->assertCreated(); + + $platform = Post::where('workspace_id', $this->workspace->id)->first() + ->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first(); + expect($platform->meta['aspect_ratio'])->toBe('original'); +}); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index c234460d..dfd631b4 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -323,3 +323,36 @@ $response->assertHasErrors(); }); + +test('create post returns the platform meta in the response (read-back)', function () { + TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']], + ], + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->where('platforms.0.meta.aspect_ratio', '4:5')->etc()); +}); + +test('update post accepts a valid aspect_ratio and persists it', 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, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'platforms' => [ + ['id' => $platform->id, 'meta' => ['aspect_ratio' => '16:9']], + ], + ]) + ->assertOk(); + + expect($platform->fresh()->meta['aspect_ratio'])->toBe('16:9'); +}); diff --git a/tests/Unit/Enums/AspectRatioTest.php b/tests/Unit/Enums/AspectRatioTest.php new file mode 100644 index 00000000..8adebf0a --- /dev/null +++ b/tests/Unit/Enums/AspectRatioTest.php @@ -0,0 +1,19 @@ +toEqualCanonicalizing(['1:1', '4:5', '16:9', 'original']); +}); + +test('aspect ratio maps to the correct crop float', function (string $value, float $expected) { + expect(AspectRatio::from($value)->toFloat())->toBe($expected); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], + '16:9' => ['16:9', 16 / 9], + 'original' => ['original', 1.0], +]);