pp-planer/tests/Feature/SongEditModalTest.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

208 lines
6 KiB
PHP

<?php
use App\Models\Label;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementLabel;
use App\Models\User;
beforeEach(function () {
$this->user = User::factory()->create();
});
test('show returns song with full detail for modal', function () {
$song = Song::factory()->create([
'title' => 'Großer Gott wir loben Dich',
'ccli_id' => '100200',
'copyright_text' => '© Public Domain',
]);
$label1 = Label::factory()->create([
'name' => 'Strophe 1',
'color' => '#3B82F6',
]);
$label2 = Label::factory()->create([
'name' => 'Refrain',
'color' => '#10B981',
]);
$section1 = songSectionFor($song, $label1, 1);
$section2 = songSectionFor($song, $label2, 2);
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Normal',
'is_default' => true,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section1->id,
'order' => 1,
]);
SongArrangementLabel::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_section_id' => $section2->id,
'order' => 2,
]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
$response->assertOk()
->assertJsonPath('data.title', 'Großer Gott wir loben Dich')
->assertJsonPath('data.ccli_id', '100200')
->assertJsonPath('data.copyright_text', '© Public Domain')
->assertJsonStructure([
'data' => [
'id', 'title', 'ccli_id', 'copyright_text',
'groups' => [['id', 'name', 'color', 'order', 'slides']],
'arrangements' => [['id', 'name', 'is_default', 'arrangement_groups']],
],
]);
expect($response->json('data.groups.0.name'))->toBe('Strophe 1');
expect($response->json('data.groups.1.name'))->toBe('Refrain');
expect($response->json('data.arrangements.0.name'))->toBe('Normal');
expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2);
});
test('update saves title via auto-save', function () {
$song = Song::factory()->create(['title' => 'Original Title']);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => 'Neuer Titel',
]);
$response->assertOk()
->assertJsonFragment(['message' => 'Song erfolgreich aktualisiert']);
$song->refresh();
expect($song->title)->toBe('Neuer Titel');
});
test('update saves ccli_id via auto-save', function () {
$song = Song::factory()->create(['ccli_id' => '111111']);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => $song->title,
'ccli_id' => '999888',
]);
$response->assertOk();
$song->refresh();
expect($song->ccli_id)->toBe('999888');
});
test('update saves copyright_text via auto-save', function () {
$song = Song::factory()->create(['copyright_text' => null]);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => $song->title,
'copyright_text' => '© 2024 Neuer Copyright-Text',
]);
$response->assertOk();
$song->refresh();
expect($song->copyright_text)->toBe('© 2024 Neuer Copyright-Text');
});
test('update can clear optional fields with null', function () {
$song = Song::factory()->create([
'ccli_id' => '555555',
'copyright_text' => 'Some copyright',
]);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => $song->title,
'ccli_id' => null,
'copyright_text' => null,
]);
$response->assertOk();
$song->refresh();
expect($song->ccli_id)->toBeNull();
expect($song->copyright_text)->toBeNull();
});
test('update returns full song detail with arrangements', function () {
$song = Song::factory()->create();
SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => 'Updated Song',
]);
$response->assertOk()
->assertJsonStructure([
'data' => [
'id', 'title', 'ccli_id', 'copyright_text',
'groups', 'arrangements',
],
]);
});
test('update validates title is required', function () {
$song = Song::factory()->create();
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => '',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
test('update validates unique ccli_id against other songs', function () {
Song::factory()->create(['ccli_id' => '777777']);
$song = Song::factory()->create(['ccli_id' => '888888']);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => $song->title,
'ccli_id' => '777777',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['ccli_id']);
});
test('update requires authentication', function () {
$song = Song::factory()->create();
$response = $this->putJson("/api/songs/{$song->id}", [
'title' => 'Should Fail',
]);
$response->assertUnauthorized();
});
test('show returns 404 for soft-deleted song', function () {
$song = Song::factory()->create(['deleted_at' => now()]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
$response->assertNotFound();
});
test('update returns 404 for nonexistent song', function () {
$response = $this->actingAs($this->user)
->putJson('/api/songs/99999', [
'title' => 'Ghost Song',
]);
$response->assertNotFound();
});