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.
342 lines
11 KiB
PHP
342 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Recordings;
|
|
|
|
use App\Jobs\TranscribeRecording;
|
|
use App\Livewire\Recordings\Index;
|
|
use App\Models\Recording;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
class IndexTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_index_page_renders_as_livewire(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Pocket note',
|
|
'original_filename' => 'note.mp3',
|
|
'file_path' => 'recordings/note.mp3',
|
|
'file_size_bytes' => 1024,
|
|
'transcription_status' => 'done',
|
|
'transcript' => 'hello world',
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(route('recordings.index'))
|
|
->assertOk()
|
|
->assertSeeLivewire(Index::class)
|
|
->assertSee('Pocket note');
|
|
}
|
|
|
|
public function test_empty_index_shows_upload_dropzone(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->get(route('recordings.index'))
|
|
->assertOk()
|
|
->assertSeeLivewire('upload-recordings')
|
|
->assertSee('Drop audio files here or click to browse')
|
|
->assertDontSee('No recordings yet.')
|
|
->assertDontSee('Upload your first MP3');
|
|
}
|
|
|
|
public function test_search_filters_recordings(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Office chat',
|
|
'original_filename' => 'office.mp3',
|
|
'file_path' => 'recordings/office.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'done',
|
|
'transcript' => "talking about the pocket recorder today\nsecond line stays hidden",
|
|
]);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Unrelated',
|
|
'original_filename' => 'other.mp3',
|
|
'file_path' => 'recordings/other.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'done',
|
|
'transcript' => 'nothing useful',
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->set('search', 'pocket recorder')
|
|
->assertSee('Office chat')
|
|
->assertSee('talking about the pocket recorder today')
|
|
->assertDontSee('second line stays hidden')
|
|
->assertDontSee('Unrelated');
|
|
}
|
|
|
|
public function test_queue_pending_dispatches_jobs(): void
|
|
{
|
|
Bus::fake();
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Needs work',
|
|
'original_filename' => 'needs.mp3',
|
|
'file_path' => 'recordings/needs.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'pending',
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('queuePending');
|
|
|
|
Bus::assertDispatched(TranscribeRecording::class, 1);
|
|
}
|
|
|
|
public function test_index_polls_while_transcriptions_are_active(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'In progress',
|
|
'original_filename' => 'active.mp3',
|
|
'file_path' => 'recordings/active.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'processing',
|
|
'transcription_progress' => 'Transcribing locally…',
|
|
'transcription_percent' => 40,
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSee('wire:poll', false)
|
|
->assertSee('Transcribing')
|
|
->assertSee('40%')
|
|
->assertDontSee('Transcribing locally…')
|
|
->assertSeeHtml('bg-amber-400');
|
|
}
|
|
|
|
public function test_queued_recordings_do_not_show_fake_percent(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Waiting in line',
|
|
'original_filename' => 'queued.mp3',
|
|
'file_path' => 'recordings/queued.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'pending',
|
|
'transcription_progress' => 'Queued — waiting to start…',
|
|
'transcription_percent' => null,
|
|
'transcription_driver' => 'local',
|
|
'transcription_started_at' => now(),
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSee('Waiting in line')
|
|
->assertSee('Queued')
|
|
->assertDontSee('Queued — waiting to start…')
|
|
->assertDontSee('5%')
|
|
->assertSeeHtml('bg-zinc-400/15')
|
|
->assertDontSeeHtml('bg-amber-400');
|
|
}
|
|
|
|
public function test_index_does_not_poll_when_all_transcriptions_are_idle(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Finished',
|
|
'original_filename' => 'done.mp3',
|
|
'file_path' => 'recordings/done.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'done',
|
|
'transcript' => 'all done',
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->assertDontSee('wire:poll', false);
|
|
}
|
|
|
|
public function test_user_can_delete_recording_from_index(): void
|
|
{
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
|
|
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Delete from list',
|
|
'original_filename' => 'delete-me.mp3',
|
|
'file_path' => 'recordings/delete-me.mp3',
|
|
'file_size_bytes' => 5,
|
|
'transcription_status' => 'done',
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSee('Delete from list')
|
|
->call('delete', $recording->id)
|
|
->assertDontSee('Delete from list');
|
|
|
|
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
|
|
Storage::disk('local')->assertMissing('recordings/delete-me.mp3');
|
|
}
|
|
|
|
public function test_user_can_delete_all_recordings_from_index(): void
|
|
{
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('recordings/one.mp3', 'one');
|
|
Storage::disk('local')->put('recordings/two.mp3', 'two');
|
|
Storage::disk('local')->put('recordings/other.mp3', 'other');
|
|
|
|
$user = User::factory()->create();
|
|
$other = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Mine one',
|
|
'original_filename' => 'one.mp3',
|
|
'file_path' => 'recordings/one.mp3',
|
|
'file_size_bytes' => 3,
|
|
'transcription_status' => 'done',
|
|
]);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Mine two',
|
|
'original_filename' => 'two.mp3',
|
|
'file_path' => 'recordings/two.mp3',
|
|
'file_size_bytes' => 3,
|
|
'transcription_status' => 'pending',
|
|
]);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $other->id,
|
|
'title' => 'Someone else',
|
|
'original_filename' => 'other.mp3',
|
|
'file_path' => 'recordings/other.mp3',
|
|
'file_size_bytes' => 5,
|
|
'transcription_status' => 'done',
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->assertSee('Delete all')
|
|
->call('deleteAll')
|
|
->assertDontSee('Mine one')
|
|
->assertDontSee('Mine two');
|
|
|
|
$this->assertDatabaseMissing('recordings', ['user_id' => $user->id]);
|
|
$this->assertDatabaseHas('recordings', ['user_id' => $other->id, 'title' => 'Someone else']);
|
|
Storage::disk('local')->assertMissing('recordings/one.mp3');
|
|
Storage::disk('local')->assertMissing('recordings/two.mp3');
|
|
Storage::disk('local')->assertExists('recordings/other.mp3');
|
|
}
|
|
|
|
public function test_user_cannot_delete_another_users_recording_from_index(): void
|
|
{
|
|
Storage::fake('local');
|
|
|
|
$owner = User::factory()->create();
|
|
$intruder = User::factory()->create();
|
|
|
|
$recording = Recording::query()->create([
|
|
'user_id' => $owner->id,
|
|
'title' => 'Keep me',
|
|
'original_filename' => 'keep.mp3',
|
|
'file_path' => 'recordings/keep.mp3',
|
|
'file_size_bytes' => 10,
|
|
'transcription_status' => 'done',
|
|
]);
|
|
|
|
$this->actingAs($intruder);
|
|
|
|
try {
|
|
Livewire::test(Index::class)
|
|
->call('delete', $recording->id);
|
|
|
|
$this->fail('Expected deleting another user\'s recording to fail.');
|
|
} catch (ModelNotFoundException) {
|
|
// Owned-query findOrFail hides other users' recordings.
|
|
}
|
|
|
|
$this->assertDatabaseHas('recordings', ['id' => $recording->id]);
|
|
}
|
|
|
|
public function test_recordings_can_be_sorted_by_title(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Zebra',
|
|
'original_filename' => 'z.mp3',
|
|
'file_path' => 'recordings/z.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'done',
|
|
'created_at' => now()->subDay(),
|
|
]);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Alpha',
|
|
'original_filename' => 'a.mp3',
|
|
'file_path' => 'recordings/a.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'done',
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('sort', 'title')
|
|
->assertSet('sortBy', 'title')
|
|
->assertSet('sortDirection', 'asc')
|
|
->assertSeeInOrder(['Alpha', 'Zebra'])
|
|
->call('sort', 'title')
|
|
->assertSet('sortDirection', 'desc')
|
|
->assertSeeInOrder(['Zebra', 'Alpha']);
|
|
}
|
|
|
|
public function test_invalid_sort_column_is_ignored(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->actingAs($user);
|
|
|
|
Recording::query()->create([
|
|
'user_id' => $user->id,
|
|
'title' => 'Only one',
|
|
'original_filename' => 'one.mp3',
|
|
'file_path' => 'recordings/one.mp3',
|
|
'file_size_bytes' => 100,
|
|
'transcription_status' => 'done',
|
|
]);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('sort', 'not_a_column')
|
|
->assertSet('sortBy', 'uploaded')
|
|
->assertSet('sortDirection', 'desc');
|
|
}
|
|
}
|