fix(tenant): check the environment wherever a uuid comes from the request

Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot
reach. Measuring first — as the plan required — moved the target: all 22 children
and their 20 repositories were already sound. Every list query anchors on its
root, and ServiceItemRepository even joins service_sections and filters on the
pair by hand. A repository-level guard would have found nothing.

The real exposure was one layer up. Where a uuid arrives from a request body or
query string, the entity it names is loaded by uuid alone, and the filter is no
help: aggregate children have no tenant column, and a panel user who never chose
an environment is not filtered at all. Three leaks, each proven by removing the
fix and watching the new tests go red:

- GET /api/v1/appointment-service-slots accepted service_item_uuids from any
  environment. Existence, bookable state and duration leaked through the error
  messages and the returned slots. The booking path in the same controller had
  guarded this since it was written; the slot path never did.
- POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid,
  staff_uuid and the service list without any check, and persisted them onto the
  appointment. A write, not just a read.
- PatientService did the same in all three of its loops — pricing, session
  create, session update — so another environment's service price entered the
  invoice and its SessionService row was stored, staff included.

TenantOwnershipChecker is the single place that answers "does this belong to the
current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now
delegates that pair to its section: an aggregate child exposing the tenant it
inherits. An entity that exposes no pair throws rather than returning false —
silence here builds an always-closed guard, which is its own bug.

TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its
place immediately: the first run found more sites than the manual grep had, and
reviewing them turned up the third PatientService loop. StaffController looked
unguarded until read properly — ownsStaff sits two lines below the null check.

