- 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.
222 lines
8 KiB
PHP
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']);
|
|
}
|
|
}
|