pp-planer/tests/Feature/ExportProFileProbundleEmbedTest.php
Thorsten Bus fdabaadb89 fix(export): preserve probundle extension and attach slide macros
- store() persisted uploads under Laravel's MIME-guessed extension,
  turning a .probundle (a ZIP) into .zip; embedExportProFile then
  branched on the stored path, missed the bundle branch and embedded
  the raw ZIP as a broken .pro (ProPresenter showed an empty slide).
  Store with the real client extension and derive the bundle type from
  the original filename, treating .zip as a bundle for legacy uploads.
- Skip export .pro/.probundle files that parse to zero content slides,
  recording a German warning instead of injecting an empty slide.
- Attach position macros (first_slide/last_slide/all_slides) per slide
  for information/moderation/sermon parts in the agenda and legacy paths.
- Cover both with ExportProFileProbundleEmbedTest and PlaylistSermonMacroTest.
2026-07-06 11:19:56 +02:00

222 lines
8 KiB
PHP

<?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\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
use ZipArchive;
/**
* Regression cover for the "empty keyword/prefix show" bug: a .probundle uploaded
* as a keyword/prefix/postfix export is stored by Laravel under a MIME-guessed
* .zip extension. Branching on the stored path then misses the probundle branch
* and embeds the raw ZIP bytes as a broken .pro (ProPresenter shows 0 slides).
*
* These tests use REAL, slide-bearing .pro bytes (generated by the parser) wrapped
* in a real ZIP — dummy string bytes are exactly what hid the original bug.
*/
final class ExportProFileProbundleEmbedTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Storage::fake('public');
}
/** Build real, parseable .pro bytes with the given groups/arrangements. */
private function realProBytes(string $name, array $groups, array $arrangements): string
{
$tmp = tempnam(sys_get_temp_dir(), 'fixture-pro-').'.pro';
ProFileGenerator::generateAndWrite($tmp, $name, $groups, $arrangements);
$bytes = file_get_contents($tmp);
@unlink($tmp);
return $bytes;
}
/** Wrap real .pro bytes (+ optional media) into a real .probundle ZIP, return the archive bytes. */
private function probundleBytes(string $proBytes, array $mediaFiles = []): string
{
$tmp = tempnam(sys_get_temp_dir(), 'fixture-bundle-').'.probundle';
$zip = new ZipArchive;
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('presentation.pro', $proBytes);
foreach ($mediaFiles as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
$bytes = file_get_contents($tmp);
@unlink($tmp);
return $bytes;
}
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 allParserSlides(\ProPresenter\Parser\Song $parserSong): array
{
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
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_mis_stored_as_zip_wird_mit_folien_injiziert(): void
{
$service = Service::factory()->create(['title' => 'Bundle Service', 'date' => now()]);
$song = $this->createSongWithContent('Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Lied',
'order' => 1,
]);
// Real inner .pro with a group of 2 content slides.
$proBytes = $this->realProBytes('intro', [
['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => [
['text' => 'Folie eins'],
['text' => 'Folie zwei'],
]],
], [['name' => 'normal', 'groupNames' => ['Verse']]]);
$bundleBytes = $this->probundleBytes($proBytes, ['hintergrund.jpg' => 'jpg-bytes']);
// Reproduce the mis-stored state: original name .probundle, stored path .zip.
$storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip';
Storage::disk('local')->put($storedPath, $bundleBytes);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'intro.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->assertContains('intro', $entryNames, 'Prefix bundle presentation must be injected');
$embeddedSong = $playlist->getEmbeddedSong('PREFIX_1_intro.pro');
$this->assertNotNull($embeddedSong, 'Inner .pro must be embedded, not the raw ZIP');
$this->assertCount(2, $this->allParserSlides($embeddedSong), 'Inner .pro must keep its 2 content slides');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('hintergrund.jpg', $embeddedFiles, 'Bundle media must be embedded');
$this->assertSame('jpg-bytes', $embeddedFiles['hintergrund.jpg']);
$this->assertSame([], $result['warnings'], 'A slide-bearing bundle must not be skipped');
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_ohne_folien_wird_uebersprungen_mit_warnung(): void
{
$service = Service::factory()->create(['title' => 'Leer Service', 'date' => now()]);
$song = $this->createSongWithContent('Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Lied',
'order' => 1,
]);
// Real inner .pro that parses fine but has a group with ZERO slides.
$proBytes = $this->realProBytes('leer', [
['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => []],
], [['name' => 'normal', 'groupNames' => ['Verse']]]);
$bundleBytes = $this->probundleBytes($proBytes);
$storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip';
Storage::disk('local')->put($storedPath, $bundleBytes);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'leer.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('leer', $entryNames, 'A contentless bundle must not be injected');
$this->assertNull($playlist->getEmbeddedSong('PREFIX_1_leer.pro'), 'A contentless bundle must not be embedded');
$this->assertContains(
"Export-Datei 'leer.probundle' übersprungen: keine Folien gefunden.",
$result['warnings'],
'A contentless bundle must be skipped with a German warning',
);
// The valid song is still exported.
$this->assertContains('Lied', $entryNames);
$this->cleanupTempDir($result['temp_dir']);
}
}