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