'Super Admin']); Role::firstOrCreate(['name' => 'admin']); Role::firstOrCreate(['name' => 'Project Manager']); Role::firstOrCreate(['name' => 'Main Contractor Admin']); Role::firstOrCreate(['name' => 'Construction Supervisor']); Role::firstOrCreate(['name' => 'Site Technical']); // Create test users $this->superAdmin = User::factory()->create(['user_type' => 'admin']); $this->superAdmin->assignRole('Super Admin'); $this->admin = User::factory()->create(['user_type' => 'admin']); $this->admin->assignRole('admin'); $this->projectManager = User::factory()->create(['user_type' => 'employee']); $this->projectManager->assignRole('Project Manager'); $this->contractorAdmin = User::factory()->create(['user_type' => 'contractor']); $this->contractorAdmin->assignRole('Main Contractor Admin'); $this->supervisor = User::factory()->create(['user_type' => 'employee']); $this->supervisor->assignRole('Construction Supervisor'); $this->siteTech = User::factory()->create(['user_type' => 'employee']); $this->siteTech->assignRole('Site Technical'); $permission = \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'projects.access']); $this->projectManager->givePermissionTo($permission); $this->admin->givePermissionTo($permission); $this->project = Project::factory()->create(); $this->project->personnel()->attach($this->supervisor->id, ['role' => 'supervisor']); } #[Test] public function supervisor_can_create_cash_advance_request() { $response = $this->actingAs($this->supervisor) ->post(route('cash-advances.store'), [ 'project_ulid' => $this->project->ulid, 'amount' => 500.00, 'reason' => 'Petty cash for emergency site fasteners', ]); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('cash_advances', [ 'amount' => 500.00, 'status' => 'pending', 'requested_by' => $this->supervisor->id, ]); } #[Test] public function project_manager_can_approve_cash_advance_request() { $cashAdvance = CashAdvance::create([ 'project_id' => $this->project->id, 'amount' => 500.00, 'reason' => 'Emergency fasteners', 'status' => 'pending', 'requested_by' => $this->supervisor->id, ]); $response = $this->actingAs($this->projectManager) ->patch(route('cash-advances.approve', $cashAdvance->ulid)); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('cash_advances', [ 'id' => $cashAdvance->id, 'status' => 'approved', 'approved_by' => $this->projectManager->id, ]); } #[Test] public function site_technical_cannot_approve_cash_advances() { $cashAdvance = CashAdvance::create([ 'project_id' => $this->project->id, 'amount' => 500.00, 'reason' => 'Emergency fasteners', 'status' => 'pending', 'requested_by' => $this->supervisor->id, ]); // Attempting approval as Site Technical should not approve $response = $this->actingAs($this->siteTech) ->patch(route('cash-advances.approve', $cashAdvance->ulid)); // Status remains pending $this->assertDatabaseHas('cash_advances', [ 'id' => $cashAdvance->id, 'status' => 'pending', ]); } #[Test] public function admin_and_super_admin_can_approve_cash_advances() { $cashAdvance = CashAdvance::create([ 'project_id' => $this->project->id, 'amount' => 300.00, 'reason' => 'Local refreshments', 'status' => 'pending', 'requested_by' => $this->supervisor->id, ]); $response = $this->actingAs($this->superAdmin) ->patch(route('cash-advances.approve', $cashAdvance->ulid)); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('cash_advances', [ 'id' => $cashAdvance->id, 'status' => 'approved', 'approved_by' => $this->superAdmin->id, ]); } #[Test] public function user_cannot_self_approve_cash_advance() { $cashAdvance = CashAdvance::create([ 'project_id' => $this->project->id, 'amount' => 400.00, 'reason' => 'Self request test', 'status' => 'pending', 'requested_by' => $this->projectManager->id, ]); $response = $this->actingAs($this->projectManager) ->patch(route('cash-advances.approve', $cashAdvance->ulid)); $this->assertDatabaseHas('cash_advances', [ 'id' => $cashAdvance->id, 'status' => 'pending', ]); } #[Test] public function supervisor_cannot_approve_submitted_invoice() { $invoice = \Modules\FinancialManagement\Models\FinancialInvoice::create([ 'project_id' => $this->project->id, 'invoice_number' => 'INV-TEST-001', 'status' => 'submitted', 'subtotal' => 100000, 'retention_rate' => 10, 'retention_amount' => 10000, 'total_amount' => 90000, 'billed_percentage' => 10, 'invoice_date' => now(), ]); $response = $this->actingAs($this->supervisor) ->patch(route('finance.approve', $invoice->ulid)); $this->assertDatabaseHas('financial_invoices', [ 'id' => $invoice->id, 'status' => 'submitted', ]); } #[Test] public function super_admin_and_admin_can_approve_submitted_invoice() { $invoice = \Modules\FinancialManagement\Models\FinancialInvoice::create([ 'project_id' => $this->project->id, 'invoice_number' => 'INV-TEST-002', 'status' => 'submitted', 'subtotal' => 100000, 'retention_rate' => 10, 'retention_amount' => 10000, 'total_amount' => 90000, 'billed_percentage' => 10, 'invoice_date' => now(), ]); $response = $this->actingAs($this->superAdmin) ->patch(route('finance.approve', $invoice->ulid)); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('financial_invoices', [ 'id' => $invoice->id, 'status' => 'approved', ]); } #[Test] public function project_wizard_submission_by_pm_automatically_targets_executive_roles() { $response = $this->actingAs($this->projectManager) ->post(route('projects.wizard.submit', $this->project->ulid), [ 'notes' => 'PM project wizard setup ready for higher-up signoff', ]); $response->assertSessionHasNoErrors(); $response->assertRedirect(route('projects.show', $this->project->ulid)); // Verify approval chain created and assigned to Admin/SuperAdmin $this->assertDatabaseHas('approval_chains', [ 'approvable_type' => $this->project->getMorphClass(), 'approvable_id' => $this->project->id, 'type' => 'project_estimation', 'status' => 'in_review', ]); } #[Test] public function admin_project_submission_escalates_to_super_admin_for_approval() { $response = $this->actingAs($this->admin) ->post(route('projects.wizard.submit', $this->project->ulid), [ 'notes' => 'Admin submission requiring Super Admin review', ]); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('approval_chains', [ 'approvable_id' => $this->project->id, 'type' => 'project_estimation', ]); } #[Test] public function all_roles_can_access_their_permissioned_pages_without_errors() { // Give permissions to roles $permission = \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'finance.access']); $this->projectManager->givePermissionTo($permission); // Test PM navigation $this->actingAs($this->projectManager) ->get(route('finance.index')) ->assertStatus(200); $this->actingAs($this->projectManager) ->get(route('retention.index')) ->assertStatus(200); // Test Super Admin navigation $this->actingAs($this->superAdmin) ->get(route('finance.index')) ->assertStatus(200); $this->actingAs($this->superAdmin) ->get(route('approvals.index')) ->assertStatus(200); } #[Test] public function test_comprehensive_all_roles_all_modules_page_access_and_crud_integrity(): void { // Grant permissions matching RolesPermissionsDatabaseSeeder $permissions = [ 'dashboard.access', 'projects.access', 'contractors.access', 'users.access', 'materials-catalog.access', 'inventory.access', 'finance.access', 'documents.access', 'approvals.access', 'roles.access', 'labors.access', 'equipments.access' ]; foreach ($permissions as $p) { \Spatie\Permission\Models\Permission::firstOrCreate(['name' => $p]); } $this->projectManager->syncPermissions([ 'dashboard.access', 'projects.access', 'contractors.access', 'materials-catalog.access', 'inventory.access', 'finance.access', 'documents.access', 'approvals.access', 'labors.access', 'equipments.access' ]); $this->contractorAdmin->syncPermissions([ 'dashboard.access', 'projects.access', 'finance.access', 'documents.access' ]); $this->supervisor->syncPermissions([ 'dashboard.access', 'projects.access', 'inventory.access', 'materials-catalog.access', 'documents.access' ]); $this->siteTech->syncPermissions([ 'dashboard.access', 'projects.access', 'inventory.access', 'documents.access' ]); // 1. Super Admin Page Access & CRUD Across All Modules $this->actingAs($this->superAdmin); $superAdminRoutes = [ 'dashboard', 'projects.index', 'contractors.index', 'users.index', 'materials-catalog.items.index', 'inventory.index', 'labors.index', 'equipments.index', 'resources.index', 'finance.index', 'retention.index', 'cash-advances.index', 'documents.index', 'approvals.index', 'rolespermissions.index', 'purchase-orders.index', 'requisitions.index' ]; foreach ($superAdminRoutes as $routeName) { $response = $this->get(route($routeName)); $response->assertStatus(200); } // 2. Project Manager Page Access Across Permissioned Modules $this->actingAs($this->projectManager); $pmRoutes = [ 'dashboard', 'projects.index', 'materials-catalog.items.index', 'inventory.index', 'labors.index', 'equipments.index', 'finance.index', 'retention.index', 'documents.index', 'approvals.index' ]; foreach ($pmRoutes as $routeName) { $response = $this->get(route($routeName)); $response->assertStatus(200); } // 3. Contractor Admin Page Access $this->actingAs($this->contractorAdmin); $contractorRoutes = ['dashboard', 'projects.index', 'finance.index', 'documents.index']; foreach ($contractorRoutes as $routeName) { $response = $this->get(route($routeName)); $response->assertStatus(200); } // 4. Construction Supervisor Page Access $this->actingAs($this->supervisor); $supervisorRoutes = ['dashboard', 'projects.index', 'inventory.index', 'materials-catalog.items.index', 'documents.index', 'cash-advances.index']; foreach ($supervisorRoutes as $routeName) { $response = $this->get(route($routeName)); $response->assertStatus(200); } // 5. Site Technical Page Access $this->actingAs($this->siteTech); $siteTechRoutes = ['dashboard', 'projects.index', 'inventory.index', 'documents.index', 'cash-advances.index']; foreach ($siteTechRoutes as $routeName) { $response = $this->get(route($routeName)); $response->assertStatus(200); } } #[Test] public function test_distinct_role_dashboards_render_correct_role_analytics_payload(): void { // 1. Super Admin Dashboard Payload $response = $this->actingAs($this->superAdmin)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Super Admin') ->has('roleAnalytics.financials') ->has('roleAnalytics.approvals') ); // 2. Project Manager Dashboard Payload $response = $this->actingAs($this->projectManager)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Project Manager') ->has('roleAnalytics.milestones') ); // 3. Contractor Admin Dashboard Payload $response = $this->actingAs($this->contractorAdmin)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Main Contractor Admin') ->has('roleAnalytics.financials') ); // 4. Supervisor Dashboard Payload $response = $this->actingAs($this->supervisor)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Construction Supervisor') ->has('roleAnalytics.logistics') ); } #[Test] public function test_custom_or_newly_created_roles_dynamically_resolve_assigned_dashboard(): void { // 1. Custom Contractor Role -> Contractor Financials Dashboard $newContractorRole = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Subcontractor Estimator']); $contractorUser = \App\Models\User::factory()->create([ 'user_type' => 'contractor', 'status' => 'active', ]); $contractorUser->assignRole($newContractorRole); $response = $this->actingAs($contractorUser)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Subcontractor Estimator') ->where('roleAnalytics.user_type', 'contractor') ->has('roleAnalytics.financials') ); // 2. Custom Executive Admin Role -> Executive Governance Dashboard $newExecutiveRole = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Regional Admin Officer']); $executiveUser = \App\Models\User::factory()->create([ 'user_type' => 'admin', 'status' => 'active', ]); $executiveUser->assignRole($newExecutiveRole); $response = $this->actingAs($executiveUser)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Regional Admin Officer') ->where('roleAnalytics.user_type', 'admin') ->has('roleAnalytics.financials') ->has('roleAnalytics.approvals') ); // 3. Custom Project Manager Role -> Project Operations Dashboard $newPMRole = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Senior Project Manager']); $pmUser = \App\Models\User::factory()->create([ 'user_type' => 'employee', 'status' => 'active', ]); $pmUser->assignRole($newPMRole); $response = $this->actingAs($pmUser)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Senior Project Manager') ->where('roleAnalytics.user_type', 'employee') ->has('roleAnalytics.milestones') ); // 4. Custom Site Officer Role -> Site Execution Dashboard $newSiteRole = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Chief Safety Officer']); $siteUser = \App\Models\User::factory()->create([ 'user_type' => 'employee', 'status' => 'active', ]); $siteUser->assignRole($newSiteRole); $response = $this->actingAs($siteUser)->get(route('dashboard')); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('roleAnalytics.role', 'Chief Safety Officer') ->where('roleAnalytics.user_type', 'employee') ); } #[Test] public function test_resource_rollcall_widget_reflects_daily_report_labor_and_equipment_logs() { // 1. Create Daily Report with Labor & Equipment logs for test project $dailyReport = \Modules\DailyReports\Models\DailyReport::create([ 'project_id' => $this->project->id, 'user_id' => $this->siteTech->id, 'report_number' => 'DR-TEST-999', 'report_date' => now()->toDateString(), 'weather' => 'Sunny', 'notes' => 'Test daily report', ]); \Modules\DailyReports\Models\DailyReportLabor::create([ 'daily_report_id' => $dailyReport->id, 'trade' => 'Steel Fixers', 'workers_count' => 18, 'hours_worked' => 8, ]); \Modules\DailyReports\Models\DailyReportLabor::create([ 'daily_report_id' => $dailyReport->id, 'trade' => 'Masons', 'workers_count' => 12, 'hours_worked' => 8, ]); \Modules\DailyReports\Models\DailyReportEquipment::create([ 'daily_report_id' => $dailyReport->id, 'equipment_name' => 'Caterpillar 320 Excavator', 'status' => 'active', 'hours_used' => 8, ]); \Modules\DailyReports\Models\DailyReportEquipment::create([ 'daily_report_id' => $dailyReport->id, 'equipment_name' => 'Tower Crane 1', 'status' => 'maintenance', 'hours_used' => 0, ]); // 2. Query Dashboard as Project Manager $response = $this->actingAs($this->projectManager) ->get(route('dashboard', ['project' => $this->project->ulid])); $response->assertStatus(200) ->assertInertia(fn ($page) => $page ->component('Dashboard') ->where('resources.labor.actual', 30) ->where('resources.labor.trades.Steel Fixers', 18) ->where('resources.labor.trades.Masons', 12) ->where('resources.equipment.active', 1) ->where('resources.equipment.maintenance', 1) ); } public function test_two_step_executive_payment_release_and_contractor_receipt_confirmation(): void { $perm = \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'finance.access']); $this->superAdmin->givePermissionTo($perm); $this->contractorAdmin->givePermissionTo($perm); $invoice = \Modules\FinancialManagement\Models\FinancialInvoice::create([ 'project_id' => $this->project->id, 'invoice_number' => 'INV-TEST-001', 'status' => 'approved', 'billed_percentage' => 50, 'subtotal' => 100000, 'retention_amount' => 10000, 'total_amount' => 90000, 'paid_amount' => 0, 'invoice_date' => now()->toDateString(), ]); // 1. Super Admin releases payment $response = $this->actingAs($this->superAdmin) ->post(route('finance.payment', $invoice->ulid), ['amount' => 90000]); $response->assertRedirect(); $invoice->refresh(); $this->assertEquals('payment_sent', $invoice->status->value); $this->assertEquals(90000, (float) $invoice->paid_amount); // Verify summary outstanding remains because contractor has not confirmed $summaryResp = $this->actingAs($this->superAdmin)->get(route('finance.index')); $summaryResp->assertStatus(200) ->assertInertia(fn ($page) => $page ->where('summary.total_paid', 0) ->where('summary.outstanding', 90000) ); // 2. Contractor confirms payment receipt $confirmResp = $this->actingAs($this->contractorAdmin) ->patch(route('finance.confirm-payment', $invoice->ulid)); $confirmResp->assertRedirect(); $invoice->refresh(); $this->assertEquals('paid', $invoice->status->value); $this->assertNotNull($invoice->paid_at); // Verify summary total_paid updates to 90,000 once confirmed $updatedSummaryResp = $this->actingAs($this->superAdmin)->get(route('finance.index')); $updatedSummaryResp->assertStatus(200) ->assertInertia(fn ($page) => $page ->where('summary.total_paid', 90000) ->where('summary.outstanding', 0) ); } public function test_two_step_retention_payment_release_and_contractor_confirmation(): void { $perm = \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'finance.access']); $this->superAdmin->givePermissionTo($perm); $this->contractorAdmin->givePermissionTo($perm); $retentionEntry = \Modules\FinancialManagement\Models\RetentionEntry::create([ 'project_id' => $this->project->id, 'type' => 'credit', 'status' => 'submitted', 'amount' => 50000, 'description' => 'Retention release request', ]); // 1. Super Admin marks retention as paid (transitions to payment_sent) $resp = $this->actingAs($this->superAdmin) ->patch(route('retention.paid', $retentionEntry->ulid)); $resp->assertRedirect(); $retentionEntry->refresh(); $this->assertEquals('payment_sent', $retentionEntry->status); // 2. Contractor confirms receipt of retention payment $confirmResp = $this->actingAs($this->contractorAdmin) ->patch(route('retention.confirm', $retentionEntry->ulid)); $confirmResp->assertRedirect(); $retentionEntry->refresh(); $this->assertEquals('paid', $retentionEntry->status); } public function test_executive_roles_forbidden_from_confirming_payment_receipt(): void { $perm = \Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'finance.access']); $this->superAdmin->givePermissionTo($perm); $this->admin->givePermissionTo($perm); $retentionEntry = \Modules\FinancialManagement\Models\RetentionEntry::create([ 'project_id' => $this->project->id, 'type' => 'credit', 'status' => 'payment_sent', 'amount' => 25000, 'description' => 'Retention pending confirmation', ]); $invoice = \Modules\FinancialManagement\Models\FinancialInvoice::create([ 'project_id' => $this->project->id, 'invoice_number' => 'INV-EXEC-DENY', 'status' => 'payment_sent', 'billed_percentage' => 100, 'subtotal' => 50000, 'retention_amount' => 5000, 'total_amount' => 45000, 'paid_amount' => 45000, 'invoice_date' => now()->toDateString(), ]); // Super Admin attempts to confirm retention receipt -> 403 Forbidden $superAdminRetResp = $this->actingAs($this->superAdmin) ->patch(route('retention.confirm', $retentionEntry->ulid)); $superAdminRetResp->assertStatus(403); // Admin attempts to confirm invoice receipt -> 403 Forbidden $adminInvResp = $this->actingAs($this->admin) ->patch(route('finance.confirm-payment', $invoice->ulid)); $adminInvResp->assertStatus(403); } public function test_contractor_project_manager_has_operational_access_but_forbidden_from_approvals(): void { $permissions = [ 'dashboard.access', 'projects.access', 'materials-catalog.access', 'inventory.access', 'documents.access', 'labors.access', 'equipments.access', 'create tasks', 'edit tasks', 'delete tasks', ]; foreach ($permissions as $p) { \Spatie\Permission\Models\Permission::firstOrCreate(['name' => $p]); } $contractorRole = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Contractor Project Manager']); $contractorRole->syncPermissions($permissions); $contractor = \Modules\ContractorManagement\Models\Contractor::create([ 'company_name' => 'Apex Build Subcontractor Corp', 'contact_person' => 'Subcon Manager', 'email' => 'subcon@apex.test', 'phone' => '09123456789', 'status' => 'active', ]); $contractorPM = \App\Models\User::factory()->create([ 'user_type' => 'contractor', 'contractor_id' => $contractor->id, 'status' => 'active', ]); $contractorPM->assignRole($contractorRole); // 1. Can access operational pages (Dashboard, Projects, Materials Catalog, Documents, Labors, Equipments) $this->actingAs($contractorPM); $operationalRoutes = [ 'dashboard', 'projects.index', 'materials-catalog.items.index', 'documents.index', 'labors.index', 'equipments.index', ]; foreach ($operationalRoutes as $route) { $resp = $this->get(route($route)); $resp->assertStatus(200); } // 2. Forbidden from Financial Approval Actions & Chain Approvals (No finance.access or approvals.access) $invoice = \Modules\FinancialManagement\Models\FinancialInvoice::create([ 'project_id' => $this->project->id, 'contractor_id' => $contractor->id, 'invoice_number' => 'INV-CPM-TEST', 'status' => 'submitted', 'billed_percentage' => 10, 'subtotal' => 20000, 'retention_amount' => 2000, 'total_amount' => 18000, 'invoice_date' => now()->toDateString(), ]); // Attempt invoice approval -> Forbidden $approveResp = $this->patch(route('finance.approve', $invoice->ulid)); $this->assertEquals(\Modules\FinancialManagement\Enums\InvoiceStatus::Submitted, $invoice->fresh()->status); // Attempt accessing approvals page -> Forbidden $approvalIndexResp = $this->get(route('approvals.index')); $approvalIndexResp->assertForbidden(); } }