create([ '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'); $response = $this->post(route('recordings.store'), [ 'audio' => [$file], 'title' => 'Team meeting', ]); $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); $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(); $response = $this->post(route('recordings.store'), [ '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'); $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') ->assertSee('max 2 GB each', false) ->assertSee('name="audio[]"', false); } 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); $this->from(route('recordings.create')) ->post(route('recordings.store'), [ 'audio' => [$file], ]) ->assertSessionHasErrors(['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]) { $response = $this->post(route('recordings.store'), [ 'audio' => [UploadedFile::fake()->createWithContent($name, $contents)], 'title' => $name, ]); $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); } Bus::assertDispatched(TranscribeRecording::class, 3); } public function test_unsupported_audio_type_is_rejected(): void { Storage::fake('local'); $this->from(route('recordings.create')) ->post(route('recordings.store'), [ 'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')], ]) ->assertSessionHasErrors(['audio.0']); } public function test_user_can_queue_local_transcription(): void { $recording = Recording::query()->create([ '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.']); $this->post(route('recordings.transcribe', $recording)) ->assertRedirect(); $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([ '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([ '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([ '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([ '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([ '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([ '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), ]); $this->post(route('recordings.transcribe', $recording)) ->assertRedirect(); $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([ '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(), ]); $this->post(route('recordings.transcribe.cancel', $recording)) ->assertRedirect() ->assertSessionHas('success'); $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([ '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(), ]); $this->post(route('recordings.transcribe', $recording)) ->assertRedirect() ->assertSessionHas('success'); $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([ '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([ '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([ '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([ '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(), ]); $this->post(route('recordings.transcribe', $recording)) ->assertRedirect(); $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([ 'title' => 'Needs work', 'original_filename' => 'needs.mp3', 'file_path' => 'recordings/needs.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'pending', ]); Recording::query()->create([ 'title' => 'Already done', 'original_filename' => 'done.mp3', 'file_path' => 'recordings/done.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'done', 'transcript' => 'Finished text', ]); $this->from(route('recordings.index')) ->post(route('recordings.transcribe-pending')) ->assertRedirect(route('recordings.index')) ->assertSessionHas('success'); Bus::assertDispatched(TranscribeRecording::class, 1); } public function test_index_shows_human_status_labels(): void { Recording::query()->create([ '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); } }