One assertion was wrong before it was right: the create-path test read
`$session['services'] ?? []`, which passes vacuously. It now counts the stored
rows through the repository, and fails without the fix.

Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 13:39:59 +03:30
co-authored by Claude Opus 5
parent 0ae9570850
commit d2f4b5c428
10 changed files with 718 additions and 8 deletions
@@ -119,4 +119,163 @@ class ServiceModeSectionDurationTest extends ApiTestCase
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
self::assertSame(30, $reloaded->getDurationMinutes());
}
// ── جداسازی محیط روی سرویس‌های انتخابی ──────────────────────────────────
/**
* سرویس‌ها فرزند aggregateاند (service_items → service_sections) و ستون محیط
* ندارند، پس TenantFilter پوششان نمی‌دهد. مسیر ثبت نوبت با
* assertServicesMatchContext محافظت می‌شد ولی مسیر محاسبهٔ اسلات نه — با uuid
* سرویسِ محیط دیگر می‌شد وجود، فعال‌بودن و مدتش را استنتاج کرد.
*/
public function testServiceSlotsRejectsAServiceFromAnotherEnvironment(): void
{
[$owner, $doctor, $date] = $this->serviceDoctor();
[, $stranger] = $this->serviceDoctor();
$foreign = $this->service($stranger, 'سرویس بیگانه', 45);
$this->authJson('GET', sprintf(
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
$doctor->getUuid(), $date, $foreign->getUuid(),
), $owner);
self::assertSame(422, $this->responseCode());
}
/** حتی وقتی یک سرویسِ خودی هم در فهرست است، سرویسِ بیگانه کل درخواست را رد می‌کند. */
public function testOneForeignServiceInvalidatesTheWholeRequest(): void
{
[$owner, $doctor, $date] = $this->serviceDoctor();
$mine = $this->service($doctor, 'سرویس خودی', 30);
[, $stranger] = $this->serviceDoctor();
$foreign = $this->service($stranger, 'سرویس بیگانه', 45);
$this->authJson('GET', sprintf(
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s',
$doctor->getUuid(), $date, $mine->getUuid(), $foreign->getUuid(),
), $owner);
self::assertSame(422, $this->responseCode());
}
/** ⚠️ مسیر سالم نباید بشکند: سرویس همان پزشک همچنان اسلات می‌دهد. */
public function testServiceSlotsStillWorkForTheDoctorsOwnService(): void
{
[$owner, $doctor, $date] = $this->serviceDoctor();
$mine = $this->service($doctor, 'سرویس خودی', 30);
$res = $this->authJson('GET', sprintf(
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
$doctor->getUuid(), $date, $mine->getUuid(),
), $owner);
self::assertSame(200, $this->responseCode());
self::assertSame(30, $res['data']['total_duration_minutes']);
self::assertNotEmpty($res['data']['start_times']);
}
/**
* نشتیِ نوشتنی: مسیر ثبت نوبت از پنل، بخش/سرویس/پرسنل را با uuid از بدنهٔ
* درخواست می‌گرفت و بدون بررسی محیط به نوبت می‌چسباند — یعنی دادهٔ محیط دیگری
* ذخیره می‌شد، نه فقط خوانده.
*/
public function testPanelBookingRejectsAServiceFromAnotherEnvironment(): void
{
[$owner, $doctor] = $this->serviceDoctor();
[, $stranger] = $this->serviceDoctor();
$foreign = $this->service($stranger, 'سرویس بیگانه', 30);
$start = time() + 86_400 + random_int(0, 3_600) * 100;
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'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),
'service_item_uuids' => [$foreign->getUuid()],
]);
self::assertSame(422, $this->responseCode());
}
/** همان نشتی از مسیر service_item_uuid تکی (فیلد workflow کلینیک). */
public function testPanelBookingRejectsAForeignServiceItemField(): void
{
[$owner, $doctor] = $this->serviceDoctor();
[, $stranger] = $this->serviceDoctor();
$foreign = $this->service($stranger, 'سرویس بیگانه', 30);
$start = time() + 86_400 + random_int(0, 3_600) * 100;
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'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),
'service_item_uuid' => $foreign->getUuid(),
]);
self::assertSame(422, $this->responseCode());
}
/** و بخشِ محیط دیگر هم رد می‌شود. */
public function testPanelBookingRejectsAForeignServiceSection(): void
{
[$owner, $doctor] = $this->serviceDoctor();
[, $stranger] = $this->serviceDoctor();
$foreignSection = $this->service($stranger, 'سرویس بیگانه', 30)->getSection();
$start = time() + 86_400 + random_int(0, 3_600) * 100;
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'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),
'service_section_uuid' => $foreignSection->getUuid(),
]);
self::assertSame(422, $this->responseCode());
}
/** ⚠️ مسیر سالم نباید بشکند: سرویس خودی همچنان ثبت می‌شود. */
public function testPanelBookingStillAcceptsTheDoctorsOwnService(): void
{
[$owner, $doctor] = $this->serviceDoctor();
$mine = $this->service($doctor, 'سرویس خودی', 30);
$start = time() + 86_400 + random_int(0, 3_600) * 100;
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'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),
'service_item_uuids' => [$mine->getUuid()],
'service_item_uuid' => $mine->getUuid(),
]);
self::assertSame(201, $this->responseCode());
}
/** uuid کاملاً ناموجود هم همان ۴۲۲ را می‌گیرد — پیام «وجود ندارد» جدا لو نمی‌رود. */
public function testUnknownServiceUuidIsIndistinguishableFromAForeignOne(): void
{
[$owner, $doctor, $date] = $this->serviceDoctor();
$this->authJson('GET', sprintf(
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
$doctor->getUuid(), $date, '00000000-0000-4000-8000-000000000000',
), $owner);
self::assertSame(422, $this->responseCode());
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Tests\Patient;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
/**
* سرویس‌های یک مراجعه با uuid از بدنهٔ درخواست می‌آیند. service_items فرزند
* aggregate است (→ service_sections) و ستون محیط ندارد، پس TenantFilter پوششش
* نمی‌دهد؛ کالای مصرفی این قاعده را از قبل رعایت می‌کرد ولی سرویس نه — قیمتِ
* سرویسِ محیط دیگر وارد محاسبهٔ فاکتور می‌شد و روی SessionService ذخیره می‌ماند.
*/
class SessionServiceTenantTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
private function recordFor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$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, $doctor, $record];
}
private function serviceFor(Doctor $doctor, string $name, int $price): ServiceItem
{
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
$this->em->persist($section);
$item = new ServiceItem($section, $name, $price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function createSession(\App\Auth\Entity\User $owner, PatientRecord $record, array $body = []): array
{
$res = $this->authJson(
'POST',
'/api/v1/patient/' . $record->getUuid() . '/session',
$owner,
$body + ['visit_price_rials' => 500_000],
);
self::assertSame(201, $this->responseCode(), 'ساخت مراجعه باید موفق باشد');
return $res['data'];
}
/** ✅ سرویس خودی وارد فاکتور می‌شود. */
public function testOwnServiceIsPriced(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$mine = $this->serviceFor($doctor, 'سرویس خودی', 900_000);
$session = $this->createSession($owner, $record, [
'services' => [['service_item_uuid' => $mine->getUuid(), 'quantity' => 1]],
]);
self::assertSame(900_000, $session['services_total_rials'], 'سرویس خودی باید قیمت بخورد');
}
/** ❌ سرویسِ محیط دیگر نه در قیمت می‌آید نه ذخیره می‌شود. */
public function testAnotherEnvironmentsServiceIsIgnoredOnCreate(): void
{
[$owner, , $record] = $this->recordFor();
[, $stranger] = $this->recordFor();
$foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000);
$session = $this->createSession($owner, $record, [
'services' => [['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1]],
]);
self::assertSame(0, $session['services_total_rials'], 'قیمت سرویس بیگانه نباید اضافه شود');
// قیمت‌گذاری و ذخیره‌سازی دو حلقهٔ جدا در PatientService هستند؛ این یکی
// ذخیره را می‌سنجد و مستقیم از دیتابیس می‌خواند تا به شکل پاسخ وابسته نباشد.
self::assertSame(0, $this->storedServiceCount($session['uuid']), 'نباید روی مراجعه ذخیره شود');
}
private function storedServiceCount(string $sessionUuid): int
{
$this->em->clear();
$session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]);
return $session === null ? -1 : $session->getServices()->count();
}
/** ❌ همان قاعده در ویرایش مراجعه. */
public function testAnotherEnvironmentsServiceIsIgnoredOnUpdate(): void
{
[$owner, , $record] = $this->recordFor();
[, $stranger] = $this->recordFor();
$foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000);
$session = $this->createSession($owner, $record);
$res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [
'services' => [['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1]],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(0, $res['data']['services_total_rials'], 'سرویس بیگانه نباید در فاکتور بنشیند');
self::assertSame([], $res['data']['services'] ?? [], 'و نباید روی مراجعه ذخیره شود');
}
/** ⚠️ مرزی: سرویس خودی می‌ماند حتی وقتی یک بیگانه هم در فهرست است. */
public function testOwnServiceSurvivesAlongsideAForeignOne(): void
{
[$owner, $doctor, $record] = $this->recordFor();
[, $stranger] = $this->recordFor();
$mine = $this->serviceFor($doctor, 'سرویس خودی', 300_000);
$foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000);
$session = $this->createSession($owner, $record);
$res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [
'services' => [
['service_item_uuid' => $mine->getUuid(), 'quantity' => 1],
['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1],
],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(300_000, $res['data']['services_total_rials'], 'فقط سرویس خودی باید بماند');
self::assertCount(1, $res['data']['services'] ?? []);
}
/** ❌ پرسنلِ محیط دیگر هم روی سرویس نمی‌نشیند. */
public function testAnotherEnvironmentsStaffIsNotAttached(): void
{
[$owner, $doctor, $record] = $this->recordFor();
[, $stranger] = $this->recordFor();
$mine = $this->serviceFor($doctor, 'سرویس خودی', 300_000);
$foreignStaff = new ClinicStaff('doctor', $stranger->getId(), 'پرسنل بیگانه');
$this->em->persist($foreignStaff);
$this->em->flush();
$session = $this->createSession($owner, $record);
$res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [
'services' => [[
'service_item_uuid' => $mine->getUuid(),
'staff_uuid' => $foreignStaff->getUuid(),
'quantity' => 1,
]],
]);
self::assertSame(200, $this->responseCode());
$service = ($res['data']['services'] ?? [])[0] ?? null;
self::assertNotNull($service, 'سرویس خودی باید ثبت شود');
self::assertNull($service['staff_uuid'] ?? null, 'پرسنل بیگانه نباید چسبیده باشد');
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Tests\Shared;
use PHPUnit\Framework\TestCase;
/**
* فهرستِ بازبینی‌شدهٔ جست‌وجوهای «uuid از درخواست → موجودیت محیط‌دار».
*
* سه نشتی این فاز همگی همین شکل را داشتند و هیچ‌کدام در repository نبودند؛ در
* کنترلر و سرویس بودند، جایی که uuid از بدنه/کوئری می‌آید و کسی محیط را نمی‌سنجد.
* پس گارد هم باید همان‌جا باشد، نه روی DQLهای repository.
*
* قاعده: عدد هر فایل = تعداد findByUuid روی repositoryهای محیط‌دار در آن فایل، که
* همگی بازبینی شده‌اند. اگر عدد عوض شود یعنی جست‌وجوی تازه‌ای اضافه شده و باید
* ثابت شود محیطش بررسی می‌شود — با ownsRecord/ownsSection/ownedItem،
* TenantOwnershipChecker، یا لنگرزدن به ریشه‌ای که خودش بررسی شده.
*
* @see \App\Shared\Tenant\TenantOwnershipChecker
* @see docs/architecture/tenancy.md
*/
class TenantLookupInventoryTest extends TestCase
{
/** repositoryهایی که موجودیت محیط‌دار یا فرزند aggregate برمی‌گردانند. */
private const TENANT_REPOSITORY_VARIABLES = [
'itemRepo', 'serviceItemRepo', 'sectionRepo', 'staffRepo',
'noteRepo', 'attachmentRepo', 'callRepo', 'messageRepo', 'medicalRepo',
'sessionRepo', 'sessionPaymentRepo', 'recordRepo',
'tenantInsuranceRepo', 'tenantTagRepo', 'packageRepo', 'ruleRepo',
];
/**
* فایل => تعداد جست‌وجوی بازبینی‌شده.
*
* @var array<string, int>
*/
private const REVIEWED = [
// assertServicesMatchContext در هر سه مسیر (اسلات سرویسی، ثبت، و خودِ گارد)
'src/Appointment/Controller/AppointmentController.php' => 3,
// TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها
'src/Appointment/Controller/MyAppointmentsController.php' => 1,
// ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه
'src/Billing/Controller/BillingController.php' => 3,
// ownsSection ×۹ و مقایسهٔ مستقیم جفت ×۳ (پکیج، کالا، پرسنل)
'src/ClinicService/Controller/ClinicServiceController.php' => 10,
'src/Discount/Controller/DiscountController.php' => 1,
// قرارداد بیمه با جفت، و سرویس با getSection()->getEntityType()
'src/Insurance/Controller/InsuranceController.php' => 5,
// ownedItem() / ownedPackage()
'src/Inventory/Controller/InventoryController.php' => 2,
'src/Inventory/Service/InventoryService.php' => 1,
// ownsRecord ×۳۳ و دو پرداختِ لنگرخورده به مراجعهٔ بررسی‌شده
'src/Patient/Controller/PatientController.php' => 35,
// TenantOwnershipChecker در هر سه حلقه (قیمت‌گذاری، ساخت، ویرایش) + کالا
'src/Patient/Service/PatientService.php' => 5,
// ownsStaff()
'src/Staff/Controller/StaffController.php' => 2,
];
private function projectDir(): string
{
return dirname(__DIR__, 2);
}
/** @return array<string, int> */
private function countLookups(): array
{
$pattern = '/->(' . implode('|', self::TENANT_REPOSITORY_VARIABLES) . ')->findByUuid\(/';
$counts = [];
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->projectDir() . '/src', \FilesystemIterator::SKIP_DOTS),
);
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$found = preg_match_all($pattern, (string) file_get_contents($file->getPathname()));
if ($found > 0) {
$relative = str_replace($this->projectDir() . '/', '', $file->getPathname());
$counts[$relative] = $found;
}
}
ksort($counts);
return $counts;
}
public function testNoUnreviewedTenantLookupExists(): void
{
$actual = $this->countLookups();
$expected = self::REVIEWED;
ksort($expected);
self::assertSame($expected, $actual, <<<'MSG'
جست‌وجوی «uuid از درخواست موجودیت محیط‌دار» تغییر کرده.
برای هر مورد تازه ثابت کن محیطش بررسی می‌شود (ownsRecord/ownsSection/
ownedItem، TenantOwnershipChecker، یا لنگر به ریشهٔ بررسی‌شده) و بعد عدد
همان فایل را در REVIEWED به‌روز کن. اگر عدد کم شده، فقط عدد را کم کن.
MSG);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Tests\Shared;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Shared\Tenant\TenantOwnershipChecker;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
/**
* تنها محافظِ موجودیت‌هایی که uuidشان از خودِ درخواست می‌آید و TenantFilter
* پوششان نمی‌دهد — یا چون فرزند aggregateاند یا چون کاربر محیطی انتخاب نکرده.
*/
class TenantOwnershipCheckerTest extends ApiTestCase
{
private function checker(): TenantOwnershipChecker
{
return static::getContainer()->get(TenantOwnershipChecker::class);
}
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر مالکیت');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function serviceFor(Doctor $doctor): ServiceItem
{
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
$this->em->persist($section);
$item = new ServiceItem($section, 'سرویس', 0);
$this->em->persist($item);
$this->em->flush();
return $item;
}
public function testEntityOfTheSameEnvironmentBelongs(): void
{
$doctor = $this->makeDoctor();
self::assertTrue(
$this->checker()->belongsTo(EntityContext::forDoctor($doctor), $this->serviceFor($doctor)),
);
}
public function testEntityOfAnotherEnvironmentDoesNot(): void
{
$doctor = $this->makeDoctor();
$foreign = $this->serviceFor($this->makeDoctor());
self::assertFalse($this->checker()->belongsTo(EntityContext::forDoctor($doctor), $foreign));
}
/** فرزند aggregate جفتش را از ریشه به ارث می‌برد — ServiceItem از ServiceSection. */
public function testAggregateChildIsComparedThroughItsRoot(): void
{
$doctor = $this->makeDoctor();
$item = $this->serviceFor($doctor);
self::assertSame('doctor', $item->getEntityType());
self::assertSame($doctor->getId(), $item->getEntityId());
}
/** ❌ null یعنی «پیدا نشد» و همان‌قدر رد می‌شود که یک موجودیت بیگانه. */
public function testNullIsNeverOwned(): void
{
$doctor = $this->makeDoctor();
self::assertFalse($this->checker()->belongsTo(EntityContext::forDoctor($doctor), null));
self::assertFalse($this->checker()->belongsToPair('doctor', $doctor->getId(), null));
}
/** ❌ محیط حل‌نشده هیچ‌چیز را مالک نیست. */
public function testUnresolvedContextOwnsNothing(): void
{
$item = $this->serviceFor($this->makeDoctor());
self::assertFalse($this->checker()->belongsTo(EntityContext::unknown(), $item));
}
/**
* ❌ موجودیتی که جفت محیطش را expose نمی‌کند باید بلند خطا بدهد، نه اینکه
* بی‌صدا false برگرداند — سکوت اینجا یعنی گاردِ همیشه-بسته که باگ می‌سازد.
*/
public function testEntityWithoutATenantPairThrows(): void
{
$doctor = $this->makeDoctor();
$this->expectException(\InvalidArgumentException::class);
$this->checker()->belongsTo(EntityContext::forDoctor($doctor), new \stdClass());
}
/** ⚠️ یک بیگانه در فهرست، کل فهرست را رد می‌کند. */
public function testOneForeignEntityRejectsTheWholeList(): void
{
$doctor = $this->makeDoctor();
$mine = $this->serviceFor($doctor);
$foreign = $this->serviceFor($this->makeDoctor());
$context = EntityContext::forDoctor($doctor);
self::assertTrue($this->checker()->allBelongTo($context, [$mine]));
self::assertFalse($this->checker()->allBelongTo($context, [$mine, $foreign]));
self::assertTrue($this->checker()->allBelongTo($context, []), 'فهرست خالی مانعی ندارد');
}
/** نوعِ محیط هم بخشی از هویت است: کلینیک ۵ با پزشک ۵ یکی نیست. */
public function testTypeIsPartOfTheIdentityNotJustTheId(): void
{
$doctor = $this->makeDoctor();
$item = $this->serviceFor($doctor);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک');
$this->em->persist($clinic);
$this->em->flush();
self::assertFalse(
$this->checker()->belongsToPair('clinic', $doctor->getId(), $item),
'همان شناسه ولی نوع دیگر نباید مالک شمرده شود',
);
}
/** موجودیت‌هایی که خودشان جفت دارند (نه از ریشه) هم با همین checker سنجیده می‌شوند. */
public function testEntitiesCarryingTheirOwnPairWorkToo(): void
{
$doctor = $this->makeDoctor();
$staff = new ClinicStaff('doctor', $doctor->getId(), 'پرسنل');
$this->em->persist($staff);
$this->em->flush();
self::assertTrue($this->checker()->belongsTo(EntityContext::forDoctor($doctor), $staff));
self::assertFalse($this->checker()->belongsToPair('doctor', $doctor->getId() + 1, $staff));
}
}