Browse and clean up the media library through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1289,6 +1289,11 @@ GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el
|
|||||||
GET /api/v1/maintenance modo mantenimiento de la web pública
|
GET /api/v1/maintenance modo mantenimiento de la web pública
|
||||||
PUT /api/v1/maintenance {enabled, message, until}
|
PUT /api/v1/maintenance {enabled, message, until}
|
||||||
POST /api/v1/media subir una imagen (fichero o URL)
|
POST /api/v1/media subir una imagen (fichero o URL)
|
||||||
|
GET /api/v1/media la biblioteca; filtros: for, mime, search,
|
||||||
|
pending, failed, orphans; cada una dice si
|
||||||
|
algo la usa (referenced)
|
||||||
|
DELETE /api/v1/media/{id} solo si nada la usa (409 si no), con sus versiones
|
||||||
|
POST /api/v1/media/{id}/reprocess volver a derivar versiones (cola)
|
||||||
```
|
```
|
||||||
|
|
||||||
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
|
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
|
||||||
|
|||||||
@@ -7,19 +7,22 @@ namespace App\Http\Controllers\Api\V1;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\MediaRequest;
|
use App\Http\Requests\Api\V1\MediaRequest;
|
||||||
use App\Http\Resources\MediaResource;
|
use App\Http\Resources\MediaResource;
|
||||||
|
use App\Jobs\ProcessMedia;
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Services\Media\RemoteImage;
|
use App\Services\Media\RemoteImage;
|
||||||
use enshrined\svgSanitize\Sanitizer;
|
use enshrined\svgSanitize\Sanitizer;
|
||||||
use finfo;
|
use finfo;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/media
|
* The media library: every image on the media disk, and uploading new ones.
|
||||||
*
|
*
|
||||||
* Stores an image and answers with the path a record points at — what the
|
* `store` stores an image and answers with the path a record points at — what the
|
||||||
* panel's upload field does on a form, without the form. It exists for the MCP
|
* panel's upload field does on a form, without the form. It exists for the MCP
|
||||||
* server, which otherwise could create a page but never give it a picture.
|
* server, which otherwise could create a page but never give it a picture.
|
||||||
*
|
*
|
||||||
@@ -44,6 +47,75 @@ class MediaController extends Controller
|
|||||||
'image/svg+xml' => 'svg',
|
'image/svg+xml' => 'svg',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters: for (the kind of record it was uploaded for), mime, search
|
||||||
|
* (path), pending, failed, orphans (what `content:prune --media` would
|
||||||
|
* take away).
|
||||||
|
*/
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Media::class);
|
||||||
|
|
||||||
|
// Worked out once for the page rather than once per file: it reads
|
||||||
|
// every record that can point at an image.
|
||||||
|
$referenced = array_flip(Media::referencedPaths());
|
||||||
|
|
||||||
|
$directory = config('media.upload.for.'.$request->string('for'));
|
||||||
|
|
||||||
|
$media = Media::query()
|
||||||
|
->when(is_string($directory), fn ($q) => $q->where('path', 'like', $directory.'/%'))
|
||||||
|
->when($request->filled('mime'), fn ($q) => $q->where('mime', $request->string('mime')))
|
||||||
|
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
|
||||||
|
->when($request->boolean('pending'), fn ($q) => $q->pending())
|
||||||
|
->when($request->boolean('failed'), fn ($q) => $q->whereNotNull('error'))
|
||||||
|
->when($request->boolean('orphans'), fn ($q) => $q->whereNotIn('path', array_keys($referenced)))
|
||||||
|
->latest('id')
|
||||||
|
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
$media->getCollection()->each(fn (Media $item) => $item->setAttribute('referenced', isset($referenced[$item->path])));
|
||||||
|
|
||||||
|
return MediaResource::collection($media);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Media $media): MediaResource
|
||||||
|
{
|
||||||
|
$this->authorize('view', $media);
|
||||||
|
|
||||||
|
return MediaResource::make($media->setAttribute('referenced', $media->isReferenced()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only a file nothing points at: deleting one in use would put a broken
|
||||||
|
* image on the public site. Its derived versions go with it.
|
||||||
|
*/
|
||||||
|
public function destroy(Media $media): JsonResponse
|
||||||
|
{
|
||||||
|
// The permission first, so whether a file is in use is only told to
|
||||||
|
// someone who could have deleted it.
|
||||||
|
$this->authorize('media.delete');
|
||||||
|
|
||||||
|
abort_if($media->isReferenced(), 409, __('rules.media.in_use'));
|
||||||
|
|
||||||
|
$media->deleteDerived();
|
||||||
|
Storage::disk($media->disk)->delete($media->path);
|
||||||
|
$media->delete();
|
||||||
|
|
||||||
|
return response()->json(status: 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the smaller versions and the placeholder again, on the queue.
|
||||||
|
*/
|
||||||
|
public function reprocess(Media $media): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $media);
|
||||||
|
|
||||||
|
ProcessMedia::dispatch($media);
|
||||||
|
|
||||||
|
return MediaResource::make($media)->response()->setStatusCode(202);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
|
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
|
||||||
{
|
{
|
||||||
$for = (string) $request->input('for', 'pages');
|
$for = (string) $request->input('for', 'pages');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class MediaResource extends JsonResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
// What a record stores — `featured_image` on a page — and so the one
|
// What a record stores — `featured_image` on a page — and so the one
|
||||||
// field a caller has to keep.
|
// field a caller has to keep.
|
||||||
'path' => $this->path,
|
'path' => $this->path,
|
||||||
@@ -30,6 +31,13 @@ class MediaResource extends JsonResource
|
|||||||
// False until the queue has derived the smaller versions and the
|
// False until the queue has derived the smaller versions and the
|
||||||
// placeholder. The image serves either way.
|
// placeholder. The image serves either way.
|
||||||
'processed' => $this->isProcessed(),
|
'processed' => $this->isProcessed(),
|
||||||
|
'error' => $this->error,
|
||||||
|
// Only in the library, where it decides whether a file may go.
|
||||||
|
'referenced' => $this->when(
|
||||||
|
array_key_exists('referenced', $this->resource->getAttributes()),
|
||||||
|
fn () => (bool) $this->resource->getAttribute('referenced'),
|
||||||
|
),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'media' => [
|
'media' => [
|
||||||
|
'in_use' => 'La imatge s\'està fent servir: treu-la primer d\'on apareix.',
|
||||||
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
||||||
'remote' => [
|
'remote' => [
|
||||||
'https_only' => 'La URL de la imatge ha de començar per https://.',
|
'https_only' => 'La URL de la imatge ha de començar per https://.',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'media' => [
|
'media' => [
|
||||||
|
'in_use' => 'The image is in use: remove it from where it appears first.',
|
||||||
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
|
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
|
||||||
'remote' => [
|
'remote' => [
|
||||||
'https_only' => 'The image URL must start with https://.',
|
'https_only' => 'The image URL must start with https://.',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'media' => [
|
'media' => [
|
||||||
|
'in_use' => 'La imagen se está usando: quítala antes de donde aparece.',
|
||||||
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
|
||||||
'remote' => [
|
'remote' => [
|
||||||
'https_only' => 'La URL de la imagen tiene que empezar por https://.',
|
'https_only' => 'La URL de la imagen tiene que empezar por https://.',
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ tags:
|
|||||||
- name: Users
|
- name: Users
|
||||||
description: Local user administration
|
description: Local user administration
|
||||||
- name: Media
|
- name: Media
|
||||||
description: Images uploaded for a record to point at
|
description: The media library, and images uploaded for a record to point at
|
||||||
- name: Roles
|
- name: Roles
|
||||||
description: Local authorisation model (read-only)
|
description: Local authorisation model (read-only)
|
||||||
|
|
||||||
@@ -2651,6 +2651,43 @@ paths:
|
|||||||
"422": { $ref: "#/components/responses/ValidationError" }
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
/v1/media:
|
/v1/media:
|
||||||
|
get:
|
||||||
|
tags: [Media]
|
||||||
|
summary: List the media library
|
||||||
|
description: Every image on the media disk, newest first. Requires `media.view_any`.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/PerPage"
|
||||||
|
- name: for
|
||||||
|
in: query
|
||||||
|
description: The kind of record it was uploaded for.
|
||||||
|
schema: { $ref: "#/components/schemas/MediaFor" }
|
||||||
|
- name: mime
|
||||||
|
in: query
|
||||||
|
description: "e.g. `image/svg+xml`."
|
||||||
|
schema: { type: string }
|
||||||
|
- name: search
|
||||||
|
in: query
|
||||||
|
description: "Matches the path."
|
||||||
|
schema: { type: string }
|
||||||
|
- name: pending
|
||||||
|
in: query
|
||||||
|
description: "Only those the queue has not processed yet."
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: failed
|
||||||
|
in: query
|
||||||
|
description: "Only those whose processing failed."
|
||||||
|
schema: { type: boolean }
|
||||||
|
- name: orphans
|
||||||
|
in: query
|
||||||
|
description: "Only those nothing points at \u2014 what `content:prune --media` would delete."
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Paginated media
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/MediaCollection" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
post:
|
post:
|
||||||
tags: [Media]
|
tags: [Media]
|
||||||
summary: Upload an image
|
summary: Upload an image
|
||||||
@@ -2715,6 +2752,60 @@ paths:
|
|||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
"422": { $ref: "#/components/responses/ValidationError" }
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
|
||||||
|
/v1/media/{media}:
|
||||||
|
parameters:
|
||||||
|
- name: media
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
get:
|
||||||
|
tags: [Media]
|
||||||
|
summary: Show an image
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The image, with whether anything uses it
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Media" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { description: Not found }
|
||||||
|
delete:
|
||||||
|
tags: [Media]
|
||||||
|
summary: Delete an image
|
||||||
|
description: |
|
||||||
|
The file, its derived versions and its record. Only when nothing points
|
||||||
|
at it — a page, a logo, an image inside a text — since deleting one in
|
||||||
|
use would put a broken image on the public site. Requires
|
||||||
|
`media.delete`.
|
||||||
|
responses:
|
||||||
|
"204": { description: Deleted }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"409": { description: The image is in use. }
|
||||||
|
|
||||||
|
/v1/media/{media}/reprocess:
|
||||||
|
post:
|
||||||
|
tags: [Media]
|
||||||
|
summary: Process an image again
|
||||||
|
description: Queues deriving the smaller versions and the placeholder again.
|
||||||
|
parameters:
|
||||||
|
- name: media
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Processing queued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data: { $ref: "#/components/schemas/Media" }
|
||||||
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
|
||||||
/v1/users:
|
/v1/users:
|
||||||
get:
|
get:
|
||||||
tags: [Users]
|
tags: [Users]
|
||||||
@@ -4305,6 +4396,7 @@ components:
|
|||||||
Media:
|
Media:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
id: { type: integer }
|
||||||
path:
|
path:
|
||||||
type: string
|
type: string
|
||||||
description: What a record stores, e.g. as a page's `featured_image`.
|
description: What a record stores, e.g. as a page's `featured_image`.
|
||||||
@@ -4319,6 +4411,21 @@ components:
|
|||||||
description: |
|
description: |
|
||||||
Whether the queue has derived the smaller versions and the
|
Whether the queue has derived the smaller versions and the
|
||||||
placeholder yet. The image serves either way.
|
placeholder yet. The image serves either way.
|
||||||
|
error: { type: string, nullable: true, description: "Why processing failed, when it did." }
|
||||||
|
referenced:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
description: Whether anything points at it. Sent by the library only, not on upload.
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
MediaCollection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/Media" }
|
||||||
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
|
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
||||||
|
|
||||||
Category:
|
Category:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -226,8 +226,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
|
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
|
||||||
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
|
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
|
||||||
|
|
||||||
// Upload only: the file is then referenced from a record by its path.
|
// The library. A file is uploaded here and then referenced from a
|
||||||
|
// record by its path; it is never edited, only processed again.
|
||||||
|
Route::get('media', [MediaController::class, 'index'])->name('media.index');
|
||||||
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
||||||
|
Route::get('media/{media}', [MediaController::class, 'show'])->name('media.show');
|
||||||
|
Route::delete('media/{media}', [MediaController::class, 'destroy'])->name('media.destroy');
|
||||||
|
Route::post('media/{media}/reprocess', [MediaController::class, 'reprocess'])->name('media.reprocess');
|
||||||
|
|
||||||
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
|
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Application;
|
|||||||
use App\Models\Call;
|
use App\Models\Call;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
|
use App\Models\Media;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\MissingUrl;
|
use App\Models\MissingUrl;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
@@ -90,6 +91,7 @@ class AdminApiContractTest extends TestCase
|
|||||||
'redirects' => ['path' => '/v1/redirects'],
|
'redirects' => ['path' => '/v1/redirects'],
|
||||||
'missing-urls' => ['path' => '/v1/missing-urls'],
|
'missing-urls' => ['path' => '/v1/missing-urls'],
|
||||||
'repositories' => ['path' => '/v1/repositories'],
|
'repositories' => ['path' => '/v1/repositories'],
|
||||||
|
'media' => ['path' => '/v1/media'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ class AdminApiContractTest extends TestCase
|
|||||||
$missingUrl = MissingUrl::factory()->create();
|
$missingUrl = MissingUrl::factory()->create();
|
||||||
$repository = Repository::factory()->create();
|
$repository = Repository::factory()->create();
|
||||||
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
Setting::set(Maintenance::KEY, ['enabled' => false]);
|
||||||
|
$media = Media::factory()->create();
|
||||||
|
|
||||||
// One request, one documented path — the URL a real caller hits and
|
// One request, one documented path — the URL a real caller hits and
|
||||||
// the path key that describes it, side by side rather than derived
|
// the path key that describes it, side by side rather than derived
|
||||||
@@ -148,6 +151,8 @@ class AdminApiContractTest extends TestCase
|
|||||||
'settings' => ['/v1/settings', '/v1/settings'],
|
'settings' => ['/v1/settings', '/v1/settings'],
|
||||||
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
|
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
|
||||||
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
|
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
|
||||||
|
'media' => ['/v1/media', '/v1/media'],
|
||||||
|
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
|
||||||
'users' => ['/v1/users', '/v1/users'],
|
'users' => ['/v1/users', '/v1/users'],
|
||||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||||
'me' => ['/v1/me', '/v1/me'],
|
'me' => ['/v1/me', '/v1/me'],
|
||||||
|
|||||||
133
tests/Feature/Api/MediaLibraryApiTest.php
Normal file
133
tests/Feature/Api/MediaLibraryApiTest.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Jobs\ProcessMedia;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\FakesPireon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media library through the admin API: what is on the disk, what uses it,
|
||||||
|
* and deleting what nothing does.
|
||||||
|
*/
|
||||||
|
class MediaLibraryApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use FakesPireon, RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->seed(RolePermissionSeeder::class);
|
||||||
|
|
||||||
|
Storage::fake((string) config('media.disk'));
|
||||||
|
Queue::fake();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actingWithRole(string $role): User
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->syncRoles([$role]);
|
||||||
|
|
||||||
|
$this->withHeaders($this->apiHeadersFor($user));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function stored(string $path): Media
|
||||||
|
{
|
||||||
|
Storage::disk((string) config('media.disk'))->put($path, 'x');
|
||||||
|
|
||||||
|
return Media::for($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_library_says_what_is_in_use(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$logo = $this->stored('technologies/angular.svg');
|
||||||
|
$orphan = $this->stored('pages/forgotten.jpg');
|
||||||
|
|
||||||
|
Technology::factory()->create(['logo' => $logo->path]);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(2, 'data')
|
||||||
|
->assertJsonPath('data.0.path', 'pages/forgotten.jpg')
|
||||||
|
->assertJsonPath('data.0.referenced', false)
|
||||||
|
->assertJsonPath('data.1.referenced', true);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media?orphans=1')
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $orphan->id);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media?for=technologies')
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $logo->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_image_nothing_uses_is_deleted_with_its_file(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$orphan = $this->stored('pages/forgotten.jpg');
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/media/{$orphan->id}")->assertNoContent();
|
||||||
|
|
||||||
|
$this->assertModelMissing($orphan);
|
||||||
|
Storage::disk((string) config('media.disk'))->assertMissing('pages/forgotten.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_image_in_use_is_not_deleted(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$image = $this->stored('pages/hero.jpg');
|
||||||
|
Page::factory()->create(['featured_image' => $image->path]);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/media/{$image->id}")->assertConflict();
|
||||||
|
|
||||||
|
$this->assertModelExists($image);
|
||||||
|
Storage::disk((string) config('media.disk'))->assertExists('pages/hero.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_writer_sees_the_library_but_deletes_nothing(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('writer');
|
||||||
|
|
||||||
|
$orphan = $this->stored('pages/forgotten.jpg');
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/media')->assertOk();
|
||||||
|
$this->getJson("/api/v1/media/{$orphan->id}")->assertOk()->assertJsonPath('data.referenced', false);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/v1/media/{$orphan->id}")->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function an_image_is_processed_again_on_request(): void
|
||||||
|
{
|
||||||
|
$this->actingWithRole('editor');
|
||||||
|
|
||||||
|
$image = $this->stored('pages/hero.jpg');
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->postJson("/api/v1/media/{$image->id}/reprocess")->assertAccepted();
|
||||||
|
|
||||||
|
Queue::assertPushed(ProcessMedia::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user