feat: Asset Library list, preview, and attach via API and MCP (#282)

* feat: list, preview, and attach Asset Library media via API and MCP

Let API and MCP clients reuse workspace assets instead of re-uploading, sharing the same scoped query, signed preview, and idempotent attach path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Align Asset Library API and MCP with main media patterns.

Drop the signed-preview stack, return Storage URLs and PostResource like existing attach flows, and query medias by morph owner instead of getMedia().

Co-authored-by: Cursor <cursoragent@cursor.com>

* Paginate workspace assets with the app default page size.

Keep list pagination in the action via config('app.pagination.default') instead of a hardcoded API page size.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move asset API and MCP input rules into FormRequests.

Keep controllers and tools free of inline field validation; MCP tools reuse the request rule definitions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document asset MCP tools with explicit parameters and constraints.

Spell out workspace scope, return fields, sibling tools, and rejection cases so agents can call list/get/attach without guessing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden asset attach against races and keep library metadata on the post.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Relock the library asset on attach so a deleted file cannot land on the post.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Document that omitting alt on attach keeps the library alt text.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-08-16 17:08:20 -03:00 committed by GitHub
parent 4546425532
commit eb2b345163
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1572 additions and 1 deletions

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Actions\Media;
use App\Models\Media;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class FindWorkspaceAsset
{
public static function execute(Workspace $workspace, string $assetId, bool $lockForUpdate = false): ?Media
{
return Media::query()
->where('mediable_type', Relation::getMorphAlias(Workspace::class))
->where('mediable_id', $workspace->id)
->where('collection', 'assets')
->whereKey($assetId)
->when($lockForUpdate, fn (Builder $query) => $query->lockForUpdate())
->first();
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions\Media;
use App\Models\Media;
use App\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class ListWorkspaceAssets
{
public static function execute(Workspace $workspace, ?string $search = null, ?string $type = null): LengthAwarePaginator
{
return self::query($workspace, $search, $type)
->paginate((int) config('app.pagination.default'));
}
/**
* @return Builder<Media>
*/
public static function query(Workspace $workspace, ?string $search = null, ?string $type = null): Builder
{
return Media::query()
->where('mediable_type', Relation::getMorphAlias(Workspace::class))
->where('mediable_id', $workspace->id)
->where('collection', 'assets')
->when(filled($search), fn (Builder $query) => $query->where('original_filename', 'ilike', '%'.trim($search).'%'))
->when(filled($type), fn (Builder $query) => $query->where('type', $type))
->latest()
->orderByDesc('id');
}
}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Actions\Post;
use App\Actions\Media\FindWorkspaceAsset;
use App\Models\Media;
use App\Models\Post;
use App\Support\PostStatusRules;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AttachExistingAsset
{
public const UNSUPPORTED_TYPE_MESSAGE = 'This file type is not supported by the platforms enabled on the post.';
public const ASSET_NOT_FOUND_MESSAGE = 'Asset not found.';
/**
* Append a snapshot of the workspace asset to the post exactly once.
*/
public static function execute(Post $post, Media $media, ?string $alt = null): void
{
DB::transaction(function () use ($post, $media, $alt): void {
$fresh = Post::query()->whereKey($post->id)->lockForUpdate()->firstOrFail();
if (PostStatusRules::blocksEditing($fresh)) {
throw ValidationException::withMessages([
'asset_id' => PostStatusRules::editBlockedMessage(),
]);
}
$workspace = $fresh->workspace;
if ($workspace === null) {
throw ValidationException::withMessages([
'asset_id' => self::ASSET_NOT_FOUND_MESSAGE,
]);
}
$asset = FindWorkspaceAsset::execute($workspace, $media->id, lockForUpdate: true);
if ($asset === null) {
throw ValidationException::withMessages([
'asset_id' => self::ASSET_NOT_FOUND_MESSAGE,
]);
}
if (! in_array($asset->type, $fresh->allowedMediaTypes(), true)) {
throw ValidationException::withMessages([
'asset_id' => self::UNSUPPORTED_TYPE_MESSAGE,
]);
}
$alreadyAttached = collect($fresh->media ?? [])
->contains(fn (array $row): bool => data_get($row, 'id') === $asset->id);
if ($alreadyAttached) {
$post->setRawAttributes($fresh->getAttributes(), true);
return;
}
$fresh->update([
'media' => collect($fresh->media ?? [])->push(self::snapshot($asset, $alt))->all(),
]);
$post->setRawAttributes($fresh->getAttributes(), true);
});
}
/**
* @return array<string, mixed>
*/
private static function snapshot(Media $media, ?string $alt): array
{
$item = [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type->value,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
'size' => $media->size,
];
$meta = is_array($media->meta) ? $media->meta : [];
if (filled($alt) && $media->isImage()) {
$meta['alt_text'] = $alt;
}
if ($meta !== []) {
$item['meta'] = $meta;
}
return $item;
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Actions\Media\FindWorkspaceAsset;
use App\Actions\Media\ListWorkspaceAssets;
use App\Http\Requests\Api\Asset\IndexAssetRequest;
use App\Http\Resources\Api\AssetResource;
use App\Models\Media;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class AssetController extends Controller
{
public function index(IndexAssetRequest $request): AnonymousResourceCollection
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('createPost', $workspace);
return AssetResource::collection(ListWorkspaceAssets::execute(
$workspace,
data_get($request->validated(), 'search'),
data_get($request->validated(), 'type'),
));
}
public function show(Request $request, Media $media): AssetResource
{
$workspace = $request->user()->currentWorkspace;
$this->authorize('createPost', $workspace);
$asset = FindWorkspaceAsset::execute($workspace, $media->id);
abort_if($asset === null, Response::HTTP_NOT_FOUND);
return new AssetResource($asset);
}
}

View file

@ -4,6 +4,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Post\AttachExistingAsset;
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\HostInlineMedia;
@ -11,6 +12,7 @@
use App\Enums\Media\Type as MediaType;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\CreatedVia;
use App\Http\Requests\Api\Post\AttachExistingAssetRequest;
use App\Http\Requests\Api\Post\AttachMediaFromUrlRequest;
use App\Http\Requests\Api\Post\StoreMediaRequest;
use App\Http\Requests\Api\Post\StorePostRequest;
@ -146,6 +148,28 @@ public function storeMedia(StoreMediaRequest $request, Post $post): PostResource
return new PostResource($post);
}
public function attachExistingAsset(AttachExistingAssetRequest $request, Post $post): PostResource|JsonResponse
{
$this->authorize('update', $post);
if (PostStatusRules::blocksEditing($post)) {
return response()->json(
['message' => PostStatusRules::editBlockedMessage()],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
AttachExistingAsset::execute(
$post,
$request->asset(),
$request->validated('alt'),
);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
return new PostResource($post);
}
public function attachMediaFromUrl(AttachMediaFromUrlRequest $request, Post $post): PostMediaAttachResource
{
$this->authorize('update', $post);

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Asset;
use App\Enums\Media\Type as MediaType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class IndexAssetRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public static function filterRules(): array
{
return [
'search' => ['sometimes', 'string', 'max:255'],
'type' => ['sometimes', 'string', Rule::enum(MediaType::class)],
];
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return self::filterRules();
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\Post;
use App\Actions\Media\FindWorkspaceAsset;
use App\Actions\Post\AttachExistingAsset;
use App\Models\Media;
use App\Models\Post;
use App\Support\PostMediaRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Symfony\Component\HttpFoundation\Response;
class AttachExistingAssetRequest extends FormRequest
{
private ?Media $resolvedAsset = null;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'asset_id' => ['required', 'uuid'],
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
if ($validator->errors()->has('asset_id')) {
return;
}
$post = $this->route('post');
$workspace = $this->user()?->currentWorkspace;
if (! $post instanceof Post || $workspace === null) {
$validator->errors()->add('asset_id', AttachExistingAsset::ASSET_NOT_FOUND_MESSAGE);
return;
}
// Cross-tenant posts must 404 from PostPolicy, not 422 from a
// type check against the foreign post's platforms.
if ($post->workspace_id !== $this->user()?->current_workspace_id) {
return;
}
$asset = FindWorkspaceAsset::execute($workspace, (string) $this->input('asset_id'));
if ($asset === null) {
$validator->errors()->add('asset_id', AttachExistingAsset::ASSET_NOT_FOUND_MESSAGE);
return;
}
if (! in_array($asset->type, $post->allowedMediaTypes(), true)) {
$validator->errors()->add(
'asset_id',
AttachExistingAsset::UNSUPPORTED_TYPE_MESSAGE,
);
return;
}
$this->resolvedAsset = $asset;
});
}
public function asset(): Media
{
abort_if($this->resolvedAsset === null, Response::HTTP_NOT_FOUND);
return $this->resolvedAsset;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Mcp\Asset;
use App\Http\Requests\Api\Post\AttachExistingAssetRequest as ApiAttachExistingAssetRequest;
use Illuminate\Foundation\Http\FormRequest;
class AttachExistingAssetRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'post_id' => ['required', 'uuid'],
...(new ApiAttachExistingAssetRequest)->rules(),
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Mcp\Asset;
use Illuminate\Foundation\Http\FormRequest;
class GetAssetRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'asset_id' => ['required', 'uuid'],
];
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Mcp\Asset;
use App\Http\Requests\Api\Asset\IndexAssetRequest;
use Illuminate\Foundation\Http\FormRequest;
class ListAssetsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
...IndexAssetRequest::filterRules(),
'limit' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AssetResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'original_filename' => $this->original_filename,
'type' => $this->type->value,
'mime_type' => $this->mime_type,
'size' => $this->size,
'url' => $this->url,
'meta' => $this->meta,
'created_at' => $this->created_at->format('Y-m-d H:i:s'),
];
}
}

View file

@ -7,6 +7,9 @@
use App\Mcp\Tools\ApiKey\CreateApiKeyTool;
use App\Mcp\Tools\ApiKey\DeleteApiKeyTool;
use App\Mcp\Tools\ApiKey\ListApiKeysTool;
use App\Mcp\Tools\Asset\AttachExistingAssetTool;
use App\Mcp\Tools\Asset\GetAssetTool;
use App\Mcp\Tools\Asset\ListAssetsTool;
use App\Mcp\Tools\Label\CreateLabelTool;
use App\Mcp\Tools\Label\DeleteLabelTool;
use App\Mcp\Tools\Label\ListLabelsTool;
@ -41,7 +44,7 @@
#[Name('TryPost')]
#[Version('1.0.0')]
#[Icon('images/trypost/icon.png', mimeType: 'image/png')]
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, signatures, labels, social accounts, workspaces, and API keys.')]
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, the Asset Library, signatures, labels, social accounts, workspaces, and API keys.')]
class TryPostServer extends Server
{
public int $defaultPaginationLength = 100;
@ -60,6 +63,11 @@ class TryPostServer extends Server
AttachMediaFromUploadTool::class,
GetPostMetricsTool::class,
// Assets
ListAssetsTool::class,
GetAssetTool::class,
AttachExistingAssetTool::class,
// Platforms (read-only metadata)
ListContentTypesTool::class,

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Asset;
use App\Actions\Media\FindWorkspaceAsset;
use App\Actions\Post\AttachExistingAsset;
use App\Http\Requests\Mcp\Asset\AttachExistingAssetRequest;
use App\Http\Resources\Api\PostResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Models\Post;
use App\Support\PostStatusRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Reuse an existing Asset Library item on a post in the current workspace (the workspace bound to this MCP token). Does not upload a new file — for new media use request-media-upload-tool then attach-media-from-upload-tool, or attach-media-from-url-tool. Discover ids with list-assets-tool or get-asset-tool. The post must be draft or scheduled; published, partially published, failed, and publishing posts are rejected. The asset must already be in this workspace library. The asset type must be accepted by every enabled platform on the post (e.g. TikTok-only posts reject images). Repeating the same post_id and asset_id does not duplicate the media or change alt. Optional alt overrides image alt text only (ignored for video/document), maximum 2000 characters; omit to keep the library item\'s existing alt_text. Requires permission to update the post. Returns the updated post (same shape as other post tools).')]
class AttachExistingAssetTool extends Tool
{
use AuthorizesMcpTool;
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate((new AttachExistingAssetRequest)->rules());
$post = Post::where('workspace_id', $request->user()?->current_workspace_id)
->find(data_get($validated, 'post_id'));
if (! $post) {
return Response::error('Post not found.');
}
if ($denied = $this->denyUnlessCan($request, 'update', $post, 'Not authorized to update this post.')) {
return $denied;
}
if (PostStatusRules::blocksEditing($post)) {
return Response::error(PostStatusRules::editBlockedMessage());
}
$workspace = $request->user()?->currentWorkspace;
if ($workspace === null) {
return Response::error(AttachExistingAsset::ASSET_NOT_FOUND_MESSAGE);
}
$asset = FindWorkspaceAsset::execute($workspace, data_get($validated, 'asset_id'));
if (! $asset) {
return Response::error(AttachExistingAsset::ASSET_NOT_FOUND_MESSAGE);
}
if (! in_array($asset->type, $post->allowedMediaTypes(), true)) {
return Response::error(AttachExistingAsset::UNSUPPORTED_TYPE_MESSAGE);
}
AttachExistingAsset::execute(
$post,
$asset,
data_get($validated, 'alt'),
);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
return Response::structured([
'post' => (new PostResource($post))->resolve(),
]);
}
public function schema(JsonSchema $schema): array
{
return [
'post_id' => $schema->string()->required()->description('Required UUID of the post to update. Must belong to the current workspace and be draft or scheduled. Other workspaces return "Post not found." Published, partially published, failed, or publishing posts are rejected.'),
'asset_id' => $schema->string()->required()->description('Required UUID of an Asset Library item in the current workspace (from list-assets-tool or get-asset-tool). Missing, other-workspace, or non-library media returns "Asset not found." Type must be allowed by the post\'s enabled platforms.'),
'alt' => $schema->string()->description('Optional accessibility alt text for images (ignored for video and document). Maximum 2000 characters. When set, replaces any alt_text already on the library item. Omit to keep the library item\'s existing alt_text.'),
];
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Asset;
use App\Actions\Media\FindWorkspaceAsset;
use App\Http\Requests\Mcp\Asset\GetAssetRequest;
use App\Http\Resources\Api\AssetResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Get one Asset Library item by id from the current workspace (the workspace bound to this MCP token). The id must belong to the workspace "assets" collection — logos, avatars, and other workspaces return "Asset not found." Returns id, original_filename, type (image|video|document), mime_type, size, url, meta, and created_at. Does not include the storage path. url is the stored public file URL, not a short-lived signed preview. Requires permission to create posts (viewers cannot). Use list-assets-tool to discover ids. To attach this item to a draft or scheduled post, call attach-existing-asset-tool with the same asset_id.')]
class GetAssetTool extends Tool
{
use AuthorizesMcpTool;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->authorizeCurrentWorkspace(
$request,
'createPost',
'Not authorized to view assets.',
);
if (! $workspace instanceof Workspace) {
return $workspace;
}
$validated = $request->validate((new GetAssetRequest)->rules());
$asset = FindWorkspaceAsset::execute($workspace, data_get($validated, 'asset_id'));
if (! $asset) {
return Response::error('Asset not found.');
}
return Response::structured((new AssetResource($asset))->resolve());
}
public function schema(JsonSchema $schema): array
{
return [
'asset_id' => $schema->string()->required()->description('Required UUID of an Asset Library media item in the current workspace. Same id returned by list-assets-tool. Wrong workspace, missing id, or a non-library collection (logo/avatar) fails with "Asset not found."'),
];
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Asset;
use App\Actions\Media\ListWorkspaceAssets;
use App\Http\Requests\Mcp\Asset\ListAssetsRequest;
use App\Http\Resources\Api\AssetResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List Asset Library media for the current workspace (the workspace bound to this MCP token). Returns only the workspace "assets" collection — not logos or avatars. Newest first (created_at, then id). Each item includes id, original_filename, type (image|video|document), mime_type, size, url, meta, and created_at. Does not include the storage path. Use search to match original_filename (case-insensitive substring, max 255 characters) and type to keep a single media type. limit is 1100 (default 50). has_more is true when more items exist beyond this page. Requires permission to create posts (viewers cannot list). Use get-asset-tool for one item by id, or attach-existing-asset-tool to reuse an item on a draft/scheduled post. To add new files, use request-media-upload-tool or attach-media-from-url-tool instead.')]
class ListAssetsTool extends Tool
{
use AuthorizesMcpTool;
public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->authorizeCurrentWorkspace(
$request,
'createPost',
'Not authorized to view assets.',
);
if (! $workspace instanceof Workspace) {
return $workspace;
}
$validated = $request->validate((new ListAssetsRequest)->rules());
$limit = (int) data_get($validated, 'limit', 50);
$assets = ListWorkspaceAssets::query(
$workspace,
data_get($validated, 'search'),
data_get($validated, 'type'),
)->limit($limit + 1)->get();
$hasMore = $assets->count() > $limit;
return Response::structured([
'assets' => AssetResource::collection($assets->take($limit))->resolve(),
'has_more' => $hasMore,
]);
}
public function schema(JsonSchema $schema): array
{
return [
'search' => $schema->string()->description('Optional. Case-insensitive substring matched only against original_filename (not tags or captions). Maximum 255 characters. Omit to return every filename (still subject to type and limit).'),
'type' => $schema->string()->enum(['image', 'video', 'document'])->description('Optional. Keep only this media type. Omit to include image, video, and document. Rejects unknown values such as audio.'),
'limit' => $schema->integer()->description('Optional. Maximum number of items to return. Integer from 1 to 100. Defaults to 50 when omitted. When more matches exist, has_more is true.'),
];
}
}

View file

@ -50,6 +50,24 @@ public function video(): static
]);
}
public function assets(): static
{
return $this->state(fn (array $attributes) => [
'collection' => 'assets',
]);
}
public function document(): static
{
return $this->state(fn (array $attributes) => [
'type' => MediaType::Document,
'path' => 'media/'.now()->format('Y-m').'/'.$this->faker->uuid().'.pdf',
'original_filename' => $this->faker->word().'.pdf',
'mime_type' => 'application/pdf',
'meta' => [],
]);
}
public function logo(): static
{
return $this->state(fn (array $attributes) => [

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Http\Controllers\Api\ApiKeyController;
use App\Http\Controllers\Api\AssetController;
use App\Http\Controllers\Api\LabelController;
use App\Http\Controllers\Api\PlatformController;
use App\Http\Controllers\Api\PostController;
@ -26,6 +27,7 @@
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('api.posts.destroy');
Route::post('/posts/{post}/media', [PostController::class, 'storeMedia'])->name('api.posts.store-media');
Route::post('/posts/{post}/media/from-url', [PostController::class, 'attachMediaFromUrl'])->name('api.posts.attach-media-from-url');
Route::post('/posts/{post}/media/from-asset', [PostController::class, 'attachExistingAsset'])->name('api.posts.attach-existing-asset');
Route::get('/posts/{post}/metrics', [PostController::class, 'metrics'])->name('api.posts.metrics');
Route::get('/posts/{post}/preview', [PostController::class, 'preview'])->name('api.posts.preview');
@ -41,6 +43,10 @@
Route::put('/signatures/{signature}', [SignatureController::class, 'update'])->name('api.signatures.update');
Route::delete('/signatures/{signature}', [SignatureController::class, 'destroy'])->name('api.signatures.destroy');
// Assets
Route::get('/assets', [AssetController::class, 'index'])->name('api.assets.index');
Route::get('/assets/{media}', [AssetController::class, 'show'])->name('api.assets.show');
// Labels
Route::get('/labels', [LabelController::class, 'index'])->name('api.labels.index');
Route::post('/labels', [LabelController::class, 'store'])->name('api.labels.store');

View file

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
use App\Enums\Media\Type as MediaType;
use App\Enums\UserWorkspace\Role;
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$result = createApiTestToken();
$this->user = $result['user'];
$this->workspace = $result['workspace'];
$this->plainToken = $result['plain_token'];
Storage::fake();
});
test('lists current workspace asset library media', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'hero.jpg',
]);
Media::factory()->logo()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$other = Workspace::factory()->create();
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $other->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index'))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $asset->id)
->assertJsonPath('data.0.original_filename', 'hero.jpg')
->assertJsonPath('data.0.type', MediaType::Image->value)
->assertJsonPath('data.0.url', $asset->url)
->assertJsonMissingPath('data.0.path');
});
test('filters assets by filename search and type', function () {
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'campaign-hero.jpg',
]);
Media::factory()->assets()->video()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'campaign-reel.mp4',
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index', ['search' => 'hero', 'type' => 'image']))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.original_filename', 'campaign-hero.jpg');
});
test('paginates assets with the application page size', function () {
$perPage = (int) config('app.pagination.default');
Media::factory()->assets()->count($perPage + 1)->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index'))
->assertOk()
->assertJsonCount($perPage, 'data')
->assertJsonPath('meta.per_page', $perPage)
->assertJsonPath('meta.total', $perPage + 1)
->assertJsonPath('meta.current_page', 1);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index', ['page' => 2]))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('meta.current_page', 2);
});
test('filters assets by document type', function () {
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$document = Media::factory()->assets()->document()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'brief.pdf',
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index', ['type' => 'document']))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $document->id)
->assertJsonPath('data.0.type', MediaType::Document->value);
});
test('rejects unknown type filters', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.index', ['type' => 'audio']))
->assertUnprocessable()
->assertJsonValidationErrors(['type']);
});
test('shows an asset', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.show', $asset))
->assertOk()
->assertJsonPath('id', $asset->id)
->assertJsonPath('url', $asset->url)
->assertJsonMissingPath('path');
});
test('does not show a logo or avatar as an asset', function () {
$logo = Media::factory()->logo()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$avatar = Media::factory()->avatar()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.show', $logo))
->assertNotFound();
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.show', $avatar))
->assertNotFound();
});
test('does not reveal another workspace asset', function () {
$other = Workspace::factory()->create();
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $other->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.assets.show', $asset))
->assertNotFound();
});
test('viewers cannot list or show assets', function () {
$viewer = User::factory()->create(['account_id' => $this->user->account_id]);
$this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]);
$viewer->update(['current_workspace_id' => $this->workspace->id]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$token = passportToken($viewer, $this->workspace);
$this->withHeaders(['Authorization' => 'Bearer '.$token])
->getJson(route('api.assets.index'))
->assertForbidden();
$this->withHeaders(['Authorization' => 'Bearer '.$token])
->getJson(route('api.assets.show', $asset))
->assertForbidden();
});

