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->assertFalse($this->modules->isDeskSpecialist($this->organization, $member)); $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')); // 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('Dentistry'); } 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('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->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember)); $this->actingAs($pharmacistUser) ->get(route('care.dashboard')) ->assertOk() ->assertSee('>Specialty', false) ->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]; } /** * @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