first
This commit is contained in:
397
tests/Feature/Project/TranscriptionJobTest.php
Normal file
397
tests/Feature/Project/TranscriptionJobTest.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Project;
|
||||
|
||||
use App\Jobs\OllamaPostProcessJob;
|
||||
use App\Jobs\TranscribeAudioJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use App\Services\OllamaService;
|
||||
use App\Services\StorageService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TranscriptionJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProject(User $owner, array $attrs = []): TranscriptionProject
|
||||
{
|
||||
return TranscriptionProject::factory()->create(array_merge([
|
||||
'owner_user_id' => $owner->id,
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Status endpoint (JSON polling)
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_owner_can_poll_status(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, ['transcription_status' => 'processing']);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->getJson(route('user.projects.status', $project))
|
||||
->assertOk()
|
||||
->assertJsonFragment(['status' => 'processing']);
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_poll_status(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($other)
|
||||
->getJson(route('user.projects.status', $project))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_cannot_poll_status(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->getJson(route('user.projects.status', $project))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_status_returns_error_message_only_when_failed(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'transcription_status' => 'failed',
|
||||
'error_message' => 'Connection refused',
|
||||
]);
|
||||
|
||||
$data = $this->actingAs($owner)
|
||||
->getJson(route('user.projects.status', $project))
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$this->assertEquals('failed', $data['status']);
|
||||
$this->assertEquals('Connection refused', $data['error_message']);
|
||||
}
|
||||
|
||||
public function test_status_hides_error_message_when_not_failed(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, ['transcription_status' => 'processing']);
|
||||
|
||||
$data = $this->actingAs($owner)
|
||||
->getJson(route('user.projects.status', $project))
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$this->assertNull($data['error_message']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Retry endpoint
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_owner_can_retry_failed_transcription(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'transcription_status' => 'failed',
|
||||
'error_message' => 'timeout',
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->post(route('user.projects.retry', $project))
|
||||
->assertRedirect();
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals('pending', $project->transcription_status);
|
||||
$this->assertNull($project->error_message);
|
||||
|
||||
Queue::assertPushed(TranscribeAudioJob::class);
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_retry(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$project = $this->makeProject($owner, ['transcription_status' => 'failed']);
|
||||
|
||||
$this->actingAs($other)
|
||||
->post(route('user.projects.retry', $project))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_retry_not_allowed_when_not_failed(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, ['transcription_status' => 'completed']);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->post(route('user.projects.retry', $project))
|
||||
->assertForbidden();
|
||||
|
||||
Queue::assertNotPushed(TranscribeAudioJob::class);
|
||||
}
|
||||
|
||||
public function test_admin_cannot_retry(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, ['transcription_status' => 'failed']);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('user.projects.retry', $project))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// TranscribeAudioJob — mock worker HTTP call
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_transcribe_job_marks_completed_on_success(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
// Put a fake file so exists() passes
|
||||
Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes');
|
||||
|
||||
Http::fake([
|
||||
'*/transcribe' => Http::response([
|
||||
'success' => true,
|
||||
'transcript' => 'Salam sejahtera.',
|
||||
'confidence' => 0.92,
|
||||
'duration_seconds' => 45.3,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new TranscribeAudioJob($project))->handle(app(StorageService::class));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals('completed', $project->transcription_status);
|
||||
$this->assertEquals('Salam sejahtera.', $project->transcript_text);
|
||||
$this->assertNotNull($project->processed_at);
|
||||
}
|
||||
|
||||
public function test_transcribe_job_marks_failed_on_worker_error(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes');
|
||||
|
||||
Http::fake([
|
||||
'*/transcribe' => Http::response(['success' => false, 'error' => 'OOM'], 200),
|
||||
]);
|
||||
|
||||
// Job catches the exception internally, marks project failed, then calls $this->fail($e).
|
||||
// When invoked directly (not via queue worker), fail() does not re-throw.
|
||||
(new TranscribeAudioJob($project))->handle(app(StorageService::class));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals('failed', $project->transcription_status);
|
||||
$this->assertStringContainsString('OOM', $project->error_message);
|
||||
}
|
||||
|
||||
public function test_transcribe_job_marks_failed_on_http_error(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes');
|
||||
|
||||
Http::fake([
|
||||
'*/transcribe' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
(new TranscribeAudioJob($project))->handle(app(StorageService::class));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals('failed', $project->transcription_status);
|
||||
$this->assertStringContainsString('500', $project->error_message);
|
||||
}
|
||||
|
||||
public function test_transcribe_job_dispatches_ollama_job_when_enabled(): void
|
||||
{
|
||||
Queue::fake();
|
||||
Storage::fake('private');
|
||||
|
||||
config(['speech2text.ollama.enabled' => true]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes');
|
||||
|
||||
Http::fake([
|
||||
'*/transcribe' => Http::response([
|
||||
'success' => true,
|
||||
'transcript' => 'Terima kasih.',
|
||||
'confidence' => 0.88,
|
||||
'duration_seconds' => 10.0,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new TranscribeAudioJob($project))->handle(app(StorageService::class));
|
||||
|
||||
Queue::assertPushed(OllamaPostProcessJob::class);
|
||||
}
|
||||
|
||||
public function test_transcribe_job_does_not_dispatch_ollama_when_disabled(): void
|
||||
{
|
||||
Queue::fake();
|
||||
Storage::fake('private');
|
||||
|
||||
config(['speech2text.ollama.enabled' => false]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes');
|
||||
|
||||
Http::fake([
|
||||
'*/transcribe' => Http::response([
|
||||
'success' => true,
|
||||
'transcript' => 'Terima kasih.',
|
||||
'confidence' => 0.88,
|
||||
'duration_seconds' => 10.0,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new TranscribeAudioJob($project))->handle(app(StorageService::class));
|
||||
|
||||
Queue::assertNotPushed(OllamaPostProcessJob::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// OllamaPostProcessJob
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_ollama_job_updates_transcript_when_available(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'transcription_status' => 'completed',
|
||||
'transcript_text' => 'teks mentah daripada whisper',
|
||||
]);
|
||||
|
||||
$mockOllama = $this->createMock(OllamaService::class);
|
||||
$mockOllama->method('isAvailable')->willReturn(true);
|
||||
$mockOllama->method('cleanTranscript')->willReturn('Teks bersih daripada Ollama.');
|
||||
|
||||
(new OllamaPostProcessJob($project))->handle($mockOllama);
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals('Teks bersih daripada Ollama.', $project->transcript_text);
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'transcript_postprocessed',
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ollama_job_skips_when_unavailable(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'transcription_status' => 'completed',
|
||||
'transcript_text' => 'teks asal',
|
||||
]);
|
||||
|
||||
$mockOllama = $this->createMock(OllamaService::class);
|
||||
$mockOllama->method('isAvailable')->willReturn(false);
|
||||
$mockOllama->expects($this->never())->method('cleanTranscript');
|
||||
|
||||
(new OllamaPostProcessJob($project))->handle($mockOllama);
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals('teks asal', $project->transcript_text);
|
||||
}
|
||||
|
||||
public function test_ollama_job_skips_when_project_not_completed(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'transcription_status' => 'failed',
|
||||
'transcript_text' => null,
|
||||
]);
|
||||
|
||||
$mockOllama = $this->createMock(OllamaService::class);
|
||||
$mockOllama->expects($this->never())->method('isAvailable');
|
||||
$mockOllama->expects($this->never())->method('cleanTranscript');
|
||||
|
||||
(new OllamaPostProcessJob($project))->handle($mockOllama);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Audit log ditulis oleh job
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_audit_log_written_on_transcription_completed(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$project = $this->makeProject($owner, [
|
||||
'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3',
|
||||
'mime_type' => 'audio/mpeg',
|
||||
'transcription_status' => 'pending',
|
||||
]);
|
||||
|
||||
Storage::disk('private')->put($project->stored_audio_path, 'bytes');
|
||||
|
||||
Http::fake([
|
||||
'*/transcribe' => Http::response([
|
||||
'success' => true,
|
||||
'transcript' => 'Log audit.',
|
||||
'confidence' => 0.9,
|
||||
'duration_seconds' => 5.0,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new TranscribeAudioJob($project))->handle(app(StorageService::class));
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'transcription_completed',
|
||||
'project_id' => $project->id,
|
||||
'actor_role' => 'system',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user