View file

@ -2,16 +2,23 @@
declare(strict_types=1);
use App\Actions\Post\AttachExistingAsset;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Support\PostMediaRules;
use App\Support\PostStatusRules;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
// A public IP literal as the host lets SafeHttpFetcher's SSRF guard pass without
// a real DNS lookup; Http::fake() intercepts the request before any network I/O.
@ -629,3 +636,314 @@
expect(Post::where('content', 'Alt text too long post')->exists())->toBeFalse();
});
it('attaches an existing workspace asset to a post', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'library.jpg',
'size' => 12345,
'meta' => [
'width' => 1920,
'height' => 1080,
'duration' => 12.5,
'color_space' => 'srgb',
],
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
'alt' => 'Library hero',
])
->assertOk()
->assertJsonPath('id', $this->post->id);
expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.id'))->toBe($asset->id)
->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345)
->and(data_get($this->post->fresh()->media, '0.meta'))->toBe([
'width' => 1920,
'height' => 1080,
'duration' => 12.5,
'color_space' => 'srgb',
'alt_text' => 'Library hero',
]);
});
it('preserves library alt text when attach omits alt', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'meta' => [
'width' => 800,
'height' => 600,
'alt_text' => 'From library',
],
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
])
->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta'))->toBe([
'width' => 800,
'height' => 600,
'alt_text' => 'From library',
]);
});
it('attaches an existing document asset without inventing meta', function () {
$asset = Media::factory()->assets()->document()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'brief.pdf',
'meta' => [],
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
'alt' => 'ignored for pdf',
])
->assertOk();
expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.id'))->toBe($asset->id)
->and(data_get($this->post->fresh()->media, '0.type'))->toBe('document')
->and(data_get($this->post->fresh()->media, '0.meta'))->toBeNull();
});
it('does not duplicate an already attached asset', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
'alt' => 'First alt',
])
->assertOk()
->assertJsonPath('id', $this->post->id);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
'alt' => 'Replacement alt',
])
->assertOk()
->assertJsonPath('id', $this->post->id);
expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('First alt');
});
it('does not store alt text for existing non-image assets', function () {
$asset = Media::factory()->assets()->video()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
'alt' => 'ignored for video',
])
->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBeNull()
->and(data_get($this->post->fresh()->media, '0.meta.duration'))->not->toBeNull();
});
it('rejects existing-asset alt text over the stored maximum', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
'alt' => str_repeat('a', PostMediaRules::ALT_TEXT_MAX_LENGTH + 1),
])
->assertUnprocessable()
->assertJsonValidationErrors(['alt']);
expect($this->post->fresh()->media)->toHaveCount(0);
});
it('attaches an existing asset to a scheduled post', function () {
$this->post->update([
'status' => PostStatus::Scheduled,
'scheduled_at' => now()->addDay(),
]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
])
->assertOk();
expect($this->post->fresh()->media)->toHaveCount(1);
});
it('does not reveal a post from another workspace when attaching an asset', function () {
$other = Workspace::factory()->create();
$foreignPost = Post::factory()->create([
'workspace_id' => $other->id,
'user_id' => $this->user->id,
]);
$tiktok = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $other->id,
]);
PostPlatform::factory()->tiktok()->create([
'post_id' => $foreignPost->id,
'social_account_id' => $tiktok->id,
'enabled' => true,
]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $foreignPost), [
'asset_id' => $asset->id,
])
->assertNotFound();
});
it('does not duplicate an asset when two post instances attach it', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$first = $this->post->fresh();
$second = $this->post->fresh();
AttachExistingAsset::execute($first, $asset);
AttachExistingAsset::execute($second, $asset);
expect($this->post->fresh()->media)->toHaveCount(1);
});
it('forbids viewers from attaching an existing asset', function () {
$viewer = User::factory()->create(['account_id' => $this->user->account_id]);
$this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]);
$viewer->update(['current_workspace_id' => $this->workspace->id]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.passportToken($viewer, $this->workspace)])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
])
->assertForbidden();
expect($this->post->fresh()->media)->toHaveCount(0);
});
it('rejects an asset from another workspace', function () {
$other = Workspace::factory()->create();
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $other->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
])
->assertUnprocessable()
->assertJsonValidationErrors(['asset_id']);
expect($this->post->fresh()->media)->toHaveCount(0);
});
it('rejects attaching an existing asset when the post cannot be edited', function (PostStatus $status) {
$this->post->update(['status' => $status]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
])
->assertUnprocessable()
->assertJsonPath('message', PostStatusRules::editBlockedMessage());
expect($this->post->fresh()->media)->toHaveCount(0);
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
]);
it('rejects attaching via the action when the post cannot be edited', function () {
$this->post->update(['status' => PostStatus::Published]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
expect(fn () => AttachExistingAsset::execute($this->post, $asset))
->toThrow(ValidationException::class);
expect($this->post->fresh()->media)->toHaveCount(0);
});
it('rejects attaching via the action when the library asset was deleted', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$stale = $asset->fresh();
$asset->delete();
expect(fn () => AttachExistingAsset::execute($this->post, $stale))
->toThrow(ValidationException::class);
expect($this->post->fresh()->media)->toHaveCount(0);
});
it('rejects an asset type the enabled platforms cannot publish', function () {
$this->socialAccount->update(['platform' => Platform::TikTok]);
$this->post->postPlatforms()->delete();
PostPlatform::factory()->tiktok()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'enabled' => true,
]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-existing-asset', $this->post), [
'asset_id' => $asset->id,
])
->assertUnprocessable()
->assertJsonValidationErrors(['asset_id']);
expect($this->post->fresh()->media)->toHaveCount(0);
});

