fix: login redirect, nametag/worship resolution, macros, export headers & probundle

- Post-login (OAuth + dev-login) now redirects to the next upcoming
  service's edit page instead of /dashboard, mirroring the GET / route.
- NameTagResolver now reads the real ChurchTools `responsible` shape
  (persons[].person.title) and resolves moderator/preacher/worship-leader
  by responsible ROLE ([Moderation]/[Predigt]/[Lobpreis]). This fixes
  missing name slides and makes the worship-leader arrangement trigger
  (e.g. service 12 → "Benedikt Hardt" / "Jennifer Schneider").
- NameTagSlideBuilder no longer silently drops the name slide when the
  configured macro id points to a missing macro; it emits the slide
  without a macro instead.
- Song export: the "first slide" / "last slide" macro now applies only to
  the song's very first/last slide (global slide index across all
  sections), not the first slide of every section.
- Export "headlines" for content-less agenda items are now emitted as
  proper ProPresenter playlist HEADER items instead of text presentations.
- Prefix/postfix export files now also accept .probundle (unzipped: inner
  .pro + media embedded) in addition to .pro, both for upload validation
  and export injection.

Full suite green (587 passed).
This commit is contained in:
Thorsten Bus 2026-06-01 22:17:31 +02:00
parent e33418f716
commit d5f3990f3b
15 changed files with 883 additions and 143 deletions

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -52,7 +53,12 @@ public function callback(): RedirectResponse
Auth::login($user, remember: true);
return redirect()->intended(route('dashboard'));
$service = Service::nextUpcoming()->first();
$target = $service
? route('services.edit', $service->id)
: route('services.index');
return redirect()->intended($target);
}
/**

View file

@ -15,7 +15,9 @@ public function store(Request $request): JsonResponse
$request->validate([
'type' => ['required', Rule::in(['prefix', 'postfix'])],
'files' => ['required', 'array'],
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro'],
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro,probundle'],
], [
'files.*.extensions' => 'Nur .pro- und .probundle-Dateien sind erlaubt.',
]);
$type = $request->input('type');

View file

@ -21,6 +21,12 @@ public function moderatorFor(Service $service): ?string
return $override;
}
// Prefer the person whose responsible role is "Moderation".
$byRole = $this->nameForRole($service, 'moderation');
if ($byRole !== null) {
return $byRole;
}
$firstAgendaItem = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
@ -32,14 +38,7 @@ public function moderatorFor(Service $service): ?string
public function worshipLeaderFor(Service $service): ?string
{
$worshipItem = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
->orderBy('id')
->get()
->first(fn (ServiceAgendaItem $item) => $this->isWorshipItem($item));
return $worshipItem ? $this->namesFromResponsible($worshipItem->responsible) : null;
return $this->nameForRole($service, 'lobpreis');
}
public function preacherFor(Service $service): ?string
@ -54,6 +53,12 @@ public function preacherFor(Service $service): ?string
return $preacherName;
}
// Prefer the person whose responsible role is "Predigt".
$byRole = $this->nameForRole($service, 'predigt');
if ($byRole !== null) {
return $byRole;
}
$sermonItem = $service->agendaItems()
->where('is_before_event', false)
->whereNull('service_song_id')
@ -65,6 +70,29 @@ public function preacherFor(Service $service): ?string
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null;
}
/**
* Find the first visible agenda item carrying a responsible person whose
* role (persons[].service, e.g. "[Lobpreis]") matches $roleNeedle, and
* return that person's display name.
*/
private function nameForRole(Service $service, string $roleNeedle): ?string
{
$items = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
->orderBy('id')
->get();
foreach ($items as $item) {
$name = $this->nameFromResponsibleByRole($item->responsible, $roleNeedle);
if ($name !== null) {
return $name;
}
}
return null;
}
private function filledString(?string $value): ?string
{
$trimmed = trim((string) $value);
@ -78,10 +106,17 @@ private function namesFromResponsible(mixed $responsible): ?string
return null;
}
$people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
// Real CTS shape: { "text": "...", "persons": [ { "service": "[Lobpreis]", "person": { "title": "...", ... } } ] }
if (array_key_exists('persons', $responsible)) {
$entries = $responsible['persons'];
} elseif (Arr::isAssoc($responsible)) {
$entries = [$responsible];
} else {
$entries = $responsible;
}
$names = collect($people)
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
$names = collect($entries)
->map(fn (mixed $entry) => $this->nameFromResponsibleEntry($entry))
->filter()
->values()
->all();
@ -89,35 +124,94 @@ private function namesFromResponsible(mixed $responsible): ?string
return $names === [] ? null : implode(', ', $names);
}
private function nameFromResponsiblePerson(mixed $person): ?string
/**
* Extract a person name from a responsible entry.
* Handles both the real CTS shape (entry has a 'person' sub-key) and the
* legacy/test shape (entry is a flat array with 'name'/'firstName'/'lastName' or a string).
*/
private function nameFromResponsibleEntry(mixed $entry): ?string
{
if (is_string($person)) {
return $this->filledString($person);
if (is_string($entry)) {
return $this->filledString($entry);
}
if (! is_array($person)) {
if (! is_array($entry)) {
return null;
}
$name = $this->filledString($person['name'] ?? null);
// Real CTS shape: { "service": "[Lobpreis]", "person": { "title": "Kornelius Weiß", ... } }
if (isset($entry['person']) && is_array($entry['person'])) {
return $this->nameFromPersonNode($entry['person']);
}
// Legacy / test shape: { "name": "Anna Müller" } or { "firstName": "...", "lastName": "..." }
return $this->nameFromPersonNode($entry);
}
/** Extract display name from a person node (title preferred, then firstName+lastName). */
private function nameFromPersonNode(mixed $node): ?string
{
if (! is_array($node)) {
return null;
}
$title = $this->filledString($node['title'] ?? null);
if ($title !== null) {
return $title;
}
$name = $this->filledString($node['name'] ?? null);
if ($name !== null) {
return $name;
}
$firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? '';
$lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? '';
$domainAttrs = $node['domainAttributes'] ?? [];
$firstName = $this->filledString(
$node['firstName'] ?? $node['first_name'] ?? ($domainAttrs['firstName'] ?? null)
) ?? '';
$lastName = $this->filledString(
$node['lastName'] ?? $node['last_name'] ?? ($domainAttrs['lastName'] ?? null)
) ?? '';
$fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName;
}
private function isWorshipItem(ServiceAgendaItem $item): bool
/**
* Scan a responsible array for a person whose role (persons[].service)
* contains $roleNeedle (e.g. 'lobpreis', 'predigt', 'moderation').
* Returns the person's display name, or null if none found.
*/
private function nameFromResponsibleByRole(mixed $responsible, string $roleNeedle): ?string
{
$title = Str::lower($item->title);
$type = Str::lower($item->type ?? '');
if (! is_array($responsible) || $responsible === []) {
return null;
}
return str_contains($title, 'lobpreis')
|| str_contains($type, 'lobpreis');
// Real CTS shape: { "persons": [ { "service": "[Lobpreis]", "person": { ... } } ] }
$entries = array_key_exists('persons', $responsible) ? $responsible['persons'] : [];
foreach ($entries as $entry) {
if (! is_array($entry)) {
continue;
}
$role = $this->normalizeRole($entry['service'] ?? '');
if ($role !== '' && str_contains($role, $roleNeedle)) {
$name = $this->nameFromResponsibleEntry($entry);
if ($name !== null) {
return $name;
}
}
}
return null;
}
/** Strip brackets, whitespace, and lowercase a role string for comparison. */
private function normalizeRole(string $role): string
{
return Str::lower(preg_replace('/[^a-zA-ZäöüÄÖÜß]/', '', $role) ?? '');
}
private function isSermonItem(ServiceAgendaItem $item): bool

View file

@ -37,6 +37,10 @@ public function build(string $name, string $title): ?array
],
];
}
// macro_id is configured but the Macro record is missing — still return a slide
// without a macro so the name tag is not silently dropped.
return ['text' => $name."\n".$title];
}
$macroName = Setting::get('namenseinblender_macro_name');

