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,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'));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user