first
This commit is contained in:
257
tests/Feature/Admin/Phase5Test.php
Normal file
257
tests/Feature/Admin/Phase5Test.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\TranscriptionProject;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Phase5Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeAdmin(): User
|
||||
{
|
||||
return User::factory()->admin()->create();
|
||||
}
|
||||
|
||||
private function makeUser(): User
|
||||
{
|
||||
return User::factory()->create();
|
||||
}
|
||||
|
||||
private function makeProject(User $owner, array $attrs = []): TranscriptionProject
|
||||
{
|
||||
return TranscriptionProject::factory()->create(array_merge([
|
||||
'owner_user_id' => $owner->id,
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Dashboard
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_dashboard_loads_with_full_stats(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertOk()
|
||||
->assertViewIs('admin.dashboard')
|
||||
->assertViewHas('stats')
|
||||
->assertViewHas('topUsers')
|
||||
->assertViewHas('trendRaw')
|
||||
->assertViewHas('deptStats');
|
||||
}
|
||||
|
||||
public function test_user_cannot_access_admin_dashboard(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_dashboard_shows_storage_stats(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
|
||||
TranscriptionProject::factory()->create([
|
||||
'owner_user_id' => $owner->id,
|
||||
'file_size' => 10485760, // 10 MB
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertOk();
|
||||
|
||||
$stats = $response->viewData('stats');
|
||||
$this->assertGreaterThanOrEqual(10485760, $stats['storage_bytes']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Transfer Ownership
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_can_transfer_project_ownership(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
$newOwner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => $newOwner->id,
|
||||
'justification' => 'Pekerja telah berpindah jabatan dan projek perlu dipindahkan.',
|
||||
])->assertRedirect(route('admin.projects.index'));
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals($newOwner->id, $project->owner_user_id);
|
||||
}
|
||||
|
||||
public function test_transfer_ownership_creates_audit_log(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
$newOwner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => $newOwner->id,
|
||||
'justification' => 'Sebab pemindahan yang sah dan lengkap.',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'project_ownership_transferred',
|
||||
'project_id' => $project->id,
|
||||
'target_user_id' => $newOwner->id,
|
||||
]);
|
||||
|
||||
$log = AuditLog::where('action', 'project_ownership_transferred')->first();
|
||||
$this->assertStringContainsString('Sebab pemindahan', $log->justification);
|
||||
}
|
||||
|
||||
public function test_user_cannot_transfer_ownership(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
$owner = $this->makeUser();
|
||||
$newOwner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => $newOwner->id,
|
||||
'justification' => 'Cuba pindah oleh pengguna biasa.',
|
||||
])->assertForbidden();
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals($owner->id, $project->owner_user_id);
|
||||
}
|
||||
|
||||
public function test_transfer_requires_justification(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
$newOwner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => $newOwner->id,
|
||||
'justification' => 'Singkat', // less than 10 chars
|
||||
])->assertSessionHasErrors('justification');
|
||||
|
||||
$project->refresh();
|
||||
$this->assertEquals($owner->id, $project->owner_user_id);
|
||||
}
|
||||
|
||||
public function test_transfer_requires_valid_new_owner(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => 99999,
|
||||
'justification' => 'Justifikasi yang sah untuk pemindahan projek ini.',
|
||||
])->assertSessionHasErrors('new_owner_id');
|
||||
}
|
||||
|
||||
public function test_cannot_transfer_to_admin_user(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$admin2 = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => $admin2->id,
|
||||
'justification' => 'Justifikasi yang sah untuk pemindahan projek ini.',
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_cannot_transfer_to_same_owner(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
$owner = $this->makeUser();
|
||||
$project = $this->makeProject($owner);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.projects.transfer-owner', $project), [
|
||||
'new_owner_id' => $owner->id,
|
||||
'justification' => 'Justifikasi yang sah untuk pemindahan projek ini.',
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Audit Log Export
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_can_export_audit_log_csv(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
|
||||
AuditLog::create([
|
||||
'actor_user_id' => $admin->id,
|
||||
'actor_role' => 'admin',
|
||||
'action' => 'user_created',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->get(route('admin.audit-logs.export'));
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('text/csv', $response->headers->get('Content-Type'));
|
||||
$this->assertStringContainsString('audit-log-', $response->headers->get('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function test_user_cannot_export_audit_log(): void
|
||||
{
|
||||
$user = $this->makeUser();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.audit-logs.export'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_audit_log_export_respects_filters(): void
|
||||
{
|
||||
$admin = $this->makeAdmin();
|
||||
|
||||
AuditLog::create([
|
||||
'actor_user_id' => $admin->id,
|
||||
'actor_role' => 'admin',
|
||||
'action' => 'user_activated',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::create([
|
||||
'actor_user_id' => $admin->id,
|
||||
'actor_role' => 'admin',
|
||||
'action' => 'user_deactivated',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->get(route('admin.audit-logs.export', ['action' => 'user_activated']));
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->streamedContent();
|
||||
$this->assertStringContainsString('user_activated', $content);
|
||||
$this->assertStringNotContainsString('user_deactivated', $content);
|
||||
}
|
||||
}
|
||||
166
tests/Feature/Admin/UserManagementTest.php
Normal file
166
tests/Feature/Admin/UserManagementTest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Access Control
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_regular_user_cannot_access_admin_user_list(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->get(route('admin.users.index'))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_view_user_list(): void
|
||||
{
|
||||
$this->actingAs($this->admin)
|
||||
->get(route('admin.users.index'))
|
||||
->assertOk()
|
||||
->assertViewIs('admin.users.index');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Create User
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_can_create_user(): void
|
||||
{
|
||||
$this->actingAs($this->admin)
|
||||
->post(route('admin.users.store'), [
|
||||
'name' => 'Pengguna Baru',
|
||||
'email' => 'baru@mbip.my',
|
||||
'password' => 'Password@123',
|
||||
'password_confirmation' => 'Password@123',
|
||||
'role' => 'user',
|
||||
'department_id' => null,
|
||||
])->assertRedirect(route('admin.users.index'));
|
||||
|
||||
$this->assertDatabaseHas('users', ['email' => 'baru@mbip.my', 'role' => 'user']);
|
||||
$this->assertDatabaseHas('audit_logs', ['action' => 'user_created']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Deactivate / Activate
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_can_deactivate_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch(route('admin.users.deactivate', $user))
|
||||
->assertRedirect(route('admin.users.index'));
|
||||
|
||||
$this->assertFalse($user->fresh()->is_active);
|
||||
$this->assertDatabaseHas('audit_logs', ['action' => 'user_deactivated', 'target_user_id' => $user->id]);
|
||||
}
|
||||
|
||||
public function test_admin_can_activate_user(): void
|
||||
{
|
||||
$user = User::factory()->inactive()->create();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch(route('admin.users.activate', $user))
|
||||
->assertRedirect(route('admin.users.index'));
|
||||
|
||||
$this->assertTrue($user->fresh()->is_active);
|
||||
$this->assertDatabaseHas('audit_logs', ['action' => 'user_reactivated']);
|
||||
}
|
||||
|
||||
public function test_deactivated_user_cannot_login(): void
|
||||
{
|
||||
$user = User::factory()->inactive()->create();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Email Change
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_can_change_user_email(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'lama@mbip.my']);
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->patch(route('admin.users.update-email', $user), [
|
||||
'email' => 'baru@mbip.my',
|
||||
'justification' => 'Pertukaran emel rasmi',
|
||||
])->assertRedirect(route('admin.users.index'));
|
||||
|
||||
$this->assertEquals('baru@mbip.my', $user->fresh()->email);
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'user_email_changed',
|
||||
'target_user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Delete
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_can_delete_user_without_usage(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->delete(route('admin.users.destroy', $user))
|
||||
->assertRedirect(route('admin.users.index'));
|
||||
|
||||
$this->assertNull(User::find($user->id));
|
||||
$this->assertDatabaseHas('audit_logs', ['action' => 'user_deleted']);
|
||||
}
|
||||
|
||||
public function test_admin_cannot_delete_user_with_usage(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Cipta audit log untuk simulasi usage
|
||||
AuditLog::create([
|
||||
'actor_user_id' => $user->id,
|
||||
'actor_role' => 'user',
|
||||
'action' => 'project_created',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->delete(route('admin.users.destroy', $user))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertNotNull(User::find($user->id));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Admin cannot access own account
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_admin_cannot_deactivate_self(): void
|
||||
{
|
||||
$this->actingAs($this->admin)
|
||||
->patch(route('admin.users.deactivate', $this->admin))
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
68
tests/Feature/Auth/LoginTest.php
Normal file
68
tests/Feature/Auth/LoginTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_page_loads(): void
|
||||
{
|
||||
$this->get(route('login'))->assertOk()->assertViewIs('auth.login');
|
||||
}
|
||||
|
||||
public function test_admin_redirected_to_admin_dashboard(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'email' => $admin->email,
|
||||
'password' => 'password',
|
||||
])->assertRedirect(route('admin.dashboard'));
|
||||
}
|
||||
|
||||
public function test_user_redirected_to_user_dashboard(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertRedirect(route('user.dashboard'));
|
||||
}
|
||||
|
||||
public function test_deactivated_user_cannot_login(): void
|
||||
{
|
||||
$user = User::factory()->inactive()->create();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertSessionHasErrors('email');
|
||||
}
|
||||
|
||||
public function test_wrong_password_fails(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post(route('login'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
])->assertSessionHasErrors('email');
|
||||
}
|
||||
|
||||
public function test_logout_works(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('logout'))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
13
tests/Feature/ExampleTest.php
Normal file
13
tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
public function test_root_redirects_to_login_for_guests(): void
|
||||
{
|
||||
$this->get('/')->assertRedirect(route('login'));
|
||||
}
|
||||
}
|
||||
245
tests/Feature/Project/ProjectAccessTest.php
Normal file
245
tests/Feature/Project/ProjectAccessTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
463
tests/Feature/Project/TranscriptEditorTest.php
Normal file
463
tests/Feature/Project/TranscriptEditorTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
265
tests/Feature/Security/Phase6Test.php
Normal file
265
tests/Feature/Security/Phase6Test.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Security;
|
||||
|
||||
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 Phase6Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------
|
||||
|
||||
private function makeMp3File(string $name = 'audio.mp3'): array
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'mp3_');
|
||||
// MPEG-1 Layer 3 frame sync word: FF FB = 11111111 11111011
|
||||
// Followed by header byte 0x90 (128kbps, 44100Hz) and frame data
|
||||
file_put_contents($tmpPath, "\xFF\xFB\x90\x00" . str_repeat("\xFF", 512));
|
||||
return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/mpeg', null, true)];
|
||||
}
|
||||
|
||||
private function makeWavFile(string $name = 'audio.wav'): array
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'wav_');
|
||||
// WAV: RIFF header 0x52 0x49 0x46 0x46
|
||||
file_put_contents($tmpPath, "\x52\x49\x46\x46\x00\x00\x00\x00\x57\x41\x56\x45" . str_repeat("\x00", 512));
|
||||
return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/wav', null, true)];
|
||||
}
|
||||
|
||||
private function makeInvalidFile(string $name = 'malicious.mp3'): array
|
||||
{
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'fake_');
|
||||
file_put_contents($tmpPath, "<?php echo 'malicious'; ?>" . str_repeat("X", 512));
|
||||
return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/mpeg', null, true)];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Security Headers
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_security_headers_present_on_web_response(): void
|
||||
{
|
||||
$response = $this->get(route('login'));
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
$response->assertHeader('X-XSS-Protection', '1; mode=block');
|
||||
$response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
}
|
||||
|
||||
public function test_csp_and_permissions_headers_present(): void
|
||||
{
|
||||
$response = $this->get(route('login'));
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertNotEmpty($response->headers->get('Content-Security-Policy'));
|
||||
$this->assertNotEmpty($response->headers->get('Permissions-Policy'));
|
||||
}
|
||||
|
||||
public function test_security_headers_present_on_authenticated_response(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('user.dashboard'));
|
||||
$response->assertOk();
|
||||
|
||||
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
public function test_csp_blocks_framing_from_other_origins(): void
|
||||
{
|
||||
$csp = $this->get(route('login'))->headers->get('Content-Security-Policy');
|
||||
|
||||
$this->assertStringContainsString("frame-ancestors", $csp === null ? '' : $csp);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Magic Bytes Validation
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_upload_accepts_valid_mp3_magic_bytes(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$tmpPath, $file] = $this->makeMp3File();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Audio MP3 Sah',
|
||||
'audio' => $file,
|
||||
])->assertRedirect();
|
||||
|
||||
Queue::assertPushed(\App\Jobs\TranscribeAudioJob::class);
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
public function test_upload_accepts_valid_wav_magic_bytes(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$tmpPath, $file] = $this->makeWavFile();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Audio WAV Sah',
|
||||
'audio' => $file,
|
||||
])->assertRedirect();
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
public function test_upload_rejects_file_with_invalid_magic_bytes(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$tmpPath, $file] = $this->makeInvalidFile();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Fail Palsu',
|
||||
'audio' => $file,
|
||||
])->assertSessionHasErrors('audio');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
Storage::disk('private')->assertDirectoryEmpty('transcriptions');
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
public function test_upload_rejects_polyglot_file_with_valid_extension_wrong_content(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'poly_');
|
||||
// HTML disguised as MP3
|
||||
file_put_contents($tmpPath, '<html><body>XSS</body></html>' . str_repeat('X', 512));
|
||||
$file = new UploadedFile($tmpPath, 'audio.mp3', 'audio/mpeg', null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('user.projects.store'), [
|
||||
'title' => 'Polyglot',
|
||||
'audio' => $file,
|
||||
])->assertSessionHasErrors('audio');
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Rate Limiting — Login (5 per minute)
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_login_throttled_after_5_failed_attempts(): void
|
||||
{
|
||||
User::factory()->create(['email' => 'pengguna@test.com']);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/login', [
|
||||
'email' => 'pengguna@test.com',
|
||||
'password' => 'kata-laluan-salah',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => 'pengguna@test.com',
|
||||
'password' => 'kata-laluan-salah',
|
||||
])->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_login_allows_successful_login_within_limit(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'sah@test.com',
|
||||
'password' => bcrypt('kata-laluan-betul'),
|
||||
]);
|
||||
|
||||
// 2 failed attempts
|
||||
$this->post('/login', ['email' => 'sah@test.com', 'password' => 'salah']);
|
||||
$this->post('/login', ['email' => 'sah@test.com', 'password' => 'salah']);
|
||||
|
||||
// Successful login still works under limit
|
||||
$this->post('/login', [
|
||||
'email' => 'sah@test.com',
|
||||
'password' => 'kata-laluan-betul',
|
||||
])->assertRedirect();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Rate Limiting — Upload (10 per hour)
|
||||
// -------------------------------------------------------
|
||||
|
||||
public function test_upload_throttled_after_10_requests(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 10 requests — will fail validation (no valid magic bytes) but throttle still counts
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->actingAs($user)->post(route('user.projects.store'), [
|
||||
'title' => "Projek {$i}",
|
||||
'audio' => UploadedFile::fake()->create("audio-{$i}.mp3", 1, 'audio/mpeg'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 11th request must be throttled regardless of payload
|
||||
$this->actingAs($user)->post(route('user.projects.store'), [
|
||||
'title' => 'Projek 11',
|
||||
'audio' => UploadedFile::fake()->create('audio.mp3', 1, 'audio/mpeg'),
|
||||
])->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_upload_throttle_is_per_user(): void
|
||||
{
|
||||
Storage::fake('private');
|
||||
Queue::fake();
|
||||
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
|
||||
// Exhaust userA's quota
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->actingAs($userA)->post(route('user.projects.store'), [
|
||||
'title' => "A-{$i}",
|
||||
'audio' => UploadedFile::fake()->create("a-{$i}.mp3", 1, 'audio/mpeg'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->actingAs($userA)->post(route('user.projects.store'), [
|
||||
'title' => 'A-11',
|
||||
'audio' => UploadedFile::fake()->create('a.mp3', 1, 'audio/mpeg'),
|
||||
])->assertStatus(429);
|
||||
|
||||
// userB's quota is independent — not throttled
|
||||
[$tmpPath, $file] = $this->makeMp3File('b.mp3');
|
||||
$this->actingAs($userB)->post(route('user.projects.store'), [
|
||||
'title' => 'B-1',
|
||||
'audio' => $file,
|
||||
])->assertRedirect();
|
||||
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user