This commit is contained in:
Saufi
2026-06-02 17:35:45 +08:00
commit 4ef99b1f81
148 changed files with 21134 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
<?php
namespace Tests\Feature\Project;
use App\Models\ProjectCollaborator;
use App\Models\TranscriptionProject;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProjectAccessTest extends TestCase
{
use RefreshDatabase;
private function makeProject(User $owner, array $attrs = []): TranscriptionProject
{
return TranscriptionProject::factory()->create(array_merge([
'owner_user_id' => $owner->id,
], $attrs));
}
// -------------------------------------------------------
// Admin tidak boleh akses kandungan projek
// -------------------------------------------------------
public function test_admin_cannot_view_project_detail(): void
{
$admin = User::factory()->admin()->create();
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($admin)
->get(route('user.projects.show', $project))
->assertForbidden();
}
public function test_admin_cannot_stream_audio(): void
{
$admin = User::factory()->admin()->create();
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($admin)
->get(route('user.projects.audio.stream', $project))
->assertForbidden();
}
public function test_admin_can_view_project_metadata_list(): void
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get(route('admin.projects.index'))
->assertOk()
->assertViewIs('admin.projects.index');
}
// -------------------------------------------------------
// Owner boleh akses projek sendiri
// -------------------------------------------------------
public function test_owner_can_view_own_project(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($owner)
->get(route('user.projects.show', $project))
->assertOk()
->assertViewIs('user.projects.show');
}
// -------------------------------------------------------
// Collaborator boleh akses projek yang dikongsi
// -------------------------------------------------------
public function test_collaborator_can_view_shared_project(): void
{
$owner = User::factory()->create();
$collab = User::factory()->create();
$project = $this->makeProject($owner);
ProjectCollaborator::create([
'project_id' => $project->id,
'user_id' => $collab->id,
'role' => 'editor',
'added_by' => $owner->id,
]);
$this->actingAs($collab)
->get(route('user.projects.show', $project))
->assertOk();
}
// -------------------------------------------------------
// Bukan collaborator tidak boleh akses
// -------------------------------------------------------
public function test_non_collaborator_cannot_view_project(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($other)
->get(route('user.projects.show', $project))
->assertForbidden();
}
// -------------------------------------------------------
// Hanya owner boleh delete projek
// -------------------------------------------------------
public function test_only_owner_can_delete_project(): void
{
$owner = User::factory()->create();
$collab = User::factory()->create();
$project = $this->makeProject($owner);
ProjectCollaborator::create([
'project_id' => $project->id,
'user_id' => $collab->id,
'role' => 'editor',
'added_by' => $owner->id,
]);
// Collaborator tidak boleh delete
$this->actingAs($collab)
->delete(route('user.projects.destroy', $project))
->assertForbidden();
// Owner boleh delete
$this->actingAs($owner)
->delete(route('user.projects.destroy', $project))
->assertRedirect(route('user.dashboard'));
$this->assertSoftDeleted('transcription_projects', ['id' => $project->id]);
}
// -------------------------------------------------------
// Upload audio dan dispatch job
// -------------------------------------------------------
public function test_user_can_upload_audio_and_job_dispatched(): void
{
Queue::fake();
Storage::fake('private');
$user = User::factory()->create();
// Create a temp file with valid MPEG-1 L3 frame sync bytes (FF FB)
$tmpPath = tempnam(sys_get_temp_dir(), 'mp3_');
file_put_contents($tmpPath, "\xFF\xFB\x90\x00" . str_repeat("\xFF", 1024));
$file = new UploadedFile($tmpPath, 'rakaman.mp3', 'audio/mpeg', null, true);
$this->actingAs($user)
->post(route('user.projects.store'), [
'title' => 'Mesyuarat Mac 2025',
'description' => 'Rakaman mesyuarat bulanan',
'audio' => $file,
'language' => 'ms',
])->assertRedirect();
$project = TranscriptionProject::where('title', 'Mesyuarat Mac 2025')->first();
$this->assertNotNull($project);
$this->assertEquals('pending', $project->transcription_status);
$this->assertEquals($user->id, $project->owner_user_id);
// Fail disimpan dalam private storage (bukan public)
Storage::disk('private')->assertExists($project->stored_audio_path);
// Job dihantar ke queue
Queue::assertPushed(\App\Jobs\TranscribeAudioJob::class);
@unlink($tmpPath);
}
// -------------------------------------------------------
// Admin — metadata list tidak dedahkan transcript
// -------------------------------------------------------
public function test_admin_metadata_list_does_not_expose_transcript(): void
{
$admin = User::factory()->admin()->create();
$owner = User::factory()->create();
TranscriptionProject::factory()->create([
'owner_user_id' => $owner->id,
'transcript_text' => 'Ini adalah teks rahsia yang sensitif.',
'transcription_status' => 'completed',
]);
$response = $this->actingAs($admin)
->get(route('admin.projects.index'))
->assertOk();
$response->assertDontSee('Ini adalah teks rahsia yang sensitif.');
}
// -------------------------------------------------------
// Collaborator management
// -------------------------------------------------------
public function test_owner_can_add_collaborator(): void
{
$owner = User::factory()->create();
$newUser = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($owner)
->post(route('user.projects.collaborators.store', $project), [
'email' => $newUser->email,
'role' => 'editor',
])->assertRedirect();
$this->assertDatabaseHas('project_collaborators', [
'project_id' => $project->id,
'user_id' => $newUser->id,
'role' => 'editor',
]);
}
public function test_non_owner_cannot_add_collaborator(): void
{
$owner = User::factory()->create();
$collab = User::factory()->create();
$other = User::factory()->create();
$project = $this->makeProject($owner);
ProjectCollaborator::create([
'project_id' => $project->id,
'user_id' => $collab->id,
'role' => 'editor',
'added_by' => $owner->id,
]);
$this->actingAs($collab)
->post(route('user.projects.collaborators.store', $project), [
'email' => $other->email,
])->assertForbidden();
}
}

