Merge pull request #101 from trypostit/feat/mcp-api-platform-meta
Full per-platform meta parity for posts (API + MCP)
This commit is contained in:
commit
ce1073918a
11 changed files with 532 additions and 64 deletions
|
|
@ -277,6 +277,14 @@ ## Backend Validation
|
|||
- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App/<Group>/`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
|
||||
- Naming: `<Verb><Resource>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'`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -50,8 +50,7 @@ public function rules(): array
|
|||
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||
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
|
||||
{
|
||||
|
|
@ -51,8 +52,7 @@ public function rules(): array
|
|||
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||
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 +66,23 @@ public function rules(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $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;
|
||||
|
|
@ -70,29 +70,7 @@ public function rules(): array
|
|||
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||
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 +91,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;
|
||||
|
|
@ -38,8 +38,7 @@ 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)],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
]);
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), $validated);
|
||||
|
|
@ -61,7 +60,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;
|
||||
|
|
@ -49,8 +49,7 @@ 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'],
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
]);
|
||||
|
||||
$payload = collect($validated)->except('post_id')->all();
|
||||
|
|
@ -84,7 +83,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.'),
|
||||
];
|
||||
|
|
|
|||
140
app/Support/PostPlatformMetaRules.php
Normal file
140
app/Support/PostPlatformMetaRules.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?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 `platforms.*.meta` and all its per-platform sub-keys.
|
||||
* Spread into a FormRequest/MCP tool rule set as the complete meta contract.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function rules(): array
|
||||
{
|
||||
return [
|
||||
'platforms.*.meta' => ['sometimes', 'nullable', 'array'],
|
||||
|
||||
// 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) => [
|
||||
|
|
|
|||
158
tests/Feature/Api/PostApiPlatformMetaTest.php
Normal file
158
tests/Feature/Api/PostApiPlatformMetaTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?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;
|
||||
|
||||
// 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 () {
|
||||
$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]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->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', 'allow_comments' => true]],
|
||||
],
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
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],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'platforms.0.meta.board_id',
|
||||
'platforms.1.meta.privacy_level',
|
||||
]);
|
||||
});
|
||||
|
||||
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]],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->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);
|
||||
});
|
||||
180
tests/Feature/Mcp/PostPlatformMetaToolTest.php
Normal file
180
tests/Feature/Mcp/PostPlatformMetaToolTest.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
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('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,
|
||||
'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 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();
|
||||
|
||||
$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