pp-planer/tests/Feature/SongSectionControllerTest.php
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00

192 lines
7.5 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\SongSection;
use App\Models\SongSlide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SongSectionControllerTest extends TestCase
{
use RefreshDatabase;
public function test_patch_edits_only_target_song_section(): void
{
$this->actingAs(User::factory()->create());
$label = Label::factory()->create(['name' => 'Chorus']);
[$songA, $sectionA] = $this->createSongWithSection($label, ['Alter Refrain']);
[, $sectionB] = $this->createSongWithSection($label, ['Anderer Refrain']);
$response = $this->patchJson(route('songs.sections.update', [$songA, $sectionA]), [
'slides' => [
['text_content' => 'Neuer Refrain', 'text_content_translated' => 'New chorus'],
['text_content' => 'Zweiter Block', 'text_content_translated' => null],
],
]);
$response->assertOk()
->assertJsonPath('data.has_translation', true)
->assertJsonPath('data.groups.0.slides.0.text_content', 'Neuer Refrain');
$this->assertSame(['Neuer Refrain', 'Zweiter Block'], $sectionA->slides()->orderBy('order')->pluck('text_content')->all());
$this->assertSame(['Anderer Refrain'], $sectionB->slides()->orderBy('order')->pluck('text_content')->all());
$this->assertTrue($songA->fresh()->has_translation);
}
public function test_post_adds_section_reuses_normalized_label_and_appends_default_arrangement(): void
{
$this->actingAs(User::factory()->create());
$song = Song::factory()->create(['has_translation' => false]);
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Normal',
'is_default' => true,
]);
$existingLabel = Label::factory()->create(['name' => 'Verse 3', 'color' => '#111111']);
$response = $this->postJson(route('songs.sections.store', $song), [
'label_name' => 'Strophe 3',
'color' => '#ABCDEF',
'slides' => [
['text_content' => 'Neue Strophe', 'text_content_translated' => null],
],
]);
$response->assertCreated()
->assertJsonPath('data.groups.0.name', 'Verse 3')
->assertJsonPath('data.groups.0.slides.0.text_content', 'Neue Strophe');
$this->assertSame(1, Label::query()->where('name', 'Verse 3')->count());
$section = SongSection::query()->where('song_id', $song->id)->where('label_id', $existingLabel->id)->first();
$this->assertNotNull($section);
$this->assertDatabaseHas('song_slides', [
'song_section_id' => $section->id,
'text_content' => 'Neue Strophe',
]);
$this->assertDatabaseHas('song_arrangement_labels', [
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 1,
]);
}
public function test_delete_removes_section_slides_and_junction_for_this_song_only_but_keeps_global_label(): void
{
$this->actingAs(User::factory()->create());
$label = Label::factory()->create(['name' => 'Bridge']);
[$songA, $sectionA, $arrangementA] = $this->createSongWithSection($label, ['Bridge A']);
[, $sectionB] = $this->createSongWithSection($label, ['Bridge B']);
$slideId = $sectionA->slides()->first()->id;
$junctionId = SongArrangementLabel::query()
->where('song_arrangement_id', $arrangementA->id)
->where('song_section_id', $sectionA->id)
->value('id');
$response = $this->deleteJson(route('songs.sections.destroy', [$songA, $sectionA]));
$response->assertOk()
->assertJsonPath('data.groups', []);
$this->assertDatabaseMissing('song_sections', ['id' => $sectionA->id]);
$this->assertDatabaseMissing('song_slides', ['id' => $slideId]);
$this->assertDatabaseMissing('song_arrangement_labels', ['id' => $junctionId]);
$this->assertDatabaseHas('labels', ['id' => $label->id]);
$this->assertDatabaseHas('song_sections', ['id' => $sectionB->id]);
$this->assertSame(['Bridge B'], $sectionB->slides()->pluck('text_content')->all());
}
public function test_validation_and_ownership_errors_are_german(): void
{
$this->actingAs(User::factory()->create());
$label = Label::factory()->create(['name' => 'Verse 1']);
[$songA, $sectionA] = $this->createSongWithSection($label, ['Song A']);
[$songB] = $this->createSongWithSection(Label::factory()->create(['name' => 'Chorus']), ['Song B']);
$this->patchJson(route('songs.sections.update', [$songB, $sectionA]), [
'slides' => [
['text_content' => 'Falsch'],
],
])->assertNotFound()
->assertJsonPath('message', 'Sektion nicht gefunden.');
$this->postJson(route('songs.sections.store', $songA), [
'slides' => [
['text_content' => 'Ohne Label'],
],
])->assertUnprocessable()
->assertJsonPath('message', 'Bitte gib einen Namen für die Sektion ein.');
$this->postJson(route('songs.sections.store', $songA), [
'label_name' => 'Verse 1',
'slides' => [
['text_content' => 'Doppelt'],
],
])->assertUnprocessable()
->assertJsonPath('message', 'Dieser Abschnitt existiert bereits in diesem Lied.');
}
public function test_has_translation_is_recomputed_after_edits(): void
{
$this->actingAs(User::factory()->create());
[$song, $section] = $this->createSongWithSection(Label::factory()->create(['name' => 'Verse 1']), ['Original']);
$this->patchJson(route('songs.sections.update', [$song, $section]), [
'slides' => [
['text_content' => 'Original', 'text_content_translated' => 'Übersetzt'],
],
])->assertOk()
->assertJsonPath('data.has_translation', true);
$this->assertTrue($song->fresh()->has_translation);
$this->patchJson(route('songs.sections.update', [$song, $section]), [
'slides' => [
['text_content' => 'Original', 'text_content_translated' => ''],
],
])->assertOk()
->assertJsonPath('data.has_translation', false);
$this->assertFalse($song->fresh()->has_translation);
}
private function createSongWithSection(Label $label, array $slides): array
{
$song = Song::factory()->create(['has_translation' => false]);
$section = SongSection::factory()->create([
'song_id' => $song->id,
'label_id' => $label->id,
'order' => 1,
]);
foreach ($slides as $index => $text) {
SongSlide::factory()->create([
'song_section_id' => $section->id,
'order' => $index + 1,
'text_content' => $text,
'text_content_translated' => null,
]);
}
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Normal',
'is_default' => true,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section->id,
'order' => 1,
]);
return [$song, $section, $arrangement];
}
}