diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 2972898..092ddba 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -637,7 +637,8 @@ class SpecialtyModuleController extends Controller $services = $shell->provisionedServices($organization, $module); $permissions = app(\App\Services\Care\CarePermissions::class); - // Match every ability that mutate routes actually check — module canManage alone is not enough. + // Ability-based for every role (doctor, nurse, receptionist, lab, pharmacist, …). + // Module canManage alone is not enough — flags must match what mutate routes authorize. $canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty; $canAdvanceStage = $canConsult; $canStartConsultation = $canConsult; diff --git a/tests/Feature/CareSpecialtyAccessTest.php b/tests/Feature/CareSpecialtyAccessTest.php index 87cd975..0521b52 100644 --- a/tests/Feature/CareSpecialtyAccessTest.php +++ b/tests/Feature/CareSpecialtyAccessTest.php @@ -13,6 +13,7 @@ use App\Models\Practitioner; use App\Models\User; use App\Services\Care\SpecialtyModuleService; use Illuminate\Foundation\Testing\RefreshDatabase; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class CareSpecialtyAccessTest extends TestCase @@ -397,33 +398,115 @@ class CareSpecialtyAccessTest extends TestCase return [$user, $member, $visit]; } - public function test_nurse_on_dentistry_does_not_see_consultation_mutate_actions(): void + /** + * @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit} + */ + protected function emergencyVisitForRole(string $role, string $suffix): array { - [$nurseUser, $nurseMember, $visit] = $this->dentistryVisitForRole('nurse', 'den-nurse'); + [$user, $member] = $this->makeStaff($role, $suffix); - $this->assertTrue($this->modules->memberCanManage($this->organization, $nurseMember, 'dentistry')); - $this->assertFalse(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'consultations.manage')); + $dept = Department::query()->where('type', 'emergency')->firstOrFail(); + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-ER-'.$suffix, + 'first_name' => 'Kojo', + 'last_name' => 'Floor', + 'gender' => 'male', + 'date_of_birth' => '1988-01-01', + ]); + $visit = \App\Models\Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => \App\Models\Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + 'specialty_stage' => 'arrival', + ]); + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'department_id' => $dept->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'waiting_at' => now(), + ]); - $html = $this->actingAs($nurseUser) - ->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview'])) + return [$user, $member, $visit]; + } + + /** + * Roles that can manage a specialty module but lack consultations.manage must not see + * Start / stage / chart-mutate CTAs. Gating is ability-based — not nurse-hardcoded. + * + * @return list + */ + public static function floorRolesWithoutConsultManage(): array + { + return [ + ['nurse', 'dentistry', 'floor-nurse-den'], + ['receptionist', 'dentistry', 'floor-recv-den'], + ['lab_technician', 'emergency', 'floor-lab-er'], + ['pharmacist', 'emergency', 'floor-rx-er'], + ]; + } + + #[DataProvider('floorRolesWithoutConsultManage')] + public function test_floor_roles_without_consult_do_not_see_mutate_ctas( + string $role, + string $module, + string $suffix, + ): void { + [$user, $member, $visit] = $module === 'dentistry' + ? $this->dentistryVisitForRole($role, $suffix) + : $this->emergencyVisitForRole($role, $suffix); + + $permissions = app(\App\Services\Care\CarePermissions::class); + $this->assertTrue($this->modules->memberCanManage($this->organization, $member, $module)); + $this->assertFalse($permissions->can($member, 'consultations.manage')); + // Prove gating is not nurse-only: same CTA rules for every floor role without consult. + $this->assertNotSame('hospital_admin', $member->role); + + $html = $this->actingAs($user) + ->get(route('care.specialty.workspace', ['module' => $module, 'visit' => $visit, 'tab' => 'overview'])) ->assertOk() - ->assertSee('Efua Boateng') ->assertSee('consultation access') ->assertDontSee('Seat at chair') - ->assertDontSee('Register / book') + ->assertDontSee('Start triage') ->assertSee('Back to queue') ->assertSee('Patient chart') ->getContent(); - $this->assertStringContainsString('Waiting', $html); $this->assertStringNotContainsString( - route('care.specialty.dentistry.stage', $visit, absolute: false), - $html, - ); - $this->assertStringNotContainsString( - route('care.specialty.consultation.start', ['module' => 'dentistry', 'visit' => $visit], absolute: false), + route('care.specialty.consultation.start', ['module' => $module, 'visit' => $visit], absolute: false), $html, ); + + if ($module === 'dentistry') { + $this->assertStringNotContainsString( + route('care.specialty.dentistry.stage', $visit, absolute: false), + $html, + ); + $this->assertStringContainsString('Waiting', $html); + } else { + $this->assertStringNotContainsString( + route('care.specialty.emergency.stage', $visit, absolute: false), + $html, + ); + } + + if ($permissions->can($member, 'appointments.manage')) { + $this->assertStringContainsString('Register / book', $html); + } else { + $this->assertStringNotContainsString('Register / book', $html); + } } public function test_nurse_dentistry_mutate_posts_are_forbidden(): void @@ -446,6 +529,42 @@ class CareSpecialtyAccessTest extends TestCase ->assertForbidden(); } + public function test_receptionist_dentistry_mutate_posts_are_forbidden(): void + { + [$user, , $visit] = $this->dentistryVisitForRole('receptionist', 'den-recv-post'); + + $this->actingAs($user) + ->post(route('care.specialty.dentistry.stage', $visit), ['stage' => 'chair']) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('care.specialty.consultation.start', ['module' => 'dentistry', 'visit' => $visit])) + ->assertForbidden(); + } + + public function test_lab_technician_and_pharmacist_emergency_mutate_posts_are_forbidden(): void + { + foreach (['lab_technician' => 'lab-er-post', 'pharmacist' => 'rx-er-post'] as $role => $suffix) { + [$user, , $visit] = $this->emergencyVisitForRole($role, $suffix); + + $this->actingAs($user) + ->post(route('care.specialty.emergency.stage', $visit), ['stage' => 'treatment']) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('care.specialty.consultation.start', ['module' => 'emergency', 'visit' => $visit])) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('care.specialty.emergency.vitals', $visit), [ + 'bp_systolic' => 120, + 'bp_diastolic' => 80, + 'pulse' => 72, + ]) + ->assertForbidden(); + } + } + public function test_doctor_on_dentistry_sees_and_can_advance_stage(): void { [$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('doctor', 'den-doc'); @@ -467,6 +586,74 @@ class CareSpecialtyAccessTest extends TestCase $this->assertSame('chair', $visit->fresh()->specialty_stage); } + public function test_gp_with_consult_ability_but_no_module_manage_hides_mutate_ctas(): void + { + [$doctor, $member] = $this->makeStaff('doctor', 'gp-no-manage'); + Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'member_id' => $member->id, + 'user_ref' => $doctor->public_id, + 'name' => 'Dr GP No Manage', + 'specialty' => 'General Practice', + 'is_active' => true, + ]); + + $permissions = app(\App\Services\Care\CarePermissions::class); + $this->assertTrue($permissions->can($member, 'consultations.manage')); + $this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology')); + $this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology')); + + $dept = Department::query()->where('type', 'cardiology')->firstOrFail(); + $patient = Patient::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'P-GP-NM', + 'first_name' => 'Yaw', + 'last_name' => 'ReferOnly', + 'gender' => 'male', + 'date_of_birth' => '1975-01-01', + ]); + $visit = \App\Models\Visit::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'status' => \App\Models\Visit::STATUS_IN_PROGRESS, + 'checked_in_at' => now(), + ]); + Appointment::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_id' => $patient->id, + 'department_id' => $dept->id, + 'visit_id' => $visit->id, + 'type' => Appointment::TYPE_WALK_IN, + 'status' => Appointment::STATUS_WAITING, + 'scheduled_at' => now(), + 'checked_in_at' => now(), + 'waiting_at' => now(), + ]); + + $html = $this->actingAs($doctor) + ->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit, 'tab' => 'overview'])) + ->assertOk() + ->assertSee('view + refer') + ->assertDontSee('consultation access') + ->assertDontSee('Seat at chair') + ->assertDontSee('Start triage') + ->getContent(); + + $this->assertStringNotContainsString( + route('care.specialty.consultation.start', ['module' => 'cardiology', 'visit' => $visit], absolute: false), + $html, + ); + $this->assertStringNotContainsString('>Start', $html); + } + public function test_admin_on_dentistry_sees_mutate_actions(): void { $dept = Department::query()->where('type', 'dental')->firstOrFail();