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.
This commit is contained in:
parent
206d01f77e
commit
bdea56d5e1
10 changed files with 383 additions and 59 deletions
|
|
@ -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.*' => [
|
||||
|
|
|
|||
|
|
@ -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<int|string, Platform>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
|
|
|
|||
138
app/Support/PostPlatformMetaRules.php
Normal file
138
app/Support/PostPlatformMetaRules.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Single source of truth for per-platform `PostPlatform.meta` validation, shared
|
||||
* by the web, public REST API, and MCP post create/update flows so every entry
|
||||
* point accepts the same per-platform settings and enforces the same
|
||||
* required-on-publish rules.
|
||||
*/
|
||||
class PostPlatformMetaRules
|
||||
{
|
||||
/**
|
||||
* TikTok content visibility options.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<int, mixed> $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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => [
|
||||
|
|
|
|||
107
tests/Feature/Api/PostApiPlatformMetaTest.php
Normal file
107
tests/Feature/Api/PostApiPlatformMetaTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Jobs\PublishPost;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
$result = createApiTestToken();
|
||||
$this->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);
|
||||
});
|
||||
92
tests/Feature/Mcp/PostPlatformMetaToolTest.php
Normal file
92
tests/Feature/Mcp/PostPlatformMetaToolTest.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
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\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
Loading…
Reference in a new issue