user = User::factory()->create(); }); // --- INDEX / LIST --- test('songs index returns paginated list', function () { Song::factory()->count(3)->create(); $response = $this->actingAs($this->user) ->getJson('/api/songs'); $response->assertOk() ->assertJsonStructure([ 'data' => [['id', 'title', 'ccli_id', 'author', 'has_translation']], 'meta' => ['current_page', 'last_page', 'per_page', 'total'], ]); expect($response->json('meta.total'))->toBe(3); }); test('songs index excludes soft-deleted songs', function () { Song::factory()->count(2)->create(); Song::factory()->create(['deleted_at' => now()]); $response = $this->actingAs($this->user) ->getJson('/api/songs'); $response->assertOk(); expect($response->json('meta.total'))->toBe(2); }); test('songs index search by title', function () { Song::factory()->create(['title' => 'Amazing Grace']); Song::factory()->create(['title' => 'Holy Spirit']); $response = $this->actingAs($this->user) ->getJson('/api/songs?search=Amazing'); $response->assertOk(); expect($response->json('meta.total'))->toBe(1); expect($response->json('data.0.title'))->toBe('Amazing Grace'); }); test('songs index search by ccli id', function () { Song::factory()->create(['ccli_id' => '123456', 'title' => 'Song A']); Song::factory()->create(['ccli_id' => '789012', 'title' => 'Song B']); $response = $this->actingAs($this->user) ->getJson('/api/songs?search=123456'); $response->assertOk(); expect($response->json('meta.total'))->toBe(1); expect($response->json('data.0.ccli_id'))->toBe('123456'); }); test('songs index requires authentication', function () { $response = $this->getJson('/api/songs'); $response->assertUnauthorized(); }); // --- STORE / CREATE --- test('store creates song with default groups and arrangement', function () { $response = $this->actingAs($this->user) ->postJson('/api/songs', [ 'title' => 'Neues Lied', 'ccli_id' => '999999', 'author' => 'Test Author', ]); $response->assertCreated() ->assertJsonFragment(['message' => 'Song erfolgreich erstellt']); $song = Song::where('title', 'Neues Lied')->first(); expect($song)->not->toBeNull(); // Default groups: Strophe 1, Refrain, Bridge expect($song->groups)->toHaveCount(3); expect($song->groups->pluck('name')->toArray()) ->toBe(['Strophe 1', 'Refrain', 'Bridge']); // Default "Normal" arrangement $arrangement = $song->arrangements()->where('is_default', true)->first(); expect($arrangement)->not->toBeNull(); expect($arrangement->name)->toBe('Normal'); expect($arrangement->arrangementGroups)->toHaveCount(3); }); test('store validates required title', function () { $response = $this->actingAs($this->user) ->postJson('/api/songs', [ 'ccli_id' => '111111', ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['title']); }); test('store validates unique ccli_id', function () { Song::factory()->create(['ccli_id' => '555555']); $response = $this->actingAs($this->user) ->postJson('/api/songs', [ 'title' => 'Duplicate Song', 'ccli_id' => '555555', ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['ccli_id']); }); test('store allows null ccli_id', function () { $response = $this->actingAs($this->user) ->postJson('/api/songs', [ 'title' => 'Song ohne CCLI', ]); $response->assertCreated(); expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull(); }); // --- SHOW --- test('show returns song with groups slides and arrangements', function () { $song = Song::factory()->create(); $group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']); SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]); $response = $this->actingAs($this->user) ->getJson("/api/songs/{$song->id}"); $response->assertOk() ->assertJsonStructure([ 'data' => [ 'id', 'title', 'ccli_id', 'author', 'copyright_text', 'has_translation', 'last_used_in_service', 'groups' => [['id', 'name', 'color', 'order', 'slides']], 'arrangements' => [['id', 'name', 'is_default', 'arrangement_groups']], ], ]); }); test('show returns 404 for nonexistent song', function () { $response = $this->actingAs($this->user) ->getJson('/api/songs/99999'); $response->assertNotFound() ->assertJsonFragment(['message' => 'Song nicht gefunden']); }); 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(); }); // --- UPDATE --- test('update modifies song metadata', function () { $song = Song::factory()->create(['title' => 'Old Title']); $response = $this->actingAs($this->user) ->putJson("/api/songs/{$song->id}", [ 'title' => 'New Title', 'author' => 'New Author', ]); $response->assertOk() ->assertJsonFragment(['message' => 'Song erfolgreich aktualisiert']); $song->refresh(); expect($song->title)->toBe('New Title'); expect($song->author)->toBe('New Author'); }); test('update validates unique ccli_id excluding self', function () { $songA = Song::factory()->create(['ccli_id' => '111111']); $songB = Song::factory()->create(['ccli_id' => '222222']); // Try setting songB's ccli_id to songA's $response = $this->actingAs($this->user) ->putJson("/api/songs/{$songB->id}", [ 'title' => $songB->title, 'ccli_id' => '111111', ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['ccli_id']); }); test('update allows keeping own ccli_id', function () { $song = Song::factory()->create(['ccli_id' => '333333']); $response = $this->actingAs($this->user) ->putJson("/api/songs/{$song->id}", [ 'title' => 'Updated Title', 'ccli_id' => '333333', ]); $response->assertOk(); }); // --- DESTROY / SOFT DELETE --- test('destroy soft-deletes a song', function () { $song = Song::factory()->create(); $response = $this->actingAs($this->user) ->deleteJson("/api/songs/{$song->id}"); $response->assertOk() ->assertJsonFragment(['message' => 'Song erfolgreich gelöscht']); expect(Song::find($song->id))->toBeNull(); expect(Song::withTrashed()->find($song->id))->not->toBeNull(); }); test('destroy returns 404 for nonexistent song', function () { $response = $this->actingAs($this->user) ->deleteJson('/api/songs/99999'); $response->assertNotFound(); }); // --- LAST USED IN SERVICE --- test('last_used_in_service returns correct date from service_songs', function () { $song = Song::factory()->create(); $serviceOld = Service::factory()->create(['date' => '2025-06-01']); $serviceNew = Service::factory()->create(['date' => '2026-01-15']); ServiceSong::factory()->create([ 'song_id' => $song->id, 'service_id' => $serviceOld->id, ]); ServiceSong::factory()->create([ 'song_id' => $song->id, 'service_id' => $serviceNew->id, ]); $response = $this->actingAs($this->user) ->getJson("/api/songs/{$song->id}"); $response->assertOk(); expect($response->json('data.last_used_in_service'))->toBe('2026-01-15'); }); test('last_used_in_service returns null when never used', function () { $song = Song::factory()->create(); $response = $this->actingAs($this->user) ->getJson("/api/songs/{$song->id}"); $response->assertOk(); expect($response->json('data.last_used_in_service'))->toBeNull(); }); // --- SONG SERVICE: DUPLICATE ARRANGEMENT --- test('duplicate arrangement clones arrangement with groups', function () { $song = Song::factory()->create(); $group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]); $group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]); $arrangement = SongArrangement::factory()->create([ 'song_id' => $song->id, 'name' => 'Original', 'is_default' => true, ]); SongArrangementGroup::factory()->create([ 'song_arrangement_id' => $arrangement->id, 'song_group_id' => $group1->id, 'order' => 1, ]); SongArrangementGroup::factory()->create([ 'song_arrangement_id' => $arrangement->id, 'song_group_id' => $group2->id, 'order' => 2, ]); $service = app(\App\Services\SongService::class); $clone = $service->duplicateArrangement($arrangement, 'Klone'); expect($clone->name)->toBe('Klone'); expect($clone->is_default)->toBeFalse(); expect($clone->arrangementGroups)->toHaveCount(2); expect($clone->arrangementGroups->pluck('song_group_id')->toArray()) ->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray()); });