From bdea56d5e10e1d100a1b35759f56046b4fe3e061 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 17:39:07 -0300 Subject: [PATCH 1/5] feat(api,mcp): full per-platform meta parity for posts Centralize per-platform PostPlatform.meta validation in PostPlatformMetaRules (shared by web, REST API and MCP). The API and MCP previously only accepted aspect_ratio, silently stripping channel_id/board_id/privacy_level and the rest via validated(), so Discord/Pinterest/TikTok couldn't be configured or published through those entry points. Now all per-platform meta is accepted and persisted, required-on-publish is enforced on API update (TikTok privacy, Pinterest board, Discord channel), and the MCP publish tool guards a post's stored meta before dispatching. Adds API + MCP tests and a PostPlatform discord() factory state. --- .../Requests/Api/Post/StorePostRequest.php | 4 +- .../Requests/Api/Post/UpdatePostRequest.php | 22 ++- .../Requests/App/Post/UpdatePostRequest.php | 56 +------ app/Mcp/Tools/Post/CreatePostTool.php | 6 +- app/Mcp/Tools/Post/PublishPostTool.php | 3 + app/Mcp/Tools/Post/UpdatePostTool.php | 6 +- app/Support/PostPlatformMetaRules.php | 138 ++++++++++++++++++ database/factories/PostPlatformFactory.php | 8 + tests/Feature/Api/PostApiPlatformMetaTest.php | 107 ++++++++++++++ .../Feature/Mcp/PostPlatformMetaToolTest.php | 92 ++++++++++++ 10 files changed, 383 insertions(+), 59 deletions(-) create mode 100644 app/Support/PostPlatformMetaRules.php create mode 100644 tests/Feature/Api/PostApiPlatformMetaTest.php create mode 100644 tests/Feature/Mcp/PostPlatformMetaToolTest.php diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 979a448c..303ae8ba 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -4,12 +4,12 @@ namespace App\Http\Requests\Api\Post; -use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Models\SocialAccount; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPlatform; +use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; @@ -51,7 +51,7 @@ public function rules(): array new ContentTypeMatchesPlatform, ], 'platforms.*.meta' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + ...PostPlatformMetaRules::rules(), '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 939e4a5d..22487d0b 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -5,16 +5,17 @@ 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; use App\Models\PostPlatform; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPostPlatform; +use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpdatePostRequest extends FormRequest { @@ -52,7 +53,7 @@ public function rules(): array new ContentTypeMatchesPostPlatform, ], 'platforms.*.meta' => ['nullable', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + ...PostPlatformMetaRules::rules(), 'scheduled_at' => [ 'nullable', 'date', @@ -66,6 +67,23 @@ public function rules(): array ]; } + public function withValidator(Validator $validator): void + { + $validator->after(function ($validator): void { + if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { + return; + } + + $platformsById = $this->resolveSelectedPlatforms(); + + PostPlatformMetaRules::addRequiredOnPublishErrors( + $validator, + $this->input('platforms', []), + fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null, + ); + }); + } + /** * @return Collection */ diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 9e6d9c74..29c7343c 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -6,11 +6,11 @@ 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; use App\Rules\ContentTypeCompatibleWithMedia; +use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; @@ -71,28 +71,7 @@ public function rules(): array Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]), ], 'platforms.*.meta' => ['nullable', 'array'], - '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'], - 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'], - 'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'], - 'platforms.*.meta.disclose' => ['sometimes', 'boolean'], - 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], - 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], - 'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.mentions.*.token' => ['required', 'string'], - 'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'], - 'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'], - 'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'], - 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], - 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], - 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + ...PostPlatformMetaRules::rules(), 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; @@ -113,32 +92,11 @@ public function withValidator(Validator $validator): void ->whereIn('id', $ids) ->pluck('platform', 'id'); - foreach ($platforms as $i => $platform) { - $platformEnum = $platformsById[data_get($platform, 'id')] ?? null; - if ($platformEnum === Platform::TikTok - && blank(data_get($platform, 'meta.privacy_level'))) { - $validator->errors()->add( - "platforms.{$i}.meta.privacy_level", - trans('posts.form.tiktok.privacy_required'), - ); - } - - if ($platformEnum === Platform::Pinterest - && blank(data_get($platform, 'meta.board_id'))) { - $validator->errors()->add( - "platforms.{$i}.meta.board_id", - trans('posts.form.pinterest.board_required'), - ); - } - - if ($platformEnum === Platform::Discord - && blank(data_get($platform, 'meta.channel_id'))) { - $validator->errors()->add( - "platforms.{$i}.meta.channel_id", - trans('posts.form.discord.channel_required'), - ); - } - } + PostPlatformMetaRules::addRequiredOnPublishErrors( + $validator, + $platforms, + fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null, + ); }); } diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 35437834..2fb51f25 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -5,10 +5,10 @@ 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; +use App\Support\PostPlatformMetaRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Validation\Rule; use Laravel\Mcp\Request; @@ -39,7 +39,7 @@ public function handle(Request $request): ResponseFactory ], '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)], + ...PostPlatformMetaRules::rules(), ]); $post = CreatePost::execute($workspace, $request->user(), $validated); @@ -61,7 +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).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/PublishPostTool.php b/app/Mcp/Tools/Post/PublishPostTool.php index ee95f439..da2feac9 100644 --- a/app/Mcp/Tools/Post/PublishPostTool.php +++ b/app/Mcp/Tools/Post/PublishPostTool.php @@ -9,6 +9,7 @@ use App\Enums\Post\Status; use App\Http\Resources\Api\PostResource; use App\Models\Post; +use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; @@ -41,6 +42,8 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.'); } + PostPlatformMetaRules::assertStoredPostPublishable($post); + $scheduledAt = data_get($validated, 'scheduled_at'); $result = UpdatePost::execute($workspace, $post, [ diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index c3e26aae..aa0903ca 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -7,11 +7,11 @@ 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; use App\Rules\ContentTypeMatchesPostPlatform; +use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Validation\Rule; @@ -50,7 +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)], + ...PostPlatformMetaRules::rules(), ]); $payload = collect($validated)->except('post_id')->all(); @@ -84,7 +84,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 (e.g. aspect_ratio, board_id for Pinterest).'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), ])) ->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 new file mode 100644 index 00000000..65529b47 --- /dev/null +++ b/app/Support/PostPlatformMetaRules.php @@ -0,0 +1,138 @@ + + */ + public const TIKTOK_PRIVACY_LEVELS = [ + 'PUBLIC_TO_EVERYONE', + 'MUTUAL_FOLLOW_FRIENDS', + 'FOLLOWER_OF_CREATOR', + 'SELF_ONLY', + ]; + + /** + * Validation rules for the `platforms.*.meta.*` keys. The caller keeps its own + * `platforms.*.meta` (array) parent rule; these are the per-platform sub-keys. + * + * @return array + */ + public static function rules(): array + { + return [ + // Instagram / Facebook + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + + // TikTok + 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(self::TIKTOK_PRIVACY_LEVELS)], + 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'], + 'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'], + 'platforms.*.meta.disclose' => ['sometimes', 'boolean'], + 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], + 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], + + // Pinterest + 'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'], + + // Discord + 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.mentions.*.token' => ['required', 'string'], + 'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'], + 'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'], + 'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'], + 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], + 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], + 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + ]; + } + + /** + * Adds validation errors for per-platform meta that becomes mandatory once a + * post is published or scheduled (TikTok privacy, Pinterest board, Discord + * channel), based on the submitted request platforms. The caller resolves each + * platform row to its Platform enum, since that lookup differs between create + * (by social account) and update (by post platform). + * + * @param array $platforms + * @param callable(mixed, int): ?Platform $resolvePlatform + */ + public static function addRequiredOnPublishErrors(Validator $validator, array $platforms, callable $resolvePlatform): void + { + foreach ($platforms as $index => $platform) { + $violation = self::requiredMetaViolation($resolvePlatform($platform, $index), data_get($platform, 'meta')); + + if ($violation !== null) { + [$field, $message] = $violation; + $validator->errors()->add("platforms.{$index}.meta.{$field}", $message); + } + } + } + + /** + * Asserts that every ENABLED platform already stored on a post has the meta it + * needs to publish. Used by entry points that publish a post's stored state + * without resubmitting platforms (e.g. the MCP publish tool), so a misconfigured + * post fails fast with a clear message instead of only at publish time. + * + * @throws ValidationException + */ + public static function assertStoredPostPublishable(Post $post): void + { + $errors = []; + + foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) { + $violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta); + + if ($violation !== null) { + [$field, $message] = $violation; + $errors["platforms.{$index}.meta.{$field}"] = $message; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($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". + * + * @return array{0: string, 1: string}|null [field, message] + */ + private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array + { + return match (true) { + $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], + $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], + $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], + default => null, + }; + } +} diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 508abec8..cb39e970 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -155,6 +155,14 @@ public function facebook(): static ]); } + public function discord(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Discord, + 'content_type' => ContentType::DiscordMessage, + ]); + } + public function facebookReel(): static { return $this->state(fn (array $attributes) => [ diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php new file mode 100644 index 00000000..aa06fbd5 --- /dev/null +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -0,0 +1,107 @@ +user = $result['user']; + $this->workspace = $result['workspace']; + $this->plainToken = $result['plain_token']; + + $this->headers = ['Authorization' => 'Bearer '.$this->plainToken]; + + $this->discordAccount = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); +}); + +it('persists Discord channel, mentions and embeds meta on store', function () { + $this->withHeaders($this->headers) + ->postJson(route('api.posts.store'), [ + 'content' => 'Hello Discord', + 'platforms' => [[ + 'social_account_id' => $this->discordAccount->id, + 'content_type' => ContentType::DiscordMessage->value, + 'meta' => [ + 'channel_id' => '444555666', + 'mentions' => [['token' => '@everyone', 'label' => '@everyone']], + 'embeds' => [['title' => 'Release', 'color' => '#5865F2']], + ], + ]], + ]) + ->assertCreated(); + + $meta = PostPlatform::where('social_account_id', $this->discordAccount->id)->sole()->meta; + + expect($meta['channel_id'])->toBe('444555666') + ->and($meta['mentions'])->toHaveCount(1) + ->and($meta['embeds.0.title'] ?? data_get($meta, 'embeds.0.title'))->toBe('Release'); +}); + +it('persists Pinterest board and TikTok privacy meta on store', function () { + $pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]); + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + + $this->withHeaders($this->headers) + ->postJson(route('api.posts.store'), [ + 'content' => 'Cross-platform', + 'platforms' => [ + ['social_account_id' => $pinterest->id, 'content_type' => ContentType::PinterestPin->value, 'meta' => ['board_id' => 'board-99']], + ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'SELF_ONLY']], + ], + ]) + ->assertCreated(); + + expect(PostPlatform::where('social_account_id', $pinterest->id)->sole()->meta['board_id'])->toBe('board-99') + ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['privacy_level'])->toBe('SELF_ONLY'); +}); + +it('rejects publishing a Discord post without a channel', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => true, + 'meta' => [], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id]], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['platforms.0.meta.channel_id']); +}); + +it('publishes a Discord post when the channel is set', function () { + Queue::fake(); + + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => true, + 'meta' => [], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id, 'meta' => ['channel_id' => '444555666']]], + ]) + ->assertOk(); + + expect($platform->fresh()->meta['channel_id'])->toBe('444555666'); + Queue::assertPushed(PublishPost::class); +}); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php new file mode 100644 index 00000000..2cff7b8d --- /dev/null +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -0,0 +1,92 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->discordAccount = SocialAccount::factory()->discord()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => '111222333', + ]); +}); + +test('create post persists Discord channel + embeds meta', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'content' => 'Hello Discord', + 'platforms' => [[ + 'social_account_id' => $this->discordAccount->id, + 'content_type' => ContentType::DiscordMessage->value, + 'meta' => [ + 'channel_id' => '444555666', + 'embeds' => [['title' => 'Release']], + ], + ]], + ]); + + $response->assertOk(); + + $meta = PostPlatform::where('social_account_id', $this->discordAccount->id)->sole()->meta; + + expect($meta['channel_id'])->toBe('444555666') + ->and(data_get($meta, 'embeds.0.title'))->toBe('Release'); +}); + +test('publish post rejects a Discord platform without a channel', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => true, + 'meta' => [], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.discord.channel_required')]); +}); + +test('publish post succeeds for a Discord platform with a channel', function () { + Queue::fake(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => true, + 'meta' => ['channel_id' => '444555666'], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertOk(); + Queue::assertPushed(PublishPost::class); +}); From 3aa4b2a2f0be14d8729d85fce47e79dac88e88e0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 17:44:27 -0300 Subject: [PATCH 2/5] test(api,mcp): cover per-platform meta across networks Broaden the post meta tests beyond Discord: store persists Instagram aspect ratio, Pinterest board, TikTok privacy + flags; API update enforces TikTok privacy and Pinterest board on publish and allows drafts without required meta; MCP update merges meta and the publish guard ignores disabled platforms. --- tests/Feature/Api/PostApiPlatformMetaTest.php | 56 +++++++++++++++-- .../Feature/Mcp/PostPlatformMetaToolTest.php | 61 +++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index aa06fbd5..f54f30d2 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -48,7 +48,8 @@ ->and($meta['embeds.0.title'] ?? data_get($meta, 'embeds.0.title'))->toBe('Release'); }); -it('persists Pinterest board and TikTok privacy meta on store', function () { +it('persists per-platform meta across networks on store', function () { + $instagram = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]); $pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]); $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); @@ -56,14 +57,61 @@ ->postJson(route('api.posts.store'), [ 'content' => 'Cross-platform', 'platforms' => [ + ['social_account_id' => $instagram->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => ['aspect_ratio' => '4:5']], ['social_account_id' => $pinterest->id, 'content_type' => ContentType::PinterestPin->value, 'meta' => ['board_id' => 'board-99']], - ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'SELF_ONLY']], + ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'SELF_ONLY', 'allow_comments' => true]], ], ]) ->assertCreated(); - expect(PostPlatform::where('social_account_id', $pinterest->id)->sole()->meta['board_id'])->toBe('board-99') - ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['privacy_level'])->toBe('SELF_ONLY'); + expect(PostPlatform::where('social_account_id', $instagram->id)->sole()->meta['aspect_ratio'])->toBe('4:5') + ->and(PostPlatform::where('social_account_id', $pinterest->id)->sole()->meta['board_id'])->toBe('board-99') + ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['privacy_level'])->toBe('SELF_ONLY') + ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['allow_comments'])->toBeTrue(); +}); + +it('allows saving a Discord draft without a channel', function () { + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => true, + 'meta' => [], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Draft->value, + 'platforms' => [['id' => $platform->id]], + ]) + ->assertOk(); +}); + +it('rejects publishing without TikTok privacy and Pinterest board', function () { + $pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]); + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $pinterestPlatform = PostPlatform::factory()->pinterest()->create([ + 'post_id' => $post->id, 'social_account_id' => $pinterest->id, 'enabled' => true, 'meta' => [], + ]); + $tiktokPlatform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, 'social_account_id' => $tiktok->id, 'enabled' => true, 'meta' => [], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [ + ['id' => $pinterestPlatform->id], + ['id' => $tiktokPlatform->id], + ], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors([ + 'platforms.0.meta.board_id', + 'platforms.1.meta.privacy_level', + ]); }); it('rejects publishing a Discord post without a channel', function () { diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 2cff7b8d..5b215393 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -4,11 +4,13 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; use App\Jobs\PublishPost; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\CreatePostTool; use App\Mcp\Tools\Post\PublishPostTool; +use App\Mcp\Tools\Post\UpdatePostTool; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -50,6 +52,65 @@ ->and(data_get($meta, 'embeds.0.title'))->toBe('Release'); }); +test('update post merges per-platform meta', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + $platform = PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => true, + 'meta' => ['channel_name' => 'general'], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'platforms' => [[ + 'id' => $platform->id, + 'meta' => ['channel_id' => '444555666'], + ]], + ]); + + $response->assertOk(); + + $meta = $platform->fresh()->meta; + expect($meta['channel_id'])->toBe('444555666') + ->and($meta['channel_name'])->toBe('general'); // merged, not overwritten +}); + +test('publish guard ignores disabled platforms missing meta', function () { + Queue::fake(); + + $linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->linkedin()->create([ + 'post_id' => $post->id, + 'social_account_id' => $linkedin->id, + 'enabled' => true, + ]); + // Disabled Discord with no channel must not block the publish. + PostPlatform::factory()->discord()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->discordAccount->id, + 'enabled' => false, + 'meta' => [], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertOk(); + Queue::assertPushed(PublishPost::class); +}); + test('publish post rejects a Discord platform without a channel', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, From ba20534a6dde63b5798a86f24fe14bb7db93fc49 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 17:50:40 -0300 Subject: [PATCH 3/5] refactor(meta): own the platforms.*.meta parent rule in PostPlatformMetaRules The shared rules() returned only meta sub-keys, so every caller still repeated its own 'platforms.*.meta' => array parent (with slightly different sometimes/ nullable combos). Fold the parent into rules() with a single safe contract so callers just spread one source for the whole meta block. --- app/Http/Requests/Api/Post/StorePostRequest.php | 1 - app/Http/Requests/Api/Post/UpdatePostRequest.php | 1 - app/Http/Requests/App/Post/UpdatePostRequest.php | 1 - app/Mcp/Tools/Post/CreatePostTool.php | 1 - app/Mcp/Tools/Post/UpdatePostTool.php | 1 - app/Support/PostPlatformMetaRules.php | 6 ++++-- 6 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 303ae8ba..cbdb4c0a 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -50,7 +50,6 @@ public function rules(): array Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform, ], - 'platforms.*.meta' => ['sometimes', 'nullable', 'array'], ...PostPlatformMetaRules::rules(), 'scheduled_at' => ['nullable', 'date', 'after:now'], 'label_ids' => ['sometimes', 'array'], diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 22487d0b..02fa34d0 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -52,7 +52,6 @@ public function rules(): array Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform, ], - 'platforms.*.meta' => ['nullable', 'array'], ...PostPlatformMetaRules::rules(), 'scheduled_at' => [ 'nullable', diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 29c7343c..c246ff57 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -70,7 +70,6 @@ public function rules(): array Rule::in(array_column(ContentType::cases(), 'value')), Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]), ], - 'platforms.*.meta' => ['nullable', 'array'], ...PostPlatformMetaRules::rules(), 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 2fb51f25..80851f06 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -38,7 +38,6 @@ 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'], ...PostPlatformMetaRules::rules(), ]); diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index aa0903ca..055b5942 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -49,7 +49,6 @@ public function handle(Request $request): Response|ResponseFactory Rule::exists('post_platforms', 'id')->where('post_id', $post->id), ], 'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform], - 'platforms.*.meta' => ['sometimes', 'array'], ...PostPlatformMetaRules::rules(), ]); diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 65529b47..d7a12682 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -32,14 +32,16 @@ class PostPlatformMetaRules ]; /** - * Validation rules for the `platforms.*.meta.*` keys. The caller keeps its own - * `platforms.*.meta` (array) parent rule; these are the per-platform sub-keys. + * Validation rules for `platforms.*.meta` and all its per-platform sub-keys. + * Spread into a FormRequest/MCP tool rule set as the complete meta contract. * * @return array */ public static function rules(): array { return [ + 'platforms.*.meta' => ['sometimes', 'nullable', 'array'], + // Instagram / Facebook 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], From bf399c80faf4c7fb3882baa7df61078d995be9b2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 17:52:50 -0300 Subject: [PATCH 4/5] docs: document the single-source PostPlatform.meta validation rule Record in CLAUDE.md that all platforms.*.meta validation and required-on-publish logic lives only in PostPlatformMetaRules, why (validated() strips unlisted keys), and where to add new per-platform meta + tests. --- CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0c066141..ce363040 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,6 +277,14 @@ ## Backend Validation - Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App//`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller. - Naming: `Request.php` (e.g. `StorePostRequest`, `ApplyPostTemplateRequest`, `IndexPostTemplateRequest`). +## Per-Platform Post Meta (`PostPlatform.meta`) + +- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`. + - Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()`. NEVER add a per-platform meta rule inline to a single request/tool. + - Why: `FormRequest::validated()` (and MCP `$request->validate()`) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized. +- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id`) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator`), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool`). Add new required-meta rules to `requiredMetaViolation()`, not inline. +- When adding a new platform's meta field, add it (and any publish requirement) to `PostPlatformMetaRules` ONLY, and cover it in `tests/Feature/Api/PostApiPlatformMetaTest.php` + `tests/Feature/Mcp/PostPlatformMetaToolTest.php`. + ## Pest / Feature Tests - ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`. From 40425d7bdce6c9ba66f5c7d579e611cd63797e3a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 18:11:29 -0300 Subject: [PATCH 5/5] test,refactor: review fixes for per-platform meta parity - Assert nested Discord meta keys (mention token/label, embed title/color) survive validated(), replacing a vacuous coalesced assertion. - Cover the MCP publish guard for TikTok privacy and Pinterest board, not just Discord. - Use assertUnprocessable() to match sibling API tests; type-hint the API UpdatePostRequest withValidator closure. --- .../Requests/Api/Post/UpdatePostRequest.php | 2 +- tests/Feature/Api/PostApiPlatformMetaTest.php | 13 +++++---- .../Feature/Mcp/PostPlatformMetaToolTest.php | 27 +++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 02fa34d0..7d3ef18e 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -68,7 +68,7 @@ public function rules(): array public function withValidator(Validator $validator): void { - $validator->after(function ($validator): void { + $validator->after(function (Validator $validator): void { if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { return; } diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index f54f30d2..4865df41 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -43,9 +43,12 @@ $meta = PostPlatform::where('social_account_id', $this->discordAccount->id)->sole()->meta; - expect($meta['channel_id'])->toBe('444555666') - ->and($meta['mentions'])->toHaveCount(1) - ->and($meta['embeds.0.title'] ?? data_get($meta, 'embeds.0.title'))->toBe('Release'); + // Assert nested keys survive validated() — the exact stripping bug this PR fixes. + expect(data_get($meta, 'channel_id'))->toBe('444555666') + ->and(data_get($meta, 'mentions.0.token'))->toBe('@everyone') + ->and(data_get($meta, 'mentions.0.label'))->toBe('@everyone') + ->and(data_get($meta, 'embeds.0.title'))->toBe('Release') + ->and(data_get($meta, 'embeds.0.color'))->toBe('#5865F2'); }); it('persists per-platform meta across networks on store', function () { @@ -107,7 +110,7 @@ ['id' => $tiktokPlatform->id], ], ]) - ->assertStatus(422) + ->assertUnprocessable() ->assertJsonValidationErrors([ 'platforms.0.meta.board_id', 'platforms.1.meta.privacy_level', @@ -128,7 +131,7 @@ 'status' => PostStatus::Publishing->value, 'platforms' => [['id' => $platform->id]], ]) - ->assertStatus(422) + ->assertUnprocessable() ->assertJsonValidationErrors(['platforms.0.meta.channel_id']); }); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 5b215393..43f6ee26 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -130,6 +130,33 @@ $response->assertHasErrors([__('posts.form.discord.channel_required')]); }); +test('publish guard enforces required meta for TikTok and Pinterest', function (string $factoryState, string $field, string $messageKey) { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => $factoryState === 'tiktok' ? Platform::TikTok : Platform::Pinterest, + ]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->{$factoryState}()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => [], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__($messageKey)]); +})->with([ + 'tiktok' => ['tiktok', 'privacy_level', 'posts.form.tiktok.privacy_required'], + 'pinterest' => ['pinterest', 'board_id', 'posts.form.pinterest.board_required'], +]); + test('publish post succeeds for a Discord platform with a channel', function () { Queue::fake();