Files
ladill-care/tests/Feature/CareAssessmentCaptureTest.php
T
isaaccladandCursor 847d63c1c2
Deploy Ladill Care / deploy (push) Successful in 40s
Allow consultation assessments when patient home branch differs.
Doctors scoped to the visit branch were getting app 404s on nested
assessment routes because authorizePatient compared the patient's home
branch, unlike consultation/prescription auth which uses the visit.

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

624 lines
21 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentQuestion;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\Consultation;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\CareFeatures;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class CareAssessmentCaptureTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected AssessmentTemplate $universal;
protected AssessmentTemplate $nihss;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'assess-capture-user',
'name' => 'Capture User',
'email' => 'capture@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Capture Clinic',
'slug' => 'capture-clinic',
'timezone' => 'UTC',
'settings' => [
'onboarded' => true,
'rollout' => [
CareFeatures::ASSESSMENTS_ENGINE => true,
],
],
]);
$this->branch = Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$this->member = Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'doctor',
'branch_id' => $this->branch->id,
]);
// Doctors are branch-scoped via linked practitioner desks.
Practitioner::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'member_id' => $this->member->id,
'user_ref' => $this->user->public_id,
'name' => 'Dr Capture',
'specialty' => 'General Practice',
'is_active' => true,
]);
$this->patient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'LC-CAP-001',
'first_name' => 'Ama',
'last_name' => 'Mensah',
]);
$this->universal = AssessmentTemplate::create([
'code' => 'universal_intake',
'name' => 'Universal Intake',
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
'version' => 1,
'is_current' => true,
'is_active' => true,
'meta' => ['capture_roles' => ['doctor', 'nurse']],
]);
AssessmentQuestion::create([
'template_id' => $this->universal->id,
'code' => 'chief_complaint',
'label' => 'Chief complaint',
'answer_type' => AssessmentQuestion::TYPE_TEXT,
'is_required' => true,
'sort_order' => 1,
]);
AssessmentQuestion::create([
'template_id' => $this->universal->id,
'code' => 'pain_score',
'label' => 'Pain score',
'answer_type' => AssessmentQuestion::TYPE_SCALE,
'options' => ['min' => 0, 'max' => 10],
'is_required' => false,
'sort_order' => 2,
]);
$this->nihss = AssessmentTemplate::create([
'code' => 'nihss',
'name' => 'NIH Stroke Scale',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => true,
'is_active' => true,
'scoring_strategy' => 'sum_items',
'meta' => ['capture_roles' => ['doctor']],
]);
AssessmentQuestion::create([
'template_id' => $this->nihss->id,
'code' => 'loc',
'label' => 'Level of consciousness',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => [
'choices' => [
['code' => '0', 'label' => 'Alert', 'score' => 0],
['code' => '1', 'label' => 'Not alert', 'score' => 1],
['code' => '2', 'label' => 'Obtunded', 'score' => 2],
],
],
'is_required' => true,
'sort_order' => 1,
]);
}
protected function setMemberRole(string $role): void
{
$this->member->update(['role' => $role]);
}
public function test_routes_404_when_feature_flag_disabled(): void
{
$this->organization->update([
'settings' => ['onboarded' => true, 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => false]],
]);
$this->actingAs($this->user)
->get(route('care.assessments.index', $this->patient))
->assertNotFound();
}
public function test_doctor_can_start_nihss(): void
{
$this->setMemberRole('doctor');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), [
'template_code' => 'nihss',
])
->assertRedirect();
$assessment = Assessment::first();
$this->assertNotNull($assessment);
$this->assertSame(Assessment::STATUS_DRAFT, $assessment->status);
$this->assertSame($this->nihss->id, $assessment->template_id);
$this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.started']);
}
public function test_nurse_can_start_universal_but_not_nihss(): void
{
$this->setMemberRole('nurse');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), [
'template_code' => 'universal_intake',
])
->assertRedirect();
$this->assertSame(1, Assessment::count());
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), [
'template_code' => 'nihss',
])
->assertForbidden();
}
public function test_hospital_admin_can_start_nihss(): void
{
$this->setMemberRole('hospital_admin');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), [
'template_code' => 'nihss',
])
->assertRedirect();
$this->assertSame(1, Assessment::where('template_id', $this->nihss->id)->count());
}
public function test_super_admin_can_start_nihss(): void
{
$this->setMemberRole('super_admin');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), [
'template_code' => 'nihss',
])
->assertRedirect();
$this->assertSame(1, Assessment::count());
}
public function test_start_is_idempotent_for_same_draft(): void
{
$this->setMemberRole('doctor');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
->assertRedirect();
$first = Assessment::first();
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
->assertRedirect(route('care.assessments.show', $first));
$this->assertSame(1, Assessment::count());
}
public function test_save_answers_complete_and_immutable(): void
{
$this->setMemberRole('doctor');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => [
'chief_complaint' => 'Headache',
'pain_score' => 4,
],
'notes' => 'Initial',
])
->assertRedirect(route('care.assessments.show', $assessment));
$this->assertDatabaseHas('care_assessment_answers', [
'assessment_id' => $assessment->id,
'value_text' => 'Headache',
]);
$this->assertDatabaseHas('care_assessment_answers', [
'assessment_id' => $assessment->id,
'value_number' => 4,
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$assessment->refresh();
$this->assertSame(Assessment::STATUS_COMPLETED, $assessment->status);
$this->assertNotNull($assessment->completed_at);
$this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.completed']);
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => ['chief_complaint' => 'Changed'],
])
->assertSessionHasErrors('status');
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertSessionHasErrors('status');
}
public function test_complete_requires_required_answers(): void
{
$this->setMemberRole('nurse');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertSessionHasErrors('answers.chief_complaint');
$this->assertSame(Assessment::STATUS_DRAFT, $assessment->fresh()->status);
}
public function test_cancel_draft_only(): void
{
$this->setMemberRole('doctor');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'universal_intake'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->post(route('care.assessments.cancel', $assessment))
->assertRedirect(route('care.assessments.index', $this->patient));
$this->assertSame(Assessment::STATUS_CANCELLED, $assessment->fresh()->status);
$this->assertDatabaseHas('care_audit_logs', ['action' => 'assessment.cancelled']);
}
public function test_doctor_can_view_consultation_scoped_assessments_index(): void
{
$this->setMemberRole('doctor');
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
$consultation = Consultation::create([
'owner_ref' => $this->user->public_id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
$this->actingAs($this->user)
->get(route('care.consultations.assessments.index', $consultation))
->assertOk()
->assertSee('Clinical assessments')
->assertSee($this->patient->fullName());
// Path shape matches care.ladill.com/consultations/{uuid}/assessments
$this->actingAs($this->user)
->get('/consultations/'.$consultation->uuid.'/assessments')
->assertOk();
}
public function test_consultation_assessments_allow_patient_home_branch_mismatch(): void
{
$this->setMemberRole('doctor');
$homeBranch = Branch::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Home Branch',
'code' => 'HOME',
'is_active' => true,
]);
$this->patient->update(['branch_id' => $homeBranch->id]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
$consultation = Consultation::create([
'owner_ref' => $this->user->public_id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
$this->assertNotSame($homeBranch->id, $this->branch->id);
$this->actingAs($this->user)
->get(route('care.consultations.assessments.index', $consultation))
->assertOk();
$this->actingAs($this->user)
->post(route('care.consultations.assessments.store', $consultation), [
'template_code' => 'universal_intake',
])
->assertRedirect();
$assessment = Assessment::first();
$this->assertNotNull($assessment);
$this->assertSame($this->branch->id, $assessment->branch_id);
}
public function test_consultation_scoped_start_links_consultation_and_visit(): void
{
$this->setMemberRole('doctor');
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
$consultation = Consultation::create([
'owner_ref' => $this->user->public_id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
$this->actingAs($this->user)
->post(route('care.consultations.assessments.store', $consultation), [
'template_code' => 'universal_intake',
])
->assertRedirect();
$assessment = Assessment::first();
$this->assertSame($consultation->id, $assessment->consultation_id);
$this->assertSame($visit->id, $assessment->visit_id);
$this->assertSame($this->branch->id, $assessment->branch_id);
}
public function test_consultation_create_show_and_store_open_forms_for_doctor(): void
{
$this->setMemberRole('doctor');
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
$consultation = Consultation::create([
'owner_ref' => $this->user->public_id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
// Nested create (was unmatched → Laravel 404 before this fix)
$this->actingAs($this->user)
->get(route('care.consultations.assessments.create', $consultation))
->assertOk()
->assertSee('Start assessment')
->assertSee('Universal Intake');
$this->actingAs($this->user)
->get('/consultations/'.$consultation->uuid.'/assessments/create')
->assertOk();
$response = $this->actingAs($this->user)
->post(route('care.consultations.assessments.store', $consultation), [
'template_code' => 'universal_intake',
]);
$assessment = Assessment::first();
$this->assertNotNull($assessment);
$response->assertRedirect(route('care.consultations.assessments.show', [
'consultation' => $consultation,
'assessment' => $assessment,
]));
// Nested show (was unmatched → Laravel 404)
$this->actingAs($this->user)
->get(route('care.consultations.assessments.show', [$consultation, $assessment]))
->assertOk()
->assertSee('Universal Intake')
->assertSee('Back to consultation');
$this->actingAs($this->user)
->get('/consultations/'.$consultation->uuid.'/assessments/'.$assessment->uuid)
->assertOk();
// Flat show still works for patient-chart / bookmarks
$this->actingAs($this->user)
->get(route('care.assessments.show', $assessment))
->assertOk();
// Patient create + store with consultation_uuid also lands on nested show
$this->actingAs($this->user)
->get(route('care.assessments.create', [
'patient' => $this->patient,
'consultation_uuid' => $consultation->uuid,
]))
->assertOk();
}
public function test_public_bodies_use_consultation_uuid(): void
{
$this->setMemberRole('doctor');
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'status' => Visit::STATUS_OPEN,
'checked_in_at' => now(),
]);
$consultation = Consultation::create([
'owner_ref' => $this->user->public_id,
'visit_id' => $visit->id,
'patient_id' => $this->patient->id,
'status' => Consultation::STATUS_DRAFT,
'started_at' => now(),
]);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), [
'template_code' => 'universal_intake',
'consultation_uuid' => $consultation->uuid,
])
->assertRedirect();
$assessment = Assessment::first();
$this->assertSame($consultation->id, $assessment->consultation_id);
$this->actingAs($this->user)
->get(route('care.consultations.assessments.show', [$consultation, $assessment]))
->assertOk();
}
public function test_tenant_isolation_returns_404(): void
{
$this->setMemberRole('doctor');
$otherUser = User::create([
'public_id' => 'other-owner',
'name' => 'Other',
'email' => 'other@example.com',
]);
$otherOrg = Organization::create([
'owner_ref' => $otherUser->public_id,
'name' => 'Other Clinic',
'slug' => 'other-clinic',
'timezone' => 'UTC',
'settings' => ['onboarded' => true, 'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true]],
]);
Member::create([
'owner_ref' => $otherUser->public_id,
'organization_id' => $otherOrg->id,
'user_ref' => $otherUser->public_id,
'role' => 'doctor',
]);
$otherPatient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $otherUser->public_id,
'organization_id' => $otherOrg->id,
'patient_number' => 'LC-OTHER',
'first_name' => 'Other',
'last_name' => 'Patient',
]);
$this->actingAs($this->user)
->get(route('care.assessments.index', $otherPatient))
->assertNotFound();
}
public function test_score_item_stores_numeric_score_from_choice_code(): void
{
$this->setMemberRole('doctor');
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => ['loc' => '2'],
])
->assertRedirect();
$this->assertDatabaseHas('care_assessment_answers', [
'assessment_id' => $assessment->id,
'value_number' => 2,
'value_text' => null,
]);
}
public function test_receptionist_cannot_view_assessments(): void
{
$this->setMemberRole('receptionist');
$this->actingAs($this->user)
->get(route('care.assessments.index', $this->patient))
->assertForbidden();
}
}