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']); });