feat(ccli): add CcliPasteController endpoints
- POST /api/ccli/preview: parse-only endpoint (no DB writes) - POST /api/songs/import-from-ccli-paste: 3 modes (create / pair-with-song / assign-to-service-song) - GET /songs/import-from-ccli-paste: Inertia page with base64 bookmarklet prefill - Routes guarded by auth:sanctum + throttle:30,1 (API); auth + web stack (web) - Maps DuplicateCcliSongException to 409 with existing_song_id and edit_url - Pest tests (10 cases, 63 assertions): preview, all 3 import modes, 409 dup, 422 errors, unauth, prefill happy/error, login redirect
This commit is contained in:
parent
cd0a72124d
commit
35d3298251
|
|
@ -99,3 +99,12 @@ ### 2026-05-10 CCLI Translation Pairing
|
|||
- `CcliTranslationPairingService` returns a review-only mapping and never writes `SongSlide.text_content_translated`; callers remain responsible for persistence.
|
||||
- Pairing canonicalizes both local arrangement labels and CCLI sections with `CcliLabels::normalizeLabelName()` + lowercase, so `Strophe 1` ↔ `Verse 1` and `Refrain` ↔ `Chorus` work across languages.
|
||||
- Distribution mirrors `TranslationService::importTranslation()` by filling local slide slots in arrangement order using each local slide's original line count; overflow CCLI lines are kept on the final local slide for that section.
|
||||
|
||||
### 2026-05-11 CcliPasteController (T10)
|
||||
- `SongMatchingService::manualAssign(ServiceSong, Song)` takes a **Song object** (not int id) — different from initial task plan.
|
||||
- API routes use `auth:sanctum` middleware (not just `auth`); Sanctum's `EnsureFrontendRequestsAreStateful` is prepended globally to API in bootstrap/app.php.
|
||||
- Apply `throttle:30,1` via a nested `Route::middleware('throttle:30,1')->group(...)` inside the existing sanctum group; combined middleware shown by `route:list -vv`.
|
||||
- `assertInertia()` enforces page-component file existence by default. For pages whose Vue component is created in a later task (T16 `Songs/ImportFromCcliPaste`), pass `$shouldExist=false` to `component()` as second arg.
|
||||
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
|
||||
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
|
||||
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.
|
||||
|
|
|
|||
177
app/Http/Controllers/CcliPasteController.php
Normal file
177
app/Http/Controllers/CcliPasteController.php
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\DuplicateCcliSongException;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Services\CcliImportService;
|
||||
use App\Services\CcliPasteParser;
|
||||
use App\Services\CcliTranslationPairingService;
|
||||
use App\Services\SongMatchingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CcliPasteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
private readonly CcliImportService $importService,
|
||||
private readonly CcliTranslationPairingService $pairingService,
|
||||
private readonly SongMatchingService $matchingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/ccli/preview
|
||||
* Parse raw text and return DTO as JSON. No DB writes.
|
||||
*/
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'raw_text' => ['required', 'string', 'max:200000'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$parsed = $this->parser->parse($validated['raw_text']);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'title' => $parsed->title,
|
||||
'author' => $parsed->author,
|
||||
'ccliId' => $parsed->ccliId,
|
||||
'year' => $parsed->year,
|
||||
'copyrightText' => $parsed->copyrightText,
|
||||
'sections' => array_map(fn ($s) => [
|
||||
'label' => $s->label,
|
||||
'kind' => $s->kind,
|
||||
'number' => $s->number,
|
||||
'modifier' => $s->modifier,
|
||||
'lines' => $s->lines,
|
||||
'hasTranslation' => $s->linesTranslated !== null,
|
||||
], $parsed->sections),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/songs/import-from-ccli-paste
|
||||
* Import a CCLI paste in one of 3 modes.
|
||||
*/
|
||||
public function importPaste(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'raw_text' => ['required', 'string', 'max:200000'],
|
||||
'mode' => ['required', Rule::in(['create', 'pair-with-song', 'assign-to-service-song'])],
|
||||
'target_id' => ['required_unless:mode,create', 'nullable', 'integer'],
|
||||
'source_url' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$rawText = $validated['raw_text'];
|
||||
$mode = $validated['mode'];
|
||||
$targetId = $validated['target_id'] ?? null;
|
||||
$sourceUrl = $validated['source_url'] ?? null;
|
||||
|
||||
try {
|
||||
if ($mode === 'create') {
|
||||
$result = $this->importService->import($rawText, $sourceUrl);
|
||||
|
||||
return response()->json([
|
||||
'song_id' => $result['song']->id,
|
||||
'status' => $result['status'],
|
||||
'warnings' => $result['warnings'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($mode === 'pair-with-song') {
|
||||
$localSong = Song::findOrFail($targetId);
|
||||
$result = $this->pairingService->pair($localSong, $rawText);
|
||||
|
||||
session()->flash('ccli_prefilled', $result['distributed_text']);
|
||||
|
||||
return response()->json([
|
||||
'song_id' => $localSong->id,
|
||||
'mapping' => $result['mapping'],
|
||||
'unmatched_labels' => $result['unmatched_labels'],
|
||||
'redirect_to' => route('songs.translate', $localSong->id).'?prefilled=true',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($mode === 'assign-to-service-song') {
|
||||
$result = $this->importService->import($rawText, $sourceUrl);
|
||||
$song = $result['song'];
|
||||
|
||||
$serviceSong = ServiceSong::findOrFail($targetId);
|
||||
$this->matchingService->manualAssign($serviceSong, $song);
|
||||
|
||||
return response()->json([
|
||||
'song_id' => $song->id,
|
||||
'service_song_id' => $serviceSong->id,
|
||||
'status' => $result['status'],
|
||||
], 201);
|
||||
}
|
||||
} catch (DuplicateCcliSongException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
'existing_song_id' => $e->existingSongId,
|
||||
'edit_url' => route('songs.index').'#song-'.$e->existingSongId,
|
||||
], 409);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Unbekannter Modus'], 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /songs/import-from-ccli-paste
|
||||
* Render the Inertia page for bookmarklet redirect.
|
||||
*/
|
||||
public function showImportPage(Request $request): InertiaResponse
|
||||
{
|
||||
$prefill = $request->query('prefill');
|
||||
$prefilledText = null;
|
||||
$prefilledMetadata = null;
|
||||
$prefillError = null;
|
||||
|
||||
if ($prefill !== null && is_string($prefill)) {
|
||||
try {
|
||||
$decoded = base64_decode($prefill, strict: true);
|
||||
if ($decoded === false) {
|
||||
throw new InvalidArgumentException('Ungültige Kodierung');
|
||||
}
|
||||
$payload = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new InvalidArgumentException('Ungültiges Payload-Format');
|
||||
}
|
||||
|
||||
$prefilledText = $payload['rawText'] ?? null;
|
||||
$prefilledMetadata = [
|
||||
'title' => $payload['title'] ?? null,
|
||||
'author' => $payload['author'] ?? null,
|
||||
'ccliId' => $payload['ccliId'] ?? null,
|
||||
'sourceUrl' => $payload['sourceUrl'] ?? null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
$prefillError = 'Lesezeichen-Daten konnten nicht gelesen werden. Bitte den Liedtext manuell einfügen.';
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('Songs/ImportFromCcliPaste', [
|
||||
'prefilledText' => $prefilledText,
|
||||
'prefilledMetadata' => $prefilledMetadata,
|
||||
'prefillError' => $prefillError,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\CcliPasteController;
|
||||
use App\Http\Controllers\ProFileController;
|
||||
use App\Http\Controllers\ServiceSongController;
|
||||
use App\Http\Controllers\SongController;
|
||||
|
|
@ -47,4 +48,12 @@
|
|||
|
||||
Route::get('/songs/{song}/download-pro', [ProFileController::class, 'downloadPro'])
|
||||
->name('api.songs.download-pro');
|
||||
|
||||
// CCLI Paste Import (manuelles Einfügen oder Bookmarklet)
|
||||
Route::middleware('throttle:30,1')->group(function () {
|
||||
Route::post('/ccli/preview', [CcliPasteController::class, 'preview'])
|
||||
->name('api.ccli.preview');
|
||||
Route::post('/songs/import-from-ccli-paste', [CcliPasteController::class, 'importPaste'])
|
||||
->name('api.ccli.import');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BookmarkletController;
|
||||
use App\Http\Controllers\CcliPasteController;
|
||||
use App\Http\Controllers\LabelImportController;
|
||||
use App\Http\Controllers\MacroAssignmentController;
|
||||
use App\Http\Controllers\MacroImportController;
|
||||
|
|
@ -72,6 +73,9 @@
|
|||
return Inertia::render('Songs/Index');
|
||||
})->name('songs.index');
|
||||
|
||||
Route::get('/songs/import-from-ccli-paste', [CcliPasteController::class, 'showImportPage'])
|
||||
->name('songs.import-from-ccli-paste');
|
||||
|
||||
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
|
||||
Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body');
|
||||
|
||||
|
|
|
|||
171
tests/Feature/CcliPasteControllerTest.php
Normal file
171
tests/Feature/CcliPasteControllerTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function ccliPasteControllerFixture(string $name): string
|
||||
{
|
||||
return file_get_contents(base_path("tests/fixtures/ccli/{$name}"));
|
||||
}
|
||||
|
||||
test('preview returns parsed DTO without writing to DB', function () {
|
||||
$user = User::factory()->create();
|
||||
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
|
||||
|
||||
$songCountBefore = Song::count();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.preview'), ['raw_text' => $content]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure(['title', 'author', 'ccliId', 'year', 'copyrightText', 'sections']);
|
||||
expect($response->json('title'))->toBe('Test Song 1');
|
||||
expect($response->json('ccliId'))->toBe('9999001');
|
||||
expect(Song::count())->toBe($songCountBefore);
|
||||
});
|
||||
|
||||
test('importPaste create mode persists song and returns 201', function () {
|
||||
$user = User::factory()->create();
|
||||
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.import'), [
|
||||
'raw_text' => $content,
|
||||
'mode' => 'create',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['song_id', 'status', 'warnings']);
|
||||
expect($response->json('status'))->toBe('created');
|
||||
expect(Song::find($response->json('song_id')))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('importPaste returns 409 on duplicate ccli_id with existing_song_id', function () {
|
||||
$user = User::factory()->create();
|
||||
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
|
||||
|
||||
$first = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']);
|
||||
$existingId = $first->json('song_id');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$response->assertJsonStructure(['message', 'existing_song_id', 'edit_url']);
|
||||
expect($response->json('existing_song_id'))->toBe($existingId);
|
||||
expect($response->json('message'))->toContain('existiert bereits');
|
||||
});
|
||||
|
||||
test('importPaste returns 422 on malformed paste with German error', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.import'), [
|
||||
'raw_text' => 'This has no section labels at all',
|
||||
'mode' => 'create',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['message']);
|
||||
expect($response->json('message'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('importPaste returns 422 on empty raw_text', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.import'), [
|
||||
'raw_text' => '',
|
||||
'mode' => 'create',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('importPaste assign-to-service-song imports and assigns song to service song', function () {
|
||||
$user = User::factory()->create();
|
||||
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
|
||||
|
||||
$service = Service::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'song_id' => null,
|
||||
'song_arrangement_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson(route('api.ccli.import'), [
|
||||
'raw_text' => $content,
|
||||
'mode' => 'assign-to-service-song',
|
||||
'target_id' => $serviceSong->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['song_id', 'service_song_id', 'status']);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($response->json('song_id'));
|
||||
expect($serviceSong->matched_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('importPaste rejects unauthenticated requests with 401', function () {
|
||||
$response = $this->postJson(route('api.ccli.import'), [
|
||||
'raw_text' => 'test',
|
||||
'mode' => 'create',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('showImportPage renders Inertia page with prefilled data from valid base64', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->withoutVite();
|
||||
|
||||
$payload = json_encode([
|
||||
'title' => 'Amazing Grace',
|
||||
'author' => 'John Newton',
|
||||
'ccliId' => '4760',
|
||||
'sourceUrl' => 'https://songselect.ccli.com/Songs/4760',
|
||||
'rawText' => "Amazing Grace\nJohn Newton\n\nVerse 1\nAmazing grace how sweet the sound\n\n© 1779\nCCLI # 4760",
|
||||
]);
|
||||
$encoded = base64_encode($payload);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('songs.import-from-ccli-paste', ['prefill' => $encoded]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page->component('Songs/ImportFromCcliPaste', false)
|
||||
->has('prefilledText')
|
||||
->has('prefilledMetadata')
|
||||
->where('prefilledMetadata.title', 'Amazing Grace')
|
||||
->where('prefilledMetadata.ccliId', '4760')
|
||||
);
|
||||
});
|
||||
|
||||
test('showImportPage renders with error for invalid base64', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->withoutVite();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('songs.import-from-ccli-paste', ['prefill' => 'NOT_VALID_BASE64!!!']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page->component('Songs/ImportFromCcliPaste', false)
|
||||
->where('prefillError', fn ($v) => $v !== null)
|
||||
);
|
||||
});
|
||||
|
||||
test('showImportPage redirects unauthenticated to login', function () {
|
||||
$response = $this->get(route('songs.import-from-ccli-paste'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
Loading…
Reference in a new issue