feat: add RichTextEditor component for rich text editing in articles

feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
This commit is contained in:
hamed
2026-08-08 11:40:17 +03:30
parent 934405c42d
commit 47323daa27
36 changed files with 3461 additions and 12686 deletions
+12
View File
@@ -54,6 +54,18 @@ abstract class ApiTestCase extends WebTestCase
}
$this->em = static::getContainer()->get(EntityManagerInterface::class);
// بستنِ manager تنها راهِ آلوده‌شدنِ تستِ بعدی نیست. تستی که موجودیتی را
// `persist()` می‌کند و بی‌`flush()` تمام می‌شود — یا درخواستِ کرنلی که
// ارجاع‌هایش را نیمه‌کاره رها می‌کند — همان unit of work را برای تستِ بعدی
// به ارث می‌گذارد. آنجا اولین `flush()` با «A new entity was found through
// the relationship …» می‌شکند؛ خطایی که همیشه جای دیگری می‌افتد و در اجرای
// تکی هرگز تکرار نمی‌شود.
//
// `clear()` نه `resetManager()`: همان نمونه می‌ماند، پس هیچ ارجاعی به
// managerِ مرده نمی‌رسد؛ فقط identity map خالی می‌شود.
$this->em->clear();
$this->ensureFreePlan();
}
@@ -0,0 +1,100 @@
<?php
namespace App\Tests\Appointment;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* اتصال نوبت به جلسهٔ درمان در `POST /api/v1/my/appointment`.
*
* تا ۲۰۲۶-۰۸-۰۸ این شاخه هیچ تستی نداشت و به همین دلیل شکسته بود: کنترلر
* `$this->branches->pair($user)` را صدا می‌زد ولی `AddressResolver` هرگز تزریق
* نشده بود، پس هر درخواستِ دارای `treatment_session_uuid` روی «Undefined
* property» ۵۰۰ می‌گرفت. phpstan همان را گزارش می‌کرد، اما بین ۱۶ خطای بی‌اثر
* دیگر گم شده بود.
*/
class AppointmentTreatmentSessionLinkTest extends ApiTestCase
{
/**
* این تست چند درخواستِ کرنل پشت‌سرهم می‌زند و هر کدام `$this->em` را کهنه
* می‌کند. بدون ریست، همان نمونه به تست بعدی ارث می‌رسد و آنجا — نه اینجا —
* با «Multiple non-persisted new entities» می‌شکند. همان دامی که
* ApiLeastPrivilegeTest قبلاً برایش همین tearDown را گذاشت.
*/
protected function tearDown(): void
{
static::getContainer()->get('doctrine')->resetManager();
parent::tearDown();
}
/** @return array{0: User, 1: Doctor} */
private function doctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
private function body(string $doctorUuid, array $extra = []): array
{
$start = time() + 86_400 + random_int(0, 3_600) * 100;
return $extra + [
'doctor_uuid' => $doctorUuid,
'slot_start' => $start,
'slot_end' => $start + 1_800,
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
'patient_name' => 'بیمار تست',
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
];
}
/** موفق: مسیر عادی بدون اتصال، دست‌نخورده. */
public function testBookingWithoutSessionLinkStillWorks(): void
{
[$owner, $doctor] = $this->doctor();
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid()));
self::assertSame(201, $this->responseCode());
}
/**
* خطا: uuidِ ناموجود باید ۴۰۴ـی بگیرد که از `SessionBookingLink` می‌آید.
*
* ۵۰۰ گرفتن یعنی اجرا اصلاً به آن سرویس نرسیده — همان رگرسیونی که این تست
* برایش نوشته شده.
*/
public function testBookingWithUnknownSessionUuidIsRejectedNotCrashed(): void
{
[$owner, $doctor] = $this->doctor();
$body = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body(
$doctor->getUuid(),
['treatment_session_uuid' => '00000000-0000-0000-0000-000000000000'],
));
self::assertSame(404, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code']);
self::assertSame('treatment_session_uuid', $body['errors'][0]['field'] ?? null);
}
/** مرزی: رشتهٔ خالی یعنی «اتصالی در کار نیست»، نه uuidِ نامعتبر. */
public function testEmptySessionUuidIsTreatedAsNoLink(): void
{
[$owner, $doctor] = $this->doctor();
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body(
$doctor->getUuid(),
['treatment_session_uuid' => ' '],
));
self::assertSame(201, $this->responseCode());
}
}
+24
View File
@@ -97,6 +97,30 @@ class BlogBodySanitizerTest extends ApiTestCase
$this->assertStringContainsString('ویرایش', $body);
}
/**
* جدول‌های مقاله‌های موجود ظاهرشان را از attributeهای قدیمیِ HTML می‌گیرند.
* این سه غیرقابل‌اجرا هستند و از ۲۰۲۶-۰۸-۰۸ مجازند؛ `style` همچنان می‌رود چون
* تنها attributeِ ظاهریِ جدول است که می‌تواند بارِ اجرایی حمل کند.
*/
public function testTableKeepsInertLayoutAttributesButLosesStyle(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'مقالهٔ جدول',
'body' => '<table border="1" cellpadding="7" cellspacing="0" style="width:100%">'
. '<tr><td>سلول</td></tr></table>',
]);
$uuid = $res['data']['data']['uuid'] ?? $res['data']['uuid'];
$body = $this->storedBody($uuid);
$this->assertStringContainsString('border="1"', $body);
$this->assertStringContainsString('cellpadding="7"', $body);
$this->assertStringContainsString('cellspacing="0"', $body);
$this->assertStringNotContainsString('style=', $body);
$this->assertStringContainsString('سلول', $body);
}
/** بدنه‌ای که چیزی جز markup ناامن ندارد، بعد از پاک‌سازی خالی است → ۴۲۲، نه ذخیره. */
public function testBodyThatIsOnlyUnsafeMarkupIsRejected(): void
{
+67 -5
View File
@@ -160,14 +160,44 @@ class ApiLeastPrivilegeTest extends ApiTestCase
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
// ── گِیت دارند ولی هدفشان از بدنه می‌آید، نه از path ──────────────────
// با بدنهٔ خالی روی uuidِ ناموجودِ داخلِ بدنه ۴۰۴ می‌دهند. مثل روت‌های
// پارامتردار، ولی چون path parameter ندارند سطح اولِ قاعده شاملشان می‌شد.
'app_appointment_appointmentsettings_createschedule' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createoverride' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createholiday' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده می‌شود',
];
/**
* روت‌هایی که پیش‌چکِ منشی **پیش از واکشی** دارند، پس حتی با uuidِ ناموجود هم
* باید `403` بدهند نه `404`.
*
* این فهرست پیشرفتِ یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷ را قفل می‌کند: چکِ اصلیِ این روت‌ها
* شیء‌محور است و بالا نمی‌رود، ولی سهمِ منشی از آن بالا برده شد. اگر کسی آن خط را
* بردارد، پاسخ به `404` برمی‌گردد و همین تست قرمز می‌شود.
*
* @var list<string>
*/
private const GATE_BEFORE_LOOKUP = [
'app_appointment_appointment_updatestatus',
'app_appointment_appointment_confirm',
'app_appointment_appointment_update',
'app_appointment_appointment_servicereschedule',
'app_appointment_appointmentsettings_createschedule',
'app_appointment_appointmentsettings_updateschedule',
'app_appointment_appointmentsettings_deleteschedule',
'app_appointment_appointmentsettings_createoverride',
'app_appointment_appointmentsettings_updateoverride',
'app_appointment_appointmentsettings_deleteoverride',
'app_appointment_appointmentsettings_createholiday',
'app_appointment_appointmentsettings_updateholiday',
'app_appointment_appointmentsettings_deleteholiday',
'app_clinic_clinic_update',
'app_clinic_clinic_detachdoctor',
'app_clinic_clinicdoctorpermission_updatepermissions',
'app_clinicinvitation_clinicinvitation_invitedoctor',
'app_clinicinvitation_clinicinvitation_resendinvitation',
'app_clinicinvitation_clinicinvitation_changeinvitationstatus',
'app_clinicinvitation_clinicinvitation_deleteinvitation',
'resource_block_create',
'resource_block_delete',
];
/**
* این تست ~۱۳۰ درخواست پشت‌سرهم می‌زند و هر درخواست کرنل را دوباره بالا
* می‌آورد، پس `$this->em` تا انتهای تست به یک نمونهٔ کهنه اشاره می‌کند. بدون
@@ -344,6 +374,38 @@ class ApiLeastPrivilegeTest extends ApiTestCase
));
}
/**
* قفلِ پیشرفت: هر روتِ `GATE_BEFORE_LOOKUP` با uuidِ ناموجود باید `403` بدهد.
*
* `404` یعنی گِیت دوباره پایین‌تر از واکشی رفته و enumeration oracle برگشته.
*/
public function testHoistedGatesAnswer403BeforeTheLookup(): void
{
$secretary = $this->makePowerlessSecretary();
$router = self::getContainer()->get('router');
$regressed = [];
foreach (self::GATE_BEFORE_LOOKUP as $name) {
$route = $router->getRouteCollection()->get($name);
$this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — فهرست را به‌روز کن");
$method = array_values(array_intersect(
$route->getMethods(),
['POST', 'PUT', 'PATCH', 'DELETE'],
))[0];
$this->authJson($method, self::probePath($route), $secretary);
if ($this->responseCode() !== 403) {
$regressed[] = sprintf('%s → %d', $name, $this->responseCode());
}
}
$this->assertSame([], $regressed, sprintf(
"این روت‌ها دیگر پیش از واکشی گِیت نمی‌خورند:\n%s",
implode("\n", $regressed),
));
}
/**
* بدهی نباید بی‌صدا بماند: به‌محض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
* تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
+13
View File
@@ -38,6 +38,19 @@ use App\Treatment\Entity\TreatmentSession;
*/
class StaffCrossTenantTest extends ApiTestCase
{
/**
* این تست چند درخواستِ کرنل پشت‌سرهم می‌زند و هر کدام `$this->em` را کهنه
* می‌کند. بدون ریست، همان نمونه به تست بعدی ارث می‌رسد و آنجا — نه اینجا —
* با «Multiple non-persisted new entities» می‌شکند. همان دامی که
* ApiLeastPrivilegeTest قبلاً برایش همین tearDown را گذاشت.
*/
protected function tearDown(): void
{
static::getContainer()->get('doctrine')->resetManager();
parent::tearDown();
}
private const LASER_SCHEMA = [
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
];