feat(assessments): layered clinical assessment engine end-to-end
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:
isaacclad
2026-07-16 22:58:09 +00:00
parent 8896088425
commit 2ce4bc8993
94 changed files with 13430 additions and 56 deletions
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\Branch;
use App\Models\ClinicalPathway;
use App\Models\Consultation;
use App\Models\Diagnosis;
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 Database\Seeders\AssessmentTemplateSeeder;
use Database\Seeders\ClinicalPathwaySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class CareAssessmentApiTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'api-assess-user',
'name' => 'API User',
'email' => 'api-assess@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'API Clinic',
'slug' => 'api-clinic',
'timezone' => 'UTC',
'settings' => [
'onboarded' => true,
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'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-API-001',
'first_name' => 'Ama',
'last_name' => 'Api',
]);
$this->seed(AssessmentTemplateSeeder::class);
$this->seed(ClinicalPathwaySeeder::class);
Sanctum::actingAs($this->user);
}
public function test_api_lists_templates_and_404_when_flag_off(): void
{
$this->getJson('/api/v1/assessment-templates')
->assertOk()
->assertJsonPath('data.0.code', fn ($c) => is_string($c));
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => false],
]),
]);
$this->getJson('/api/v1/assessment-templates')->assertNotFound();
}
public function test_api_assessment_lifecycle_with_uuids(): void
{
$create = $this->postJson("/api/v1/patients/{$this->patient->uuid}/assessments", [
'template_code' => 'mrs',
])->assertSuccessful();
$uuid = $create->json('uuid');
$this->assertNotEmpty($uuid);
$updated = $this->putJson("/api/v1/assessments/{$uuid}", [
'answers' => ['mrs_score' => '3'],
])->assertOk();
$this->assertEquals(3, (float) $updated->json('answers.mrs_score'));
$completed = $this->postJson("/api/v1/assessments/{$uuid}/complete")
->assertOk()
->assertJsonPath('status', 'completed');
$this->assertEquals(3, (float) $completed->json('score.total_score'));
$this->putJson("/api/v1/assessments/{$uuid}", [
'answers' => ['mrs_score' => '4'],
])->assertStatus(422);
}
public function test_api_pathway_suggestions_and_activate(): void
{
$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(),
]);
Diagnosis::create([
'owner_ref' => $this->user->public_id,
'consultation_id' => $consultation->id,
'code' => 'I63.9',
'description' => 'Cerebral infarction',
'is_primary' => true,
]);
$this->getJson("/api/v1/consultations/{$consultation->uuid}/pathway-suggestions")
->assertOk()
->assertJsonPath('data.0.pathway_code', 'stroke');
$this->postJson("/api/v1/patients/{$this->patient->uuid}/pathways", [
'pathway_code' => 'stroke',
'consultation_uuid' => $consultation->uuid,
])
->assertCreated()
->assertJsonPath('pathway_code', 'stroke')
->assertJsonPath('status', 'active');
$this->getJson("/api/v1/patients/{$this->patient->uuid}/pathways")
->assertOk()
->assertJsonPath('active.0.pathway_code', 'stroke');
$this->assertGreaterThanOrEqual(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
}
public function test_api_tenant_isolation(): void
{
$other = User::create([
'public_id' => 'other-api',
'name' => 'Other',
'email' => 'other-api@example.com',
]);
$otherOrg = Organization::create([
'owner_ref' => $other->public_id,
'name' => 'Other',
'slug' => 'other-api',
'timezone' => 'UTC',
'settings' => [
'onboarded' => true,
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
],
]);
Member::create([
'owner_ref' => $other->public_id,
'organization_id' => $otherOrg->id,
'user_ref' => $other->public_id,
'role' => 'doctor',
]);
$otherPatient = Patient::create([
'uuid' => (string) Str::uuid(),
'owner_ref' => $other->public_id,
'organization_id' => $otherOrg->id,
'patient_number' => 'LC-OTHER',
'first_name' => 'X',
'last_name' => 'Y',
]);
$this->getJson("/api/v1/patients/{$otherPatient->uuid}/assessments")->assertNotFound();
}
public function test_api_catalog_includes_pr10_pathways(): void
{
$this->getJson('/api/v1/pathways')
->assertOk()
->assertJsonFragment(['code' => 'copd'])
->assertJsonFragment(['code' => 'heart_failure'])
->assertJsonFragment(['code' => 'dementia']);
$this->assertNotNull(ClinicalPathway::findByCode('pregnancy'));
$this->assertNotNull(ClinicalPathway::findByCode('orthopaedics'));
}
}
+455
View File
@@ -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();
}
}
+339
View File
@@ -0,0 +1,339 @@
<?php
namespace Tests\Feature;
use App\Models\Assessment;
use App\Models\AssessmentAnswer;
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 Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* PR 1 schema, models, relations, catalog uniqueness (no service/UI yet).
*/
class CareAssessmentEngineTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->user = User::create([
'public_id' => 'test-user-assess-001',
'name' => 'Assessment Test User',
'email' => 'assess@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Assess Clinic',
'slug' => 'assess-clinic',
'timezone' => 'UTC',
'settings' => ['onboarded' => true],
]);
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-2026-ASSESS-001',
'first_name' => 'Ama',
'last_name' => 'Mensah',
]);
}
public function test_template_auto_generates_uuid_and_has_no_owner_ref(): void
{
$template = AssessmentTemplate::create([
'code' => 'universal_intake',
'name' => 'Universal Intake',
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
'version' => 1,
'is_current' => true,
'is_active' => true,
'meta' => ['capture_roles' => ['nurse', 'doctor']],
]);
$this->assertNotEmpty($template->uuid);
$this->assertTrue(Str::isUuid($template->uuid));
$this->assertNull($template->organization_id);
$this->assertFalse(isset($template->getAttributes()['owner_ref']));
$this->assertSame(['capture_roles' => ['nurse', 'doctor']], $template->meta);
$this->assertSame('uuid', $template->getRouteKeyName());
}
public function test_system_template_code_version_is_unique(): void
{
AssessmentTemplate::create([
'code' => 'nihss',
'name' => 'NIHSS v1',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => true,
]);
$this->expectException(QueryException::class);
AssessmentTemplate::create([
'code' => 'nihss',
'name' => 'NIHSS v1 duplicate',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => false,
]);
}
public function test_same_code_different_versions_allowed(): void
{
AssessmentTemplate::create([
'code' => 'mrs',
'name' => 'mRS v1',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => false,
]);
$v2 = AssessmentTemplate::create([
'code' => 'mrs',
'name' => 'mRS v2',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 2,
'is_current' => true,
]);
$this->assertSame(2, AssessmentTemplate::where('code', 'mrs')->count());
$this->assertTrue($v2->is_current);
$this->assertSame($v2->id, AssessmentTemplate::currentSystemByCode('mrs')?->id);
}
public function test_question_unique_per_template_and_ordered_relation(): void
{
$template = AssessmentTemplate::create([
'code' => 'fixture_scale',
'name' => 'Fixture',
'category' => AssessmentTemplate::CATEGORY_SCREENING,
'version' => 1,
'is_current' => true,
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'item_b',
'label' => 'Item B',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'sort_order' => 2,
'is_required' => true,
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'item_a',
'label' => 'Item A',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'sort_order' => 1,
'is_required' => true,
]);
$codes = $template->questions()->pluck('code')->all();
$this->assertSame(['item_a', 'item_b'], $codes);
$this->expectException(QueryException::class);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'item_a',
'label' => 'Duplicate',
'answer_type' => AssessmentQuestion::TYPE_TEXT,
'sort_order' => 3,
]);
}
public function test_assessment_instance_relations_and_owned_scope(): void
{
$template = AssessmentTemplate::create([
'code' => 'universal_intake',
'name' => 'Universal Intake',
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
'version' => 1,
'is_current' => true,
]);
$question = AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'chief_complaint',
'label' => 'Chief complaint',
'answer_type' => AssessmentQuestion::TYPE_TEXT,
'sort_order' => 1,
'is_required' => true,
]);
$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(),
]);
$assessment = Assessment::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'template_id' => $template->id,
'consultation_id' => $consultation->id,
'visit_id' => $visit->id,
'status' => Assessment::STATUS_DRAFT,
'started_by' => $this->user->public_id,
]);
$this->assertNotEmpty($assessment->uuid);
$this->assertTrue(Str::isUuid($assessment->uuid));
$this->assertTrue($assessment->isDraft());
$this->assertFalse($assessment->isCompleted());
AssessmentAnswer::create([
'owner_ref' => $this->user->public_id,
'assessment_id' => $assessment->id,
'question_id' => $question->id,
'value_text' => 'Headache for 2 days',
]);
$this->assertSame(1, $assessment->answers()->count());
$this->assertSame('Headache for 2 days', $assessment->answers->first()->authoritativeValue());
$this->assertTrue($this->patient->assessments->contains($assessment));
$this->assertTrue($consultation->assessments->contains($assessment));
$this->assertTrue($visit->assessments->contains($assessment));
$this->assertSame($template->id, $assessment->template->id);
$this->assertSame(1, Assessment::query()->owned($this->user->public_id)->count());
$this->assertSame(0, Assessment::query()->owned('other-owner')->count());
}
public function test_answer_unique_per_assessment_and_question(): void
{
$template = AssessmentTemplate::create([
'code' => 'fixture',
'name' => 'Fixture',
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
'version' => 1,
'is_current' => true,
]);
$question = AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'pain_score',
'label' => 'Pain',
'answer_type' => AssessmentQuestion::TYPE_NUMBER,
'sort_order' => 1,
]);
$assessment = Assessment::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $this->patient->id,
'template_id' => $template->id,
'status' => Assessment::STATUS_DRAFT,
]);
AssessmentAnswer::create([
'owner_ref' => $this->user->public_id,
'assessment_id' => $assessment->id,
'question_id' => $question->id,
'value_number' => 5,
]);
$this->expectException(QueryException::class);
AssessmentAnswer::create([
'owner_ref' => $this->user->public_id,
'assessment_id' => $assessment->id,
'question_id' => $question->id,
'value_number' => 7,
]);
}
public function test_deleting_assessment_cascades_answers_but_not_template(): void
{
$template = AssessmentTemplate::create([
'code' => 'cascade_fixture',
'name' => 'Cascade',
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
'version' => 1,
'is_current' => true,
]);
$question = AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'q1',
'label' => 'Q1',
'answer_type' => AssessmentQuestion::TYPE_BOOLEAN,
'sort_order' => 1,
]);
$assessment = Assessment::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'patient_id' => $this->patient->id,
'template_id' => $template->id,
'status' => Assessment::STATUS_DRAFT,
]);
AssessmentAnswer::create([
'owner_ref' => $this->user->public_id,
'assessment_id' => $assessment->id,
'question_id' => $question->id,
'value_boolean' => true,
]);
$assessmentId = $assessment->id;
$assessment->forceDelete();
$this->assertDatabaseMissing('care_assessments', ['id' => $assessmentId]);
$this->assertDatabaseMissing('care_assessment_answers', ['assessment_id' => $assessmentId]);
$this->assertDatabaseHas('care_assessment_templates', ['id' => $template->id]);
$this->assertDatabaseHas('care_assessment_questions', ['id' => $question->id]);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Services\Care\CareFeatures;
use App\Services\Care\FhirAssessmentExporter;
use Database\Seeders\AssessmentTemplateSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class CareAssessmentExtrasTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'extras-user',
'name' => 'Extras',
'email' => 'extras@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Extras Clinic',
'slug' => 'extras-clinic',
'timezone' => 'UTC',
'settings' => [
'onboarded' => true,
'plan' => 'enterprise',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'hospital_admin',
]);
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,
'patient_number' => 'LC-EX-001',
'first_name' => 'Extra',
'last_name' => 'Patient',
]);
$this->seed(AssessmentTemplateSeeder::class);
}
public function test_enterprise_assessment_analytics_report(): void
{
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), ['answers' => ['mrs_score' => '2']]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment));
$this->actingAs($this->user)
->get(route('care.reports.show', ['type' => 'assessments']))
->assertOk()
->assertSee('Assessment analytics')
->assertSee('By template')
->assertSee('mrs');
}
public function test_pro_plan_blocked_from_assessment_analytics_without_feature(): void
{
// Pro can access paid reports middleware, but lacks assessment_analytics (Enterprise-only).
$this->organization->update([
'settings' => [
'onboarded' => true,
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
],
]);
$this->actingAs($this->user)
->get(route('care.reports.show', ['type' => 'assessments']))
->assertForbidden();
}
public function test_fhir_export_web_and_api(): void
{
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), ['answers' => ['mrs_score' => '1']]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment));
$bundle = app(FhirAssessmentExporter::class)->exportBundle($assessment->fresh(['template.questions', 'answers.question', 'patient', 'score']));
$this->assertSame('Bundle', $bundle['resourceType']);
$this->assertSame('Questionnaire', $bundle['entry'][0]['resource']['resourceType']);
$this->assertSame('QuestionnaireResponse', $bundle['entry'][1]['resource']['resourceType']);
$this->assertSame('completed', $bundle['entry'][1]['resource']['status']);
$this->actingAs($this->user)
->get(route('care.assessments.fhir', $assessment).'?download=1')
->assertOk();
Sanctum::actingAs($this->user);
$this->getJson('/api/v1/assessments/'.$assessment->uuid.'/fhir')
->assertOk()
->assertJsonPath('resourceType', 'Bundle')
->assertJsonPath('entry.1.resource.resourceType', 'QuestionnaireResponse');
}
}
+288
View File
@@ -0,0 +1,288 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentQuestion;
use App\Models\AssessmentScore;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Services\Care\CareFeatures;
use App\Services\Care\ScoringService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class CareAssessmentScoringTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'scoring-user',
'name' => 'Scoring User',
'email' => 'scoring@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Score Clinic',
'slug' => 'score-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-SCORE-001',
'first_name' => 'Ama',
'last_name' => 'Score',
]);
}
public function test_sum_items_materializes_total_and_max(): void
{
$template = AssessmentTemplate::create([
'code' => 'nihss_fixture',
'name' => 'NIHSS fixture',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => true,
'scoring_strategy' => 'sum_items',
'meta' => ['capture_roles' => ['doctor']],
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'loc',
'label' => 'LOC',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => [
'choices' => [
['code' => '0', 'label' => 'Alert', 'score' => 0],
['code' => '2', 'label' => 'Obtunded', 'score' => 2],
],
],
'is_required' => true,
'sort_order' => 1,
'score_key' => 'loc',
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'motor',
'label' => 'Motor',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => [
'max' => 4,
'choices' => [
['code' => '0', 'label' => 'None', 'score' => 0],
['code' => '3', 'label' => 'Severe', 'score' => 3],
],
],
'is_required' => true,
'sort_order' => 2,
'score_key' => 'motor',
]);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss_fixture'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => ['loc' => '2', 'motor' => '3'],
])
->assertRedirect();
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$score = AssessmentScore::where('assessment_id', $assessment->id)->first();
$this->assertNotNull($score);
$this->assertEquals(5, (float) $score->total_score);
$this->assertEquals(6, (float) $score->max_score); // 2 + 4
$this->assertSame('nihss_fixture', $score->template_code);
$this->assertEquals(2, $score->subscores['loc']);
$this->assertEquals(3, $score->subscores['motor']);
$this->actingAs($this->user)
->get(route('care.assessments.show', $assessment))
->assertOk()
->assertSee('Score')
->assertSee('5');
}
public function test_single_value_strategy_for_mrs_style(): void
{
$template = AssessmentTemplate::create([
'code' => 'mrs_fixture',
'name' => 'mRS fixture',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => true,
'scoring_strategy' => 'single_value',
'meta' => ['capture_roles' => ['doctor']],
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'mrs_score',
'label' => 'mRS',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => [
'min' => 0,
'max' => 6,
'choices' => [
['code' => '0', 'label' => 'None', 'score' => 0],
['code' => '3', 'label' => 'Moderate', 'score' => 3],
['code' => '6', 'label' => 'Dead', 'score' => 6],
],
],
'is_required' => true,
'sort_order' => 1,
'score_key' => 'total',
]);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs_fixture'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->from(route('care.assessments.show', $assessment))
->put(route('care.assessments.update', $assessment), [
'answers' => ['mrs_score' => '3'],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$preview = app(ScoringService::class)->preview($assessment->fresh(['template.questions', 'answers.question']));
$this->assertEquals(3, $preview['total']);
$this->assertEquals(6, $preview['max']);
$score = AssessmentScore::first();
$this->assertEquals(3, (float) $score->total_score);
$this->assertEquals(6, (float) $score->max_score);
}
public function test_null_scoring_strategy_skips_score_row(): void
{
$template = AssessmentTemplate::create([
'code' => 'no_score',
'name' => 'No score',
'category' => AssessmentTemplate::CATEGORY_UNIVERSAL,
'version' => 1,
'is_current' => true,
'scoring_strategy' => null,
'meta' => ['capture_roles' => ['doctor', 'nurse']],
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'note',
'label' => 'Note',
'answer_type' => AssessmentQuestion::TYPE_TEXT,
'is_required' => true,
'sort_order' => 1,
]);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'no_score'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => ['note' => 'hello'],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$this->assertSame(0, AssessmentScore::count());
$this->assertTrue($assessment->fresh()->isCompleted());
}
public function test_complete_fails_when_score_items_missing(): void
{
$template = AssessmentTemplate::create([
'code' => 'need_scores',
'name' => 'Need scores',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => true,
'scoring_strategy' => 'sum_items',
'meta' => ['capture_roles' => ['doctor']],
]);
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => 'item_a',
'label' => 'A',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]]],
'is_required' => false,
'sort_order' => 1,
]);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'need_scores'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->from(route('care.assessments.show', $assessment))
->post(route('care.assessments.complete', $assessment))
->assertSessionHasErrors('answers.item_a');
$this->assertSame(Assessment::STATUS_DRAFT, $assessment->fresh()->status);
$this->assertSame(0, AssessmentScore::count());
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace Tests\Feature;
use App\Models\AssessmentTemplate;
use App\Models\ClinicalPathway;
use Database\Seeders\AssessmentTemplateSeeder;
use Database\Seeders\ClinicalPathwaySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/** PR 10 — additional specialty content packs seed cleanly. */
class CareContentPacksTest extends TestCase
{
use RefreshDatabase;
public function test_all_core_assessment_packs_seed(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$expected = [
'universal_intake',
'nihss',
'mrs',
'barthel',
'gcs',
'stroke_swallow',
'stroke_clinical',
'diabetes_core',
'outcome_core',
'heart_failure_core',
'ckd_core',
'hypertension_core',
'asthma_core',
'copd_core',
'dementia_core',
'parkinsons_core',
'cancer_core',
'pregnancy_core',
'orthopaedics_core',
];
foreach ($expected as $code) {
$this->assertNotNull(
AssessmentTemplate::currentSystemByCode($code),
"Missing template {$code}"
);
}
}
public function test_all_pathways_seed_with_required_bindings(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$this->seed(ClinicalPathwaySeeder::class);
$expected = [
'stroke',
'diabetes',
'heart_failure',
'ckd',
'hypertension',
'asthma',
'copd',
'dementia',
'parkinsons',
'cancer',
'pregnancy',
'orthopaedics',
];
foreach ($expected as $code) {
$pathway = ClinicalPathway::findByCode($code);
$this->assertNotNull($pathway, "Missing pathway {$code}");
$this->assertGreaterThanOrEqual(
1,
$pathway->templates()->where('is_required_on_activation', true)->count(),
"Pathway {$code} needs a required template"
);
}
}
public function test_copd_and_heart_failure_scoring_templates(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$copd = AssessmentTemplate::currentSystemByCode('copd_core');
$hf = AssessmentTemplate::currentSystemByCode('heart_failure_core');
$this->assertSame('single_value', $copd->scoring_strategy);
$this->assertSame('single_value', $hf->scoring_strategy);
$this->assertTrue($copd->questions()->where('code', 'mmrc')->exists());
$this->assertTrue($hf->questions()->where('code', 'nyha')->exists());
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\ClinicalPathway;
use App\Models\Consultation;
use App\Models\Diagnosis;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\PatientPathway;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\CareFeatures;
use App\Services\Care\PathwayMatcher;
use App\Services\Care\PathwayService;
use Database\Seeders\AssessmentTemplateSeeder;
use Database\Seeders\ClinicalPathwaySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/** PR 7 — diabetes_core + diabetes pathway. */
class CareDiabetesPathwayTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'dm-user',
'name' => 'DM User',
'email' => 'dm@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'DM Clinic',
'slug' => 'dm-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-DM-001',
'first_name' => 'Abena',
'last_name' => 'Darko',
]);
$this->seed(AssessmentTemplateSeeder::class);
$this->seed(ClinicalPathwaySeeder::class);
}
public function test_diabetes_pack_and_pathway_seeded(): void
{
$template = AssessmentTemplate::currentSystemByCode('diabetes_core');
$this->assertNotNull($template);
$this->assertTrue($template->questions()->where('code', 'foot_assessment')->exists());
$this->assertTrue($template->questions()->where('code', 'hba1c')->exists());
$pathway = ClinicalPathway::findByCode('diabetes');
$this->assertNotNull($pathway);
$this->assertTrue(
$pathway->templates()->where('template_code', 'diabetes_core')->where('is_required_on_activation', true)->exists()
);
}
public function test_matcher_matches_type2_and_e11(): void
{
$matcher = app(PathwayMatcher::class);
$byCode = $matcher->match([['code' => 'E11.9', 'description' => '']]);
$this->assertTrue($byCode->contains(fn ($r) => $r['pathway_code'] === 'diabetes'));
$byKw = $matcher->match([['code' => '', 'description' => 'Type 2 diabetes mellitus']]);
$this->assertTrue($byKw->contains(fn ($r) => $r['pathway_code'] === 'diabetes'));
}
public function test_activate_diabetes_creates_core_draft_and_comorbidity_with_stroke(): void
{
$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(),
]);
Diagnosis::create([
'owner_ref' => $this->user->public_id,
'consultation_id' => $consultation->id,
'code' => 'E11.9',
'description' => 'Type 2 diabetes mellitus',
'is_primary' => true,
]);
$this->actingAs($this->user)
->post(route('care.pathways.store', $this->patient), [
'pathway_code' => 'diabetes',
'consultation_uuid' => $consultation->uuid,
])
->assertRedirect();
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
$this->assertTrue(
Assessment::query()->whereHas('template', fn ($q) => $q->where('code', 'diabetes_core'))->exists()
);
// Comorbidity: also activate stroke
app(PathwayService::class)->activate(
$this->patient,
ClinicalPathway::findByCode('stroke'),
$this->user->public_id,
$this->member,
['consultation' => $consultation, 'actor' => $this->user->public_id],
);
$this->assertSame(2, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
$this->assertGreaterThanOrEqual(3, Assessment::where('status', Assessment::STATUS_DRAFT)->count()); // dm + nihss + mrs
$this->actingAs($this->user)
->get(route('care.consultations.show', $consultation))
->assertOk()
->assertSee('Diabetes')
->assertSee('Stroke')
->assertSee('diabetes_core');
}
public function test_nurse_can_complete_diabetes_core(): void
{
$this->member->update(['role' => 'nurse']);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'diabetes_core'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => [
'foot_assessment' => 'normal',
'hba1c' => 7.2,
'diet_adherence' => 'good',
],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$this->assertTrue($assessment->fresh()->isCompleted());
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Services\Care\CareFeatures;
use Database\Seeders\AssessmentTemplateSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/** PR 8 — outcome_core + patient trends. */
class CareOutcomeTrendTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'outcome-user',
'name' => 'Outcome User',
'email' => 'outcome@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Outcome Clinic',
'slug' => 'outcome-clinic',
'timezone' => 'UTC',
'settings' => [
'onboarded' => true,
'plan' => 'free',
'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' => 'nurse',
]);
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,
'patient_number' => 'LC-OUT-001',
'first_name' => 'Kofi',
'last_name' => 'Owusu',
]);
$this->seed(AssessmentTemplateSeeder::class);
}
public function test_outcome_core_seeded(): void
{
$t = AssessmentTemplate::currentSystemByCode('outcome_core');
$this->assertNotNull($t);
$this->assertSame(AssessmentTemplate::CATEGORY_OUTCOME, $t->category);
$this->assertTrue($t->questions()->where('code', 'quality_of_life')->exists());
$this->assertTrue($t->questions()->where('code', 'falls_count')->exists());
}
public function test_nurse_can_record_outcomes_and_see_trends_on_free_plan(): void
{
$this->actingAs($this->user)
->post(route('care.outcomes.store', $this->patient))
->assertRedirect();
$assessment = Assessment::first();
$this->assertSame('outcome_core', $assessment->template->code);
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => [
'quality_of_life' => 7,
'pain_score' => 3,
'falls_count' => 1,
'hospital_admissions_since_last' => 0,
'medication_adherence' => 'good',
],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$this->actingAs($this->user)
->get(route('care.outcomes.index', $this->patient))
->assertOk()
->assertSee('Longitudinal outcomes')
->assertSee('Quality of life trend')
->assertSee('7')
->assertSee('Pain score trend')
->assertSee('3')
->assertSee('Enterprise'); // free plan notice
$this->actingAs($this->user)
->get(route('care.patients.show', $this->patient))
->assertOk()
->assertSee('Outcomes');
}
public function test_second_outcome_builds_series(): void
{
foreach ([[5, 4], [8, 2]] as [$qol, $pain]) {
$this->actingAs($this->user)
->post(route('care.outcomes.store', $this->patient))
->assertRedirect();
$assessment = Assessment::query()->where('status', Assessment::STATUS_DRAFT)->latest('id')->first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => [
'quality_of_life' => $qol,
'pain_score' => $pain,
],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment));
}
// Idempotent start when draft exists would reuse — after complete, second store creates new.
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_COMPLETED)->count());
$html = $this->actingAs($this->user)
->get(route('care.outcomes.index', $this->patient))
->assertOk()
->getContent();
$this->assertStringContainsString('8', $html);
$this->assertStringContainsString('5', $html);
}
}
+336
View File
@@ -0,0 +1,336 @@
<?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\ClinicalPathway;
use App\Models\Consultation;
use App\Models\Diagnosis;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\PatientPathway;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\CareFeatures;
use App\Services\Care\PathwayMatcher;
use App\Services\Care\PathwayService;
use Database\Seeders\ClinicalPathwaySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class CarePathwayTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'pathway-user',
'name' => 'Pathway User',
'email' => 'pathway@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Pathway Clinic',
'slug' => 'pathway-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-PATH-001',
'first_name' => 'Kojo',
'last_name' => 'Stroke',
]);
}
public function test_clinical_pathway_seeder_loads_stroke(): void
{
$this->seed(ClinicalPathwaySeeder::class);
$pathway = ClinicalPathway::findByCode('stroke');
$this->assertNotNull($pathway);
$this->assertTrue($pathway->is_active);
$this->assertContains('I63', $pathway->match_rules['icd_prefixes'] ?? []);
$this->assertGreaterThanOrEqual(2, $pathway->templates()->count());
$this->assertTrue($pathway->templates->contains('template_code', 'nihss'));
$this->assertTrue($pathway->templates->contains('template_code', 'mrs'));
$required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all();
$this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required);
}
public function test_matcher_icd_keyword_exclude_and_no_match(): void
{
$this->seed(ClinicalPathwaySeeder::class);
$matcher = app(PathwayMatcher::class);
$icd = $matcher->match([['code' => 'I63.9', 'description' => '']]);
$this->assertCount(1, $icd);
$this->assertSame('stroke', $icd[0]['pathway_code']);
$this->assertSame(100, $icd[0]['rank']);
$this->assertStringStartsWith('icd_prefix:', $icd[0]['match_reason']);
$kw = $matcher->match([['code' => '', 'description' => 'Ischaemic stroke']]);
$this->assertCount(1, $kw);
$this->assertSame(50, $kw[0]['rank']);
$this->assertStringContainsString('keyword:', $kw[0]['match_reason']);
$tia = $matcher->match([['code' => 'G45.9', 'description' => 'TIA']]);
$this->assertCount(1, $tia);
$this->assertSame('stroke', $tia[0]['pathway_code']);
$none = $matcher->match([['code' => '', 'description' => 'History unclear']]);
$this->assertCount(0, $none);
$excluded = $matcher->match([['code' => '', 'description' => 'Family history of stroke']]);
$this->assertCount(0, $excluded);
}
public function test_activate_is_idempotent_and_creates_required_drafts(): void
{
$this->seed(ClinicalPathwaySeeder::class);
// Minimal current templates so activation can draft them.
foreach (['nihss' => 'NIHSS', 'mrs' => 'mRS'] as $code => $name) {
$t = AssessmentTemplate::create([
'code' => $code,
'name' => $name,
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => true,
'scoring_strategy' => $code === 'mrs' ? 'single_value' : 'sum_items',
'meta' => ['capture_roles' => ['doctor']],
]);
AssessmentQuestion::create([
'template_id' => $t->id,
'code' => 'item',
'label' => 'Item',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]], 'max' => 1],
'is_required' => true,
'sort_order' => 1,
]);
}
$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(),
]);
Diagnosis::create([
'owner_ref' => $this->user->public_id,
'consultation_id' => $consultation->id,
'code' => 'I63.9',
'description' => 'Cerebral infarction',
'is_primary' => true,
]);
$this->actingAs($this->user)
->post(route('care.pathways.store', $this->patient), [
'pathway_code' => 'stroke',
'consultation_uuid' => $consultation->uuid,
])
->assertRedirect(route('care.pathways.index', $this->patient));
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
$pp = PatientPathway::first();
$this->assertStringContainsString('I63.9', (string) $pp->activation_diagnosis_text);
$this->assertSame($consultation->id, $pp->activation_consultation_id);
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
$this->assertTrue(Assessment::where('patient_pathway_id', $pp->id)->exists());
$this->assertDatabaseHas('care_audit_logs', ['action' => 'pathway.activated']);
// Idempotent re-activate
$this->actingAs($this->user)
->post(route('care.pathways.store', $this->patient), [
'pathway_code' => 'stroke',
'consultation_uuid' => $consultation->uuid,
])
->assertRedirect();
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
}
public function test_suggestions_use_persisted_diagnoses_only(): void
{
$this->seed(ClinicalPathwaySeeder::class);
$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)
->getJson(route('care.consultations.pathway-suggestions', $consultation))
->assertOk()
->assertJsonPath('data', []);
Diagnosis::create([
'owner_ref' => $this->user->public_id,
'consultation_id' => $consultation->id,
'code' => null,
'description' => 'Ischaemic stroke',
'is_primary' => true,
]);
$this->actingAs($this->user)
->getJson(route('care.consultations.pathway-suggestions', $consultation))
->assertOk()
->assertJsonPath('data.0.pathway_code', 'stroke')
->assertJsonPath('data.0.already_active', false);
$this->actingAs($this->user)
->get(route('care.consultations.show', $consultation))
->assertOk()
->assertSee('Clinical pathways')
->assertSee('Stroke')
->assertSee('keyword:');
}
public function test_deactivate_pathway(): void
{
$this->seed(ClinicalPathwaySeeder::class);
$pathway = ClinicalPathway::findByCode('stroke');
$pp = app(PathwayService::class)->activate(
$this->patient,
$pathway,
$this->user->public_id,
$this->member,
['actor' => $this->user->public_id, 'activation_diagnosis_text' => 'stroke'],
);
$this->actingAs($this->user)
->post(route('care.pathways.deactivate', [$this->patient, $pp]))
->assertRedirect();
$this->assertSame(PatientPathway::STATUS_INACTIVE, $pp->fresh()->status);
$this->assertDatabaseHas('care_audit_logs', ['action' => 'pathway.deactivated']);
}
public function test_nurse_cannot_activate_pathway(): void
{
$this->seed(ClinicalPathwaySeeder::class);
$this->member->update(['role' => 'nurse']);
$this->actingAs($this->user)
->post(route('care.pathways.store', $this->patient), [
'pathway_code' => 'stroke',
])
->assertForbidden();
}
public function test_template_version_bump_still_resolves_pathway_binding(): void
{
$this->seed(ClinicalPathwaySeeder::class);
AssessmentTemplate::create([
'code' => 'nihss',
'name' => 'NIHSS v1',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 1,
'is_current' => false,
'meta' => ['capture_roles' => ['doctor']],
]);
$v2 = AssessmentTemplate::create([
'code' => 'nihss',
'name' => 'NIHSS v2',
'category' => AssessmentTemplate::CATEGORY_DISEASE,
'version' => 2,
'is_current' => true,
'meta' => ['capture_roles' => ['doctor']],
]);
AssessmentQuestion::create([
'template_id' => $v2->id,
'code' => 'item',
'label' => 'Item',
'answer_type' => AssessmentQuestion::TYPE_SCORE_ITEM,
'options' => ['choices' => [['code' => '1', 'label' => '1', 'score' => 1]]],
'is_required' => true,
'sort_order' => 1,
]);
// Only nihss required for this test — remove mrs binding temporarily via pathway seed already has both.
// Activate stroke; mrs missing is skipped; nihss v2 should draft.
$pathway = ClinicalPathway::findByCode('stroke');
$pp = app(PathwayService::class)->activate(
$this->patient,
$pathway,
$this->user->public_id,
$this->member,
['actor' => $this->user->public_id],
);
$draft = Assessment::where('patient_pathway_id', $pp->id)->where('template_id', $v2->id)->first();
$this->assertNotNull($draft);
$this->assertSame(2, $draft->template->version);
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentScore;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\ClinicalPathway;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\User;
use App\Services\Care\CareFeatures;
use Database\Seeders\AssessmentTemplateSeeder;
use Database\Seeders\ClinicalPathwaySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/** PR 6b — Barthel, GCS, swallow, stroke_clinical. */
class CareStrokeExtendedTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Patient $patient;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'stroke-ext-user',
'name' => 'Stroke Ext',
'email' => 'stroke-ext@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Ext Clinic',
'slug' => 'ext-clinic',
'timezone' => 'UTC',
'settings' => [
'onboarded' => true,
'rollout' => [CareFeatures::ASSESSMENTS_ENGINE => true],
],
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id,
'role' => 'doctor',
]);
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,
'patient_number' => 'LC-SEXT-001',
'first_name' => 'Yaw',
'last_name' => 'Mensah',
]);
$this->seed(AssessmentTemplateSeeder::class);
$this->seed(ClinicalPathwaySeeder::class);
}
public function test_stroke_pathway_includes_extended_optional_instruments(): void
{
$pathway = ClinicalPathway::findByCode('stroke');
$codes = $pathway->templates()->pluck('template_code')->all();
$this->assertContains('barthel', $codes);
$this->assertContains('gcs', $codes);
$this->assertContains('stroke_swallow', $codes);
$this->assertContains('stroke_clinical', $codes);
$required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all();
$this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required);
}
public function test_gcs_golden_score_e4_v5_m6_is_fifteen(): void
{
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'gcs'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => ['eye' => '4', 'verbal' => '5', 'motor' => '6'],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$score = AssessmentScore::first();
$this->assertEquals(15, (float) $score->total_score);
$this->assertEquals(15, (float) $score->max_score);
}
public function test_barthel_fully_independent_is_one_hundred(): void
{
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'barthel'])
->assertRedirect();
$assessment = Assessment::first();
$answers = [
'feeding' => '10',
'bathing' => '5',
'grooming' => '5',
'dressing' => '10',
'bowels' => '10',
'bladder' => '10',
'toilet' => '10',
'transfers' => '15',
'mobility' => '15',
'stairs' => '10',
];
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), ['answers' => $answers]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$this->assertEquals(100, (float) AssessmentScore::first()->total_score);
$this->assertEquals(100, (float) AssessmentScore::first()->max_score);
}
public function test_stroke_clinical_records_tia_subtype(): void
{
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'stroke_clinical'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => [
'stroke_subtype' => 'tia',
'thrombolysis_status' => 'not_indicated',
],
]);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$this->assertTrue($assessment->fresh()->isCompleted());
$this->assertSame(0, AssessmentScore::count()); // no scoring strategy
$subtype = $assessment->fresh()->answers->first(
fn ($a) => $a->question?->code === 'stroke_subtype'
);
$this->assertSame('tia', $subtype->value_text);
}
public function test_all_extended_templates_seeded(): void
{
foreach (['barthel', 'gcs', 'stroke_swallow', 'stroke_clinical'] as $code) {
$this->assertNotNull(AssessmentTemplate::currentSystemByCode($code), $code);
}
}
}
+289
View File
@@ -0,0 +1,289 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Assessment;
use App\Models\AssessmentScore;
use App\Models\AssessmentTemplate;
use App\Models\Branch;
use App\Models\ClinicalPathway;
use App\Models\Consultation;
use App\Models\Diagnosis;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Patient;
use App\Models\PatientPathway;
use App\Models\User;
use App\Models\Visit;
use App\Services\Care\CareFeatures;
use App\Services\Care\ScoringService;
use Database\Seeders\AssessmentTemplateSeeder;
use Database\Seeders\ClinicalPathwaySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* PR 6 Stroke MVP content packs (NIHSS + mRS) and M2 vertical slice.
*/
class CareStrokeMvpTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'stroke-mvp-user',
'name' => 'Stroke MVP',
'email' => 'stroke@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Stroke Clinic',
'slug' => 'stroke-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-STROKE-001',
'first_name' => 'Efua',
'last_name' => 'Asante',
]);
$this->seed(AssessmentTemplateSeeder::class);
$this->seed(ClinicalPathwaySeeder::class);
}
public function test_seed_packs_load_nihss_and_mrs(): void
{
$nihss = AssessmentTemplate::currentSystemByCode('nihss');
$mrs = AssessmentTemplate::currentSystemByCode('mrs');
$this->assertNotNull($nihss);
$this->assertNotNull($mrs);
$this->assertSame('sum_items', $nihss->scoring_strategy);
$this->assertSame('single_value', $mrs->scoring_strategy);
$this->assertSame(15, $nihss->questions()->count());
$this->assertSame(1, $mrs->questions()->count());
$this->assertSame(['doctor'], $nihss->meta['capture_roles'] ?? null);
$pathway = ClinicalPathway::findByCode('stroke');
$this->assertNotNull($pathway);
$codes = $pathway->templates()->pluck('template_code')->all();
$this->assertContains('nihss', $codes);
$this->assertContains('mrs', $codes);
$required = $pathway->templates()->where('is_required_on_activation', true)->pluck('template_code')->all();
$this->assertEqualsCanonicalizing(['nihss', 'mrs'], $required);
}
public function test_golden_mrs_score_three(): void
{
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'mrs'])
->assertRedirect();
$assessment = Assessment::first();
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => ['mrs_score' => '3'],
])
->assertRedirect();
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$score = AssessmentScore::where('assessment_id', $assessment->id)->first();
$this->assertNotNull($score);
$this->assertEquals(3, (float) $score->total_score);
$this->assertEquals(6, (float) $score->max_score);
$this->assertSame('mrs', $score->template_code);
}
public function test_golden_nihss_fixture_total_twelve_max_forty_two(): void
{
// Moderate left hemisphere pattern → total 12; max 42.
$answers = [
'1a_loc' => '1',
'1b_loc_questions' => '1',
'1c_loc_commands' => '0',
'2_gaze' => '0',
'3_visual' => '1',
'4_facial' => '2',
'5a_motor_arm_left' => '2',
'5b_motor_arm_right' => '0',
'6a_motor_leg_left' => '2',
'6b_motor_leg_right' => '0',
'7_ataxia' => '0',
'8_sensory' => '1',
'9_language' => '1',
'10_dysarthria' => '1',
'11_extinction' => '0',
];
// 1+1+0+0+1+2+2+0+2+0+0+1+1+1+0 = 12
$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' => $answers])
->assertRedirect();
$preview = app(ScoringService::class)->preview(
$assessment->fresh(['template.questions', 'answers.question'])
);
$this->assertEquals(12.0, $preview['total']);
$this->assertEquals(42.0, $preview['max']);
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$score = AssessmentScore::first();
$this->assertEquals(12, (float) $score->total_score);
$this->assertEquals(42, (float) $score->max_score);
$this->assertArrayHasKey('loc', $score->subscores);
$this->assertEquals(2.0, (float) $score->subscores['loc']); // 1a+1b+1c = 2
}
public function test_m2_e2e_activate_stroke_complete_nihss_and_mrs(): void
{
$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(),
]);
Diagnosis::create([
'owner_ref' => $this->user->public_id,
'consultation_id' => $consultation->id,
'code' => 'I63.9',
'description' => 'Cerebral infarction, unspecified',
'is_primary' => true,
]);
$this->actingAs($this->user)
->get(route('care.consultations.show', $consultation))
->assertOk()
->assertSee('Stroke')
->assertSee('Activate');
$this->actingAs($this->user)
->post(route('care.pathways.store', $this->patient), [
'pathway_code' => 'stroke',
'consultation_uuid' => $consultation->uuid,
])
->assertRedirect();
$this->assertSame(1, PatientPathway::where('status', PatientPathway::STATUS_ACTIVE)->count());
$this->assertSame(2, Assessment::where('status', Assessment::STATUS_DRAFT)->count());
$this->actingAs($this->user)
->get(route('care.consultations.show', $consultation))
->assertOk()
->assertSee('Pathway instruments')
->assertSee('NIH Stroke Scale')
->assertSee('Modified Rankin Scale')
->assertSee('Continue');
$nihss = Assessment::query()
->whereHas('template', fn ($q) => $q->where('code', 'nihss'))
->first();
$mrs = Assessment::query()
->whereHas('template', fn ($q) => $q->where('code', 'mrs'))
->first();
$this->assertNotNull($nihss);
$this->assertNotNull($mrs);
$this->assertSame($consultation->id, $nihss->consultation_id);
// Minimal valid NIHSS (all zeros) → total 0.
$zeroAnswers = collect($nihss->template->questions)->mapWithKeys(
fn ($q) => [$q->code => '0']
)->all();
$this->actingAs($this->user)
->put(route('care.assessments.update', $nihss), ['answers' => $zeroAnswers])
->assertRedirect();
$this->actingAs($this->user)
->post(route('care.assessments.complete', $nihss))
->assertRedirect();
$this->assertEquals(0, (float) AssessmentScore::where('assessment_id', $nihss->id)->value('total_score'));
$this->assertEquals(42, (float) AssessmentScore::where('assessment_id', $nihss->id)->value('max_score'));
$this->actingAs($this->user)
->put(route('care.assessments.update', $mrs), ['answers' => ['mrs_score' => '2']])
->assertRedirect();
$this->actingAs($this->user)
->post(route('care.assessments.complete', $mrs))
->assertRedirect();
$this->assertEquals(2, (float) AssessmentScore::where('assessment_id', $mrs->id)->value('total_score'));
$this->actingAs($this->user)
->get(route('care.consultations.show', $consultation))
->assertOk()
->assertSee('score 0')
->assertSee('score 2');
}
public function test_nurse_cannot_start_seeded_nihss(): void
{
$this->member->update(['role' => 'nurse']);
$this->actingAs($this->user)
->post(route('care.assessments.store', $this->patient), ['template_code' => 'nihss'])
->assertForbidden();
}
}
+199
View File
@@ -0,0 +1,199 @@
<?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 Database\Seeders\AssessmentTemplateSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class CareUniversalIntakeTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Organization $organization;
protected Branch $branch;
protected Patient $patient;
protected Member $member;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->user = User::create([
'public_id' => 'universal-intake-user',
'name' => 'Intake User',
'email' => 'intake@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->user->public_id,
'name' => 'Intake Clinic',
'slug' => 'intake-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' => 'nurse',
]);
$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-INTAKE-001',
'first_name' => 'Kwame',
'last_name' => 'Boateng',
]);
}
public function test_assessment_template_seeder_loads_universal_intake_pack(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$template = AssessmentTemplate::currentSystemByCode('universal_intake');
$this->assertNotNull($template);
$this->assertSame(1, $template->version);
$this->assertSame(AssessmentTemplate::CATEGORY_UNIVERSAL, $template->category);
$this->assertNull($template->organization_id);
$this->assertSame(['doctor', 'nurse'], $template->meta['capture_roles'] ?? null);
$codes = $template->questions()->pluck('code')->all();
$this->assertContains('chief_complaint', $codes);
$this->assertContains('hpi', $codes);
$this->assertContains('pain_score', $codes);
$this->assertContains('smoking_status', $codes);
$this->assertContains('mobility', $codes);
$this->assertContains('baseline_labs_summary', $codes);
$chief = $template->questions()->where('code', 'chief_complaint')->first();
$this->assertTrue($chief->is_required);
$this->assertSame(AssessmentQuestion::TYPE_TEXTAREA, $chief->answer_type);
}
public function test_seeder_is_idempotent_and_replaces_questions(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$firstId = AssessmentTemplate::currentSystemByCode('universal_intake')->id;
$questionCount = AssessmentQuestion::where('template_id', $firstId)->count();
$this->seed(AssessmentTemplateSeeder::class);
$this->assertSame(1, AssessmentTemplate::where('code', 'universal_intake')->count());
$this->assertSame($firstId, AssessmentTemplate::currentSystemByCode('universal_intake')->id);
$this->assertSame($questionCount, AssessmentQuestion::where('template_id', $firstId)->count());
}
public function test_nurse_can_complete_seeded_universal_intake_on_consultation(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$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(),
'symptoms' => 'Free-text headache narrative',
]);
// Nurse has vitals + assessments.capture; free-text symptoms fields require consultations.manage.
$this->actingAs($this->user)
->get(route('care.consultations.show', $consultation))
->assertOk()
->assertSee('Universal intake')
->assertSee('narrative source of truth', false)
->assertSee('Start universal intake');
$this->actingAs($this->user)
->post(route('care.consultations.assessments.store', $consultation), [
'template_code' => 'universal_intake',
])
->assertRedirect();
$assessment = Assessment::first();
$this->assertNotNull($assessment);
$this->assertSame($consultation->id, $assessment->consultation_id);
$this->assertSame('universal_intake', $assessment->template->code);
$this->actingAs($this->user)
->put(route('care.assessments.update', $assessment), [
'answers' => [
'chief_complaint' => 'Severe headache for 2 days',
'pain_score' => 7,
'smoking_status' => 'never',
'mobility' => 'independent',
],
])
->assertRedirect();
$this->actingAs($this->user)
->post(route('care.assessments.complete', $assessment))
->assertRedirect();
$assessment->refresh();
$this->assertTrue($assessment->isCompleted());
// Dual narrative: free-text symptoms unchanged by structured intake.
$this->assertSame('Free-text headache narrative', $consultation->fresh()->symptoms);
$this->actingAs($this->user)
->get(route('care.patients.show', $this->patient))
->assertOk()
->assertSee('Universal intake')
->assertSee('Severe headache for 2 days');
}
public function test_create_form_lists_seeded_universal_for_nurse(): void
{
$this->seed(AssessmentTemplateSeeder::class);
$this->actingAs($this->user)
->get(route('care.assessments.create', $this->patient))
->assertOk()
->assertSee('Universal Intake')
->assertSee('universal_intake');
}
}