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; namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -52,7 +53,12 @@ public function callback(): RedirectResponse
Auth::login($user, remember: true); 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([ $request->validate([
'type' => ['required', Rule::in(['prefix', 'postfix'])], 'type' => ['required', Rule::in(['prefix', 'postfix'])],
'files' => ['required', 'array'], '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'); $type = $request->input('type');

View file

@ -21,6 +21,12 @@ public function moderatorFor(Service $service): ?string
return $override; return $override;
} }
// Prefer the person whose responsible role is "Moderation".
$byRole = $this->nameForRole($service, 'moderation');
if ($byRole !== null) {
return $byRole;
}
$firstAgendaItem = $service->agendaItems() $firstAgendaItem = $service->agendaItems()
->where('is_before_event', false) ->where('is_before_event', false)
->orderBy('sort_order') ->orderBy('sort_order')
@ -32,14 +38,7 @@ public function moderatorFor(Service $service): ?string
public function worshipLeaderFor(Service $service): ?string public function worshipLeaderFor(Service $service): ?string
{ {
$worshipItem = $service->agendaItems() return $this->nameForRole($service, 'lobpreis');
->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;
} }
public function preacherFor(Service $service): ?string public function preacherFor(Service $service): ?string
@ -54,6 +53,12 @@ public function preacherFor(Service $service): ?string
return $preacherName; return $preacherName;
} }
// Prefer the person whose responsible role is "Predigt".
$byRole = $this->nameForRole($service, 'predigt');
if ($byRole !== null) {
return $byRole;
}
$sermonItem = $service->agendaItems() $sermonItem = $service->agendaItems()
->where('is_before_event', false) ->where('is_before_event', false)
->whereNull('service_song_id') ->whereNull('service_song_id')
@ -65,6 +70,29 @@ public function preacherFor(Service $service): ?string
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null; 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 private function filledString(?string $value): ?string
{ {
$trimmed = trim((string) $value); $trimmed = trim((string) $value);
@ -78,10 +106,17 @@ private function namesFromResponsible(mixed $responsible): ?string
return null; 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) $names = collect($entries)
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person)) ->map(fn (mixed $entry) => $this->nameFromResponsibleEntry($entry))
->filter() ->filter()
->values() ->values()
->all(); ->all();
@ -89,35 +124,94 @@ private function namesFromResponsible(mixed $responsible): ?string
return $names === [] ? null : implode(', ', $names); 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)) { if (is_string($entry)) {
return $this->filledString($person); return $this->filledString($entry);
} }
if (! is_array($person)) { if (! is_array($entry)) {
return null; 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) { if ($name !== null) {
return $name; return $name;
} }
$firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? ''; $domainAttrs = $node['domainAttributes'] ?? [];
$lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? ''; $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); $fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName; 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); if (! is_array($responsible) || $responsible === []) {
$type = Str::lower($item->type ?? ''); return null;
}
return str_contains($title, 'lobpreis') // Real CTS shape: { "persons": [ { "service": "[Lobpreis]", "person": { ... } } ] }
|| str_contains($type, 'lobpreis'); $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 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'); $macroName = Setting::get('namenseinblender_macro_name');

View file

@ -11,6 +11,7 @@
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistGenerator; use ProPresenter\Parser\ProPlaylistGenerator;
use ZipArchive;
class PlaylistExportService class PlaylistExportService
{ {
@ -169,11 +170,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
); );
$keyvisualFallbackEmitted = true; $keyvisualFallbackEmitted = true;
} else { } else {
$this->addHeadlinePresentation( $this->addHeadlineItem(
$item, $item,
$tempDir,
$playlistItems, $playlistItems,
$embeddedFiles,
); );
} }
} }
@ -201,7 +200,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.'); 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'); $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); $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.'); 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'); $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title); $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
@ -514,99 +513,118 @@ private function addKeyVisualFallbackPresentation(
]; ];
} }
private function addHeadlinePresentation( private function addHeadlineItem(
ServiceAgendaItem $item, ServiceAgendaItem $item,
string $tempDir,
array &$playlistItems, array &$playlistItems,
array &$embeddedFiles,
): void { ): 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[] = [ $playlistItems[] = [
'type' => 'presentation', 'type' => 'header',
'name' => $label, 'name' => $item->title ?: 'Ablaufpunkt',
'path' => $proFilename, '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 = []; $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 = []; $postfixEmbedded = [];
$postfixItems = $this->buildExportProItems(
foreach ($postfixFiles as $file) { ExportProFile::postfix()->orderBy('order')->get(),
if (! Storage::disk('local')->exists($file->stored_path)) { 'POSTFIX',
continue; $postfixEmbedded,
} );
$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,
];
}
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems); $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
$embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded); $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 protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
{ {
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements); ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);

View file

