feat(export): wire agenda export into download flow

This commit is contained in:
Thorsten Bus 2026-03-29 12:17:50 +02:00
parent 45955b70a2
commit 18d0d6f965
2 changed files with 238 additions and 0 deletions

View file

@ -250,3 +250,50 @@ ### Test Infrastructure
- `createSlideFile()` calls `Storage::fake('public')` for each slide
- Mock playlist output: `mock-playlist:{name}\n{item1}\n{item2}` — enables position assertions via `strpos()`
- 2 pre-existing test failures (HTTP route tests) due to empty propresenter parser src — not regressions
## 2026-03-29 — Finalize → Download integration tests
### Verification
- `ServiceController::download()` already calls `PlaylistExportService->generatePlaylist($service)` — no controller changes needed
- `PlaylistExportService::generatePlaylist()` already routes to agenda path or legacy fallback (Task 9)
- Main deliverable: integration tests proving full finalize → download flow
### Integration Tests Added (PlaylistExportTest.php)
1. `test_finalize_und_download_flow_mit_agenda_items` — creates service with agenda items (2 songs + sermon with slides), finalizes, downloads, verifies playlist ordering (song1 → sermon → song2)
2. `test_finalize_und_download_flow_legacy_ohne_agenda` — creates service without agenda items, finalizes, downloads, verifies legacy path produces .proplaylist file
### Test Patterns
- Both tests use `@runInSeparateProcess` + `@preserveGlobalState disabled` + `mockProPresenterClasses()` (Mockery alias)
- Full HTTP flow: POST finalize → refresh → GET download → assert response headers
- Agenda test verifies playlist content ordering via `strpos()` position comparison
- Legacy test verifies backward compat: no ServiceAgendaItems → uses `generatePlaylistLegacy()`
## 2026-03-29 — Settings.vue Agenda Configuration UI
### Implementation
- Updated `resources/js/Pages/Settings.vue` with new Agenda-Konfiguration section
- Added 4 new fields to the `fields` array with `section: 'agenda'` property:
1. `agenda_start_title` — Ablauf-Start pattern
2. `agenda_end_title` — Ablauf-Ende pattern
3. `agenda_announcement_position` — Ankündigungen-Position with helpText
4. `agenda_sermon_matching` — Predigt-Erkennung with helpText
- Added `section: 'macro'` to existing 4 macro fields for clear separation
- Updated macro section template: `fields.filter(f => f.section === 'macro')`
- Created new agenda section with identical field input structure
- Section displays:
- Header: "Agenda-Konfiguration"
- Subtitle: "Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird."
- Fields with auto-save on blur (same `saveField()` function)
- Conditional helpText display (only if `field.helpText` present)
- Identical saving/saved/error indicators as macro section
### Build & Commit
- `npm run build` — ✓ 803 modules transformed, 17.94s gzip size for Edit component (expected growth)
- Committed: `feat(ui): add agenda settings to Settings page`
### Vue Field Definition Pattern
- Using `section` property to group related fields
- Template: `fields.filter(f => f.section === '{section}')`
- Optional `helpText` property displayed below input (agenda fields only, not macro fields)
- All fields use same `form[key]`, `saving[key]`, `saved[key]`, `errors[key]` reactive objects
- On blur: `saveField(field.key)` triggers PATCH to `route('settings.update')` endpoint

View file

