T8: Service List Page - ServiceController with index, finalize, reopen actions - Services/Index.vue with status indicators (songs mapped/arranged, slides uploaded) - German UI with finalize/reopen toggle buttons - Status aggregation via SQL subqueries for efficiency - Tests: 3 passing (46 assertions) T9: Song CRUD Backend - SongController with full REST API (index, store, show, update, destroy) - SongService for default groups/arrangements creation - SongRequest validation (title required, ccli_id unique) - Search by title and CCLI ID - last_used_in_service accessor via service_songs join - Tests: 20 passing (85 assertions) T10: Slide Upload Component - SlideController with store, destroy, updateExpireDate - SlideUploader.vue with vue3-dropzone drag-and-drop - SlideGrid.vue with thumbnail grid and inline expire date editing - Multi-format support: images (sync), PPT (async job), ZIP (extract) - Type validation: information (global), moderation/sermon (service-specific) - Tests: 15 passing (37 assertions) T11: Arrangement Configurator - ArrangementController with store, clone, update, destroy - ArrangementConfigurator.vue with vue-draggable-plus - Drag-and-drop arrangement editor with colored group pills - Clone from default or existing arrangement - Color picker for group customization - Prevent deletion of last arrangement - Tests: 4 passing (17 assertions) T12: Song Matching Service - SongMatchingService with autoMatch, manualAssign, requestCreation, unassign - ServiceSongController API endpoints for song assignment - Auto-match by CCLI ID during CTS sync - Manual assignment with searchable song select - Email request for missing songs (MissingSongRequest mailable) - Tests: 14 passing (33 assertions) T13: Translation Service - TranslationService with fetchFromUrl, importTranslation, removeTranslation - TranslationController API endpoints - URL scraping (best-effort HTTP fetch with strip_tags) - Line-count distribution algorithm (match original slide line counts) - Mark song as translated, remove translation - Tests: 18 passing (18 assertions) All tests passing: 103/103 (488 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form
310 lines
9.6 KiB
PHP
310 lines
9.6 KiB
PHP
<?php
|
|
|
|
use App\Models\Service;
|
|
use App\Models\ServiceSong;
|
|
use App\Models\Song;
|
|
use App\Models\SongArrangement;
|
|
use App\Models\SongArrangementGroup;
|
|
use App\Models\SongGroup;
|
|
use App\Models\User;
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Song CRUD API Tests
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
$this->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());
|
|
});
|