Ship FrankenPHP Compose services with Reverb WebSockets for real-time transcription status, skip re-uploading identical audio via content hash, and format disk usage without requiring intl.
476 lines
17 KiB
PHP
476 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Jobs\TranscribeRecording;
|
|
use App\Models\Recording;
|
|
use App\Services\TranscriptionService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Laravel\Ai\Transcription;
|
|
use Tests\TestCase;
|
|
|
|
class RecordingUploadTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_index_shows_recordings(): void
|
|
{
|
|
Recording::query()->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('name="audio[]"', false);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|