View file

@ -11,6 +11,7 @@
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistGenerator;
use ZipArchive;
class PlaylistExportService
{
@ -169,11 +170,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
);
$keyvisualFallbackEmitted = true;
} else {
$this->addHeadlinePresentation(
$this->addHeadlineItem(
$item,
$tempDir,
$playlistItems,
$embeddedFiles,
);
}
}
@ -201,7 +200,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
}
$this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir);
$this->injectExportProFiles($playlistItems, $embeddedFiles);
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
@ -309,7 +308,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
}
$this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir);
$this->injectExportProFiles($playlistItems, $embeddedFiles);
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
@ -514,99 +513,118 @@ private function addKeyVisualFallbackPresentation(
];
}
private function addHeadlinePresentation(
private function addHeadlineItem(
ServiceAgendaItem $item,
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
): void {
$label = $item->title ?: 'Ablaufpunkt';
$slideData = ['text' => $label];
$groups = [
[
'name' => $label,
'color' => [0, 0, 0, 1],
'slides' => [$slideData],
],
];
$arrangements = [
[
'name' => 'normal',
'groupNames' => [$label],
],
];
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
$proFilename = $safeLabel.'-headline-'.uniqid().'.pro';
$proPath = $tempDir.'/'.$proFilename;
$this->writeProFile($proPath, $label, $groups, $arrangements);
$embeddedFiles[$proFilename] = file_get_contents($proPath);
$playlistItems[] = [
'type' => 'presentation',
'name' => $label,
'path' => $proFilename,
'type' => 'header',
'name' => $item->title ?: 'Ablaufpunkt',
'color' => [0.5, 0.5, 0.5, 1.0],
];
}
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, string $tempDir): void
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
{
$prefixFiles = ExportProFile::prefix()->orderBy('order')->get();
$postfixFiles = ExportProFile::postfix()->orderBy('order')->get();
$prefixItems = [];
$prefixEmbedded = [];
$prefixItems = $this->buildExportProItems(
ExportProFile::prefix()->orderBy('order')->get(),
'PREFIX',
$prefixEmbedded,
);
foreach ($prefixFiles as $file) {
if (! Storage::disk('local')->exists($file->stored_path)) {
continue;
}
$bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) {
continue;
}
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
$embeddedFilename = 'PREFIX_'.$file->order.'_'.$safeBase.'.pro';
$prefixEmbedded[$embeddedFilename] = $bytes;
$prefixItems[] = [
'type' => 'presentation',
'name' => pathinfo($file->original_name, PATHINFO_FILENAME),
'path' => $embeddedFilename,
];
}
$postfixItems = [];
$postfixEmbedded = [];
foreach ($postfixFiles as $file) {
if (! Storage::disk('local')->exists($file->stored_path)) {
continue;
}
$bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) {
continue;
}
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
$embeddedFilename = 'POSTFIX_'.$file->order.'_'.$safeBase.'.pro';
$postfixEmbedded[$embeddedFilename] = $bytes;
$postfixItems[] = [
'type' => 'presentation',
'name' => pathinfo($file->original_name, PATHINFO_FILENAME),
'path' => $embeddedFilename,
];
}
$postfixItems = $this->buildExportProItems(
ExportProFile::postfix()->orderBy('order')->get(),
'POSTFIX',
$postfixEmbedded,
);
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
$embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded);
}
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
private function buildExportProItems(\Illuminate\Support\Collection $files, string $marker, array &$embedded): array
{
$items = [];
foreach ($files as $file) {
if (! Storage::disk('local')->exists($file->stored_path)) {
continue;
}
$ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION));
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
$embeddedProName = $marker.'_'.$file->order.'_'.$safeBase.'.pro';
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
if ($ext === 'probundle') {
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
$zip = new ZipArchive;
if ($zip->open($storedAbsPath) !== true) {
continue;
}
$proBytes = null;
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
if ($entryName === false) {
continue;
}
if (str_ends_with($entryName, '/')) {
continue;
}
$entryExt = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
$entryBytes = $zip->getFromIndex($i);
if ($entryBytes === false) {
continue;
}
if ($entryExt === 'pro') {
$proBytes = $entryBytes;
} else {
$mediaName = basename($entryName);
if (! isset($embedded[$mediaName])) {
$embedded[$mediaName] = $entryBytes;
}
}
}
$zip->close();
if ($proBytes === null) {
continue;
}
$embedded[$embeddedProName] = $proBytes;
$items[] = [
'type' => 'presentation',
'name' => $displayName,
'path' => $embeddedProName,
];
} else {
$bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) {
continue;
}
$embedded[$embeddedProName] = $bytes;
$items[] = [
'type' => 'presentation',
'name' => $displayName,
'path' => $embeddedProName,
];
}
}
return $items;
}
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);