View File

@@ -0,0 +1,463 @@
<?php
namespace Tests\Feature\Project;
use App\Jobs\OllamaPostProcessJob;
use App\Models\ProjectCollaborator;
use App\Models\TranscriptVersion;
use App\Models\TranscriptionProject;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class TranscriptEditorTest extends TestCase
{
use RefreshDatabase;
private function makeProject(User $owner, array $attrs = []): TranscriptionProject
{
return TranscriptionProject::factory()->create(array_merge([
'owner_user_id' => $owner->id,
'transcription_status' => 'completed',
'transcript_text' => 'Teks asal transkripsi.',
], $attrs));
}
private function addCollaborator(TranscriptionProject $project, User $user, string $role = 'editor'): void
{
ProjectCollaborator::create([
'project_id' => $project->id,
'user_id' => $user->id,
'role' => $role,
'added_by' => $project->owner_user_id,
]);
}
// -------------------------------------------------------
// Edit transcript (PATCH)
// -------------------------------------------------------
public function test_owner_can_edit_transcript(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($owner)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Teks baru selepas diedit.',
'change_summary' => 'Perbaikan ejaan',
])->assertRedirect();
$project->refresh();
$this->assertEquals('Teks baru selepas diedit.', $project->transcript_text);
$this->assertDatabaseHas('transcript_versions', [
'project_id' => $project->id,
'version_number' => 1,
'change_summary' => 'Perbaikan ejaan',
]);
}
public function test_editor_collaborator_can_edit_transcript(): void
{
$owner = User::factory()->create();
$editor = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $editor, 'editor');
$this->actingAs($editor)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Diedit oleh editor.',
])->assertRedirect();
$project->refresh();
$this->assertEquals('Diedit oleh editor.', $project->transcript_text);
}
public function test_viewer_collaborator_cannot_edit_transcript(): void
{
$owner = User::factory()->create();
$viewer = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $viewer, 'viewer');
$this->actingAs($viewer)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Cuba edit oleh viewer.',
])->assertForbidden();
}
public function test_admin_cannot_edit_transcript(): void
{
$admin = User::factory()->admin()->create();
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($admin)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Admin cuba edit.',
])->assertForbidden();
}
public function test_non_collaborator_cannot_edit_transcript(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($other)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Cubaan haram.',
])->assertForbidden();
}
public function test_edit_creates_version_history(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner, ['transcript_text' => 'Asal.']);
$this->actingAs($owner)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Kemaskini pertama.',
]);
$this->actingAs($owner)
->patch(route('user.projects.transcript.update', $project), [
'transcript_text' => 'Kemaskini kedua.',
]);
$this->assertEquals(2, TranscriptVersion::where('project_id', $project->id)->count());
$v1 = TranscriptVersion::where('project_id', $project->id)->where('version_number', 1)->first();
$v2 = TranscriptVersion::where('project_id', $project->id)->where('version_number', 2)->first();
$this->assertEquals('Asal.', $v1->old_text);
$this->assertEquals('Kemaskini pertama.', $v1->new_text);
$this->assertEquals('Kemaskini pertama.', $v2->old_text);
$this->assertEquals('Kemaskini kedua.', $v2->new_text);
}
// -------------------------------------------------------
// Upload transkripsi luar (.txt)
// -------------------------------------------------------
public function test_owner_can_upload_external_transcript(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner, ['transcript_text' => null, 'transcription_status' => 'failed']);
$file = UploadedFile::fake()->createWithContent('transkripsi.txt', "Ini teks dari luar sistem.\nBaris kedua.");
$this->actingAs($owner)
->post(route('user.projects.transcript.upload', $project), [
'transcript_file' => $file,
])->assertRedirect();
$project->refresh();
$this->assertEquals('completed', $project->transcription_status);
$this->assertEquals('manual', $project->transcription_engine);
$this->assertStringContainsString('Ini teks dari luar sistem.', $project->transcript_text);
$this->assertDatabaseHas('transcript_versions', [
'project_id' => $project->id,
'version_number' => 1,
'change_summary' => 'Transkripsi dimuat naik secara manual',
]);
}
public function test_upload_external_replaces_existing_transcript(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner, ['transcript_text' => 'Teks whisper asal.']);
$file = UploadedFile::fake()->createWithContent('baru.txt', 'Teks baru dari sumber luar.');
$this->actingAs($owner)
->post(route('user.projects.transcript.upload', $project), [
'transcript_file' => $file,
])->assertRedirect();
$project->refresh();
$this->assertEquals('Teks baru dari sumber luar.', $project->transcript_text);
// Versi lama tersimpan
$v = TranscriptVersion::where('project_id', $project->id)->first();
$this->assertEquals('Teks whisper asal.', $v->old_text);
}
public function test_upload_external_dispatches_ollama_when_checkbox_checked(): void
{
Queue::fake();
config(['speech2text.ollama.enabled' => true]);
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$file = UploadedFile::fake()->createWithContent('t.txt', 'Teks untuk dibersihkan.');
$this->actingAs($owner)
->post(route('user.projects.transcript.upload', $project), [
'transcript_file' => $file,
'clean_with_ollama' => '1',
])->assertRedirect();
Queue::assertPushed(OllamaPostProcessJob::class);
}
public function test_upload_external_does_not_dispatch_ollama_without_checkbox(): void
{
Queue::fake();
config(['speech2text.ollama.enabled' => true]);
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$file = UploadedFile::fake()->createWithContent('t.txt', 'Teks biasa.');
$this->actingAs($owner)
->post(route('user.projects.transcript.upload', $project), [
'transcript_file' => $file,
])->assertRedirect();
Queue::assertNotPushed(OllamaPostProcessJob::class);
}
public function test_viewer_cannot_upload_external_transcript(): void
{
$owner = User::factory()->create();
$viewer = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $viewer, 'viewer');
$file = UploadedFile::fake()->createWithContent('t.txt', 'Cuba upload.');
$this->actingAs($viewer)
->post(route('user.projects.transcript.upload', $project), [
'transcript_file' => $file,
])->assertForbidden();
}
public function test_upload_rejects_non_txt_file(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$file = UploadedFile::fake()->create('teks.docx', 50, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$this->actingAs($owner)
->post(route('user.projects.transcript.upload', $project), [
'transcript_file' => $file,
])->assertSessionHasErrors('transcript_file');
}
// -------------------------------------------------------
// Version history & restore
// -------------------------------------------------------
public function test_owner_can_view_version_history(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner);
TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => $owner->id,
'version_number' => 1,
'old_text' => 'Lama.',
'new_text' => 'Baru.',
'change_summary' => 'Edit pertama',
'created_at' => now(),
]);
$this->actingAs($owner)
->get(route('user.projects.versions.index', $project))
->assertOk()
->assertViewIs('user.projects.versions')
->assertSee('Edit pertama');
}
public function test_admin_cannot_view_version_history(): void
{
$admin = User::factory()->admin()->create();
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($admin)
->get(route('user.projects.versions.index', $project))
->assertForbidden();
}
public function test_owner_can_restore_version(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner, ['transcript_text' => 'Teks semasa.']);
$version = TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => $owner->id,
'version_number' => 1,
'old_text' => 'Sebelum edit.',
'new_text' => 'Teks versi 1.',
'created_at' => now(),
]);
$this->actingAs($owner)
->post(route('user.projects.versions.restore', [$project, $version]))
->assertRedirect(route('user.projects.show', $project));
$project->refresh();
$this->assertEquals('Teks versi 1.', $project->transcript_text);
// Pemulihan cipta versi baru
$this->assertDatabaseHas('transcript_versions', [
'project_id' => $project->id,
'version_number' => 2,
'change_summary' => 'Dipulihkan dari versi 1',
]);
}
public function test_viewer_cannot_restore_version(): void
{
$owner = User::factory()->create();
$viewer = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $viewer, 'viewer');
$version = TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => $owner->id,
'version_number' => 1,
'old_text' => null,
'new_text' => 'Versi 1.',
'created_at' => now(),
]);
$this->actingAs($viewer)
->post(route('user.projects.versions.restore', [$project, $version]))
->assertForbidden();
}
// -------------------------------------------------------
// Comments
// -------------------------------------------------------
public function test_owner_can_add_comment(): void
{
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($owner)
->post(route('user.projects.comments.store', $project), [
'message' => 'Ini komen pertama saya.',
])->assertRedirect();
$this->assertDatabaseHas('project_comments', [
'project_id' => $project->id,
'user_id' => $owner->id,
'message' => 'Ini komen pertama saya.',
]);
}
public function test_collaborator_can_add_comment(): void
{
$owner = User::factory()->create();
$collab = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $collab, 'viewer');
$this->actingAs($collab)
->post(route('user.projects.comments.store', $project), [
'message' => 'Komen daripada viewer.',
])->assertRedirect();
$this->assertDatabaseHas('project_comments', [
'project_id' => $project->id,
'user_id' => $collab->id,
]);
}
public function test_non_collaborator_cannot_add_comment(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($other)
->post(route('user.projects.comments.store', $project), [
'message' => 'Cubaan haram.',
])->assertForbidden();
}
public function test_admin_cannot_add_comment(): void
{
$admin = User::factory()->admin()->create();
$owner = User::factory()->create();
$project = $this->makeProject($owner);
$this->actingAs($admin)
->post(route('user.projects.comments.store', $project), [
'message' => 'Admin cuba komen.',
])->assertForbidden();
}
public function test_owner_can_delete_any_comment_in_project(): void
{
$owner = User::factory()->create();
$collab = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $collab);
$comment = $project->comments()->create([
'user_id' => $collab->id,
'message' => 'Komen collaborator.',
]);
$this->actingAs($owner)
->delete(route('user.projects.comments.destroy', [$project, $comment]))
->assertRedirect();
$this->assertSoftDeleted('project_comments', ['id' => $comment->id]);
}
public function test_comment_author_can_delete_own_comment(): void
{
$owner = User::factory()->create();
$collab = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $collab);
$comment = $project->comments()->create([
'user_id' => $collab->id,
'message' => 'Komen saya sendiri.',
]);
$this->actingAs($collab)
->delete(route('user.projects.comments.destroy', [$project, $comment]))
->assertRedirect();
$this->assertSoftDeleted('project_comments', ['id' => $comment->id]);
}
public function test_non_author_cannot_delete_others_comment(): void
{
$owner = User::factory()->create();
$collab1 = User::factory()->create();
$collab2 = User::factory()->create();
$project = $this->makeProject($owner);
$this->addCollaborator($project, $collab1);
$this->addCollaborator($project, $collab2);
$comment = $project->comments()->create([
'user_id' => $collab1->id,
'message' => 'Milik collab1.',
]);
$this->actingAs($collab2)
->delete(route('user.projects.comments.destroy', [$project, $comment]))
->assertForbidden();
}
}

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