Files
AndyTranscribe/tests/Feature/RecordingUploadTest.php
T
ben c17f8fb506 Improve live recordings UI, upload flow, and default Flux theme.
Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login.
2026-08-12 20:14:35 +02:00

516 lines
19 KiB
PHP

<?php
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;
use Illuminate\Foundation\Testing\RefreshDatabase;
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
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_index_shows_recordings(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024,
'transcription_status' => 'done',
'transcript' => 'One two three four five',
]);
$this->get(route('recordings.index'))
->assertOk()
->assertSee('Pocket note')
->assertSee('Words')
->assertSee('5');
}
public function test_user_can_upload_an_mp3(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$this->assertSame('meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver);
$this->assertNull($recording->transcription_percent);
$this->assertNotNull($recording->transcription_started_at);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_user_can_batch_upload_multiple_audio_files(): 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)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
])
->assertRedirect(route('recordings.index'));
$this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3);
foreach (Recording::query()->get() as $recording) {
Storage::disk('local')->assertExists($recording->file_path);
$this->assertSame('pending', $recording->transcription_status);
}
}
public function test_upload_page_includes_dropzone(): void
{
$this->get(route('recordings.create'))
->assertOk()
->assertSee('Drop audio files here or click to browse')
->assertSee('max 2 GB each', false)
->assertSeeLivewire('upload-recordings');
}
public function test_files_larger_than_two_gigabytes_are_rejected(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()
->create('huge.mp3', 10, 'audio/mpeg')
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
Livewire::test(UploadRecordings::class)
->set('audio', [$file])
->assertHasErrors(['audio.0']);
$this->assertSame(0, Recording::query()->count());
Bus::assertNothingDispatched();
}
public function test_user_can_upload_wav_and_ogg(): void
{
Storage::fake('local');
Bus::fake();
foreach ([
['memo.wav', 'audio/wav', 'wav-bytes'],
['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) {
Livewire::test(UploadRecordings::class)
->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
->assertRedirect();
$expectedTitle = pathinfo($name, PATHINFO_FILENAME);
$recording = Recording::query()->where('title', $expectedTitle)->first();
$this->assertNotNull($recording, "Failed uploading {$name}");
Storage::disk('local')->assertExists($recording->file_path);
}
Bus::assertDispatched(TranscribeRecording::class, 3);
}
public function test_unsupported_audio_type_is_rejected(): void
{
Storage::fake('local');
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
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Dictation',
'original_filename' => 'dictation.mp3',
'file_path' => 'recordings/dictation.mp3',
'file_size_bytes' => 2048,
'transcription_status' => 'pending',
]);
// Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']);
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
$this->assertNotNull($recording->transcription_started_at);
$this->assertNotNull($recording->transcription_progress);
}
public function test_transcription_status_endpoint_returns_progress(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Live status',
'original_filename' => 'live.mp3',
'file_path' => 'recordings/live.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 55,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subSeconds(12),
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'processing')
->assertJsonPath('progress', 'Transcribing locally…')
->assertJsonPath('percent', 55)
->assertJsonPath('is_active', true)
->assertJsonPath('driver_label', 'Local (faster-whisper)');
}
public function test_transcription_job_stores_transcript(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/sample.mp3', 'fake-audio-bytes');
Transcription::fake(['Hello from the recorder.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Sample',
'original_filename' => 'sample.mp3',
'file_path' => 'recordings/sample.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
$recording->refresh();
$this->assertSame('done', $recording->transcription_status);
$this->assertSame('Hello from the recorder.', $recording->transcript);
$this->assertSame(100, $recording->transcription_percent);
$this->assertSame('Transcription complete', $recording->transcription_progress);
$this->assertNotNull($recording->transcribed_at);
}
public function test_transcription_job_stores_error_on_failure(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/bad.mp3', 'fake-audio-bytes');
Transcription::fake(function () {
throw new \RuntimeException('Provider unavailable');
});
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Bad',
'original_filename' => 'bad.mp3',
'file_path' => 'recordings/bad.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
try {
(new TranscribeRecording($recording))->handle(app(TranscriptionService::class));
$this->fail('Expected transcription to throw');
} catch (\RuntimeException $e) {
$this->assertSame('Provider unavailable', $e->getMessage());
}
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
$this->assertSame('Provider unavailable', $recording->transcription_error);
}
public function test_orphaned_processing_is_recovered_on_status_poll(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Stuck',
'original_filename' => 'stuck.mp3',
'file_path' => 'recordings/stuck.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'failed')
->assertJsonPath('is_active', false);
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
$this->assertStringContainsString('worker stopped', $recording->transcription_error);
}
public function test_recent_processing_is_not_marked_orphaned(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Still working',
'original_filename' => 'working.mp3',
'file_path' => 'recordings/working.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing locally…',
'transcription_percent' => 50,
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(2),
'updated_at' => now()->subMinutes(2),
]);
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'processing')
->assertJsonPath('is_active', true);
$recording->refresh();
$this->assertSame('processing', $recording->transcription_status);
}
public function test_orphaned_processing_can_be_restarted(): void
{
Transcription::fake(['Recovered transcript.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Restart me',
'original_filename' => 'restart.mp3',
'file_path' => 'recordings/restart.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinutes(10),
'updated_at' => now()->subMinutes(10),
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
}
public function test_user_can_stop_an_active_transcription(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Stop me',
'original_filename' => 'stop.mp3',
'file_path' => 'recordings/stop.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_progress' => 'Transcribing…',
'transcription_percent' => 40,
'transcription_driver' => 'local',
'transcription_started_at' => now(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('cancelTranscription');
$recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status);
$this->assertSame('Stopped by user', $recording->transcription_error);
$this->assertFalse($recording->isTranscribing());
}
public function test_user_can_restart_transcription_while_processing(): void
{
Transcription::fake(['Restarted transcript.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Restart me while busy',
'original_filename' => 'switch.mp3',
'file_path' => 'recordings/switch.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinute(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('local', $recording->transcription_driver);
$this->assertContains($recording->transcription_status, ['pending', 'processing', 'done']);
$this->assertNull($recording->transcription_error);
}
public function test_cancelled_job_does_not_overwrite_status(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/ignore.mp3', 'fake-audio-bytes');
Transcription::fake(['Should be ignored.']);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Ignore late job',
'original_filename' => 'ignore.mp3',
'file_path' => 'recordings/ignore.mp3',
'file_size_bytes' => 12,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
'transcription_started_at' => now()->subMinute(),
'transcript' => 'Previous transcript stays.',
]);
$job = new TranscribeRecording($recording);
$recording->cancelTranscription();
$job->handle(app(TranscriptionService::class));
$recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status);
$this->assertSame('Previous transcript stays.', $recording->transcript);
}
public function test_recordings_can_be_searched_by_transcript(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'We should ship the pocket recorder summary feature next week.',
]);
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Kitchen note',
'original_filename' => 'kitchen.mp3',
'file_path' => 'recordings/kitchen.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Buy milk and eggs.',
]);
$this->get(route('recordings.index', ['q' => 'pocket recorder']))
->assertOk()
->assertSee('Office chat')
->assertSee('pocket recorder')
->assertDontSee('Kitchen note');
}
public function test_restarting_transcription_keeps_previous_transcript_until_success(): void
{
Bus::fake();
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Keep me',
'original_filename' => 'keep.mp3',
'file_path' => 'recordings/keep.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcription_driver' => 'local',
'transcript' => 'Old transcript text.',
'transcribed_at' => now()->subHour(),
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('startTranscription');
$recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript);
$this->assertSame('local', $recording->transcription_driver);
$this->assertSame('pending', $recording->transcription_status);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_user_can_queue_all_pending_transcriptions(): void
{
Bus::fake();
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Already done',
'original_filename' => 'done.mp3',
'file_path' => 'recordings/done.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
public function test_index_shows_human_status_labels(): void
{
Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Label check',
'original_filename' => 'label.mp3',
'file_path' => 'recordings/label.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
$this->get(route('recordings.index'))
->assertOk()
->assertSee('Queued')
->assertDontSee('>pending<', false);
}
}