user = User::factory()->create(); $this->service = app(TranslationService::class); }); test('fetchFromUrl returns text from successful HTTP response', function () { Http::fake([ 'https://example.com/lyrics' => Http::response('

Zeile 1

Zeile 2

', 200), ]); $result = $this->service->fetchFromUrl('https://example.com/lyrics'); expect($result)->not->toBeNull(); expect($result)->toContain('Zeile 1'); expect($result)->toContain('Zeile 2'); expect($result)->not->toContain('

'); expect($result)->not->toContain(''); }); test('fetchFromUrl returns null on HTTP failure', function () { Http::fake([ 'https://example.com/broken' => Http::response('Not Found', 404), ]); $result = $this->service->fetchFromUrl('https://example.com/broken'); expect($result)->toBeNull(); }); test('fetchFromUrl returns null on connection error', function () { Http::fake([ 'https://timeout.example.com/*' => fn () => throw new \Illuminate\Http\Client\ConnectionException('Timeout'), ]); $result = $this->service->fetchFromUrl('https://timeout.example.com/lyrics'); expect($result)->toBeNull(); }); test('fetchFromUrl returns null for empty response body', function () { Http::fake([ 'https://example.com/empty' => Http::response('', 200), ]); $result = $this->service->fetchFromUrl('https://example.com/empty'); expect($result)->toBeNull(); }); function makeSongWithDefaultArrangement(): array { $song = Song::factory()->create(['has_translation' => false]); $arrangement = SongArrangement::factory()->create([ 'song_id' => $song->id, 'name' => 'Normal', 'is_default' => true, ]); return [$song, $arrangement]; } function attachLabelWithSlides(SongArrangement $arrangement, string $labelName, array $slides, int $arrangementOrder): Label { $label = Label::firstOrCreate(['name' => $labelName]); SongArrangementLabel::factory()->create([ 'song_arrangement_id' => $arrangement->id, 'label_id' => $label->id, 'order' => $arrangementOrder, ]); foreach ($slides as $slide) { SongSlide::factory()->create(array_merge( ['label_id' => $label->id], $slide, )); } return $label; } test('importTranslation distributes lines by slide line counts', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - dist', [], 1); $slide1 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 1, 'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4", ]); $slide2 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 2, 'text_content' => "Original 5\nOriginal 6", ]); $slide3 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 3, 'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10", ]); $translatedText = "Zeile 1\nZeile 2\nZeile 3\nZeile 4\nZeile 5\nZeile 6\nZeile 7\nZeile 8\nZeile 9\nZeile 10"; $this->service->importTranslation($song, $translatedText); $slide1->refresh(); $slide2->refresh(); $slide3->refresh(); expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4"); expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6"); expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10"); }); test('importTranslation distributes across multiple groups', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $label1 = attachLabelWithSlides($arrangement, 'Strophe 1 - multi', [], 1); $label2 = attachLabelWithSlides($arrangement, 'Refrain - multi', [], 2); $slide1 = SongSlide::factory()->create([ 'label_id' => $label1->id, 'order' => 1, 'text_content' => "Line A\nLine B", ]); $slide2 = SongSlide::factory()->create([ 'label_id' => $label2->id, 'order' => 1, 'text_content' => "Line C\nLine D\nLine E", ]); $translatedText = "Über A\nÜber B\nÜber C\nÜber D\nÜber E"; $this->service->importTranslation($song, $translatedText); $slide1->refresh(); $slide2->refresh(); expect($slide1->text_content_translated)->toBe("Über A\nÜber B"); expect($slide2->text_content_translated)->toBe("Über C\nÜber D\nÜber E"); }); test('importTranslation handles fewer translation lines than original', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - fewer', [], 1); $slide1 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 1, 'text_content' => "Line 1\nLine 2\nLine 3", ]); $slide2 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 2, 'text_content' => "Line 4\nLine 5", ]); $translatedText = "Zeile 1\nZeile 2\nZeile 3"; $this->service->importTranslation($song, $translatedText); $slide1->refresh(); $slide2->refresh(); expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3"); expect($slide2->text_content_translated)->toBe(''); }); test('importTranslation marks song as translated', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - mark', [], 1); SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Line 1', ]); $this->service->importTranslation($song, 'Zeile 1'); $song->refresh(); expect($song->has_translation)->toBeTrue(); }); test('markAsTranslated sets has_translation to true', function () { $song = Song::factory()->create(['has_translation' => false]); $this->service->markAsTranslated($song); $song->refresh(); expect($song->has_translation)->toBeTrue(); }); test('removeTranslation clears all translated text and sets flag to false', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $song->update(['has_translation' => true]); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - remove', [], 1); $slide1 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Original', 'text_content_translated' => 'Übersetzt', ]); $slide2 = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 2, 'text_content' => 'Original 2', 'text_content_translated' => 'Übersetzt 2', ]); $this->service->removeTranslation($song); $song->refresh(); $slide1->refresh(); $slide2->refresh(); expect($song->has_translation)->toBeFalse(); expect($slide1->text_content_translated)->toBeNull(); expect($slide2->text_content_translated)->toBeNull(); }); test('POST translation/fetch-url returns scraped text', function () { Http::fake([ 'https://lyrics.example.com/song' => Http::response('

Liedtext Zeile 1
', 200), ]); $response = $this->actingAs($this->user) ->postJson('/api/translation/fetch-url', [ 'url' => 'https://lyrics.example.com/song', ]); $response->assertOk() ->assertJsonStructure(['text']); expect($response->json('text'))->toContain('Liedtext Zeile 1'); }); test('POST translation/fetch-url returns error on failure', function () { Http::fake([ 'https://broken.example.com/*' => Http::response('', 500), ]); $response = $this->actingAs($this->user) ->postJson('/api/translation/fetch-url', [ 'url' => 'https://broken.example.com/song', ]); $response->assertStatus(422) ->assertJsonFragment(['message' => 'Konnte Text nicht abrufen']); }); test('POST translation/fetch-url validates url field', function () { $response = $this->actingAs($this->user) ->postJson('/api/translation/fetch-url', []); $response->assertUnprocessable() ->assertJsonValidationErrors(['url']); }); test('POST songs/{song}/translation/import distributes and saves translation', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - controller', [], 1); $slide = SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 1, 'text_content' => "Line 1\nLine 2", ]); $response = $this->actingAs($this->user) ->postJson("/api/songs/{$song->id}/translation/import", [ 'text' => "Zeile 1\nZeile 2", ]); $response->assertOk() ->assertJsonFragment(['message' => 'Übersetzung erfolgreich importiert']); $slide->refresh(); $song->refresh(); expect($slide->text_content_translated)->toBe("Zeile 1\nZeile 2"); expect($song->has_translation)->toBeTrue(); }); test('POST songs/{song}/translation/import validates text field', function () { $song = Song::factory()->create(); $response = $this->actingAs($this->user) ->postJson("/api/songs/{$song->id}/translation/import", []); $response->assertUnprocessable() ->assertJsonValidationErrors(['text']); }); test('POST songs/{song}/translation/import returns 404 for missing song', function () { $response = $this->actingAs($this->user) ->postJson('/api/songs/99999/translation/import', [ 'text' => 'Some text', ]); $response->assertNotFound(); }); test('DELETE songs/{song}/translation removes translation', function () { [$song, $arrangement] = makeSongWithDefaultArrangement(); $song->update(['has_translation' => true]); $label = attachLabelWithSlides($arrangement, 'Strophe 1 - delete', [], 1); SongSlide::factory()->create([ 'label_id' => $label->id, 'order' => 1, 'text_content' => 'Original', 'text_content_translated' => 'Übersetzt', ]); $response = $this->actingAs($this->user) ->deleteJson("/api/songs/{$song->id}/translation"); $response->assertOk() ->assertJsonFragment(['message' => 'Übersetzung entfernt']); $song->refresh(); expect($song->has_translation)->toBeFalse(); }); test('translation endpoints require authentication', function () { $this->postJson('/api/translation/fetch-url', ['url' => 'https://example.com']) ->assertUnauthorized(); $this->postJson('/api/songs/1/translation/import', ['text' => 'test']) ->assertUnauthorized(); $this->deleteJson('/api/songs/1/translation') ->assertUnauthorized(); });