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.
192 lines
7.5 KiB
PHP
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];
|
|
}
|
|
}
|