Files
ladill-care/tests/Feature/CareSpecialtyAccessTest.php
T
isaaccladandCursor 8a5330d3d2
Deploy Ladill Care / deploy (push) Successful in 1m3s
Gate specialty mutate CTAs on Care abilities, not only module manage.
Nurses can manage limited specialties but lack consultations.manage, so
hide Start/stage/chart edits that 403 while keeping queue/vitals/billing
aligned with what those routes actually authorize.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 18:45:00 +00:00

572 lines
23 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Appointment;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\User;
use App\Services\Care\SpecialtyModuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareSpecialtyAccessTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected SpecialtyModuleService $modules;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->owner = User::create([
'public_id' => 'access-owner',
'name' => 'Owner',
'email' => 'access-owner@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Access Clinic',
'slug' => 'access-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$this->modules = app(SpecialtyModuleService::class);
$this->modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
foreach (['cardiology', 'infusion', 'pathology', 'dentistry'] as $key) {
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key);
}
$this->organization->refresh();
}
protected function makeStaff(string $role, string $suffix): array
{
$user = User::create([
'public_id' => 'access-'.$suffix,
'name' => ucfirst($role).' '.$suffix,
'email' => $suffix.'@example.com',
]);
$member = Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $user->public_id,
'role' => $role,
'branch_id' => $this->branch->id,
]);
return [$user, $member];
}
public function test_nurse_and_pharmacist_manage_general_modules(): void
{
[, $nurse] = $this->makeStaff('nurse', 'nurse1');
[, $pharmacist] = $this->makeStaff('pharmacist', 'rx1');
[, $lab] = $this->makeStaff('lab_technician', 'lab1');
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology'));
}
public function test_gp_doctor_manages_general_but_only_views_restricted(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'gp1');
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',
'specialty' => 'General Practice',
'is_active' => true,
]);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->assertTrue($this->modules->memberCanView($this->organization, $member, 'cardiology'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'cardiology'));
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
}
public function test_cardiologist_manages_restricted_cardiology(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'cardio1');
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'department_id' => $dept->id,
'member_id' => $member->id,
'user_ref' => $doctor->public_id,
'name' => 'Dr Cardio',
'specialty' => 'Cardiology',
'is_active' => true,
]);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
}
public function test_gp_can_refer_into_restricted_specialty_queue(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'gp2');
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 GP2',
'specialty' => 'General Practice',
'is_active' => true,
]);
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-REF-1',
'first_name' => 'Ama',
'last_name' => 'Refer',
'gender' => 'female',
'date_of_birth' => '1990-01-01',
]);
$this->actingAs($doctor)
->post(route('care.specialty.refer', 'cardiology'), [
'patient_id' => $patient->id,
'branch_id' => $this->branch->id,
'reason' => 'Chest pain review',
])
->assertRedirect(route('care.queue.index'));
$this->assertDatabaseHas('care_appointments', [
'patient_id' => $patient->id,
'reason' => 'Chest pain review',
'status' => Appointment::STATUS_WAITING,
]);
}
public function test_gp_cannot_save_clinical_on_restricted_module(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'gp3');
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 GP3',
'specialty' => 'General Practice',
'is_active' => true,
]);
$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-CARD-1',
'first_name' => 'Kofi',
'last_name' => 'Heart',
'gender' => 'male',
'date_of_birth' => '1980-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_IN_CONSULTATION,
'scheduled_at' => now(),
'checked_in_at' => now(),
'started_at' => now(),
]);
$this->actingAs($doctor)
->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit, 'tab' => 'exam']))
->assertOk()
->assertSee('view + refer')
->assertDontSee('Save record')
->assertDontSee('Call next')
->assertDontSee('Add to invoice')
->assertDontSee('Upload document')
->assertSee('Refer to specialty queue')
->assertDontSee(
'action="'.route('care.specialty.consultation.start', ['module' => 'cardiology', 'visit' => $visit], false).'"',
false,
);
$this->actingAs($doctor)
->post(route('care.specialty.clinical.save', ['module' => 'cardiology', 'visit' => $visit]), [
'tab' => 'exam',
'payload' => [
'chief_complaint' => 'Should not save',
],
])
->assertForbidden();
}
public function test_sidebar_and_dashboard_exclude_modules_with_no_access(): void
{
[$pharmacistUser, $pharmacistMember] = $this->makeStaff('pharmacist', 'rx-nav');
$enabledForMember = collect($this->modules->enabledModulesForMember($this->organization, $pharmacistMember))
->pluck('key')
->all();
$this->assertContains('emergency', $enabledForMember);
$this->assertContains('infusion', $enabledForMember);
$this->assertNotContains('cardiology', $enabledForMember);
$this->assertNotContains('dentistry', $enabledForMember);
$this->actingAs($pharmacistUser)
->get(route('care.dashboard'))
->assertOk()
->assertSee('Emergency')
->assertDontSee('Cardiology');
}
public function test_manage_role_still_sees_mutate_actions_on_restricted_module(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'cardio-ui');
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'department_id' => $dept->id,
'member_id' => $member->id,
'user_ref' => $doctor->public_id,
'name' => 'Dr Cardio UI',
'specialty' => 'Cardiology',
'is_active' => true,
]);
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-CARD-UI',
'first_name' => 'Abena',
'last_name' => 'Heart',
'gender' => 'female',
'date_of_birth' => '1985-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(),
]);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->actingAs($doctor)
->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit]))
->assertOk()
->assertDontSee('view + refer')
->assertSee('Start');
}
public function test_admin_keeps_full_manage_access(): void
{
$admin = Member::query()->where('user_ref', $this->owner->public_id)->firstOrFail();
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'cardiology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency'));
}
/**
* @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit}
*/
protected function dentistryVisitForRole(string $role, string $suffix): array
{
[$user, $member] = $this->makeStaff($role, $suffix);
if ($role === 'doctor') {
Practitioner::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'member_id' => $member->id,
'user_ref' => $user->public_id,
'name' => 'Dr '.$suffix,
'specialty' => 'Dentistry',
'is_active' => true,
]);
}
$dept = Department::query()->where('type', 'dental')->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-DEN-'.$suffix,
'first_name' => 'Efua',
'last_name' => 'Boateng',
'gender' => 'female',
'date_of_birth' => '1992-06-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' => 'waiting',
]);
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(),
]);
return [$user, $member, $visit];
}
public function test_nurse_on_dentistry_does_not_see_consultation_mutate_actions(): void
{
[$nurseUser, $nurseMember, $visit] = $this->dentistryVisitForRole('nurse', 'den-nurse');
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurseMember, 'dentistry'));
$this->assertFalse(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'consultations.manage'));
$html = $this->actingAs($nurseUser)
->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk()
->assertSee('Efua Boateng')
->assertSee('consultation access')
->assertDontSee('Seat at chair')
->assertDontSee('Register / book')
->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),
$html,
);
}
public function test_nurse_dentistry_mutate_posts_are_forbidden(): void
{
[$nurseUser, , $visit] = $this->dentistryVisitForRole('nurse', 'den-nurse-post');
$this->actingAs($nurseUser)
->post(route('care.specialty.dentistry.stage', $visit), ['stage' => 'chair'])
->assertForbidden();
$this->actingAs($nurseUser)
->post(route('care.specialty.consultation.start', ['module' => 'dentistry', 'visit' => $visit]))
->assertForbidden();
$this->actingAs($nurseUser)
->post(route('care.specialty.dentistry.tooth', $visit), [
'tooth_code' => '16',
'status' => 'present',
])
->assertForbidden();
}
public function test_doctor_on_dentistry_sees_and_can_advance_stage(): void
{
[$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('doctor', 'den-doc');
$this->assertTrue($this->modules->memberCanManage($this->organization, $doctorMember, 'dentistry'));
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($doctorMember, 'consultations.manage'));
$this->actingAs($doctorUser)
->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk()
->assertSee('Seat at chair')
->assertSee('Start')
->assertDontSee('consultation access');
$this->actingAs($doctorUser)
->post(route('care.specialty.dentistry.stage', $visit), ['stage' => 'chair'])
->assertRedirect();
$this->assertSame('chair', $visit->fresh()->specialty_stage);
}
public function test_admin_on_dentistry_sees_mutate_actions(): void
{
$dept = Department::query()->where('type', 'dental')->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-DEN-ADMIN',
'first_name' => 'Admin',
'last_name' => 'Patient',
'gender' => 'male',
'date_of_birth' => '1980-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' => 'waiting',
]);
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(),
]);
$this->actingAs($this->owner)
->get(route('care.specialty.workspace', ['module' => 'dentistry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk()
->assertSee('Seat at chair')
->assertSee('Start');
}
public function test_nurse_can_save_emergency_vitals_with_vitals_manage(): void
{
[$nurseUser, $nurseMember] = $this->makeStaff('nurse', 'er-vitals');
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'vitals.manage'));
$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-VITALS',
'first_name' => 'Kojo',
'last_name' => 'Mensah',
'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(),
]);
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_IN_CONSULTATION,
'scheduled_at' => now(),
'checked_in_at' => now(),
'started_at' => now(),
]);
$this->actingAs($nurseUser)
->get(route('care.specialty.workspace', ['module' => 'emergency', 'visit' => $visit, 'tab' => 'vitals']))
->assertOk()
->assertSee('Save vitals')
->assertDontSee('Seat at chair')
->assertDontSee('Start triage');
$this->actingAs($nurseUser)
->post(route('care.specialty.emergency.vitals', $visit), [
'bp_systolic' => 120,
'bp_diastolic' => 80,
'pulse' => 72,
])
->assertRedirect();
$this->actingAs($nurseUser)
->post(route('care.specialty.emergency.stage', $visit), ['stage' => 'treatment'])
->assertForbidden();
}
}