create([ 'title' => 'Pocket note', 'original_filename' => 'note.mp3', 'file_path' => 'recordings/note.mp3', 'file_size_bytes' => 1024, 'transcription_status' => 'pending', ]); $this->get(route('recordings.index')) ->assertOk() ->assertSee('Pocket note'); } public function test_user_can_upload_an_mp3(): void { Storage::fake('local'); $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); Storage::disk('local')->assertExists($recording->file_path); } public function test_user_can_upload_wav_and_ogg(): void { Storage::fake('local'); foreach ([ ['memo.wav', 'audio/wav'], ['clip.ogg', 'audio/ogg'], ['talk.m4a', 'audio/mp4'], ] as [$name, $mime]) { $response = $this->post(route('recordings.store'), [ 'audio' => UploadedFile::fake()->create($name, 400, $mime), '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); } } 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'); } public function test_user_can_queue_cloud_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), [ 'driver' => 'cloud', ])->assertRedirect(); $recording->refresh(); $this->assertSame('cloud', $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_ollama_driver_requires_url(): void { $recording = Recording::query()->create([ 'title' => 'Secret call', 'original_filename' => 'call.mp3', 'file_path' => 'recordings/call.mp3', 'file_size_bytes' => 2048, 'transcription_status' => 'pending', ]); $this->from(route('recordings.show', $recording)) ->post(route('recordings.transcribe', $recording), [ 'driver' => 'ollama', ]) ->assertSessionHasErrors('ollama_url'); } 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' => 'Waiting for cloud transcript…', 'transcription_percent' => 55, 'transcription_driver' => 'cloud', 'transcription_started_at' => now()->subSeconds(12), ]); $this->getJson(route('recordings.transcription-status', $recording)) ->assertOk() ->assertJsonPath('status', 'processing') ->assertJsonPath('progress', 'Waiting for cloud transcript…') ->assertJsonPath('percent', 55) ->assertJsonPath('is_active', true) ->assertJsonPath('driver_label', 'Cloud (OpenAI 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' => 'cloud', ]); (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' => 'cloud', ]); 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(5), 'updated_at' => now()->subMinutes(5), ]); $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_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), [ 'driver' => 'cloud', ])->assertRedirect(); $recording->refresh(); $this->assertSame('cloud', $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_start_a_different_engine_while_processing(): void { Transcription::fake(['Switched engine transcript.']); $recording = Recording::query()->create([ 'title' => 'Switch me', '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), [ 'driver' => 'cloud', ]) ->assertRedirect() ->assertSessionHas('success'); $recording->refresh(); $this->assertSame('cloud', $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' => 'cloud', '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' => 'cloud', 'transcript' => 'Old transcript text.', 'transcribed_at' => now()->subHour(), ]); $this->post(route('recordings.transcribe', $recording), [ 'driver' => 'local', ])->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); } }