Move recordings UI to Livewire pages with Flux components.

Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
This commit is contained in:
ben
2026-08-12 19:17:52 +02:00
parent bfcfc12f58
commit 34ccf0c32b
37 changed files with 1482 additions and 1103 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ class DiskSpaceTest extends TestCase
$this->get(route('recordings.index'))
->assertOk()
->assertSee('Disk space')
->assertSee('Disk space used', false)
->assertSee('free')
->assertSee('role="progressbar"', false);
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class RecordingAudioStreamTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/note.mp3', 'fake-audio-bytes');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertOk()
->assertHeader('content-type', 'audio/mpeg')
->assertHeader('content-disposition', 'inline; filename=note.mp3');
}
public function test_show_page_includes_audio_player(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Playable',
'original_filename' => 'playable.mp3',
'file_path' => 'recordings/playable.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSee('Play', false)
->assertSee(route('recordings.audio', $recording), false)
->assertSee('<audio', false);
}
public function test_index_page_links_to_recording_show(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'List playable',
'original_filename' => 'list.mp3',
'file_path' => 'recordings/list.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSee('List playable')
->assertSee(route('recordings.show', $recording), false);
}
public function test_other_user_cannot_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/secret.mp3', 'fake-audio-bytes');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Secret',
'original_filename' => 'secret.mp3',
'file_path' => 'recordings/secret.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($intruder)
->get(route('recordings.audio', $recording))
->assertForbidden();
}
public function test_missing_audio_file_returns_not_found(): void
{
Storage::fake('local');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Gone',
'original_filename' => 'gone.mp3',
'file_path' => 'recordings/gone.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertNotFound();
}
}
+22 -23
View File
@@ -3,12 +3,14 @@
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingDuplicateUploadTest extends TestCase
@@ -33,19 +35,18 @@ class RecordingDuplicateUploadTest extends TestCase
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
$this->post(route('recordings.store'), [
'audio' => [$first],
])->assertRedirect();
Livewire::test(UploadRecordings::class)
->set('audio', [$first])
->assertRedirect();
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
$response = $this->post(route('recordings.store'), [
'audio' => [$duplicate],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$duplicate])
->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1);
}
@@ -59,18 +60,17 @@ class RecordingDuplicateUploadTest extends TestCase
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
$response = $this->post(route('recordings.store'), [
'audio' => [$one, $two, $three],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [$one, $two, $three])
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertStringContainsString('Skipped 1 duplicate', session('success'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
public function test_upload_page_mentions_duplicate_skipping(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
@@ -84,7 +84,7 @@ class RecordingDuplicateUploadTest extends TestCase
$this->get(route('recordings.create'))
->assertOk()
->assertSee('note.mp3:2048', false)
->assertSeeLivewire('upload-recordings')
->assertSee('Duplicate files', false);
}
@@ -103,12 +103,11 @@ class RecordingDuplicateUploadTest extends TestCase
'transcription_status' => 'done',
]);
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')],
]);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')])
->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count());
Bus::assertNothingDispatched();
}
@@ -120,9 +119,9 @@ class RecordingDuplicateUploadTest extends TestCase
$file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash');
$this->post(route('recordings.store'), [
'audio' => [$file],
])->assertRedirect();
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect();
$recording = Recording::query()->first();
$this->assertNotNull($recording);
+5 -2
View File
@@ -2,9 +2,11 @@
namespace Tests\Feature;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingOwnershipTest extends TestCase
@@ -74,8 +76,9 @@ class RecordingOwnershipTest extends TestCase
'transcription_status' => 'done',
]);
$this->actingAs($intruder)
->delete(route('recordings.destroy', $recording))
$this->actingAs($intruder);
Livewire::test(Show::class, ['recording' => $recording])
->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
+36 -45
View File
@@ -4,6 +4,9 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use App\Services\TranscriptionService;
@@ -12,6 +15,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription;
use Livewire\Livewire;
use Tests\TestCase;
class RecordingUploadTest extends TestCase
@@ -54,15 +58,14 @@ class RecordingUploadTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
$response = $this->post(route('recordings.store'), [
'audio' => [$file],
'title' => 'Team meeting',
]);
Livewire::test(UploadRecordings::class)
->set('title', 'Team meeting')
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$response->assertRedirect(route('recordings.show', $recording));
$this->assertSame('Team meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver);
@@ -76,16 +79,13 @@ class RecordingUploadTest extends TestCase
Storage::fake('local');
Bus::fake();
$response = $this->post(route('recordings.store'), [
'audio' => [
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
],
]);
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
])
->assertRedirect(route('recordings.index'));
$this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3);
@@ -99,9 +99,9 @@ class RecordingUploadTest extends TestCase
{
$this->get(route('recordings.create'))
->assertOk()
->assertSee('Drop audio files here')
->assertSee('Drop audio files here or click to browse')
->assertSee('max 2 GB each', false)
->assertSee('name="audio[]"', false);
->assertSeeLivewire('upload-recordings');
}
public function test_files_larger_than_two_gigabytes_are_rejected(): void
@@ -113,11 +113,9 @@ class RecordingUploadTest extends TestCase
->create('huge.mp3', 10, 'audio/mpeg')
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => [$file],
])
->assertSessionHasErrors(['audio.0']);
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertHasErrors(['audio.0']);
$this->assertSame(0, Recording::query()->count());
Bus::assertNothingDispatched();
@@ -133,15 +131,14 @@ class RecordingUploadTest extends TestCase
['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) {
$response = $this->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)],
'title' => $name,
]);
Livewire::test(UploadRecordings::class)
->set('title', $name)
->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
->assertRedirect();
$recording = Recording::query()->where('title', $name)->first();
$this->assertNotNull($recording, "Failed uploading {$name}");
$response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path);
}
@@ -152,11 +149,9 @@ class RecordingUploadTest extends TestCase
{
Storage::fake('local');
$this->from(route('recordings.create'))
->post(route('recordings.store'), [
'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')],
])
->assertSessionHasErrors(['audio.0']);
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')])
->assertHasErrors(['audio.0']);
}
public function test_user_can_queue_local_transcription(): void
@@ -173,8 +168,8 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -332,8 +327,8 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -355,9 +350,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now(),
]);
$this->post(route('recordings.transcribe.cancel', $recording))
->assertRedirect()
->assertSessionHas('success');
Livewire::test(Show::class, ['recording' => $recording])
->call('cancelTranscription');
$recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status);
@@ -380,9 +374,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect()
->assertSessionHas('success');
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
@@ -464,8 +457,8 @@ class RecordingUploadTest extends TestCase
'transcribed_at' => now()->subHour(),
]);
$this->post(route('recordings.transcribe', $recording))
->assertRedirect();
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript);
@@ -497,10 +490,8 @@ class RecordingUploadTest extends TestCase
'transcript' => 'Finished text',
]);
$this->from(route('recordings.index'))
->post(route('recordings.transcribe-pending'))
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Recordings;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
use Tests\TestCase;
class IndexTest extends TestCase
{
use RefreshDatabase;
public function test_index_page_renders_as_livewire(): void
{
$user = User::factory()->create();
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024,
'transcription_status' => 'done',
'transcript' => 'hello world',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire(Index::class)
->assertSee('Pocket note');
}
public function test_search_filters_recordings(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'talking about the pocket recorder today',
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Unrelated',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'nothing useful',
]);
Livewire::test(Index::class)
->set('search', 'pocket recorder')
->assertSee('Office chat')
->assertDontSee('Unrelated');
}
public function test_queue_pending_dispatches_jobs(): void
{
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature\Recordings;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class ShowTest extends TestCase
{
use RefreshDatabase;
public function test_show_page_renders_as_livewire(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Show me',
'original_filename' => 'show.mp3',
'file_path' => 'recordings/show.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSeeLivewire(Show::class)
->assertSee('Show me')
->assertSee('Play', false);
}
public function test_user_can_delete_own_recording(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete me',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('delete')
->assertRedirect(route('recordings.index'));
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class UploadRecordingsLivewireTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_dropping_files_saves_and_queues_transcription(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
->set('title', 'Team meeting')
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$this->assertSame('Team meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_batch_upload_redirects_to_index(): void
{
Storage::fake('local');
Bus::fake();
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
])
->assertRedirect(route('recordings.index'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
}