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); } }