Cover all floor roles in specialty CTA ability gating.
Deploy Ladill Care / deploy (push) Successful in 24s
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user