feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s
Deploy Ladill Care / deploy (push) Successful in 1m26s
Add a template-driven assessment system with universal intake, clinical pathways, disease instruments (stroke MVP + extended, diabetes, and ten specialty packs), scoring, patient outcome trends, REST/API + FHIR export, and Enterprise org-level assessment analytics. Seed packs and design/licensing docs ship for deploy and pre-GA review.
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
<?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\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->member = Member::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'doctor',
|
||||
]);
|
||||
|
||||
$this->branch = Branch::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Main',
|
||||
'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_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_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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user