View file

@ -54,6 +54,24 @@ private function buildGroups(Song $song, ?Service $service = null): array
$seenSectionIds = [];
$background = $this->backgroundData($service);
// Pre-compute the total slide count across the whole song (same filtering as the loop below)
// so that 'first_slide' and 'last_slide' macro positions refer to the song's very first/last slide.
$totalSlidesInSong = 0;
$seenForCount = [];
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
if ($section === null || $section->label === null) {
continue;
}
if (in_array($section->id, $seenForCount, true)) {
continue;
}
$seenForCount[] = $section->id;
$totalSlidesInSong += $section->slides->count();
}
$globalSlideIndex = 0;
foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section;
$label = $section?->label;
@ -69,9 +87,8 @@ private function buildGroups(Song $song, ?Service $service = null): array
$slides = [];
$sectionSlides = $section->slides->sortBy('order')->values();
$totalSlides = $sectionSlides->count();
foreach ($sectionSlides as $slideIndex => $slide) {
foreach ($sectionSlides as $slide) {
$slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) {
@ -86,7 +103,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
$macros = $this->macroResolutionService->macrosForSlide(
$service,
'song',
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id],
['index' => $globalSlideIndex, 'total' => $totalSlidesInSong, 'label_id' => $label->id],
);
if (! empty($macros)) {
@ -96,6 +113,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
}
$slides[] = $slideData;
$globalSlideIndex++;
}
$groups[] = [

View file

@ -94,7 +94,7 @@ async function deleteFile(id) {
<div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
<p class="text-xs text-gray-500">
Diese .pro-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt.
Diese .pro- und .probundle-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt.
</p>
</div>
@ -125,13 +125,13 @@ async function deleteFile(id) {
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span class="text-sm text-gray-500">
{{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }}
{{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }}
</span>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
<input
type="file"
class="hidden"
accept=".pro"
accept=".pro,.probundle"
multiple
:disabled="uploading"
data-testid="export-prefix-file-input"
@ -184,13 +184,13 @@ async function deleteFile(id) {
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<span class="text-sm text-gray-500">
{{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }}
{{ uploading ? 'Wird hochgeladen...' : '.pro / .probundle-Dateien auswählen oder hierher ziehen' }}
</span>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
<input
type="file"
class="hidden"
accept=".pro"
accept=".pro,.probundle"
multiple
:disabled="uploading"
data-testid="export-postfix-file-input"

View file

@ -43,7 +43,11 @@
);
Auth::login($user);
return redirect()->route('dashboard');
$service = Service::nextUpcoming()->first();
return $service
? redirect()->route('services.edit', $service->id)
: redirect()->route('services.index');
})->name('dev-login');
});
}

