From eb2b345163a72124e501dc01155516107f44796a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 16 Aug 2026 17:08:20 -0300 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * Harden asset attach against races and keep library metadata on the post. Co-authored-by: Cursor * Relock the library asset on attach so a deleted file cannot land on the post. Co-authored-by: Cursor * Document that omitting alt on attach keeps the library alt text. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- app/Actions/Media/FindWorkspaceAsset.php | 24 ++ app/Actions/Media/ListWorkspaceAssets.php | 35 ++ app/Actions/Post/AttachExistingAsset.php | 99 +++++ app/Http/Controllers/Api/AssetController.php | 43 ++ app/Http/Controllers/Api/PostController.php | 24 ++ .../Requests/Api/Asset/IndexAssetRequest.php | 36 ++ .../Api/Post/AttachExistingAssetRequest.php | 85 ++++ .../Mcp/Asset/AttachExistingAssetRequest.php | 27 ++ .../Requests/Mcp/Asset/GetAssetRequest.php | 25 ++ .../Requests/Mcp/Asset/ListAssetsRequest.php | 27 ++ app/Http/Resources/Api/AssetResource.php | 28 ++ app/Mcp/Servers/TryPostServer.php | 10 +- .../Tools/Asset/AttachExistingAssetTool.php | 82 ++++ app/Mcp/Tools/Asset/GetAssetTool.php | 55 +++ app/Mcp/Tools/Asset/ListAssetsTool.php | 64 +++ database/factories/MediaFactory.php | 18 + routes/api.php | 6 + tests/Feature/Api/AssetApiTest.php | 181 +++++++++ tests/Feature/Api/PostMediaApiTest.php | 318 +++++++++++++++ tests/Feature/Mcp/AssetToolTest.php | 368 ++++++++++++++++++ .../Feature/Mcp/McpRoleAuthorizationTest.php | 18 + 21 files changed, 1572 insertions(+), 1 deletion(-) create mode 100644 app/Actions/Media/FindWorkspaceAsset.php create mode 100644 app/Actions/Media/ListWorkspaceAssets.php create mode 100644 app/Actions/Post/AttachExistingAsset.php create mode 100644 app/Http/Controllers/Api/AssetController.php create mode 100644 app/Http/Requests/Api/Asset/IndexAssetRequest.php create mode 100644 app/Http/Requests/Api/Post/AttachExistingAssetRequest.php create mode 100644 app/Http/Requests/Mcp/Asset/AttachExistingAssetRequest.php create mode 100644 app/Http/Requests/Mcp/Asset/GetAssetRequest.php create mode 100644 app/Http/Requests/Mcp/Asset/ListAssetsRequest.php create mode 100644 app/Http/Resources/Api/AssetResource.php create mode 100644 app/Mcp/Tools/Asset/AttachExistingAssetTool.php create mode 100644 app/Mcp/Tools/Asset/GetAssetTool.php create mode 100644 app/Mcp/Tools/Asset/ListAssetsTool.php create mode 100644 tests/Feature/Api/AssetApiTest.php create mode 100644 tests/Feature/Mcp/AssetToolTest.php diff --git a/app/Actions/Media/FindWorkspaceAsset.php b/app/Actions/Media/FindWorkspaceAsset.php new file mode 100644 index 00000000..ea85a422 --- /dev/null +++ b/app/Actions/Media/FindWorkspaceAsset.php @@ -0,0 +1,24 @@ +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(); + } +} diff --git a/app/Actions/Media/ListWorkspaceAssets.php b/app/Actions/Media/ListWorkspaceAssets.php new file mode 100644 index 00000000..08833e85 --- /dev/null +++ b/app/Actions/Media/ListWorkspaceAssets.php @@ -0,0 +1,35 @@ +paginate((int) config('app.pagination.default')); + } + + /** + * @return Builder + */ + 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'); + } +} diff --git a/app/Actions/Post/AttachExistingAsset.php b/app/Actions/Post/AttachExistingAsset.php new file mode 100644 index 00000000..5e728b52 --- /dev/null +++ b/app/Actions/Post/AttachExistingAsset.php @@ -0,0 +1,99 @@ +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 + */ + 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; + } +} diff --git a/app/Http/Controllers/Api/AssetController.php b/app/Http/Controllers/Api/AssetController.php new file mode 100644 index 00000000..b66c4c5a --- /dev/null +++ b/app/Http/Controllers/Api/AssetController.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 7460612e..d7fa0e77 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -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); diff --git a/app/Http/Requests/Api/Asset/IndexAssetRequest.php b/app/Http/Requests/Api/Asset/IndexAssetRequest.php new file mode 100644 index 00000000..eb3030b0 --- /dev/null +++ b/app/Http/Requests/Api/Asset/IndexAssetRequest.php @@ -0,0 +1,36 @@ + + */ + public static function filterRules(): array + { + return [ + 'search' => ['sometimes', 'string', 'max:255'], + 'type' => ['sometimes', 'string', Rule::enum(MediaType::class)], + ]; + } + + /** + * @return array + */ + public function rules(): array + { + return self::filterRules(); + } +} diff --git a/app/Http/Requests/Api/Post/AttachExistingAssetRequest.php b/app/Http/Requests/Api/Post/AttachExistingAssetRequest.php new file mode 100644 index 00000000..20348ca1 --- /dev/null +++ b/app/Http/Requests/Api/Post/AttachExistingAssetRequest.php @@ -0,0 +1,85 @@ + + */ + 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; + } +} diff --git a/app/Http/Requests/Mcp/Asset/AttachExistingAssetRequest.php b/app/Http/Requests/Mcp/Asset/AttachExistingAssetRequest.php new file mode 100644 index 00000000..9be2689c --- /dev/null +++ b/app/Http/Requests/Mcp/Asset/AttachExistingAssetRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'post_id' => ['required', 'uuid'], + ...(new ApiAttachExistingAssetRequest)->rules(), + ]; + } +} diff --git a/app/Http/Requests/Mcp/Asset/GetAssetRequest.php b/app/Http/Requests/Mcp/Asset/GetAssetRequest.php new file mode 100644 index 00000000..565c9c65 --- /dev/null +++ b/app/Http/Requests/Mcp/Asset/GetAssetRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'asset_id' => ['required', 'uuid'], + ]; + } +} diff --git a/app/Http/Requests/Mcp/Asset/ListAssetsRequest.php b/app/Http/Requests/Mcp/Asset/ListAssetsRequest.php new file mode 100644 index 00000000..01db091e --- /dev/null +++ b/app/Http/Requests/Mcp/Asset/ListAssetsRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + ...IndexAssetRequest::filterRules(), + 'limit' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Resources/Api/AssetResource.php b/app/Http/Resources/Api/AssetResource.php new file mode 100644 index 00000000..a22ed059 --- /dev/null +++ b/app/Http/Resources/Api/AssetResource.php @@ -0,0 +1,28 @@ + + */ + 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'), + ]; + } +} diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index a26899d1..a8d98810 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -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, diff --git a/app/Mcp/Tools/Asset/AttachExistingAssetTool.php b/app/Mcp/Tools/Asset/AttachExistingAssetTool.php new file mode 100644 index 00000000..5d2df89c --- /dev/null +++ b/app/Mcp/Tools/Asset/AttachExistingAssetTool.php @@ -0,0 +1,82 @@ +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.'), + ]; + } +} diff --git a/app/Mcp/Tools/Asset/GetAssetTool.php b/app/Mcp/Tools/Asset/GetAssetTool.php new file mode 100644 index 00000000..2b4b64e1 --- /dev/null +++ b/app/Mcp/Tools/Asset/GetAssetTool.php @@ -0,0 +1,55 @@ +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."'), + ]; + } +} diff --git a/app/Mcp/Tools/Asset/ListAssetsTool.php b/app/Mcp/Tools/Asset/ListAssetsTool.php new file mode 100644 index 00000000..e65c0a76 --- /dev/null +++ b/app/Mcp/Tools/Asset/ListAssetsTool.php @@ -0,0 +1,64 @@ +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.'), + ]; + } +} diff --git a/database/factories/MediaFactory.php b/database/factories/MediaFactory.php index 5c62ba5f..cbd1e0a1 100644 --- a/database/factories/MediaFactory.php +++ b/database/factories/MediaFactory.php @@ -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) => [ diff --git a/routes/api.php b/routes/api.php index 0ed1f0b7..f1c1f469 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Api/AssetApiTest.php b/tests/Feature/Api/AssetApiTest.php new file mode 100644 index 00000000..c4ad4d4b --- /dev/null +++ b/tests/Feature/Api/AssetApiTest.php @@ -0,0 +1,181 @@ +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(); +}); diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index c7297666..32901a01 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -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); +}); diff --git a/tests/Feature/Mcp/AssetToolTest.php b/tests/Feature/Mcp/AssetToolTest.php new file mode 100644 index 00000000..02da5600 --- /dev/null +++ b/tests/Feature/Mcp/AssetToolTest.php @@ -0,0 +1,368 @@ +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]); +}); diff --git a/tests/Feature/Mcp/McpRoleAuthorizationTest.php b/tests/Feature/Mcp/McpRoleAuthorizationTest.php index beb03faa..465dbb0e 100644 --- a/tests/Feature/Mcp/McpRoleAuthorizationTest.php +++ b/tests/Feature/Mcp/McpRoleAuthorizationTest.php @@ -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 () {