Files
AndyTranscribe/tests/Feature/RecordingUploadTest.php
T
ben 5cea5192c2 Add transcription progress handling and local Whisper integration.
Implement a new method for calculating elapsed transcription time in the Recording model. Enhance the TranscriptionService to handle local Whisper requests, including asynchronous processing and progress reporting. Update the recordings index view to display transcription progress percentage. Add unit tests for the new transcription service functionality.
2026-08-13 12:12:10 +02:00

618 lines
23 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\DB;
use Illuminate\Support\Facades\Http;
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_service_calls_local_whisper_http(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/long.mp3', 'fake-audio-bytes');
Http::fake([
'*' => Http::response(['text' => 'Hello from whisper.']),
]);
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Long whisper',
'original_filename' => 'long.mp3',
'file_path' => 'recordings/long.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'processing',
'transcription_driver' => 'local',
]);
$text = app(TranscriptionService::class)->transcribe($recording);
$this->assertSame('Hello from whisper.', $text);
Http::assertSent(function ($request): bool {
return str_contains($request->url(), '/audio/transcriptions');
});
}
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.']);
$this->travelTo(now()->startOfSecond());
$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()->subSeconds(42),
]);
(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);
$this->assertSame(
$recording->transcribed_at->getTimestamp() - $recording->transcription_started_at->getTimestamp(),
$recording->transcription_duration_seconds,
);
$this->assertSame(42, $recording->transcription_duration_seconds);
$this->assertSame('42s', $recording->transcriptionStatusPayload()['transcription_duration_human']);
}
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('timed out', $recording->transcription_error);
}
public function test_stale_reserved_job_is_treated_as_orphaned(): void
{
$recording = Recording::query()->create([
'user_id' => $this->user->id,
'title' => 'Wedged whisper',
'original_filename' => 'wedged.mp3',
'file_path' => 'recordings/wedged.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),
]);
$job = new TranscribeRecording($recording);
$payload = json_encode([
'displayName' => TranscribeRecording::class,
'data' => [
'command' => serialize($job),
],
], JSON_THROW_ON_ERROR);
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => $payload,
'attempts' => 1,
'reserved_at' => now()->subMinutes(15)->timestamp,
'available_at' => now()->subMinutes(20)->timestamp,
'created_at' => now()->subMinutes(20)->timestamp,
]);
$this->assertFalse($recording->hasActiveTranscriptionJob());
$this->assertTrue($recording->isOrphanedTranscription());
$deleted = $recording->discardQueuedTranscriptionJobs();
$this->assertSame(1, $deleted, 'stale reserved job should be discarded');
$this->assertDatabaseCount('jobs', 0);
// Put the stale job back to exercise status-poll recovery.
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => $payload,
'attempts' => 1,
'reserved_at' => now()->subMinutes(15)->timestamp,
'available_at' => now()->subMinutes(20)->timestamp,
'created_at' => now()->subMinutes(20)->timestamp,
]);
$recording->forceFill([
'transcription_status' => 'processing',
'transcription_error' => null,
])->save();
$this->getJson(route('recordings.transcription-status', $recording))
->assertOk()
->assertJsonPath('status', 'failed');
$this->assertDatabaseCount('jobs', 0);
$recording->refresh();
$this->assertSame('failed', $recording->transcription_status);
}
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);
}
}