feat(export): wire agenda export into download flow
This commit is contained in:
parent
45955b70a2
commit
18d0d6f965
|
|
@ -250,3 +250,50 @@ ### Test Infrastructure
|
||||||
- `createSlideFile()` calls `Storage::fake('public')` for each slide
|
- `createSlideFile()` calls `Storage::fake('public')` for each slide
|
||||||
- Mock playlist output: `mock-playlist:{name}\n{item1}\n{item2}` — enables position assertions via `strpos()`
|
- 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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -616,6 +616,197 @@ public function test_agenda_export_before_event_items_ausgeschlossen(): void
|
||||||
$this->cleanupTempDir($result['temp_dir']);
|
$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
|
private function cleanupTempDir(string $dir): void
|
||||||
{
|
{
|
||||||
if (is_dir($dir)) {
|
if (is_dir($dir)) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue