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', 'psychiatry', 'surgery', 'radiology'] 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 { foreach (['vaccination', 'child_welfare', 'womens_health'] as $key) { $this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key); } $this->organization->refresh(); [, $nurse] = $this->makeStaff('nurse', 'nurse1'); [, $edNurse] = $this->makeStaff('ed_nurse', 'edn1'); [, $pharmacist] = $this->makeStaff('pharmacist', 'rx1'); [, $lab] = $this->makeStaff('lab_technician', 'lab1'); [, $receptionist] = $this->makeStaff('receptionist', 'rec1'); // Legacy floor nurse: vaccination / CWC / infusion / Women's Health manage; emergency refer only. $this->assertFalse($this->modules->memberCanManage($this->organization, $nurse, 'emergency')); $this->assertTrue($this->modules->memberCanRefer($this->organization, $nurse, 'emergency')); $this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'vaccination')); $this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'infusion')); $this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'womens_health')); // ED nurse matrix apps: Emergency, Blood Bank, Radiology, Pathology. $this->assertTrue($this->modules->memberCanManage($this->organization, $edNurse, 'emergency')); $this->assertTrue($this->modules->memberCanManage($this->organization, $edNurse, 'pathology')); // Pharmacist: no specialty manage; refer/view pharmacy-adjacent modules. $this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency')); $this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion')); $this->assertTrue($this->modules->memberCanRefer($this->organization, $pharmacist, 'infusion')); $this->assertTrue($this->modules->memberCanView($this->organization, $pharmacist, 'emergency')); $this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology')); // Lab: pathology + blood bank manage; radiology view. $this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology')); $this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank')); $this->assertSame('view', $this->modules->memberAccessLevel($this->organization, $lab, 'radiology')); // Receptionist: refer only (no specialty manage). $this->assertFalse($this->modules->memberCanManage($this->organization, $receptionist, 'emergency')); $this->assertTrue($this->modules->memberCanRefer($this->organization, $receptionist, 'emergency')); $this->assertTrue($this->modules->memberCanRefer($this->organization, $receptionist, 'dentistry')); } public function test_gp_doctor_manages_general_but_only_views_restricted(): void { [$doctor, $member] = $this->makeStaff('general_physician', '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->assertFalse($this->modules->isDeskSpecialist($this->organization, $member)); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency')); // Dentistry is not a GP primary app — refer only. $this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'dentistry')); $this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'dentistry')); // Cardiology / ophthalmology are GP primary apps (manage). $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology')); $this->modules->activate($this->organization->fresh(), $this->owner->public_id, 'ophthalmology'); $this->organization->refresh(); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'ophthalmology')); // Psychiatry is outside GP manage — refer/view via roleReferApps. $this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'psychiatry')); $this->assertTrue($this->modules->memberCanView($this->organization, $member, 'psychiatry')); $this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'psychiatry')); $this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'psychiatry')); } public function test_dedicated_cardiologist_role_manages_cardiology_only(): void { [, $member] = $this->makeStaff('cardiologist', 'cardio-role'); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology')); $this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology')); $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'emergency')); $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'dentistry')); $this->assertTrue(app(\App\Services\Care\CarePermissions::class)->isSingleAppSpecialist($member)); $this->assertFalse($this->modules->shouldShowSpecialtyNav($this->organization, $member)); } 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')); // Desk specialists do not keep GP-wide general/limited manage. $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'emergency')); $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'dentistry')); } public function test_dentistry_specialist_only_sees_matching_modules(): void { [$doctor, $member] = $this->makeStaff('doctor', 'dentist1'); 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 Dentist', 'specialty' => 'Dentistry', 'is_active' => true, ]); $this->assertTrue($this->modules->isDeskSpecialist($this->organization, $member)); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry')); $this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'dentistry')); foreach (['emergency', 'blood_bank', 'cardiology', 'infusion', 'pathology'] as $key) { $this->assertSame( 'none', $this->modules->memberAccessLevel($this->organization, $member, $key), "Dentistry specialist should not see [{$key}]", ); $this->assertFalse($this->modules->memberCanManage($this->organization, $member, $key)); $this->assertFalse($this->modules->memberCanView($this->organization, $member, $key)); $this->assertFalse($this->modules->memberCanRefer($this->organization, $member, $key)); } $enabledKeys = collect($this->modules->enabledModulesForMember($this->organization, $member)) ->pluck('key') ->all(); $this->assertContains('dentistry', $enabledKeys); $this->assertNotContains('emergency', $enabledKeys); $this->assertNotContains('blood_bank', $enabledKeys); $this->assertNotContains('cardiology', $enabledKeys); // Single-desk specialists: no Specialty sidebar group (dashboard cards remain). $this->assertFalse($this->modules->shouldShowSpecialtyNav($this->organization, $member)); $this->actingAs($doctor) ->get(route('care.dashboard')) ->assertOk() ->assertSee('Dentistry') ->assertSee('Specialty modules') ->assertDontSee('>Specialty
', false) ->assertDontSee('Emergency') ->assertDontSee('Cardiology') ->assertDontSee('Blood Bank'); } public function test_gp_keeps_specialty_sidebar_nav(): void { [$doctor, $member] = $this->makeStaff('doctor', 'gp-nav'); 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 Nav', 'specialty' => 'General Practice', 'is_active' => true, ]); $this->assertFalse($this->modules->isDeskSpecialist($this->organization, $member)); $this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $member)); $this->actingAs($doctor) ->get(route('care.dashboard')) ->assertOk() ->assertSee('>Specialty', false) ->assertSee('Emergency') ->assertSee('Cardiology') ->assertSee('Dentistry'); // GP refer access } public function test_dentistry_specialty_does_not_match_ent_keyword(): void { [$doctor, $member] = $this->makeStaff('doctor', 'dentist-ent-trap'); 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 Dentist', 'specialty' => 'Dentistry', 'is_active' => true, ]); $this->assertTrue($this->modules->specialistBelongsToModule($this->organization, $member, 'dentistry')); $this->assertFalse($this->modules->specialistBelongsToModule($this->organization, $member, 'ent')); $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'ent')); } 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.specialty.show', 'cardiology')); $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('general_physician', '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', 'psychiatry')->firstOrFail(); $patient = Patient::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_number' => 'P-PSY-1', 'first_name' => 'Kofi', 'last_name' => 'Mind', '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' => 'psychiatry', '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' => 'psychiatry', 'visit' => $visit], false).'"', false, ); $this->actingAs($doctor) ->post(route('care.specialty.clinical.save', ['module' => 'psychiatry', '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'); [, $epMember] = $this->makeStaff('emergency_physician', 'ep-nav'); $enabledForPharmacist = collect($this->modules->enabledModulesForMember($this->organization, $pharmacistMember)) ->pluck('key') ->all(); $enabledForEp = collect($this->modules->enabledModulesForMember($this->organization, $epMember)) ->pluck('key') ->all(); // Pharmacist: pharmacy-adjacent specialty refer/view (multi-app → Specialty nav). $this->assertContains('emergency', $enabledForPharmacist); $this->assertContains('infusion', $enabledForPharmacist); $this->assertNotContains('cardiology', $enabledForPharmacist); $this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember)); $this->assertContains('emergency', $enabledForEp); $this->assertContains('radiology', $enabledForEp); $this->assertNotContains('dentistry', $enabledForEp); $this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $epMember)); $this->actingAs($pharmacistUser) ->get(route('care.dashboard')) ->assertOk() ->assertSee('>Specialty', false); } 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 specialtyVisitForRole(string $role, string $module, string $suffix): array { if ($module === 'dentistry') { return $this->dentistryVisitForRole($role, $suffix); } if ($module === 'emergency') { return $this->emergencyVisitForRole($role, $suffix); } [$user, $member] = $this->makeStaff($role, $suffix); $definition = $this->modules->definition($module); $deptType = (string) ($definition['department_type'] ?? $module); $dept = Department::query()->where('type', $deptType)->firstOrFail(); $patient = Patient::create([ 'owner_ref' => $this->owner->public_id, 'organization_id' => $this->organization->id, 'branch_id' => $this->branch->id, 'patient_number' => 'P-'.strtoupper(substr($module, 0, 3)).'-'.$suffix, 'first_name' => 'Floor', 'last_name' => ucfirst($module), 'gender' => 'female', 'date_of_birth' => '1990-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(), ]); return [$user, $member, $visit]; } /** * @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 (in_array($role, ['doctor', 'dentist'], true)) { 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]; } /** * @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit} */ protected function emergencyVisitForRole(string $role, string $suffix): array { [$user, $member] = $this->makeStaff($role, $suffix); $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(), ]); 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