Deploy Ladill Care / deploy (push) Successful in 1m22s
Replace internal design jargon (Layer 1, narrative source of truth, pathway instruments) with plain clinical language on consultations, patient charts, settings, and nav.
337 lines
12 KiB
PHP
337 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Http\Middleware\EnsurePlatformSession;
|
|
use App\Models\Assessment;
|
|
use App\Models\AssessmentQuestion;
|
|
use App\Models\AssessmentTemplate;
|
|
use App\Models\Branch;
|
|
use App\Models\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('Condition 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);
|
|
}
|
|
}
|