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>
2026-08-16 20:08:20 +00:00
|
|
|
<?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');
|
|
|
|
|
});
|
|
|
|
|
|
Use whereLike for search so MySQL works alongside PostgreSQL (#302)
Seven search call sites used the `ilike` operator, which only PostgreSQL
understands. On MySQL they raise a syntax error, so post, asset, label,
signature and workspace-member search — plus the MCP list-posts tool —
were unusable on an engine `config/database.php` has always supported and
the docs advertise.
Replace them with `whereLike($column, $value)`, which the query grammars
translate per driver: PostgresGrammar emits `ilike` and MySqlGrammar emits
`like`. The generated SQL on PostgreSQL is therefore unchanged.
Verified by running the full suite on both engines:
PostgreSQL 16 3888 passed, 0 failed
MySQL 8.0.46 one pre-existing failure fixed, none introduced
Also adds case-insensitivity assertions to the five affected suites that
lacked them, and search coverage for ListPostsTool, which had none.
Note for MySQL installs: `like` is case-insensitive by virtue of the
column collation, not the operator. Under the default `utf8mb4_unicode_ci`
it is also accent-insensitive, so a search for "cafe" matches a stored
"café" — PostgreSQL's `ilike` does not. That difference comes from the
collation rather than this change. A `_bin` or `_cs` collation would make
search case-sensitive on both.
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-26 13:59:31 +00:00
|
|
|
test('filters assets by filename search case-insensitively', function () {
|
|
|
|
|
Media::factory()->assets()->create([
|
|
|
|
|
'mediable_type' => (new Workspace)->getMorphClass(),
|
|
|
|
|
'mediable_id' => $this->workspace->id,
|
|
|
|
|
'original_filename' => 'CAMPAIGN-Hero.jpg',
|
|
|
|
|
]);
|
|
|
|
|
Media::factory()->assets()->create([
|
|
|
|
|
'mediable_type' => (new Workspace)->getMorphClass(),
|
|
|
|
|
'mediable_id' => $this->workspace->id,
|
|
|
|
|
'original_filename' => 'office-shot.jpg',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
|
|
|
|
->getJson(route('api.assets.index', ['search' => 'campaign-hero']))
|
|
|
|
|
->assertOk()
|
|
|
|
|
->assertJsonCount(1, 'data')
|
|
|
|
|
->assertJsonPath('data.0.original_filename', 'CAMPAIGN-Hero.jpg');
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-08-16 20:08:20 +00:00
|
|
|
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();
|
|
|
|
|
});
|