View file

@ -0,0 +1,278 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\User;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
use ZipArchive;
final class ExportProFileInjectionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Storage::fake('public');
}
private function createSongWithContent(string $title): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$label = Label::firstOrCreate(
['name' => 'Verse - '.$title],
['color' => '#2196F3'],
);
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
private function buildProbundleZip(string $proContent, array $mediaFiles = []): string
{
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
$zip = new ZipArchive();
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('song.pro', $proContent);
foreach ($mediaFiles as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
return $tmpPath;
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
public function test_probundle_prefix_wird_korrekt_injiziert(): void
{
$user = User::factory()->create();
$service = Service::factory()->create([
'title' => 'Bundle Test Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Test Song');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Test Song',
'order' => 1,
]);
$proContent = 'dummy-pro-content';
$mediaContent = 'dummy-media-bytes';
$bundlePath = $this->buildProbundleZip($proContent, ['background.jpg' => $mediaContent]);
$storedPath = 'export-pro-files/intro.probundle';
Storage::disk('local')->put($storedPath, file_get_contents($bundlePath));
unlink($bundlePath);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'intro.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
$this->assertContains('intro', $entryNames, 'Expected prefix presentation entry named "intro"');
$songPos = array_search('Test Song', $entryNames, true);
$prefixPos = array_search('intro', $entryNames, true);
$this->assertNotFalse($prefixPos, 'Prefix entry not found');
$this->assertNotFalse($songPos, 'Song entry not found');
$this->assertLessThan($songPos, $prefixPos, 'Prefix should appear before song');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('PREFIX_1_intro.pro', $embeddedFiles, 'Inner .pro should be embedded as PREFIX_1_intro.pro');
$this->assertSame($proContent, $embeddedFiles['PREFIX_1_intro.pro']);
$this->assertArrayHasKey('background.jpg', $embeddedFiles, 'Media file from .probundle should be embedded');
$this->assertSame($mediaContent, $embeddedFiles['background.jpg']);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_postfix_wird_nach_songs_injiziert(): void
{
$service = Service::factory()->create([
'title' => 'Postfix Bundle Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Worship Song');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Worship Song',
'order' => 1,
]);
$bundlePath = $this->buildProbundleZip('outro-pro-content', ['outro-media.jpg' => 'outro-media-bytes']);
$storedPath = 'export-pro-files/outro.probundle';
Storage::disk('local')->put($storedPath, file_get_contents($bundlePath));
unlink($bundlePath);
ExportProFile::create([
'type' => 'postfix',
'original_name' => 'outro.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
$songPos = array_search('Worship Song', $entryNames, true);
$postfixPos = array_search('outro', $entryNames, true);
$this->assertNotFalse($postfixPos, 'Postfix entry not found');
$this->assertNotFalse($songPos, 'Song entry not found');
$this->assertLessThan($postfixPos, $songPos, 'Song should appear before postfix');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('POSTFIX_1_outro.pro', $embeddedFiles);
$this->assertSame('outro-pro-content', $embeddedFiles['POSTFIX_1_outro.pro']);
$this->assertArrayHasKey('outro-media.jpg', $embeddedFiles);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_ohne_pro_eintrag_wird_uebersprungen(): void
{
$service = Service::factory()->create([
'title' => 'Skip Bundle Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Einziger Song');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Einziger Song',
'order' => 1,
]);
$tmpPath = tempnam(sys_get_temp_dir(), 'test-probundle-').'.probundle';
$zip = new ZipArchive();
$zip->open($tmpPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('media.jpg', 'media-only');
$zip->close();
$storedPath = 'export-pro-files/broken.probundle';
Storage::disk('local')->put($storedPath, file_get_contents($tmpPath));
unlink($tmpPath);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'broken.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries());
$this->assertNotContains('broken', $entryNames);
$this->assertContains('Einziger Song', $entryNames);
$this->cleanupTempDir($result['temp_dir']);
}
public function test_plain_pro_prefix_weiterhin_korrekt(): void
{
$service = Service::factory()->create([
'title' => 'Plain Pro Service',
'date' => now(),
]);
$song = $this->createSongWithContent('Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Lied',
'order' => 1,
]);
$storedPath = 'export-pro-files/welcome.pro';
Storage::disk('local')->put($storedPath, 'welcome-pro-bytes');
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'welcome.pro',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entries = $playlist->getEntries();
$entryNames = array_map(fn ($e) => $e->getName(), $entries);
$this->assertContains('welcome', $entryNames);
$prefixPos = array_search('welcome', $entryNames, true);
$songPos = array_search('Lied', $entryNames, true);
$this->assertLessThan($songPos, $prefixPos, 'Plain .pro prefix should appear before song');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('PREFIX_1_welcome.pro', $embeddedFiles);
$this->assertSame('welcome-pro-bytes', $embeddedFiles['PREFIX_1_welcome.pro']);
$this->cleanupTempDir($result['temp_dir']);
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ExportProFileUploadTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
public function test_pro_datei_wird_als_prefix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('intro.pro', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'prefix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'intro.pro');
}
public function test_probundle_datei_wird_als_prefix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('intro.probundle', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'prefix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'intro.probundle');
}
public function test_pro_datei_wird_als_postfix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('outro.pro', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'postfix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'outro.pro');
}
public function test_probundle_datei_wird_als_postfix_akzeptiert(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('outro.probundle', 10, 'application/octet-stream');
$response = $this->actingAs($user)->post(route('settings.export-pro-files.store'), [
'type' => 'postfix',
'files' => [$file],
]);
$response->assertStatus(201);
$response->assertJsonPath('files.0.original_name', 'outro.probundle');
}
public function test_unerlaubte_dateiendung_wird_abgelehnt(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
$response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [
'type' => 'prefix',
'files' => [$file],
]);
$response->assertStatus(422);
}
public function test_unerlaubte_dateiendung_postfix_wird_abgelehnt(): void
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
$response = $this->actingAs($user)->postJson(route('settings.export-pro-files.store'), [
'type' => 'postfix',
'files' => [$file],
]);
$response->assertStatus(422);
}
}

View file

@ -47,24 +47,22 @@ public function test_non_song_agenda_item_without_slides_becomes_headline(): voi
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
// Content-less item before any real content → HEADLINE (not keyvisual fallback)
$this->assertCount(1, $embeddedProFiles);
// Content-less item before any real content → HEADER playlist item (no embedded .pro)
$this->assertCount(0, $playlist->getEmbeddedProFiles());
$this->assertSame($slideCountBefore, Slide::count());
// Headline filename matches pattern: Begrüßung-headline-<uniqid>.pro
$headlineKey = null;
foreach (array_keys($embeddedProFiles) as $key) {
if (str_contains($key, 'Begrüßung') && str_contains($key, '-headline-')) {
$headlineKey = $key;
// Playlist has a header entry named 'Begrüßung'
$headerEntry = null;
foreach ($playlist->getEntries() as $entry) {
if ($entry->isHeader() && $entry->getName() === 'Begrüßung') {
$headerEntry = $entry;
break;
}
}
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung');
$this->assertMatchesRegularExpression('/^Begr[üu].*-headline-.*\.pro$/', $headlineKey);
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung');
// No KEY_VISUAL.jpg embedded (headline has no background media)
// No KEY_VISUAL.jpg embedded (header has no background media)
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
$this->cleanupTempDir($result['temp_dir']);
@ -189,20 +187,20 @@ public function test_empty_non_song_item_before_content_becomes_headline(): void
$playlist = ProPlaylistReader::read($result['path']);
$embeddedProFiles = $playlist->getEmbeddedProFiles();
// Content-less item before real content → HEADLINE (adds a playlist entry)
// So we expect 2 embedded pro files: song + headline
$this->assertCount(2, $embeddedProFiles);
// Content-less item before real content → HEADER playlist item (no embedded .pro for headline)
// Only the song .pro is embedded
$this->assertCount(1, $embeddedProFiles);
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
// Headline filename matches pattern: Begrüßung ohne Folien-headline-<uniqid>.pro
$headlineKey = null;
foreach (array_keys($embeddedProFiles) as $key) {
if (str_contains($key, 'Begrüßung ohne Folien') && str_contains($key, '-headline-')) {
$headlineKey = $key;
// Playlist has a header entry named 'Begrüßung ohne Folien'
$headerEntry = null;
foreach ($playlist->getEntries() as $entry) {
if ($entry->isHeader() && $entry->getName() === 'Begrüßung ohne Folien') {
$headerEntry = $entry;
break;
}
}
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung ohne Folien');
$this->assertNotNull($headerEntry, 'Expected a HEADER playlist entry for Begrüßung ohne Folien');
// No KEY_VISUAL.jpg embedded (no keyvisual configured)
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());

View file

@ -139,21 +139,31 @@
expect($name)->toBeNull();
});
test('worship leader resolves from lobpreis agenda item responsibles', function () {
test('worship leader resolves from lobpreis role in responsible persons', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'responsible' => [['name' => 'Moderator Max']],
'responsible' => [
'text' => '[Moderation]',
'persons' => [
['service' => '[Moderation]', 'accepted' => true, 'person' => ['title' => 'Moderator Max']],
],
],
'sort_order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis',
'is_before_event' => false,
'responsible' => [['name' => 'Lea Leiter']],
'responsible' => [
'text' => '[Lobpreis]',
'persons' => [
['service' => '[Lobpreis]', 'accepted' => true, 'person' => ['title' => 'Lea Leiter']],
],
],
'sort_order' => 2,
]);
@ -177,3 +187,124 @@
expect($name)->toBeNull();
});
test('name extracted from real cts persons shape via person.title', function () {
$service = Service::factory()->create(['moderator_name' => null]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => [
'title' => 'Kornelius Weiß',
'domainAttributes' => ['firstName' => 'Kornelius', 'lastName' => 'Weiß'],
],
],
],
],
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Kornelius Weiß');
});
test('name falls back to domainAttributes when person.title is absent', function () {
$service = Service::factory()->create(['moderator_name' => null]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => [
'domainAttributes' => ['firstName' => 'Anna', 'lastName' => 'Müller'],
],
],
],
],
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Anna Müller');
});
test('worship leader resolves from persons service role lobpreis in real cts shape', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => ['title' => 'Moderator Max'],
],
],
],
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis',
'is_before_event' => false,
'sort_order' => 2,
'responsible' => [
'text' => '[Lobpreis]',
'persons' => [
[
'service' => '[Lobpreis]',
'accepted' => true,
'person' => ['title' => 'Kornelius Weiß'],
],
],
],
]);
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
expect($name)->toBe('Kornelius Weiß');
});
test('worship leader returns null when no person has lobpreis role in real cts shape', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis',
'is_before_event' => false,
'sort_order' => 1,
'responsible' => [
'text' => '[Moderation]',
'persons' => [
[
'service' => '[Moderation]',
'accepted' => true,
'person' => ['title' => 'Moderator Max'],
],
],
],
]);
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
expect($name)->toBeNull();
});

