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', ]); } }