Add Docker stack, Reverb live progress, and duplicate upload detection.
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.
This commit is contained in:
@@ -5,7 +5,6 @@ namespace Tests\Feature;
|
||||
use App\Services\DiskSpaceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DiskSpaceTest extends TestCase
|
||||
@@ -39,7 +38,6 @@ class DiskSpaceTest extends TestCase
|
||||
public function test_layout_shows_disk_space_bar(): void
|
||||
{
|
||||
Cache::flush();
|
||||
Storage::fake('local');
|
||||
|
||||
$this->get(route('recordings.index'))
|
||||
->assertOk()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\TranscribeRecording;
|
||||
use App\Models\Recording;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RecordingDuplicateUploadTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_duplicate_content_hash_is_skipped_on_upload(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
|
||||
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
|
||||
|
||||
$this->post(route('recordings.store'), [
|
||||
'audio' => [$first],
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertSame(1, Recording::query()->count());
|
||||
Bus::assertDispatched(TranscribeRecording::class, 1);
|
||||
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => [$duplicate],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('recordings.create'));
|
||||
$response->assertSessionHas('error');
|
||||
$this->assertSame(1, Recording::query()->count());
|
||||
Bus::assertDispatched(TranscribeRecording::class, 1);
|
||||
}
|
||||
|
||||
public function test_batch_upload_skips_duplicate_content_within_request(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$one = UploadedFile::fake()->createWithContent('one.mp3', 'same-bytes');
|
||||
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
|
||||
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
|
||||
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => [$one, $two, $three],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('recordings.index'));
|
||||
$response->assertSessionHas('success');
|
||||
$this->assertStringContainsString('Skipped 1 duplicate', session('success'));
|
||||
$this->assertSame(2, Recording::query()->count());
|
||||
Bus::assertDispatched(TranscribeRecording::class, 2);
|
||||
}
|
||||
|
||||
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void
|
||||
{
|
||||
Recording::query()->create([
|
||||
'title' => 'Existing',
|
||||
'original_filename' => 'note.mp3',
|
||||
'file_path' => 'recordings/note.mp3',
|
||||
'file_size_bytes' => 2048,
|
||||
'content_hash' => str_repeat('a', 64),
|
||||
'transcription_status' => 'done',
|
||||
]);
|
||||
|
||||
$this->get(route('recordings.create'))
|
||||
->assertOk()
|
||||
->assertSee('note.mp3:2048', false)
|
||||
->assertSee('Duplicate files', false);
|
||||
}
|
||||
|
||||
public function test_duplicate_filename_and_size_is_skipped_without_content_hash(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
Recording::query()->create([
|
||||
'title' => 'Legacy',
|
||||
'original_filename' => 'legacy.mp3',
|
||||
'file_path' => 'recordings/legacy.mp3',
|
||||
'file_size_bytes' => strlen('legacy-audio'),
|
||||
'content_hash' => null,
|
||||
'transcription_status' => 'done',
|
||||
]);
|
||||
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('recordings.create'));
|
||||
$response->assertSessionHas('error');
|
||||
$this->assertSame(1, Recording::query()->count());
|
||||
Bus::assertNothingDispatched();
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,9 @@ class RecordingUploadTest extends TestCase
|
||||
|
||||
$response = $this->post(route('recordings.store'), [
|
||||
'audio' => [
|
||||
UploadedFile::fake()->create('one.mp3', 400, 'audio/mpeg'),
|
||||
UploadedFile::fake()->create('two.wav', 400, 'audio/wav'),
|
||||
UploadedFile::fake()->create('three.ogg', 400, 'audio/ogg'),
|
||||
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)),
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -96,12 +96,12 @@ class RecordingUploadTest extends TestCase
|
||||
Bus::fake();
|
||||
|
||||
foreach ([
|
||||
['memo.wav', 'audio/wav'],
|
||||
['clip.ogg', 'audio/ogg'],
|
||||
['talk.m4a', 'audio/mp4'],
|
||||
] as [$name, $mime]) {
|
||||
['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()->create($name, 400, $mime)],
|
||||
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)],
|
||||
'title' => $name,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\RecordingTranscriptionUpdated;
|
||||
use App\Jobs\TranscribeRecording;
|
||||
use App\Models\Recording;
|
||||
use App\Services\TranscriptionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Ai\Transcription;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TranscriptionBroadcastTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_report_progress_broadcasts_transcription_updated(): void
|
||||
{
|
||||
Event::fake([RecordingTranscriptionUpdated::class]);
|
||||
|
||||
$recording = Recording::query()->create([
|
||||
'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([
|
||||
'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([
|
||||
'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([
|
||||
'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_public_recordings_channel(): void
|
||||
{
|
||||
$recording = Recording::query()->create([
|
||||
'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('recordings', $event->broadcastOn()[0]->name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user