import( ccliFixture('english-only-multi-verse.txt'), 'https://songselect.ccli.com/Songs/9999001', ); expect($result['status'])->toBe('created') ->and($result['warnings'])->toBeArray() ->and($result['song'])->toBeInstanceOf(Song::class); $song = $result['song']->fresh(); expect($song->title)->toBe('Test Song 1') ->and($song->author)->toBe('Test Artist 1') ->and($song->ccli_id)->toBe('9999001') ->and($song->copyright_year)->toBe('2024') ->and($song->has_translation)->toBeFalse() ->and($song->imported_from_ccli_at)->not->toBeNull() ->and($song->ccli_source_url)->toBe('https://songselect.ccli.com/Songs/9999001'); $arrangement = $song->arrangements()->where('name', 'normal')->first(); expect($arrangement)->not->toBeNull() ->and($arrangement->is_default)->toBeTrue() ->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5) ->and(SongSlide::count())->toBe(9); }); test('imports english and german fixture and stores translated slide text', function () { $service = app(CcliImportService::class); $result = $service->import(ccliFixture('english-german-side-by-side.txt')); $song = $result['song']->fresh(); expect($result['status'])->toBe('created') ->and($song->has_translation)->toBeTrue() ->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(4) ->and(SongSlide::where('text_content_translated', 'Deutsche Liedzeile 1 zum gleichen Gedanken')->exists())->toBeTrue(); }); test('blocks active duplicate ccli_id with DuplicateCcliSongException', function () { $service = app(CcliImportService::class); $first = $service->import(ccliFixture('english-only-multi-verse.txt')); expect(fn () => $service->import(ccliFixture('english-only-multi-verse.txt'))) ->toThrow(DuplicateCcliSongException::class); try { $service->import(ccliFixture('english-only-multi-verse.txt')); } catch (DuplicateCcliSongException $exception) { expect($exception->existingSongId)->toBe($first['song']->id) ->and($exception->getMessage())->toContain('existiert bereits'); } expect(Song::count())->toBe(1); }); test('fills existing empty ccli song instead of blocking as duplicate', function () { $emptySong = Song::factory()->create([ 'ccli_id' => '4327499', 'title' => 'ChurchTools Platzhalter', 'author' => null, ]); $result = app(CcliImportService::class)->import(ccliFixture('copy-icon-vers-author-trailing.txt')); $song = $result['song']->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); $arrangement = $song->arrangements->first(); expect($result['status'])->toBe('restored') ->and($song->id)->toBe($emptySong->id) ->and($song->title)->toBe('Heilig ist der Herr') ->and($song->author)->toBe('Albert Frey') ->and($arrangement)->not->toBeNull() ->and($arrangement->arrangementSections)->toHaveCount(2) ->and(SongSlide::count())->toBe(12); }); test('uses distinct label colors for imported section kinds', function () { app(CcliImportService::class)->import(ccliFixture('copy-icon-vers-author-trailing.txt')); $verse = Label::where('name', 'Verse')->first(); $chorus = Label::where('name', 'Chorus')->first(); expect($verse)->not->toBeNull() ->and($chorus)->not->toBeNull() ->and($verse->color)->toBe('#3B82F6') ->and($chorus->color)->toBe('#10B981') ->and($verse->color)->not->toBe($chorus->color); }); test('restores soft-deleted song and does not duplicate normal arrangement', function () { $service = app(CcliImportService::class); $first = $service->import(ccliFixture('english-only-multi-verse.txt')); $songId = $first['song']->id; Song::find($songId)->delete(); $result = $service->import(ccliFixture('english-only-multi-verse.txt')); $restoredSong = Song::withTrashed()->find($songId); expect($result['status'])->toBe('restored') ->and($result['song']->id)->toBe($songId) ->and($restoredSong->trashed())->toBeFalse() ->and($restoredSong->arrangements()->where('name', 'normal')->count())->toBe(1); }); test('throws RuntimeException when paste has no ccli id', function () { $content = "Test Song Title\nTest Artist\n\nVerse 1\nSome lyrics here\n\nChorus\nChorus lyrics\n\n© 2024 Publisher"; $service = app(CcliImportService::class); expect(fn () => $service->import($content))->toThrow(RuntimeException::class); expect(Song::count())->toBe(0); }); test('import creates ApiRequestLog with metadata only and no lyrics body', function () { $service = app(CcliImportService::class); $service->import(ccliFixture('english-only-multi-verse.txt')); $log = ApiRequestLog::latest()->first(); expect($log)->not->toBeNull() ->and($log->method)->toBe('import') ->and($log->endpoint)->toBe('paste') ->and($log->status)->toBe('success') ->and($log->request_context)->toMatchArray(['ccli_id' => '9999001', 'mode' => 'created']) ->and($log->response_summary)->toBe('Song created: Test Song 1') ->and($log->response_body)->toBeNull() ->and($log->response_summary)->not->toContain('Morning light breaks'); }); test('rolls back song and log when slide creation fails', function () { DB::statement("CREATE TRIGGER fail_ccli_slide_insert BEFORE INSERT ON song_slides BEGIN SELECT RAISE(ABORT, 'slide creation failed'); END"); try { expect(fn () => app(CcliImportService::class)->import(ccliFixture('english-only-multi-verse.txt'))) ->toThrow(QueryException::class); } finally { DB::statement('DROP TRIGGER IF EXISTS fail_ccli_slide_insert'); } expect(Song::count())->toBe(0) ->and(SongSlide::count())->toBe(0) ->and(ApiRequestLog::count())->toBe(0); });