Files
clinicpro/tests/Patient/PatientNoteTest.php
T
hamedandClaude Opus 4.8 15c92903e1 feat: replace patient messages tab with pinnable notes
Port the tauri AddNoteModal notes feature into the admin patient case-file,
replacing the mislabeled «پیام‌ها» (SMS log) tab with «یادداشت‌ها».

Backend (src/Patient):
- PatientNote entity + repository (record-scoped, pinned-first ordering)
- CRUD endpoints on PatientController: GET /notes, POST /note,
  PATCH /note/{uuid} (edit body + toggle pin), DELETE /note/{uuid}
- author display name captured server-side from the current user
- migration for patient_notes; docs/api/patient.md updated

Frontend (assets/admin):
- NotesTab: compose box, newest/oldest sort, pinned-first list with
  accent rail + pin/edit/delete, edit modal, confirm-delete, empty state
- tab key/label/icon messages -> notes; onAddNote deep-links the notes tab

Tests: PatientNoteTest (create/list-order/edit/pin/delete/validation/ownership),
PatientDetailPage notes cases (render/empty/pin/create).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:51:33 +03:30

127 lines
5.3 KiB
PHP

<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* Patient notes: create, list (pinned-first order), edit, pin toggle, delete,
* validation and tenant ownership scoping.
*/
class PatientNoteTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
private function recordFor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$owner->setRealName('دکتر احمدی');
$doctor = new Doctor($owner, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return [$owner, $record];
}
public function testCreateCapturesAuthorAndDefaults(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, [
'body' => 'بیمار به دارو حساسیت دارد',
]);
self::assertSame(201, $this->responseCode());
self::assertSame('بیمار به دارو حساسیت دارد', $created['data']['body']);
self::assertFalse($created['data']['pinned']);
self::assertSame('دکتر احمدی', $created['data']['author']);
self::assertNull($created['data']['updated_at']);
}
public function testListReturnsPinnedFirst(): void
{
[$owner, $record] = $this->recordFor();
$base = '/api/v1/patient/' . $record->getUuid();
// Three notes; the oldest is pinned so it must float above the two newer ones.
$pinned = $this->authJson('POST', $base . '/note', $owner, ['body' => 'قدیمی پین‌شده', 'pinned' => true]);
$this->authJson('POST', $base . '/note', $owner, ['body' => 'دوم']);
$this->authJson('POST', $base . '/note', $owner, ['body' => 'سوم (جدیدترین)']);
$list = $this->authJson('GET', $base . '/notes', $owner);
self::assertCount(3, $list['data']);
self::assertSame($pinned['data']['uuid'], $list['data'][0]['uuid']);
self::assertTrue($list['data'][0]['pinned']);
// remaining two are newest-first among the unpinned
self::assertSame('سوم (جدیدترین)', $list['data'][1]['body']);
self::assertSame('دوم', $list['data'][2]['body']);
}
public function testEditBodyAndTogglePin(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'اولیه']);
$uuid = $created['data']['uuid'];
$edited = $this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $owner, [
'body' => 'ویرایش شد', 'pinned' => true,
]);
self::assertSame(200, $this->responseCode());
self::assertSame('ویرایش شد', $edited['data']['body']);
self::assertTrue($edited['data']['pinned']);
self::assertNotNull($edited['data']['updated_at']);
// pin-only toggle keeps body intact
$unpinned = $this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $owner, ['pinned' => false]);
self::assertFalse($unpinned['data']['pinned']);
self::assertSame('ویرایش شد', $unpinned['data']['body']);
}
public function testDelete(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'برای حذف']);
$uuid = $created['data']['uuid'];
$this->authJson('DELETE', '/api/v1/patient/note/' . $uuid, $owner);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/notes', $owner);
self::assertCount(0, $after['data']);
}
public function testRequiresBodyOnCreate(): void
{
[$owner, $record] = $this->recordFor();
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => ' ']);
self::assertSame(422, $this->responseCode());
}
public function testRejectsEmptyBodyOnEdit(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'اولیه']);
$this->authJson('PATCH', '/api/v1/patient/note/' . $created['data']['uuid'], $owner, ['body' => '']);
self::assertSame(422, $this->responseCode());
}
public function testOwnershipScoped(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'x']);
$uuid = $created['data']['uuid'];
[$other] = $this->recordFor();
$this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $other, ['pinned' => true]);
self::assertSame(404, $this->responseCode());
$this->authJson('DELETE', '/api/v1/patient/note/' . $uuid, $other);
self::assertSame(404, $this->responseCode());
}
}