user = User::factory()->create(); $this->actingAs($this->user); } public function test_report_progress_broadcasts_transcription_updated(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Broadcast progress', 'original_filename' => 'progress.mp3', 'file_path' => 'recordings/progress.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), ]); $recording->reportProgress('Transcribing locally…', 50); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool { return $event->recording->is($recording) && $event->broadcastWith()['percent'] === 50 && $event->broadcastWith()['progress'] === 'Transcribing locally…'; }); } public function test_successful_transcription_broadcasts_done_status(): void { Event::fake([RecordingTranscriptionUpdated::class]); 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', 'transcription_started_at' => now(), ]); (new TranscribeRecording($recording))->handle(app(TranscriptionService::class)); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool { $payload = $event->broadcastWith(); return $event->recording->is($recording) && $payload['status'] === 'done' && $payload['percent'] === 100 && $payload['has_transcript'] === true && ($payload['word_count'] ?? 0) > 0; }); } public function test_failed_transcription_broadcasts_failure(): void { Event::fake([RecordingTranscriptionUpdated::class]); 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', 'transcription_started_at' => now(), ]); try { (new TranscribeRecording($recording))->handle(app(TranscriptionService::class)); $this->fail('Expected transcription to throw'); } catch (\RuntimeException $e) { $this->assertSame('Provider unavailable', $e->getMessage()); } Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool { $payload = $event->broadcastWith(); return $event->recording->is($recording) && $payload['status'] === 'failed' && $payload['error'] === 'Provider unavailable'; }); } public function test_cancel_transcription_broadcasts_cancelled_status(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Cancel me', 'original_filename' => 'cancel.mp3', 'file_path' => 'recordings/cancel.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), ]); $recording->cancelTranscription(); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event) use ($recording): bool { return $event->recording->is($recording) && $event->broadcastWith()['status'] === 'cancelled'; }); } public function test_broadcast_event_uses_private_recording_channel(): void { $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Channel', 'original_filename' => 'channel.mp3', 'file_path' => 'recordings/channel.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'pending', ]); $event = new RecordingTranscriptionUpdated($recording); $this->assertSame('RecordingTranscriptionUpdated', $event->broadcastAs()); $this->assertSame('private-recording.'.$recording->id, $event->broadcastOn()[0]->name); $this->assertSame('private-user.'.$this->user->id.'.recordings', $event->broadcastOn()[1]->name); } public function test_broadcast_payload_omits_transcript_text(): void { $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Long text', 'original_filename' => 'long.mp3', 'file_path' => 'recordings/long.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_percent' => 70, 'transcript' => str_repeat('word ', 5000), ]); $payload = (new RecordingTranscriptionUpdated($recording))->broadcastWith(); $this->assertArrayNotHasKey('transcript', $payload); $this->assertArrayNotHasKey('whisper', $payload); $this->assertTrue($payload['has_transcript']); $this->assertSame(70, $payload['percent']); $this->assertLessThan(10_000, strlen((string) json_encode($payload))); } public function test_report_progress_broadcasts_transcript_deltas_for_append(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Deltas', 'original_filename' => 'delta.mp3', 'file_path' => 'recordings/delta.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), 'transcript' => 'Previous finished transcript', ]); $recording->reportProgress('Transcribing locally…', 55, partialTranscript: 'Hello '); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool { $payload = $event->broadcastWith(); return ($payload['transcript_delta'] ?? null) === 'Hello ' && ($payload['transcript_replace'] ?? false) === true && ! array_key_exists('transcript', $payload); }); $recording->refresh()->reportProgress('Transcribing locally…', 60, partialTranscript: 'Hello from whisper.'); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool { $payload = $event->broadcastWith(); return ($payload['transcript_delta'] ?? null) === 'from whisper.' && ($payload['transcript_replace'] ?? true) === false; }); } public function test_report_progress_broadcasts_whisper_delta_without_full_verbose_snapshot(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Verbose live', 'original_filename' => 'delta.mp3', 'file_path' => 'recordings/delta.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), ]); $recording->reportProgress('Transcribing locally…', 40, partialTranscript: 'Hello', whisperDelta: [ 'language' => 'en', 'duration' => 12.5, 'segment' => [ 'id' => 0, 'start' => 0.0, 'end' => 1.2, 'text' => 'Hello', 'avg_logprob' => -0.2, 'words' => [ ['word' => 'Hello', 'start' => 0.0, 'end' => 1.2, 'probability' => 0.91], ], ], ]); $recording->refresh(); $this->assertSame('en', $recording->transcription_verbose['language']); $this->assertCount(1, $recording->transcription_verbose['segments']); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool { $payload = $event->broadcastWith(); return ($payload['whisper_delta']['language'] ?? null) === 'en' && ($payload['whisper_delta']['segment']['text'] ?? null) === 'Hello' && ! array_key_exists('whisper', $payload) && ! array_key_exists('transcript', $payload) && ! array_key_exists('segments', $payload['whisper_delta'] ?? []); }); } public function test_full_whisper_segments_snapshot_is_persisted_but_not_broadcast(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Snapshot', 'original_filename' => 'snap.mp3', 'file_path' => 'recordings/snap.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), ]); $recording->reportProgress('Transcribing locally…', 90, partialTranscript: 'Hello world', whisperDelta: [ 'language' => 'en', 'duration' => 4.2, 'segments' => [ ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0], ['id' => 1, 'text' => 'world', 'start' => 1.0, 'end' => 2.0], ], ]); $recording->refresh(); $this->assertCount(2, $recording->transcription_verbose['segments']); Event::assertDispatched(RecordingTranscriptionUpdated::class, function (RecordingTranscriptionUpdated $event): bool { $payload = $event->broadcastWith(); return ($payload['whisper_delta']['language'] ?? null) === 'en' && ($payload['whisper_delta']['duration'] ?? null) === 4.2 && ! array_key_exists('segments', $payload['whisper_delta'] ?? []) && ! array_key_exists('whisper', $payload); }); } public function test_report_progress_replaces_verbose_segments_when_snapshot_arrives(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Replace segments', 'original_filename' => 'replace.mp3', 'file_path' => 'recordings/replace.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), ]); $recording->reportProgress('Transcribing locally…', 40, whisperDelta: [ 'segment' => ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0], ]); $recording->refresh()->reportProgress('Transcribing locally…', 99, whisperDelta: [ 'language' => 'en', 'segments' => [ ['id' => 0, 'text' => 'Hello', 'start' => 0.0, 'end' => 1.0, 'avg_logprob' => -0.1], ], ]); $this->assertCount(1, $recording->refresh()->transcription_verbose['segments']); $this->assertSame(-0.1, $recording->transcription_verbose['segments'][0]['avg_logprob']); } public function test_consecutive_live_whisper_segments_are_appended(): void { Event::fake([RecordingTranscriptionUpdated::class]); $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Append live', 'original_filename' => 'append.mp3', 'file_path' => 'recordings/append.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', 'transcription_driver' => 'local', 'transcription_started_at' => now(), ]); $recording->reportProgress('Transcribing locally…', 40, partialTranscript: "didn't I wasn't aware", whisperDelta: [ 'language' => 'en', 'segment' => ['id' => 0, 'text' => "didn't I wasn't aware", 'start' => 254.5, 'end' => 260.8], ]); $recording->refresh()->reportProgress('Transcribing locally…', 45, partialTranscript: "didn't I wasn't aware other people", whisperDelta: [ 'language' => 'en', 'segment' => ['id' => 0, 'text' => 'other people', 'start' => 260.8, 'end' => 267.4], ]); $recording->refresh(); $this->assertCount(2, $recording->transcription_verbose['segments']); $this->assertSame('other people', $recording->transcription_verbose['segments'][1]['text']); } public function test_oversized_transcript_delta_is_not_broadcast(): void { $recording = Recording::query()->create([ 'user_id' => $this->user->id, 'title' => 'Huge delta', 'original_filename' => 'huge.mp3', 'file_path' => 'recordings/huge.mp3', 'file_size_bytes' => 100, 'transcription_status' => 'processing', ]); $huge = str_repeat('a', RecordingTranscriptionUpdated::MAX_DELTA_BYTES + 1); $payload = (new RecordingTranscriptionUpdated($recording, $huge, true))->broadcastWith(); $this->assertArrayNotHasKey('transcript_delta', $payload); $this->assertArrayNotHasKey('transcript', $payload); $this->assertLessThan(10_000, strlen((string) json_encode($payload))); } }