A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.
Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.
ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.
Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.
Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.
Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.
Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبتکننده
پروژه
clinicpro — بکاند Symfony و پنل ادمین React.
تکریپو است. nobat724_front فقط سه فراخوانی داشبورد نماینده دارد
(services/response.js خطوط ۲۰۲ تا ۲۱۱) و به هیچکدام از اندپوینتهای این تسک دست نمیزند.
زمینه
نماینده امروز میتواند پزشک و کلینیک بسازد. هنگام ساخت،
representation_id روی رکورد ست میشود:
// src/Representation/Controller/RepresentationActionController.php:299
$rep = $this->representationRepo->findByUser($user);
if ($rep !== null) {
$doctor->setRepresentationId($rep->getId());
}
ولی بعد از ساخت، هیچ راهی برای کامل کردن پروفایل ندارد. نه لگو، نه گالری، نه متن معرفی، نه تخصص، نه آدرس. عملاً onboarding نیمهکاره میماند و پزشک تازهساخته روی سایت عمومی یک رکورد خالی است.
مشکل / هدف
نماینده باید روی هر پزشک و کلینیکی که representation_id آن به او اشاره دارد،
فیلدهای محتوایی و ظاهری را ویرایش کند — و فقط همانها.
مجوز دائمی است و به representation_id گره میخورد.
هیچ فیلد جدید، هیچ migration، هیچ state تازهای لازم نیست.
معیار پذیرش
- ✅ موفق: نمایندهای که پزشک X را ساخته،
PATCH /api/v1/doctor/{X.uuid}با بدنهٔ{"info": "متن جدید", "images": [...]}میفرستد →200و رکورد ذخیره میشود. همین برایPATCH /api/v1/clinic/{uuid}با{"clinic_logo": "...", "info": "..."}. - ✅ موفق:
GET /api/v1/doctor/{X.uuid}با توکن همان نماینده →can_edit: true. همان درخواست با توکن نمایندهٔ دیگر →can_edit: false. بدون توکن →can_edit: falseو بقیهٔ پاسخ مثل قبل. - ❌ خطا: نمایندهٔ دیگری (که این پزشک را نساخته) همان PATCH را بفرستد
→
403باERR_AUTH_006. - ❌ خطا: نمایندهٔ مالک، کلید ممنوع بفرستد
(
medical_system_codeیاactiveبرای پزشک،doctorsبرای کلینیک) →403با نام همان فیلد درerrors[0].field. هیچ چیزی ذخیره نمیشود. - ⚠️ مرزی: کاربری با
ROLE_REPRESENTATIONکه ردیفRepresentationندارد →403، نه500. - ⚠️ مرزی: پزشکی که
representation_idآنnullاست → هیچ نمایندهای اجازه ندارد؛403. - ⚠️ مرزی: خودِ پزشک و مالک کلینیک و ادمین دقیقاً مثل قبل رفتار میکنند —
whitelist روی آنها اعمال نمیشود و همچنان میتوانند
medical_system_codeوdoctorsرا عوض کنند. این تسک هیچ دسترسی موجودی را تنگ نمیکند. - ⚠️ مرزی: هر PATCH موفقِ نماینده دقیقاً یک ردیف
AppLogمیسازد. PATCH مالک یا ادمین هیچ ردیفی نمیسازد.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Representation/Security/RepresentationEditPolicy.php |
جدید — مالکیت و whitelist |
src/Doctor/Controller/DoctorController.php |
update، show، سه اکشن آدرس پزشک |
src/Clinic/Controller/ClinicController.php |
update، show، سه اکشن آدرس کلینیک |
src/Representation/Repository/RepresentationRepository.php |
findByUser() موجود است |
src/Shared/Logging/AppLog.php |
entity لاگ موجود |
assets/admin/pages/DoctorDetailPage.tsx |
isReadOnly خط ۱۱۲۵ |
assets/admin/pages/ClinicDetailPage.tsx |
isReadOnly خط ۳۶۴ |
docs/api/doctor.md · docs/api/clinic.md |
سند اندپوینتها |
وضعیت فعلی
دروازهٔ پزشک — فقط خود پزشک یا ادمین
// src/Doctor/Controller/DoctorController.php:340
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUuid($uuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
$this->hydrateDoctor($doctor, $data);
$this->doctorRepo->save($doctor);
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
}
دروازهٔ کلینیک — از checker موجود رد میشود
// src/Clinic/Controller/ClinicController.php:223
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
$clinic = $this->clinicRepo->findByUuid($uuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (($err = $this->validateGallerySize($data)) !== null) {
return $err;
}
$this->hydrateClinic($clinic, $data);
$this->clinicRepo->save($clinic);
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
}
ClinicDoctorPermissionChecker::can() نقش نماینده را نمیشناسد:
// src/Clinic/Security/ClinicDoctorPermissionChecker.php:44
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return true;
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
return false;
}
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
}
آدرسها — دروازهٔ جدا
// src/Doctor/Controller/DoctorController.php:656 — PATCH آدرس پزشک
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
// src/Clinic/Controller/ClinicController.php:766 — PATCH آدرس کلینیک (و DELETE، خط ۷۹۱)
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
// src/Doctor/Controller/DoctorController.php:542 — POST آدرس پزشک
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر میتواند آدرس اضافه کند', 403);
}
UI — عمداً قفل است
// assets/admin/pages/DoctorDetailPage.tsx:1124
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
// assets/admin/pages/ClinicDetailPage.tsx:363
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
GET جزئیات — هنوز کاربر جاری را نمیگیرد
// src/Doctor/Controller/DoctorController.php:150
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
// src/Clinic/Controller/ClinicController.php:159
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
هر دو عمومیاند و #[CurrentUser] ندارند.
وظایف
۱. کلاس سیاست — RepresentationEditPolicy
فایل جدید: src/Representation/Security/RepresentationEditPolicy.php
تنها تصمیمگیرندهٔ «این نماینده روی این رکورد چه اجازهای دارد».
هیچ controllerی نباید representation_id را دستی مقایسه کند.
<?php
namespace App\Representation\Security;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Representation\Repository\RepresentationRepository;
/**
* «نمایندهٔ ثبتکننده روی پروفایلی که خودش ساخته چه اجازهای دارد؟»
*
* مجوز دائمی است و تنها به representation_id گره میخورد — نماینده تا وقتی
* رکورد به او اشاره میکند مالکِ محتوای آن است. عمداً فقط فیلدهای محتوایی
* باز است: عضویت پزشکان در کلینیک و کد نظام پزشکی و فعال/غیرفعال بودن،
* تصمیمهای صاحبِ رکوردند نه فروشندهای که او را ثبت کرده.
*/
class RepresentationEditPolicy
{
/** فیلدهایی که نماینده روی پروفایل پزشک میتواند بفرستد. */
public const DOCTOR_FIELDS = [
'title', 'gender', 'degree', 'info', 'detail',
'mobile_number', 'activity_time',
'images', 'image_data', 'social_media',
'specialties', 'doctor_services', 'expertise',
'states', 'cities',
];
/** فیلدهایی که نماینده روی پروفایل کلینیک میتواند بفرستد. */
public const CLINIC_FIELDS = [
'name', 'info', 'address', 'telephone',
'working_days', '24_7', 'latitude', 'longitude',
'practice_domain_uuid', 'state', 'city',
'social_media', 'image_clinic', 'clinic_logo',
];
public function __construct(
private readonly RepresentationRepository $repRepo,
) {}
public function ownsDoctor(User $user, Doctor $doctor): bool
{
return $this->matches($user, $doctor->getRepresentationId());
}
public function ownsClinic(User $user, Clinic $clinic): bool
{
return $this->matches($user, $clinic->getRepresentationId());
}
/**
* اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند.
*
* @param list<string> $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS
*/
public function firstForbiddenField(array $data, array $allowed): ?string
{
foreach (array_keys($data) as $key) {
if (!in_array($key, $allowed, true)) {
return (string) $key;
}
}
return null;
}
private function matches(User $user, ?int $representationId): bool
{
if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) {
return false;
}
$rep = $this->repRepo->findByUser($user);
return $rep !== null && $rep->getId() === $representationId;
}
}
نکتههای اجباری:
matches()وقتیROLE_REPRESENTATIONنیست، بدون کوئری برمیگردد. این همان حالت مرزی «کاربر با نقش نماینده ولی بدون ردیف Representation» را هم بهfalseمیبندد، نه به exception.- کلاس هیچ HTTP نمیشناسد. پاسخ ۴۰۳ کارِ controller است.
نحوه تست: unit test خالص با entityهای ساختگی —
tests/Representation/RepresentationEditPolicyTest.php.
سناریوها: مالکِ درست true؛ نمایندهٔ دیگر false؛ representation_id === null → false؛
کاربر بدون ROLE_REPRESENTATION → false؛ firstForbiddenField روی
['info' => 'x', 'doctors' => []] با CLINIC_FIELDS باید 'doctors' بدهد و
روی ['info' => 'x'] باید null بدهد.
۲. لاگکردن ویرایشِ نماینده
سرویس کوچک کنار سیاست: src/Representation/Security/RepresentationEditLogger.php
از entity موجود App\Shared\Logging\AppLog استفاده کن — هیچ جدول جدیدی نساز.
سازندهٔ آن (level, message, context, channel, path) میگیرد.
$this->em->persist(new AppLog(
'info',
sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $entityType, $uuid),
json_encode(['representation_id' => $repId, 'fields' => array_keys($data)], JSON_UNESCAPED_UNICODE),
'representation_edit',
$request->getPathInfo(),
));
فقط وقتی لاگ بنویس که ویرایشکننده نماینده باشد.
مالک و ادمین هیچ ردیفی نمیسازند — وگرنه /admin/logs پر از نویز میشود.
نحوه تست: بعد از یک PATCH موفقِ نماینده،
SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit' باید یکی زیاد شده باشد.
بعد از PATCH ادمین روی همان رکورد، عددی تغییر نکند.
۳. باز کردن PATCH /api/v1/doctor/{uuid}
در DoctorController::update شرط ۴۰۳ فعلی را نگه دار و یک شاخهٔ نماینده کنارش بگذار.
ترتیب مهم است: اول مالکیت، بعد whitelist، بعد hydrate.
$isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
$isRepOwner = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor);
if (!$isOwnerOrAdmin && !$isRepOwner) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if ($isRepOwner) {
$bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS);
if ($bad !== null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad);
}
}
whitelist فقط روی $isRepOwner اجرا میشود. مسیر مالک و ادمین دستنخورده میماند.
نحوه تست: تست فانکشنال با ApiTestCase —
tests/Representation/RepresentationProfileEditTest.php.
یک نماینده و پزشکش بساز (از POST /api/v1/representation/doctor استفاده کن، نه fixture دستی)،
بعد PATCH با info بزن و 200 بگیر، بعد PATCH با medical_system_code بزن و 403 بگیر
و مطمئن شو مقدار قبلی در دیتابیس عوض نشده.
۴. باز کردن PATCH /api/v1/clinic/{uuid}
همان الگو. ولی اینجا permChecker جلوی راه است.
آن را تغییر نده. ClinicDoctorPermissionChecker دربارهٔ عضویت پزشک در کلینیک است
و نماینده اصلاً پزشکِ عضو نیست؛ اضافهکردن نقش نماینده به آن، مسئولیتِ کلاس را دوتا میکند.
بهجایش در controller کنارش بگذار:
$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);
if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
حواست به خط ۲۳۰ باشد:
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
این پیشچکِ منشی است و پیش از واکشی رکورد اجرا میشود.
بررسی کن که برای کاربرِ ROLE_REPRESENTATION (که منشی نیست) throw نکند.
اگر throw میکند، شرطش را طوری بگذار که فقط برای نقش منشی اجرا شود — و در تست ثابتش کن.
whitelist با RepresentationEditPolicy::CLINIC_FIELDS.
نحوه تست: نماینده و کلینیکش را با POST /api/v1/representation/clinic بساز.
PATCH با {"clinic_logo": "https://x/y.png", "info": "..."} → 200.
PATCH با {"doctors": [1]} → 403 و errors[0].field === 'doctors'.
یک نمایندهٔ دوم بساز و همان PATCH را بزن → 403.
۵. آدرسها — شش اکشن
همان سیاست را در اینها هم صدا بزن:
POST /api/v1/clinic-pro/doctor-address— نماینده بایدdoctor_uuidبفرستد، دقیقاً مثل مسیر ادمین در خط ۵۵۲. مالکیت همان پزشک بررسی شود.PATCH /api/v1/clinic-pro/doctor-address/{id}DELETE /api/v1/clinic-pro/doctor-address/{id}POST /api/v1/clinic/{clinicUuid}/addressPATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}
روی آدرسها whitelist لازم نیست — کل رکورد آدرس محتوایی است.
شرط TYPE_PERSONAL در خط ۶۵۲ باید سر جایش بماند؛ نماینده هم نباید
آدرس کلینیک را از مسیر آدرسِ پزشک عوض کند.
نحوه تست: نماینده برای پزشکش آدرس بسازد، ویرایش کند، حذف کند — هر سه 200.
نمایندهٔ دوم روی همان آدرس 403 بگیرد.
۶. فلگ can_edit در پاسخ GET جزئیات
هر دو show را طوری عوض کن که کاربر جاری را اختیاری بگیرند:
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
و در آرایهٔ خروجی:
'can_edit' => $user !== null && (
$doctor->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN')
|| $this->editPolicy->ownsDoctor($user, $doctor)
),
برای کلینیک همان با permChecker->can(...) || editPolicy->ownsClinic(...).
هر دو اندپوینت عمومیاند. بدون توکن باید can_edit: false بدهند و
هیچ بخش دیگری از پاسخ عوض نشود — سایت عمومی همینها را مصرف میکند.
نحوه تست: سه بار GET /api/v1/doctor/{uuid} — بدون توکن، با توکن نمایندهٔ مالک،
با توکن نمایندهٔ دیگر. مقادیر false و true و false.
۷. باز کردن UI پنل
در DoctorDetailPage.tsx و ClinicDetailPage.tsx این خط را بردار:
const isReadOnly = primaryRole === 'representation';
و جایش از پاسخ سرور بخوان:
const isReadOnly = primaryRole === 'representation' && !doctor?.can_edit;
هیچ منطق مجوزی را در فرانت بازنویسی نکن. can_edit تنها منبع حقیقت است.
فیلدهای بیرون از whitelist باید برای نماینده در فرم مخفی یا disabled باشند، نه اینکه ارسال شوند و ۴۰۳ بگیرند:
- پزشک: کد نظام پزشکی، و کلید فعال/غیرفعال
- کلینیک: مدیریت پزشکان کلینیک
نماینده همچنان میتواند پزشک را فعال/غیرفعال کند، ولی از اندپوینت اختصاصی خودش:
POST /api/v1/representation/doctors/{uuid}/status.
اگر آن دکمه در صفحه هست، به همان اندپوینت وصلش کن نه به PATCH.
تایپها را در assets/admin/types/index.ts بهروز کن: can_edit?: boolean.
نحوه تست: npx tsc --noEmit --project tsconfig.json سبز،
ddev exec yarn dev بدون خطا، و ورود دستی با یک کاربر نماینده در
https://clinic-pro.ddev.site/admin/doctors/<uuid> — دکمهٔ ویرایش و آپلود لگو دیده شود
و کد نظام پزشکی دیده نشود.
۸. مستندات
docs/api/doctor.md—PATCH /api/v1/doctor/{uuid}: نقش نماینده، فهرست فیلدهای مجاز، و ۴۰۳ فیلد ممنوع.GET: فیلدcan_edit.docs/api/clinic.md— همان برای کلینیک.- اندپوینتهای آدرس در هر دو سند.
- JSON نمونه باید خروجی اجرای واقعی باشد، نه دستساز.
نکات مهم
- الگو: Policy Object. یک کلاس، یک سؤال: «این نماینده چه اجازهای دارد».
دلیل انتخاب: منطق مجوز الان در هشت اکشن تکرار میشود؛ اگر inline بنویسی،
فردا که قاعده عوض شود هشت جا باید عوض شود و یکی جا میماند.
ClinicDoctorPermissionCheckerرا گسترش نده — آن دربارهٔ عضویت پزشک در کلینیک است، و پزشکِ نماینده اصلاً عضو نیست. - این تسک هیچ دسترسی موجودی را تنگ نمیکند. فقط باز میکند. اگر تستی از رفتار پزشک یا مالک یا ادمین شکست، یعنی whitelist اشتباه به آن مسیر هم خورده.
activeعمداً بیرون است. نماینده اندپوینت اختصاصی دارد:POST /api/v1/representation/doctors/{uuid}/status(خط ۶۳۲ همان controller). دو مسیر برای یک کار نساز.- اندپوینتهای آپلود دستنخورده میمانند.
/file/upload/clinic_pro/doctor/field_imageو.../clinic/field_clinic_logoالان هم برای هر کاربر لاگینشده بازند و فقط URL برمیگردانند؛ دروازهٔ واقعی همان PATCH است که URL را ذخیره میکند. - کلید ممنوع = ۴۰۳، نه حذف بیصدا. حذف بیصدا یعنی نماینده فکر میکند ذخیره شده
و تا مدتها کسی نمیفهمد.
$this->error(..., 403, $fieldName)امضای فیلد را هم میگیرد. مالکیترا ازrepresentation_idبخوان، نه از نقش. هر کاربری میتواندROLE_REPRESENTATIONداشته باشد؛ آنچه مهم است اینکه رکوردRepresentationاو همان id باشد که روی پزشک/کلینیک نشسته.- قرارداد API عوض نمیشود، فقط گسترده میشود.
can_editفیلد جدید و اختیاری است. ولیGET /api/v1/doctor/{uuid}وGET /api/v1/clinic/{uuid}راnobat724_frontهم مصرف میکند؛ بعد از تغییر، صفحهٔ پزشک و کلینیک سایت عمومی را دستی باز کن و مطمئن شو چیزی نشکسته. build آنها خطا نمیدهد. - تستها زیر
tests/Representation/بروند. دیتابیس تست هرگز reset نمیشود، پس دادهٔ هر تست را با مقدار یکتا بساز (uniqid()) تا اجرای دوم هم سبز بماند.