- 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>
507 lines
19 KiB
PHP
507 lines
19 KiB
PHP
<?php
|
|
|
|
use App\Models\Display;
|
|
use App\Models\DisplayPlaylist;
|
|
use App\Models\DisplayPlaylistItem;
|
|
use App\Models\DisplayVersion;
|
|
use App\Models\DisplayVersionItem;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
beforeEach(function () {
|
|
$portalDomain = config('domains.domain_portal');
|
|
URL::forceRootUrl('https://'.$portalDomain);
|
|
});
|
|
|
|
function publishDisplayModules(Display $display, array $versionIds): DisplayPlaylist
|
|
{
|
|
$playlist = DisplayPlaylist::factory()->published()->create([
|
|
'display_id' => $display->id,
|
|
]);
|
|
|
|
foreach (array_values($versionIds) as $sortOrder => $versionId) {
|
|
DisplayPlaylistItem::factory()->create([
|
|
'display_playlist_id' => $playlist->id,
|
|
'display_version_id' => $versionId,
|
|
'sort_order' => $sortOrder,
|
|
]);
|
|
}
|
|
|
|
return $playlist;
|
|
}
|
|
|
|
function draftDisplayModules(Display $display, array $versionIds): DisplayPlaylist
|
|
{
|
|
$playlist = DisplayPlaylist::factory()->draft()->create([
|
|
'display_id' => $display->id,
|
|
]);
|
|
|
|
foreach (array_values($versionIds) as $sortOrder => $versionId) {
|
|
DisplayPlaylistItem::factory()->create([
|
|
'display_playlist_id' => $playlist->id,
|
|
'display_version_id' => $versionId,
|
|
'sort_order' => $sortOrder,
|
|
]);
|
|
}
|
|
|
|
return $playlist;
|
|
}
|
|
|
|
test('returns playlist with video-display config', function () {
|
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'video',
|
|
'content' => ['filename' => 'test.mp4', 'title' => 'Test', 'position' => 30],
|
|
]);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'footer',
|
|
'content' => ['headline' => 'Hello', 'subline' => 'World', 'url' => null],
|
|
]);
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonStructure(['playlist', 'updated_at']);
|
|
$response->assertJsonPath('playlist.0.type', 'video-display');
|
|
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', 'assets/test.mp4');
|
|
$response->assertJsonPath('playlist.0.footerContent.0.headline', 'Hello');
|
|
});
|
|
|
|
test('returns display media video urls without legacy asset prefix', function () {
|
|
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'video',
|
|
'content' => [
|
|
'filename' => '/storage/display-media/2026/05/video.mp4',
|
|
'title' => 'Uploaded Video',
|
|
'position' => 25,
|
|
],
|
|
]);
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', '/storage/display-media/2026/05/video.mp4');
|
|
});
|
|
|
|
test('returns playlist with b2in config', function () {
|
|
$version = DisplayVersion::factory()->create([
|
|
'type' => 'b2in',
|
|
'settings' => ['theme' => 'dark', 'footer_name' => 'Marcel'],
|
|
]);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'media',
|
|
'content' => [
|
|
'category' => 'immobilien',
|
|
'media_type' => 'image',
|
|
'media_url' => '../assets/test.jpg',
|
|
'headline' => 'Headline',
|
|
'subline' => 'Subline',
|
|
'duration_seconds' => 10,
|
|
],
|
|
]);
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.type', 'b2in');
|
|
$response->assertJsonPath('playlist.0.settings.theme', 'dark');
|
|
$response->assertJsonPath('playlist.0.settings.show_footer', true);
|
|
$response->assertJsonPath('playlist.0.settings.logo_position', 'top-left');
|
|
$response->assertJsonPath('playlist.0.settings.claim_position', 'top-right');
|
|
$response->assertJsonPath('playlist.0.items.0.category', 'immobilien');
|
|
});
|
|
|
|
test('b2in config passes through custom brand positions and footer toggle', function () {
|
|
$version = DisplayVersion::factory()->create([
|
|
'type' => 'b2in',
|
|
'settings' => [
|
|
'show_footer' => false,
|
|
'logo_position' => 'bottom-right',
|
|
'claim_position' => 'top-left',
|
|
],
|
|
]);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'media',
|
|
'content' => [
|
|
'category' => 'sonstiges',
|
|
'media_type' => 'image',
|
|
'media_url' => '../assets/test.jpg',
|
|
'headline' => 'H',
|
|
'subline' => 'S',
|
|
'duration_seconds' => 10,
|
|
],
|
|
]);
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.settings.show_footer', false);
|
|
$response->assertJsonPath('playlist.0.settings.logo_position', 'bottom-right');
|
|
$response->assertJsonPath('playlist.0.settings.claim_position', 'top-left');
|
|
$response->assertJsonPath('playlist.0.items.0.category', 'sonstiges');
|
|
});
|
|
|
|
test('returns playlist with offers config', function () {
|
|
$version = DisplayVersion::factory()->create([
|
|
'type' => 'offers',
|
|
'settings' => ['loop' => true],
|
|
]);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'slide',
|
|
'content' => [
|
|
'type' => 'product-hero',
|
|
'duration' => 10000,
|
|
'image_url' => '../assets/goya1.jpg',
|
|
'badge_text' => 'Einzelstück',
|
|
'eyebrow' => 'Hersteller: Sudbrock',
|
|
'title' => 'GOYA Sideboard',
|
|
'subline' => '',
|
|
'price' => '489 €',
|
|
'original_price' => 'statt 4.744 €',
|
|
'tag_text' => '',
|
|
'bullets' => [],
|
|
'disclaimer' => '',
|
|
'qr_url' => 'https://cabinet-bielefeld.de',
|
|
'qr_title' => 'Reservieren',
|
|
'contact' => "0521 98620100\nTel. oder WhatsApp",
|
|
'show_brand_text' => false,
|
|
'brand_tagline' => '',
|
|
],
|
|
]);
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.type', 'offers');
|
|
$response->assertJsonPath('playlist.0.slides.0.type', 'product-hero');
|
|
$response->assertJsonPath('playlist.0.slides.0.title', 'GOYA Sideboard');
|
|
$response->assertJsonPath('playlist.0.slides.0.price', '489 €');
|
|
$response->assertJsonPath('playlist.0.slides.0.qr_url', 'https://cabinet-bielefeld.de');
|
|
});
|
|
|
|
test('returns playlist with multiple versions in order', function () {
|
|
$videoVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Videos']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $videoVersion->id,
|
|
'item_type' => 'video',
|
|
'content' => ['filename' => 'test.mp4', 'title' => 'Test', 'position' => 25],
|
|
]);
|
|
|
|
$b2inVersion = DisplayVersion::factory()->create(['type' => 'b2in', 'name' => 'B2in']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $b2inVersion->id,
|
|
'item_type' => 'media',
|
|
'content' => ['category' => 'immobilien', 'media_type' => 'image', 'media_url' => 'test.jpg', 'headline' => 'H', 'subline' => 'S', 'duration_seconds' => 5],
|
|
]);
|
|
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$videoVersion->id, $b2inVersion->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonCount(2, 'playlist');
|
|
$response->assertJsonPath('playlist.0.type', 'video-display');
|
|
$response->assertJsonPath('playlist.1.type', 'b2in');
|
|
});
|
|
|
|
test('returns 404 for inactive display', function () {
|
|
$version = DisplayVersion::factory()->create();
|
|
$display = Display::factory()->create(['is_active' => false]);
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('returns 404 for display without versions', function () {
|
|
$display = Display::factory()->create();
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('check endpoint returns only updated_at', function () {
|
|
$version = DisplayVersion::factory()->create();
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$version->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/check");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonStructure(['updated_at']);
|
|
});
|
|
|
|
test('display overview lists active displays with published modules', function () {
|
|
$liveVersion = DisplayVersion::factory()->create(['name' => 'Live Module']);
|
|
$activeDisplay = Display::factory()->create([
|
|
'name' => 'Showroom Eingang',
|
|
'location' => 'Bielefeld',
|
|
'is_active' => true,
|
|
]);
|
|
publishDisplayModules($activeDisplay, [$liveVersion->id]);
|
|
|
|
$inactiveDisplay = Display::factory()->create(['is_active' => false]);
|
|
publishDisplayModules($inactiveDisplay, [$liveVersion->id]);
|
|
|
|
Display::factory()->create(['name' => 'Ohne Live']);
|
|
|
|
$response = $this->getJson('/api/display/overview');
|
|
|
|
$response->assertSuccessful()
|
|
->assertJsonCount(1, 'displays')
|
|
->assertJsonPath('displays.0.id', $activeDisplay->id)
|
|
->assertJsonPath('displays.0.name', 'Showroom Eingang')
|
|
->assertJsonPath('displays.0.location', 'Bielefeld')
|
|
->assertJsonPath('displays.0.is_active', true)
|
|
->assertJsonPath('displays.0.is_live', true)
|
|
->assertJsonPath('displays.0.module_count', 1)
|
|
->assertJsonPath('displays.0.url', 'https://cabinet.b2in.eu/display/?id='.$activeDisplay->id);
|
|
});
|
|
|
|
test('display config reads published playlist', function () {
|
|
$publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $publishedVersion->id,
|
|
'item_type' => 'video',
|
|
'content' => ['filename' => 'published.mp4', 'title' => 'Published', 'position' => 25],
|
|
]);
|
|
|
|
$display = Display::factory()->create();
|
|
publishDisplayModules($display, [$publishedVersion->id]);
|
|
|
|
$response = $this->getJson("/api/display/{$display->id}/config");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.version_name', 'Published');
|
|
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', 'assets/published.mp4');
|
|
});
|
|
|
|
test('draft preview returns playlist by preview token', function () {
|
|
$liveVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Live']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $liveVersion->id,
|
|
'item_type' => 'video',
|
|
'content' => ['filename' => 'live.mp4', 'title' => 'Live', 'position' => 25],
|
|
]);
|
|
|
|
$draftVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Draft']);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $draftVersion->id,
|
|
'item_type' => 'video',
|
|
'content' => ['filename' => 'draft.mp4', 'title' => 'Draft', 'position' => 25],
|
|
]);
|
|
|
|
$display = Display::factory()->create(['preview_token' => 'preview-token-123']);
|
|
publishDisplayModules($display, [$liveVersion->id]);
|
|
draftDisplayModules($display, [$draftVersion->id]);
|
|
|
|
$response = $this->getJson('/api/display/preview/preview-token-123');
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.version_name', 'Draft');
|
|
$response->assertJsonPath('playlist.0.videoPlaylist.0.src', 'assets/draft.mp4');
|
|
});
|
|
|
|
test('draft preview requires configured draft playlist', function () {
|
|
$display = Display::factory()->create(['preview_token' => 'empty-preview-token']);
|
|
|
|
$response = $this->getJson('/api/display/preview/empty-preview-token');
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('module preview returns a single module config', function () {
|
|
$version = DisplayVersion::factory()->create([
|
|
'type' => 'b2in',
|
|
'name' => 'Preview Module',
|
|
'settings' => ['theme' => 'light'],
|
|
]);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'media',
|
|
'content' => [
|
|
'category' => 'moebel',
|
|
'media_type' => 'image',
|
|
'media_url' => '../assets/module.jpg',
|
|
'headline' => 'Modul',
|
|
'subline' => 'Vorschau',
|
|
'duration_seconds' => 8,
|
|
],
|
|
]);
|
|
|
|
$response = $this->getJson("/api/display/module/{$version->id}/preview");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonCount(1, 'playlist');
|
|
$response->assertJsonPath('playlist.0.version_name', 'Preview Module');
|
|
$response->assertJsonPath('playlist.0.items.0.category', 'moebel');
|
|
});
|
|
|
|
test('offers slides carry their own logo and brand chrome', function () {
|
|
$version = DisplayVersion::factory()->create([
|
|
'type' => 'offers',
|
|
'name' => 'Custom Chrome',
|
|
]);
|
|
DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'slide',
|
|
'content' => [
|
|
'type' => 'detail',
|
|
'title' => 'Intro',
|
|
'image_url' => '../assets/intro.jpg',
|
|
'show_logo' => true,
|
|
'logo_url' => '/storage/display-media/logo.svg',
|
|
'brand_text' => 'Musterstadt',
|
|
'brand_tagline' => 'Beratung im Schauraum',
|
|
],
|
|
]);
|
|
|
|
$response = $this->getJson("/api/display/module/{$version->id}/preview");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('playlist.0.slides.0.show_logo', true);
|
|
$response->assertJsonPath('playlist.0.slides.0.logo_url', '/storage/display-media/logo.svg');
|
|
$response->assertJsonPath('playlist.0.slides.0.brand_text', 'Musterstadt');
|
|
$response->assertJsonPath('playlist.0.slides.0.brand_tagline', 'Beratung im Schauraum');
|
|
});
|
|
|
|
test('module item preview returns only the selected slide', function () {
|
|
$version = DisplayVersion::factory()->create([
|
|
'type' => 'offers',
|
|
'name' => 'Offers Module',
|
|
]);
|
|
$firstSlide = DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'slide',
|
|
'content' => [
|
|
'type' => 'product-hero',
|
|
'title' => 'First Slide',
|
|
'image_url' => '../assets/first.jpg',
|
|
],
|
|
'sort_order' => 0,
|
|
]);
|
|
$secondSlide = DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $version->id,
|
|
'item_type' => 'slide',
|
|
'content' => [
|
|
'type' => 'product-details',
|
|
'title' => 'Second Slide',
|
|
'image_url' => '../assets/second.jpg',
|
|
],
|
|
'sort_order' => 1,
|
|
]);
|
|
|
|
$response = $this->getJson("/api/display/module/{$version->id}/item/{$secondSlide->id}/preview");
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonCount(1, 'playlist');
|
|
$response->assertJsonCount(1, 'playlist.0.slides');
|
|
$response->assertJsonPath('playlist.0.slides.0.title', 'Second Slide');
|
|
$response->assertJsonMissingPath('playlist.0.slides.1');
|
|
|
|
expect($firstSlide->fresh()->content['title'])->toBe('First Slide');
|
|
});
|
|
|
|
test('module item preview requires the item to belong to the module', function () {
|
|
$version = DisplayVersion::factory()->create(['type' => 'offers']);
|
|
$otherVersion = DisplayVersion::factory()->create(['type' => 'offers']);
|
|
$item = DisplayVersionItem::factory()->create([
|
|
'display_version_id' => $otherVersion->id,
|
|
'item_type' => 'slide',
|
|
]);
|
|
|
|
$this->getJson("/api/display/module/{$version->id}/item/{$item->id}/preview")
|
|
->assertNotFound();
|
|
});
|
|
|
|
test('display player keeps previews in a strict 9 by 16 viewport', function () {
|
|
$player = file_get_contents(public_path('_cabinet/display/index.html'));
|
|
|
|
expect($player)
|
|
->toContain('width: min(100vw, calc(100vh * 9 / 16));')
|
|
->toContain('height: min(100vh, calc(100vw * 16 / 9));')
|
|
->toContain('container-type: size;')
|
|
->toContain("if (hostname === 'cabinet.b2in.eu') {")
|
|
->toContain("return 'https://portal.b2in.eu';")
|
|
->toContain('/api/display/overview')
|
|
->toContain('Aktive Live-Displays')
|
|
->toContain('renderOverview(data.displays || [])')
|
|
->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})')
|
|
->toContain('this.settings.footer_claim')
|
|
->toContain('this.settings.footer_url')
|
|
->toContain('this.settings.header_logo_url')
|
|
->toContain('this.settings.qr_label')
|
|
->toContain('this.settings.show_footer !== false')
|
|
->toContain('this.settings.show_logo !== false')
|
|
->toContain('this.settings.show_claim !== false')
|
|
->toContain('this.settings.logo_position')
|
|
->toContain('this.settings.claim_position')
|
|
->toContain('b2in-brand-logo pos-')
|
|
->toContain('b2in-scrim')
|
|
->toContain('.b2in-layer.no-footer .b2in-text');
|
|
});
|
|
|
|
test('display player stops looping when the playlist has no playable content', function () {
|
|
$player = file_get_contents(public_path('_cabinet/display/index.html'));
|
|
|
|
expect($player)
|
|
->toContain('versionHasContent(version)')
|
|
->toContain('showEmptyPlaylist()')
|
|
->toContain('this.emptyVersionStreak >= this.playlist.length')
|
|
->toContain('Noch keine Inhalte vorhanden');
|
|
});
|
|
|
|
test('preview player pages are reachable for valid display and module previews', function () {
|
|
$display = Display::factory()->create(['preview_token' => 'page-preview-token']);
|
|
$version = DisplayVersion::factory()->create();
|
|
$item = DisplayVersionItem::factory()->create(['display_version_id' => $version->id]);
|
|
|
|
$this->get('/preview/page-preview-token')
|
|
->assertSuccessful();
|
|
|
|
$this->get("/preview/module/{$version->id}")
|
|
->assertSuccessful();
|
|
|
|
$this->get("/preview/module/{$version->id}/item/{$item->id}")
|
|
->assertSuccessful();
|
|
});
|
|
|
|
test('preview player is served with a revalidation cache header', function () {
|
|
$display = Display::factory()->create(['preview_token' => 'fresh-token']);
|
|
$version = DisplayVersion::factory()->create();
|
|
|
|
foreach ([
|
|
'/preview/fresh-token',
|
|
"/preview/module/{$version->id}",
|
|
] as $url) {
|
|
$response = $this->get($url);
|
|
$response->assertSuccessful();
|
|
expect($response->headers->get('Cache-Control'))->toContain('no-cache');
|
|
}
|
|
});
|
|
|
|
test('existing display config api still works', function () {
|
|
$response = $this->getJson('/api/display/config');
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonStructure(['videoPlaylist', 'footerContent']);
|
|
});
|