Cover all floor roles in specialty CTA ability gating.
Deploy Ladill Care / deploy (push) Successful in 24s

8a5330d already keyed Start/stage/vitals on CarePermissions for every
role; add receptionist, lab, pharmacist, and GP-without-module-manage
tests so we never regress to nurse-only branching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 18:49:20 +00:00
co-authored by Cursor
parent 8a5330d3d2
commit 65e54d1253
2 changed files with 203 additions and 15 deletions
@@ -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;
+201 -14
View File
@@ -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<array{0: string, 1: string, 2: string}>
*/
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</button>', $html);
}
public function test_admin_on_dentistry_sees_mutate_actions(): void
{
$dept = Department::query()->where('type', 'dental')->firstOrFail();