@ -54,6 +54,24 @@ private function buildGroups(Song $song, ?Service $service = null): array
$seenSectionIds = []; $seenSectionIds = [];
$background = $this->backgroundData($service); $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) { foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
$section = $arrangementSection->section; $section = $arrangementSection->section;
$label = $section?->label; $label = $section?->label;
@ -69,9 +87,8 @@ private function buildGroups(Song $song, ?Service $service = null): array
$slides = []; $slides = [];
$sectionSlides = $section->slides->sortBy('order')->values(); $sectionSlides = $section->slides->sortBy('order')->values();
$totalSlides = $sectionSlides->count();
foreach ($sectionSlides as $slideIndex => $slide) { foreach ($sectionSlides as $slide) {
$slideData = ['text' => $slide->text_content ?? '']; $slideData = ['text' => $slide->text_content ?? ''];
if ($slide->text_content_translated) { if ($slide->text_content_translated) {
@ -86,7 +103,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
$macros = $this->macroResolutionService->macrosForSlide( $macros = $this->macroResolutionService->macrosForSlide(
$service, $service,
'song', 'song',
['index' => $slideIndex, 'total' => $totalSlides, 'label_id' => $label->id], ['index' => $globalSlideIndex, 'total' => $totalSlidesInSong, 'label_id' => $label->id],
); );
if (! empty($macros)) { if (! empty($macros)) {
@ -96,6 +113,7 @@ private function buildGroups(Song $song, ?Service $service = null): array
} }
$slides[] = $slideData; $slides[] = $slideData;
$globalSlideIndex++;
} }
$groups[] = [ $groups[] = [

View file

@ -94,7 +94,7 @@ async function deleteFile(id) {
<div> <div>
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3> <h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
<p class="text-xs text-gray-500"> <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> </p>
</div> </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" /> <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> </svg>
<span class="text-sm text-gray-500"> <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>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span> <span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
<input <input
type="file" type="file"
class="hidden" class="hidden"
accept=".pro" accept=".pro,.probundle"
multiple multiple
:disabled="uploading" :disabled="uploading"
data-testid="export-prefix-file-input" 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" /> <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> </svg>
<span class="text-sm text-gray-500"> <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>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span> <span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
<input <input
type="file" type="file"
class="hidden" class="hidden"
accept=".pro" accept=".pro,.probundle"
multiple multiple
:disabled="uploading" :disabled="uploading"
data-testid="export-postfix-file-input" data-testid="export-postfix-file-input"

View file

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

View file

@ -139,21 +139,31 @@
expect($name)->toBeNull(); 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(); $service = Service::factory()->create();
ServiceAgendaItem::factory()->create([ ServiceAgendaItem::factory()->create([
'service_id' => $service->id, 'service_id' => $service->id,
'title' => 'Begrüßung', 'title' => 'Begrüßung',
'is_before_event' => false, 'is_before_event' => false,
'responsible' => [['name' => 'Moderator Max']], 'responsible' => [
'text' => '[Moderation]',
'persons' => [
['service' => '[Moderation]', 'accepted' => true, 'person' => ['title' => 'Moderator Max']],
],
],
'sort_order' => 1, 'sort_order' => 1,
]); ]);
ServiceAgendaItem::factory()->create([ ServiceAgendaItem::factory()->create([
'service_id' => $service->id, 'service_id' => $service->id,
'title' => 'Lobpreis', 'title' => 'Lobpreis',
'is_before_event' => false, 'is_before_event' => false,
'responsible' => [['name' => 'Lea Leiter']], 'responsible' => [
'text' => '[Lobpreis]',
'persons' => [
['service' => '[Lobpreis]', 'accepted' => true, 'person' => ['title' => 'Lea Leiter']],
],
],
'sort_order' => 2, 'sort_order' => 2,
]); ]);
@ -177,3 +187,124 @@
expect($name)->toBeNull(); 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 <?php
use App\Models\Macro;
use App\Models\Setting; use App\Models\Setting;
use App\Services\NameTagSlideBuilder; use App\Services\NameTagSlideBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('build returns null when namenseinblender macro is not configured', function () { test('build returns null when namenseinblender macro is not configured', function () {
expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull(); expect(app(NameTagSlideBuilder::class)->build('Max Mustermann', 'Moderation'))->toBeNull();
@ -37,3 +41,26 @@
expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration") expect($builder->buildModeratorSlide('Max Mustermann')['text'])->toBe("Max Mustermann\nModeration")
->and($builder->buildPreacherSlide('Erika Beispiel')['text'])->toBe("Erika Beispiel\nPredigt"); ->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 <?php
use App\Models\Service;
use App\Models\User; use App\Models\User;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser; use Laravel\Socialite\Two\User as SocialiteUser;
@ -50,7 +51,9 @@
}); });
it('creates a new user from OAuth callback', function () { 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([ $socialiteUser->map([
'id' => '42', 'id' => '42',
'name' => 'Max Mustermann', 'name' => 'Max Mustermann',
@ -80,7 +83,7 @@
$response = $this->get('/auth/churchtools/callback'); $response = $this->get('/auth/churchtools/callback');
$response->assertRedirect(route('dashboard')); $response->assertRedirect(route('services.edit', $service->id));
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'email' => 'max@example.com', 'email' => 'max@example.com',
@ -98,13 +101,15 @@
}); });
it('updates existing user on OAuth callback', function () { it('updates existing user on OAuth callback', function () {
$service = Service::factory()->create(['date' => now()->addDays(7)]);
$existingUser = User::factory()->create([ $existingUser = User::factory()->create([
'email' => 'max@example.com', 'email' => 'max@example.com',
'name' => 'Old Name', 'name' => 'Old Name',
'churchtools_id' => 42, 'churchtools_id' => 42,
]); ]);
$socialiteUser = new SocialiteUser; $socialiteUser = new SocialiteUser();
$socialiteUser->map([ $socialiteUser->map([
'id' => '42', 'id' => '42',
'name' => 'Max Mustermann', 'name' => 'Max Mustermann',
@ -134,7 +139,7 @@
$response = $this->get('/auth/churchtools/callback'); $response = $this->get('/auth/churchtools/callback');
$response->assertRedirect(route('dashboard')); $response->assertRedirect(route('services.edit', $service->id));
$existingUser->refresh(); $existingUser->refresh();
expect($existingUser->name)->toBe('Max Mustermann'); 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 public function test_export_ohne_background_enthaelt_keine_background_actions(): void
{ {
Storage::fake('public'); Storage::fake('public');