- Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges" - B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern - B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter - Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option - Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header - Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben Co-authored-by: Cursor <cursoragent@cursor.com>
432 lines
14 KiB
PHP
432 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\DisplayMedia;
|
|
use App\Models\User;
|
|
use App\Services\DisplayMediaService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Volt\Volt;
|
|
use Spatie\Permission\Models\Role;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('public');
|
|
});
|
|
|
|
/**
|
|
* Create a real, tiny mp4 on disk using ffmpeg and return the path.
|
|
* Returns null when ffmpeg is unavailable so callers can skip.
|
|
*/
|
|
function makeRealTestVideo(): ?string
|
|
{
|
|
$ffmpeg = trim((string) shell_exec('command -v ffmpeg'));
|
|
|
|
if ($ffmpeg === '') {
|
|
return null;
|
|
}
|
|
|
|
$path = tempnam(sys_get_temp_dir(), 'vid_').'.mp4';
|
|
|
|
$command = sprintf(
|
|
'%s -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -pix_fmt yuv420p %s 2>/dev/null',
|
|
escapeshellarg($ffmpeg),
|
|
escapeshellarg($path)
|
|
);
|
|
|
|
shell_exec($command);
|
|
|
|
return is_file($path) && filesize($path) > 0 ? $path : null;
|
|
}
|
|
|
|
// ========================================
|
|
// MODEL TESTS
|
|
// ========================================
|
|
|
|
it('creates an uploaded image media', function () {
|
|
$media = DisplayMedia::factory()->create();
|
|
|
|
expect($media->isUpload())->toBeTrue()
|
|
->and($media->isExternal())->toBeFalse()
|
|
->and($media->isImage())->toBeTrue()
|
|
->and($media->isVideo())->toBeFalse()
|
|
->and($media->source_type)->toBe('upload');
|
|
});
|
|
|
|
it('creates an external video media', function () {
|
|
$media = DisplayMedia::factory()->externalVideo()->create();
|
|
|
|
expect($media->isExternal())->toBeTrue()
|
|
->and($media->isUpload())->toBeFalse()
|
|
->and($media->isVideo())->toBeTrue()
|
|
->and($media->external_url)->toStartWith('https://');
|
|
});
|
|
|
|
it('returns correct URL for uploaded media', function () {
|
|
$media = DisplayMedia::factory()->create(['path' => 'display-media/2026/03/test.webp']);
|
|
|
|
expect($media->getUrl())->toContain('display-media/2026/03/test.webp');
|
|
});
|
|
|
|
it('returns external URL for external media', function () {
|
|
$url = 'https://drive.google.com/file/d/abc123/view';
|
|
$media = DisplayMedia::factory()->external()->create(['external_url' => $url]);
|
|
|
|
expect($media->getUrl())->toBe($url);
|
|
});
|
|
|
|
it('returns human readable file size', function () {
|
|
$media = DisplayMedia::factory()->create(['file_size' => 1536000]);
|
|
expect($media->getHumanFileSize())->toBe('1.5 MB');
|
|
|
|
$external = DisplayMedia::factory()->external()->create();
|
|
expect($external->getHumanFileSize())->toBe('Extern');
|
|
});
|
|
|
|
it('returns display name from title or filename', function () {
|
|
$withTitle = DisplayMedia::factory()->create(['title' => 'Mein Video', 'filename' => 'file.mp4']);
|
|
expect($withTitle->getDisplayName())->toBe('Mein Video');
|
|
|
|
$withoutTitle = DisplayMedia::factory()->create(['title' => null, 'filename' => 'file.mp4']);
|
|
expect($withoutTitle->getDisplayName())->toBe('file.mp4');
|
|
});
|
|
|
|
it('returns the stored poster as thumbnail url for a video', function () {
|
|
$media = DisplayMedia::factory()->video()->create([
|
|
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
|
|
]);
|
|
|
|
expect($media->getThumbnailUrl())->toContain('display-media/2026/05/tour-poster.jpg');
|
|
});
|
|
|
|
it('has no thumbnail url for a video without a poster', function () {
|
|
$media = DisplayMedia::factory()->video()->create(['thumbnail_path' => null]);
|
|
|
|
expect($media->getThumbnailUrl())->toBeNull();
|
|
});
|
|
|
|
// ========================================
|
|
// SCOPE TESTS
|
|
// ========================================
|
|
|
|
it('filters by image scope', function () {
|
|
DisplayMedia::factory()->create(['type' => 'image']);
|
|
DisplayMedia::factory()->video()->create();
|
|
|
|
expect(DisplayMedia::images()->count())->toBe(1);
|
|
});
|
|
|
|
it('filters by video scope', function () {
|
|
DisplayMedia::factory()->create(['type' => 'image']);
|
|
DisplayMedia::factory()->video()->create();
|
|
|
|
expect(DisplayMedia::videos()->count())->toBe(1);
|
|
});
|
|
|
|
it('filters by upload scope', function () {
|
|
DisplayMedia::factory()->create();
|
|
DisplayMedia::factory()->external()->create();
|
|
|
|
expect(DisplayMedia::uploads()->count())->toBe(1);
|
|
});
|
|
|
|
it('filters by external scope', function () {
|
|
DisplayMedia::factory()->create();
|
|
DisplayMedia::factory()->external()->create();
|
|
|
|
expect(DisplayMedia::externals()->count())->toBe(1);
|
|
});
|
|
|
|
it('filters by collection scope', function () {
|
|
DisplayMedia::factory()->create(['collection' => 'immobilien']);
|
|
DisplayMedia::factory()->create(['collection' => 'moebel']);
|
|
|
|
expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1);
|
|
});
|
|
|
|
it('keeps preceding filters when combined with the search scope', function () {
|
|
DisplayMedia::factory()->create([
|
|
'type' => 'image',
|
|
'filename' => 'haus.webp',
|
|
'title' => 'Schaufenster',
|
|
]);
|
|
DisplayMedia::factory()->video()->create([
|
|
'filename' => 'tour.mp4',
|
|
'title' => 'Haus Tour',
|
|
]);
|
|
|
|
$results = DisplayMedia::images()->search('haus')->get();
|
|
|
|
expect($results)->toHaveCount(1)
|
|
->and($results->first()->filename)->toBe('haus.webp');
|
|
});
|
|
|
|
it('search scope respects the active filter on the media picker', function () {
|
|
DisplayMedia::factory()->create([
|
|
'filename' => 'aktiv.webp',
|
|
'title' => 'Goya',
|
|
'is_active' => true,
|
|
]);
|
|
DisplayMedia::factory()->create([
|
|
'filename' => 'inaktiv.webp',
|
|
'title' => 'Goya',
|
|
'is_active' => false,
|
|
]);
|
|
|
|
$results = DisplayMedia::active()->search('goya')->get();
|
|
|
|
expect($results)->toHaveCount(1)
|
|
->and($results->first()->filename)->toBe('aktiv.webp');
|
|
});
|
|
|
|
it('filters by active scope', function () {
|
|
DisplayMedia::factory()->create(['is_active' => true]);
|
|
DisplayMedia::factory()->create(['is_active' => false]);
|
|
|
|
expect(DisplayMedia::active()->count())->toBe(1);
|
|
});
|
|
|
|
// ========================================
|
|
// SERVICE TESTS
|
|
// ========================================
|
|
|
|
it('stores an uploaded file', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
$file = UploadedFile::fake()->image('test-photo.jpg', 800, 600);
|
|
|
|
$media = $service->storeUpload($file, 'immobilien');
|
|
|
|
expect($media)->toBeInstanceOf(DisplayMedia::class)
|
|
->and($media->filename)->toBe('test-photo.jpg')
|
|
->and($media->source_type)->toBe('upload')
|
|
->and($media->type)->toBe('image')
|
|
->and($media->collection)->toBe('immobilien')
|
|
->and($media->path)->not->toBeNull();
|
|
|
|
expect(Storage::disk('public')->exists($media->path))->toBeTrue();
|
|
});
|
|
|
|
it('stores a video upload', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
$file = UploadedFile::fake()->create('showroom.mp4', 10000, 'video/mp4');
|
|
|
|
$media = $service->storeUpload($file);
|
|
|
|
expect($media->type)->toBe('video')
|
|
->and($media->mime_type)->toBe('video/mp4');
|
|
});
|
|
|
|
it('generates a poster thumbnail when a real video is uploaded', function () {
|
|
$videoPath = makeRealTestVideo();
|
|
|
|
if ($videoPath === null) {
|
|
$this->markTestSkipped('ffmpeg is not available.');
|
|
}
|
|
|
|
$service = app(DisplayMediaService::class);
|
|
$file = new UploadedFile($videoPath, 'showroom.mp4', 'video/mp4', null, true);
|
|
|
|
$media = $service->storeUpload($file);
|
|
|
|
expect($media->thumbnail_path)->not->toBeNull();
|
|
expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue();
|
|
|
|
@unlink($videoPath);
|
|
});
|
|
|
|
it('does not set a thumbnail when the video cannot be decoded', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
$file = UploadedFile::fake()->create('broken.mp4', 500, 'video/mp4');
|
|
|
|
$media = $service->storeUpload($file);
|
|
|
|
expect($media->thumbnail_path)->toBeNull();
|
|
});
|
|
|
|
it('stores an svg upload as image media', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
$file = UploadedFile::fake()->createWithContent(
|
|
'logo.svg',
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>'
|
|
);
|
|
|
|
$media = $service->storeUpload($file);
|
|
|
|
expect($media->filename)->toBe('logo.svg')
|
|
->and($media->type)->toBe('image')
|
|
->and($media->mime_type)->toBe('image/svg+xml');
|
|
|
|
expect(Storage::disk('public')->exists($media->path))->toBeTrue();
|
|
});
|
|
|
|
it('resets pagination to page one when a filter changes', function () {
|
|
Volt::test('admin.cms.display-media-library')
|
|
->call('gotoPage', 3)
|
|
->set('filterType', 'video')
|
|
->assertSet('paginators.page', 1);
|
|
});
|
|
|
|
it('renders external image media with an inline thumbnail', function () {
|
|
$media = DisplayMedia::factory()->external()->create([
|
|
'type' => 'image',
|
|
'external_url' => 'https://example.com/foto.jpg',
|
|
]);
|
|
|
|
Volt::test('admin.cms.display-media-library')
|
|
->assertSeeHtml('src="https://example.com/foto.jpg"');
|
|
});
|
|
|
|
it('renders a stored poster image for a video with a thumbnail', function () {
|
|
DisplayMedia::factory()->video()->create([
|
|
'path' => 'display-media/2026/05/tour.mp4',
|
|
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
|
|
]);
|
|
|
|
Volt::test('admin.cms.display-media-library')
|
|
->assertSeeHtml('tour-poster.jpg');
|
|
});
|
|
|
|
it('renders an inline video frame for an uploaded video without a poster', function () {
|
|
DisplayMedia::factory()->video()->create([
|
|
'path' => 'display-media/2026/05/clip.mp4',
|
|
'thumbnail_path' => null,
|
|
'mime_type' => 'video/mp4',
|
|
]);
|
|
|
|
Volt::test('admin.cms.display-media-library')
|
|
->assertSeeHtml('clip.mp4#t=1');
|
|
});
|
|
|
|
it('accepts svg uploads in the display media library', function () {
|
|
$file = UploadedFile::fake()->createWithContent(
|
|
'brand.svg',
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg>'
|
|
);
|
|
|
|
Volt::test('admin.cms.display-media-library')
|
|
->set('uploads', [$file])
|
|
->call('handleUploads')
|
|
->assertHasNoErrors();
|
|
|
|
$media = DisplayMedia::query()->first();
|
|
|
|
expect($media)->not->toBeNull()
|
|
->and($media->filename)->toBe('brand.svg')
|
|
->and($media->type)->toBe('image')
|
|
->and($media->mime_type)->toBe('image/svg+xml');
|
|
});
|
|
|
|
it('accepts display media videos up to 200 mb', function () {
|
|
$file = UploadedFile::fake()->create('showroom.mp4', 204800, 'video/mp4');
|
|
|
|
Volt::test('admin.cms.display-media-library')
|
|
->set('uploads', [$file])
|
|
->call('handleUploads')
|
|
->assertHasNoErrors();
|
|
|
|
$media = DisplayMedia::query()->first();
|
|
|
|
expect($media)->not->toBeNull()
|
|
->and($media->filename)->toBe('showroom.mp4')
|
|
->and($media->type)->toBe('video');
|
|
});
|
|
|
|
it('configures livewire temporary uploads up to 200 mb', function () {
|
|
expect(config('livewire.temporary_file_upload.rules'))->toContain('max:204800');
|
|
});
|
|
|
|
it('creates media from external URL', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
|
|
$media = $service->createFromUrl(
|
|
url: 'https://drive.google.com/file/d/abc123/view',
|
|
type: 'video',
|
|
title: 'Showroom Tour 4K',
|
|
collection: 'brand',
|
|
);
|
|
|
|
expect($media->source_type)->toBe('external')
|
|
->and($media->external_url)->toBe('https://drive.google.com/file/d/abc123/view')
|
|
->and($media->title)->toBe('Showroom Tour 4K')
|
|
->and($media->type)->toBe('video')
|
|
->and($media->collection)->toBe('brand')
|
|
->and($media->file_size)->toBe(0);
|
|
});
|
|
|
|
it('deletes uploaded media and its file', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
$file = UploadedFile::fake()->image('delete-me.jpg');
|
|
$media = $service->storeUpload($file);
|
|
$path = $media->path;
|
|
|
|
expect(Storage::disk('public')->exists($path))->toBeTrue();
|
|
|
|
$service->delete($media);
|
|
|
|
expect(Storage::disk('public')->exists($path))->toBeFalse();
|
|
expect(DisplayMedia::find($media->id))->toBeNull();
|
|
});
|
|
|
|
it('deletes external media record', function () {
|
|
$service = app(DisplayMediaService::class);
|
|
$media = $service->createFromUrl('https://example.com/video.mp4', 'video');
|
|
|
|
$service->delete($media);
|
|
|
|
expect(DisplayMedia::find($media->id))->toBeNull();
|
|
});
|
|
|
|
// ========================================
|
|
// COMMAND TESTS
|
|
// ========================================
|
|
|
|
it('reports when there are no videos to process', function () {
|
|
$this->artisan('display-media:generate-video-thumbnails')
|
|
->expectsOutputToContain('Keine Videos zum Verarbeiten gefunden.')
|
|
->assertSuccessful();
|
|
});
|
|
|
|
it('backfills posters for existing uploaded videos', function () {
|
|
$videoPath = makeRealTestVideo();
|
|
|
|
if ($videoPath === null) {
|
|
$this->markTestSkipped('ffmpeg is not available.');
|
|
}
|
|
|
|
Storage::disk('public')->putFileAs(
|
|
'display-media/2026/05',
|
|
new UploadedFile($videoPath, 'clip.mp4', 'video/mp4', null, true),
|
|
'clip.mp4'
|
|
);
|
|
|
|
$media = DisplayMedia::factory()->video()->create([
|
|
'source_type' => 'upload',
|
|
'path' => 'display-media/2026/05/clip.mp4',
|
|
'thumbnail_path' => null,
|
|
]);
|
|
|
|
$this->artisan('display-media:generate-video-thumbnails')->assertSuccessful();
|
|
|
|
expect($media->fresh()->thumbnail_path)->not->toBeNull();
|
|
|
|
@unlink($videoPath);
|
|
});
|
|
|
|
// ========================================
|
|
// ROUTE TESTS
|
|
// ========================================
|
|
|
|
it('requires authentication for display media library', function () {
|
|
$this->get(route('admin.cms.display-media'))
|
|
->assertRedirect();
|
|
});
|
|
|
|
it('loads display media library for authenticated admin', function () {
|
|
Role::findOrCreate('Super-Admin', 'web');
|
|
$user = User::factory()->create();
|
|
$user->assignRole('Super-Admin');
|
|
|
|
$this->actingAs($user)
|
|
->get(route('admin.cms.display-media'))
|
|
->assertSuccessful();
|
|
});
|