View file

@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
use App\Actions\Post\AttachExistingAsset;
use App\Enums\Media\Type as MediaType;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\Asset\AttachExistingAssetTool;
use App\Mcp\Tools\Asset\GetAssetTool;
use App\Mcp\Tools\Asset\ListAssetsTool;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Support\PostStatusRules;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Testing\Fluent\AssertableJson;
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->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
Storage::fake();
});
test('lists current workspace assets with the asset resource shape', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'hero.jpg',
]);
Media::factory()->logo()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$other = Workspace::factory()->create();
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $other->id,
]);
TryPostServer::actingAs($this->user)
->tool(ListAssetsTool::class, [])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) use ($asset) {
$json->has('assets', 1, function (AssertableJson $item) use ($asset) {
$item->where('id', $asset->id)
->where('original_filename', 'hero.jpg')
->where('type', MediaType::Image->value)
->hasAll(['mime_type', 'size', 'url', 'meta', 'created_at'])
->missing('path');
})->where('has_more', false);
});
});
test('filters and limits listed assets', function () {
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'one.jpg',
]);
Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'two.jpg',
]);
Media::factory()->assets()->video()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'original_filename' => 'reel.mp4',
]);
TryPostServer::actingAs($this->user)
->tool(ListAssetsTool::class, ['type' => 'image', 'limit' => 1])
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->has('assets', 1)->where('has_more', true));
TryPostServer::actingAs($this->user)
->tool(ListAssetsTool::class, ['search' => 'reel', 'type' => 'video'])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('assets', 1, function (AssertableJson $item) {
$item->where('original_filename', 'reel.mp4')->etc();
})->where('has_more', false);
});
TryPostServer::actingAs($this->user)
->tool(ListAssetsTool::class, ['type' => 'image', 'limit' => 2])
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->has('assets', 2)->where('has_more', false));
});
test('rejects out of range list limits', function (int $limit) {
TryPostServer::actingAs($this->user)
->tool(ListAssetsTool::class, ['limit' => $limit])
->assertHasErrors();
})->with([0, 101]);
test('returns a workspace asset', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(GetAssetTool::class, ['asset_id' => $asset->id])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) use ($asset) {
$json->where('id', $asset->id)
->where('url', $asset->url)
->hasAll(['original_filename', 'type', 'mime_type', 'size', 'meta', 'created_at'])
->missing('path');
});
});
test('does not return a logo or avatar from the asset library', function () {
$logo = Media::factory()->logo()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
$avatar = Media::factory()->avatar()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(GetAssetTool::class, ['asset_id' => $logo->id])
->assertHasErrors(['Asset not found.']);
TryPostServer::actingAs($this->user)
->tool(GetAssetTool::class, ['asset_id' => $avatar->id])
->assertHasErrors(['Asset not found.']);
});
test('missing and cross workspace assets do not reveal metadata', function () {
$other = Workspace::factory()->create();
$foreign = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $other->id,
]);
TryPostServer::actingAs($this->user)
->tool(GetAssetTool::class, ['asset_id' => $foreign->id])
->assertHasErrors(['Asset not found.']);
TryPostServer::actingAs($this->user)
->tool(GetAssetTool::class, ['asset_id' => (string) Str::uuid()])
->assertHasErrors(['Asset not found.']);
});
test('attaches an existing workspace asset once', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'size' => 12345,
'meta' => [
'width' => 1920,
'height' => 1080,
'duration' => 12.5,
'color_space' => 'srgb',
],
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
'alt' => 'Hero image',
])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('post.id');
});
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
'alt' => 'Replacement alt',
])
->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('post.id')->etc();
});
expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345)
->and(data_get($this->post->fresh()->media, '0.meta'))->toBe([
'width' => 1920,
'height' => 1080,
'duration' => 12.5,
'color_space' => 'srgb',
'alt_text' => 'Hero image',
]);
});
test('preserves library alt text when attach omits alt', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'meta' => [
'width' => 800,
'height' => 600,
'alt_text' => 'From library',
],
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
])
->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta'))->toBe([
'width' => 800,
'height' => 600,
'alt_text' => 'From library',
]);
});
test('attaches an existing document asset without inventing meta', function () {
$asset = Media::factory()->assets()->document()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'meta' => [],
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
'alt' => 'ignored for pdf',
])
->assertOk();
expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.type'))->toBe('document')
->and(data_get($this->post->fresh()->media, '0.meta'))->toBeNull();
});
test('does not store alt text for existing non-image assets', function () {
$asset = Media::factory()->assets()->video()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
'alt' => 'ignored',
])
->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBeNull()
->and(data_get($this->post->fresh()->media, '0.meta.duration'))->not->toBeNull();
});
test('attaches an existing asset to a scheduled post', function () {
$this->post->update([
'status' => PostStatus::Scheduled,
'scheduled_at' => now()->addDay(),
]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
])
->assertOk();
expect($this->post->fresh()->media)->toHaveCount(1);
});
test('rejects cross-workspace assets and posts without mutating the post', function () {
$other = User::factory()->create();
$otherWorkspace = Workspace::factory()->create(['user_id' => $other->id]);
$foreignAsset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $otherWorkspace->id,
]);
$foreignPost = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $other->id,
]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $foreignAsset->id,
])
->assertHasErrors(['Asset not found.']);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $foreignPost->id,
'asset_id' => $asset->id,
])
->assertHasErrors(['Post not found.']);
expect($this->post->fresh()->media)->toHaveCount(0);
});
test('rejects posts in non-editable states', function (PostStatus $status) {
$this->post->update(['status' => $status]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
])
->assertHasErrors([PostStatusRules::editBlockedMessage()]);
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
]);
test('rejects assets that enabled post platforms cannot publish', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::TikTok,
]);
PostPlatform::factory()->tiktok()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'enabled' => true,
]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);
TryPostServer::actingAs($this->user)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => $asset->id,
])
->assertHasErrors([AttachExistingAsset::UNSUPPORTED_TYPE_MESSAGE]);
});

View file

@ -5,6 +5,9 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\Asset\AttachExistingAssetTool;
use App\Mcp\Tools\Asset\GetAssetTool;
use App\Mcp\Tools\Asset\ListAssetsTool;
use App\Mcp\Tools\Label\CreateLabelTool;
use App\Mcp\Tools\Label\DeleteLabelTool;
use App\Mcp\Tools\Label\ListLabelsTool;
@ -105,6 +108,21 @@
TryPostServer::actingAs($this->viewer)
->tool(RequestMediaUploadTool::class, [])
->assertHasErrors(['Not authorized to upload media.']);
TryPostServer::actingAs($this->viewer)
->tool(ListAssetsTool::class, [])
->assertHasErrors(['Not authorized to view assets.']);
TryPostServer::actingAs($this->viewer)
->tool(GetAssetTool::class, ['asset_id' => (string) Str::uuid()])
->assertHasErrors(['Not authorized to view assets.']);
TryPostServer::actingAs($this->viewer)
->tool(AttachExistingAssetTool::class, [
'post_id' => $this->post->id,
'asset_id' => (string) Str::uuid(),
])
->assertHasErrors(['Not authorized to update this post.']);
});
test('viewers cannot manage labels or signatures via mcp', function () {