View file

@ -1,7 +1,11 @@
<?php
use App\Models\Macro;
use App\Models\Setting;
use App\Services\NameTagSlideBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('build returns null when namenseinblender macro is not configured', function () {
expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull();
@ -37,3 +41,26 @@
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration")
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt");
});
test('build returns slide with macro when macro_id points to existing macro', function () {
$macro = Macro::factory()->create([
'name' => 'Namenseinblender',
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
]);
Setting::set('namenseinblender_macro_id', (string) $macro->id);
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
expect($slide)->toHaveKey('text', "Lea Leiter\nModeration")
->and($slide)->toHaveKey('macro')
->and($slide['macro']['name'])->toBe('Namenseinblender')
->and($slide['macro']['uuid'])->toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa');
});
test('build returns slide without macro when macro_id points to missing macro', function () {
Setting::set('namenseinblender_macro_id', '99999');
$slide = app(NameTagSlideBuilder::class)->build('Lea Leiter', 'Moderation');
expect($slide)->toBe(['text' => "Lea Leiter\nModeration"]);
});

View file

@ -1,5 +1,6 @@
<?php
use App\Models\Service;
use App\Models\User;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -50,7 +51,9 @@
});
it('creates a new user from OAuth callback', function () {
$socialiteUser = new SocialiteUser;
$service = Service::factory()->create(['date' => now()->addDays(7)]);
$socialiteUser = new SocialiteUser();
$socialiteUser->map([
'id' => '42',
'name' => 'Max Mustermann',
@ -80,7 +83,7 @@
$response = $this->get('/auth/churchtools/callback');
$response->assertRedirect(route('dashboard'));
$response->assertRedirect(route('services.edit', $service->id));
$this->assertDatabaseHas('users', [
'email' => 'max@example.com',
@ -98,13 +101,15 @@
});
it('updates existing user on OAuth callback', function () {
$service = Service::factory()->create(['date' => now()->addDays(7)]);
$existingUser = User::factory()->create([
'email' => 'max@example.com',
'name' => 'Old Name',
'churchtools_id' => 42,
]);
$socialiteUser = new SocialiteUser;
$socialiteUser = new SocialiteUser();
$socialiteUser->map([
'id' => '42',
'name' => 'Max Mustermann',
@ -134,7 +139,7 @@
$response = $this->get('/auth/churchtools/callback');
$response->assertRedirect(route('dashboard'));
$response->assertRedirect(route('services.edit', $service->id));
$existingUser->refresh();
expect($existingUser->name)->toBe('Max Mustermann');

View file

@ -298,6 +298,59 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
}
}
public function test_export_first_slide_macro_nur_auf_erster_folie_des_ganzen_songs(): void
{
$service = Service::factory()->create();
// Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Erste Folie Macro');
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'first_slide',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
// Exactly 3 slides: Verse-0, Verse-1, Chorus-0
$this->assertCount(3, $slides);
// Only the very first slide of the whole song gets the macro
$this->assertTrue($slides[0]->hasMacro(), 'Erste Folie (global index 0) muss Macro haben');
$this->assertSame('Erste Folie Macro', $slides[0]->getMacroName());
// Chorus slide 0 is the first slide of its section but NOT the first of the song → no macro
$this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein first_slide-Macro haben');
$this->assertFalse($slides[2]->hasMacro(), 'Chorus-Folie 0 darf kein first_slide-Macro haben (nicht erste Folie des Songs)');
}
public function test_export_last_slide_macro_nur_auf_letzter_folie_des_ganzen_songs(): void
{
$service = Service::factory()->create();
// Song: Verse(2 slides) + Chorus(1 slide) → 3 slides total; last = Chorus-0
$song = $this->createSongWithContent();
$macro = $this->createMacroForExport('Letzte Folie Macro');
MacroAssignment::create([
'part_type' => 'song',
'macro_id' => $macro->id,
'position' => 'last_slide',
'order' => 0,
]);
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
$slides = $this->allParserSlides($parserSong);
$this->assertCount(3, $slides);
// Only the very last slide of the whole song gets the macro
$this->assertFalse($slides[0]->hasMacro(), 'Verse-Folie 0 darf kein last_slide-Macro haben');
$this->assertFalse($slides[1]->hasMacro(), 'Verse-Folie 1 darf kein last_slide-Macro haben (letzte Folie der Sektion, aber nicht des Songs)');
$this->assertTrue($slides[2]->hasMacro(), 'Letzte Folie des Songs (global index 2) muss Macro haben');
$this->assertSame('Letzte Folie Macro', $slides[2]->getMacroName());
}
public function test_export_ohne_background_enthaelt_keine_background_actions(): void
{
Storage::fake('public');