@ -616,6 +616,197 @@ public function test_agenda_export_before_event_items_ausgeschlossen(): void
$this->cleanupTempDir($result['temp_dir']);
}
/**
* @runInSeparateProcess
*
* @preserveGlobalState disabled
*/
public function test_finalize_und_download_flow_mit_agenda_items(): void
{
$this->mockProPresenterClasses();
$service = Service::factory()->create([
'finalized_at' => null,
'title' => 'Integrations Service',
'date' => now(),
]);
// Create songs with content
$song1 = $this->createSongWithContent('Lobpreis Lied');
$song2 = $this->createSongWithContent('Abschluss Lied');
$arrangement1 = $song1->arrangements()->first();
$arrangement2 = $song2->arrangements()->first();
$serviceSong1 = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song1->id,
'song_arrangement_id' => $arrangement1->id,
'cts_song_name' => 'Lobpreis Lied',
'order' => 1,
]);
$serviceSong2 = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song2->id,
'song_arrangement_id' => $arrangement2->id,
'cts_song_name' => 'Abschluss Lied',
'order' => 2,
]);
// Agenda: Song → Sermon (non-song with slides) → Song
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lobpreis Lied',
'service_song_id' => $serviceSong1->id,
'sort_order' => 1,
'is_before_event' => false,
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 2,
'is_before_event' => false,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Abschluss Lied',
'service_song_id' => $serviceSong2->id,
'sort_order' => 3,
'is_before_event' => false,
]);
// Upload slides to sermon agenda item
$this->createSlideFile('sermon_slide1.jpg');
$this->createSlide([
'type' => 'sermon',
'service_id' => $service->id,
'service_agenda_item_id' => $sermonItem->id,
'original_filename' => 'sermon_slide1.jpg',
'stored_filename' => 'sermon_slide1.jpg',
'sort_order' => 1,
]);
// Add sermon slides for finalization check (legacy)
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
]);
// Step 1: Finalize the service
$finalizeResponse = $this->actingAs($this->user)
->postJson(route('services.finalize', $service), ['confirmed' => true]);
$finalizeResponse->assertOk()
->assertJson([
'needs_confirmation' => false,
'success' => 'Service wurde abgeschlossen.',
]);
$service->refresh();
$this->assertNotNull($service->finalized_at);
// Step 2: Download the finalized service
$downloadResponse = $this->actingAs($this->user)
->get(route('services.download', $service));
$downloadResponse->assertOk();
$disposition = $downloadResponse->headers->get('content-disposition');
$this->assertNotNull($disposition);
$this->assertStringContainsString('.proplaylist', $disposition);
// Verify playlist content includes songs and sermon in correct order
$tempPath = null;
foreach (glob(sys_get_temp_dir().'/playlist-export-*/*.proplaylist') as $file) {
$content = file_get_contents($file);
if (str_contains($content, 'Integrations Service')) {
$tempPath = dirname($file);
// Verify agenda ordering: song1 → sermon → song2
$pos1 = strpos($content, 'Lobpreis Lied');
$posSer = strpos($content, 'Predigt');
$pos2 = strpos($content, 'Abschluss Lied');
$this->assertNotFalse($pos1);
$this->assertNotFalse($posSer);
$this->assertNotFalse($pos2);
$this->assertLessThan($posSer, $pos1, 'Song 1 should be before sermon');
$this->assertLessThan($pos2, $posSer, 'Sermon should be before Song 2');
break;
}
}
if ($tempPath) {
$this->cleanupTempDir($tempPath);
}
}
/**
* @runInSeparateProcess
*
* @preserveGlobalState disabled
*/
public function test_finalize_und_download_flow_legacy_ohne_agenda(): void
{
$this->mockProPresenterClasses();
$service = Service::factory()->create([
'finalized_at' => null,
'title' => 'Legacy Ablauf',
'date' => now(),
]);
$song = $this->createSongWithContent('Legacy Worship');
$arrangement = $song->arrangements()->first();
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'cts_song_name' => 'Legacy Worship',
'order' => 1,
]);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'sermon',
]);
// No ServiceAgendaItems → will use legacy path
// Step 1: Finalize
$finalizeResponse = $this->actingAs($this->user)
->postJson(route('services.finalize', $service), ['confirmed' => false]);
$finalizeResponse->assertOk()
->assertJson([
'needs_confirmation' => false,
'success' => 'Service wurde abgeschlossen.',
]);
$service->refresh();
$this->assertNotNull($service->finalized_at);
// Step 2: Download — uses legacy path (no agenda items)
$downloadResponse = $this->actingAs($this->user)
->get(route('services.download', $service));
$downloadResponse->assertOk();
$disposition = $downloadResponse->headers->get('content-disposition');
$this->assertNotNull($disposition);
$this->assertStringContainsString('.proplaylist', $disposition);
$this->assertStringContainsString('Legacy Ablauf', $disposition);
// Cleanup temp files
foreach (glob(sys_get_temp_dir().'/playlist-export-*') as $dir) {
if (is_dir($dir)) {
$this->cleanupTempDir($dir);
}
}
}
private function cleanupTempDir(string $dir): void
{
if (is_dir($dir)) {