Files
GSB-Construction/tests/Feature/RoleBasedActionTest.php

735 lines
28 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\FinancialManagement\Models\CashAdvance;
use Modules\MaterialLogistics\Models\MaterialRequisition;
use Modules\MaterialLogistics\Models\PurchaseOrder;
use Modules\ProjectManagement\Models\Project;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class RoleBasedActionTest extends TestCase
{
use RefreshDatabase;
protected User $superAdmin;
protected User $admin;
protected User $projectManager;
protected User $contractorAdmin;
protected User $supervisor;
protected User $siteTech;
protected Project $project;
protected function setUp(): void
{
parent::setUp();
// Create roles
Role::firstOrCreate(['name' => '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();
}
}