diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index 761ccb9c..1bc6268f 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -100,9 +100,37 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده ### ⚠️ فرزندان aggregate تور ایمنی ندارند -فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. +فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `service_item_consumables` بدون JOIN به ریشه، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. -فرزندی که لازم است مالکیتش سنجیده شود، جفت ارثی‌اش را expose می‌کند؛ `ServiceItem` این کار را با delegate به `ServiceSection` انجام می‌دهد. +**قاعدهٔ مرزبندی (فاز ۸):** فرزندی که uuidش از خودِ درخواست می‌آید، حق ندارد فرزند بماند. + +```php +$item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']); +``` + +این **خودش** یک کوئری بی‌لنگر است. فاز ۷ نتیجه گرفته بود فرزندان ستون لازم ندارند چون «هر کوئری repository به ریشه لنگر می‌زند» — که دربارهٔ DQLهای repository درست بود و همین حالت را ندید. هر سه نشتی فاز ۷ دقیقاً همین شکل بودند. + +پس هشت فرزندِ قابل‌دسترس با uuid جفت محیط **خودشان** را گرفتند و دیگر در `AGGREGATE_CHILDREN` نیستند: + +``` +service_items · patient_sessions · session_payments · patient_notes +patient_calls · patient_messages · patient_attachments · patient_medical_records +``` + +جفت در **سازنده** از ریشه مشتق می‌شود، نه از ورودی: + +```php +public function __construct(ServiceSection $section, ...) { + $this->section = $section; + $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); +} +``` + +دو خاصیت مهم دارد: هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند، و چون محیطِ یک ریشه هرگز عوض نمی‌شود، این کپی write-once است و واگرا نمی‌شود. + +۱۷ فرزند باقی‌مانده هیچ‌کدام با uuid از درخواست لود نمی‌شوند (اندازه‌گیری‌شده، نه فرض) و فقط از ریشه پیمایش می‌شوند. + +`RequestReachableChildTenantTest` این را **بدون هیچ گارد دستی** می‌سنجد: فقط خودِ فیلتر. با برداشتن ستون، همان نشتی مالی فاز ۷ برمی‌گردد و تست قرمز می‌شود. ### uuid از درخواست — خطرناک‌ترین الگو @@ -241,3 +269,5 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql | `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند | | `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست | | `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند | +| `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابل‌دسترس با uuid را **خودِ فیلتر** می‌بندد، بدون گارد دستی | +| `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند | diff --git a/migrations/Version20260728170000.php b/migrations/Version20260728170000.php new file mode 100644 index 00000000..ed731461 --- /dev/null +++ b/migrations/Version20260728170000.php @@ -0,0 +1,94 @@ +serviceItemRepo->findByUuid($request['service_item_uuid']); + * + * A lookup by uuid *is* an unanchored query, and TenantFilter cannot help when + * the table has no column to filter on. All three leaks found in phase 7 had + * exactly this shape. With these columns the filter covers them, so a forgotten + * guard returns nothing instead of another environment's row. + * + * Only the eight children actually reachable that way are changed — measured, + * not guessed. The rest stay aggregate children and are still reached from their + * root. + * + * The pair is derived from the root, and a root never changes environment, so + * the copy is written once and can never drift. + */ +final class Version20260728170000 extends AbstractMigration +{ + /** table => [join clause, root alias] */ + private const BACKFILL = [ + 'patient_attachments' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_calls' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_medical_records' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_messages' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_notes' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'patient_sessions' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], + 'session_payments' => ['JOIN patient_sessions s ON s.id = t.session_id JOIN patient_records r ON r.id = s.record_id', 'r'], + 'service_items' => ['JOIN service_sections sec ON sec.id = t.section_id', 'sec'], + ]; + + public function getDescription(): string + { + return 'Give the request-reachable aggregate children their own tenant pair'; + } + + public function up(Schema $schema): void + { + foreach (self::BACKFILL as $table => [$join, $alias]) { + $this->connection->executeStatement( + "ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL" + ); + $this->connection->executeStatement( + "UPDATE {$table} t {$join} + SET t.entity_type = {$alias}.entity_type, t.entity_id = {$alias}.entity_id" + ); + + // A row whose root cannot be reached is already broken data; inventing + // an environment for it would hide the breakage behind a wrong owner. + $remaining = (int) $this->connection->fetchOne( + "SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL" + ); + $this->abortIf( + $remaining > 0, + "{$remaining} row(s) in {$table} have no reachable root; fix them before rerunning." + ); + + $this->connection->executeStatement( + "ALTER TABLE {$table} MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL" + ); + $this->connection->executeStatement( + "CREATE INDEX idx_{$table}_entity ON {$table} (entity_type, entity_id)" + ); + } + } + + public function down(Schema $schema): void + { + foreach (array_keys(self::BACKFILL) as $table) { + $this->addSql("DROP INDEX idx_{$table}_entity ON {$table}"); + $this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id"); + } + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index f06b22dd..3250af88 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -7,13 +7,17 @@ use App\Insurance\Enum\ServiceCategory; use App\Staff\Entity\ClinicStaff; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: ServiceItemRepository::class)] #[ORM\Table(name: 'service_items')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_items_entity')] class ServiceItem { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -97,6 +101,7 @@ class ServiceItem { $this->uuid = Uuid::v4()->toRfc4122(); $this->section = $section; + $this->assignTenantPair($section->getEntityType(), $section->getEntityId()); $this->name = $name; $this->priceRials = $priceRials; $this->createdAt = time(); @@ -153,14 +158,6 @@ class ServiceItem public function getUuid(): string { return $this->uuid; } public function getSection(): ServiceSection { return $this->section; } - /** - * محیط را از بخشِ خودش به ارث می‌برد — ستون tenant ندارد و TenantFilter پوششش - * نمی‌دهد. این دو getter همان جفت را در دسترس می‌گذارند تا مالکیتش با بقیهٔ - * موجودیت‌های محیط‌دار یکسان بررسی شود ({@see \App\Shared\Tenant\TenantOwnershipChecker}). - */ - public function getEntityType(): string { return $this->section->getEntityType(); } - - public function getEntityId(): int { return $this->section->getEntityId(); } public function getStaff(): ?ClinicStaff { return $this->staff; } public function getName(): string { return $this->name; } public function getPriceRials(): int { return $this->priceRials; } diff --git a/src/Patient/Entity/PatientAttachment.php b/src/Patient/Entity/PatientAttachment.php index b2de6b44..70107d8a 100644 --- a/src/Patient/Entity/PatientAttachment.php +++ b/src/Patient/Entity/PatientAttachment.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientAttachmentRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A file attached to a patient record (the «ضمیمه» tab). */ #[ORM\Entity(repositoryClass: PatientAttachmentRepository::class)] #[ORM\Table(name: 'patient_attachments')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_attachments_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_attachments_record')] class PatientAttachment { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -43,6 +47,7 @@ class PatientAttachment { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->name = $name; $this->url = $url; $this->mime = $mime; diff --git a/src/Patient/Entity/PatientCall.php b/src/Patient/Entity/PatientCall.php index db3a8021..086f71d5 100644 --- a/src/Patient/Entity/PatientCall.php +++ b/src/Patient/Entity/PatientCall.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientCallRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A logged phone call with a patient (the «کال سنتر» tab). */ #[ORM\Entity(repositoryClass: PatientCallRepository::class)] #[ORM\Table(name: 'patient_calls')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_calls_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_calls_record')] class PatientCall { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -51,6 +55,7 @@ class PatientCall { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->subject = $subject; $this->outcome = $outcome; $this->calledAt = $calledAt ?? time(); diff --git a/src/Patient/Entity/PatientMedicalRecord.php b/src/Patient/Entity/PatientMedicalRecord.php index bb6eda00..c0e9462e 100644 --- a/src/Patient/Entity/PatientMedicalRecord.php +++ b/src/Patient/Entity/PatientMedicalRecord.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientMedicalRecordRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A medical examination / note entry on a patient record (the «پرونده پزشکی» tab). */ #[ORM\Entity(repositoryClass: PatientMedicalRecordRepository::class)] #[ORM\Table(name: 'patient_medical_records')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_medical_records_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_pmr_record')] class PatientMedicalRecord { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -41,6 +45,7 @@ class PatientMedicalRecord { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->title = $title; $this->body = $body; $this->createdAt = time(); diff --git a/src/Patient/Entity/PatientMessage.php b/src/Patient/Entity/PatientMessage.php index a30b46f1..f5e0d53b 100644 --- a/src/Patient/Entity/PatientMessage.php +++ b/src/Patient/Entity/PatientMessage.php @@ -3,15 +3,19 @@ namespace App\Patient\Entity; use App\Patient\Repository\PatientMessageRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** A logged message/communication with a patient (the «پیام‌ها» tab). */ #[ORM\Entity(repositoryClass: PatientMessageRepository::class)] #[ORM\Table(name: 'patient_messages')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_messages_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_messages_record')] class PatientMessage { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -38,6 +42,7 @@ class PatientMessage { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->body = $body; $this->channel = $channel; $this->createdAt = time(); diff --git a/src/Patient/Entity/PatientNote.php b/src/Patient/Entity/PatientNote.php index 5a299d6e..dbdb300d 100644 --- a/src/Patient/Entity/PatientNote.php +++ b/src/Patient/Entity/PatientNote.php @@ -4,6 +4,7 @@ namespace App\Patient\Entity; use App\Auth\Entity\User; use App\Patient\Repository\PatientNoteRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -14,9 +15,12 @@ use Symfony\Component\Uid\Uuid; */ #[ORM\Entity(repositoryClass: PatientNoteRepository::class)] #[ORM\Table(name: 'patient_notes')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_notes_entity')] #[ORM\Index(columns: ['record_id'], name: 'idx_patient_notes_record')] class PatientNote { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -56,6 +60,7 @@ class PatientNote { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->body = $body; $this->pinned = $pinned; $this->createdAt = time(); diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php index 9299bd28..177c43fa 100644 --- a/src/Patient/Entity/PatientSession.php +++ b/src/Patient/Entity/PatientSession.php @@ -8,14 +8,18 @@ use App\Inventory\Entity\InventoryPackage; use App\Patient\Repository\PatientSessionRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: PatientSessionRepository::class)] #[ORM\Table(name: 'patient_sessions')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_sessions_entity')] #[ORM\Index(columns: ['created_at'], name: 'idx_patient_sessions_created_at')] class PatientSession { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -143,6 +147,7 @@ class PatientSession { $this->uuid = Uuid::v4()->toRfc4122(); $this->record = $record; + $this->assignTenantPair($record->getEntityType(), $record->getEntityId()); $this->appointment = $appointment; $this->createdAt = time(); $this->updatedAt = time(); diff --git a/src/Patient/Entity/SessionPayment.php b/src/Patient/Entity/SessionPayment.php index 8cc57c2f..c789f8ce 100644 --- a/src/Patient/Entity/SessionPayment.php +++ b/src/Patient/Entity/SessionPayment.php @@ -4,6 +4,7 @@ namespace App\Patient\Entity; use App\Auth\Entity\User; use App\Patient\Repository\SessionPaymentRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -13,8 +14,11 @@ use Symfony\Component\Uid\Uuid; */ #[ORM\Entity(repositoryClass: SessionPaymentRepository::class)] #[ORM\Table(name: 'session_payments')] +#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_session_payments_entity')] class SessionPayment { + use TenantOwnedTrait; + public const METHODS = ['wallet', 'pos', 'cash', 'card']; #[ORM\Id] @@ -60,6 +64,7 @@ class SessionPayment { $this->uuid = Uuid::v4()->toRfc4122(); $this->session = $session; + $this->assignTenantPair($session->getEntityType(), $session->getEntityId()); $this->method = $method; $this->amountRials = $amountRials; $this->paidAt = $paidAt ?? time(); diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 8a39372b..ddd32667 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -80,23 +80,19 @@ final class GlobalTables * ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون * JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن. * + * فرزندی که uuidش از خودِ درخواست می‌آید نباید اینجا بماند: چنین جست‌وجویی + * ذاتاً بی‌لنگر است و تور ایمنی ندارد. هشت مورد از این دست جفت محیط خودشان را + * گرفتند (فاز ۸)؛ باقی‌مانده‌ها فقط از ریشه پیمایش می‌شوند. + * * @var array فرزند => ریشه */ public const AGGREGATE_CHILDREN = [ \App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class, - \App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientCall::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientMedicalRecord::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientMessage::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientNote::class => \App\Patient\Entity\PatientRecord::class, - \App\Patient\Entity\PatientSession::class => \App\Patient\Entity\PatientRecord::class, \App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class, \App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class, - \App\Patient\Entity\SessionPayment::class => \App\Patient\Entity\PatientSession::class, \App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class, - \App\ClinicService\Entity\ServiceItem::class => \App\ClinicService\Entity\ServiceSection::class, \App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class, \App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class, diff --git a/tests/Shared/RequestReachableChildTenantTest.php b/tests/Shared/RequestReachableChildTenantTest.php new file mode 100644 index 00000000..2819052f --- /dev/null +++ b/tests/Shared/RequestReachableChildTenantTest.php @@ -0,0 +1,153 @@ +serviceItemRepo->findByUuid($data['service_item_uuid']); + * + * جست‌وجو با uuid **خودش** یک کوئری بی‌لنگر است، و فیلتر وقتی ستونی نباشد کاری + * نمی‌تواند بکند. هر سه نشتی فاز ۷ همین شکل را داشتند. + * + * این تست‌ها عمداً **هیچ گارد دستی صدا نمی‌زنند** — فقط خودِ فیلتر. اگر ستون‌ها + * برداشته شوند، همگی قرمز می‌شوند. + */ +class RequestReachableChildTenantTest extends ApiTestCase +{ + private function em(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } + + protected function tearDown(): void + { + $filters = $this->em()->getFilters(); + if ($filters->isEnabled(TenantFilter::NAME)) { + $filters->disable(TenantFilter::NAME); + } + + parent::tearDown(); + } + + private function enableFilterFor(string $type, int $id): void + { + $this->em()->getFilters() + ->enable(TenantFilter::NAME) + ->setParameter(TenantFilter::PARAM_TYPE, $type, 'string') + ->setParameter(TenantFilter::PARAM_ID, $id, 'integer'); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر فرزند'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function serviceItemOf(Doctor $doctor): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش'); + $this->em->persist($section); + $item = new ServiceItem($section, 'سرویس', 100); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function sessionOf(Doctor $doctor): PatientSession + { + $record = new PatientRecord('doctor', $doctor->getId(), $this->createUser(['ROLE_USER']), 'doctor', $doctor->getId()); + $this->em->persist($record); + $session = new PatientSession($record); + $this->em->persist($session); + $this->em->flush(); + + return $session; + } + + /** جفت محیط از ریشه مشتق می‌شود، نه از ورودی سازنده — پس نمی‌تواند واگرا شود. */ + public function testTheChildInheritsItsRootsEnvironmentOnConstruction(): void + { + $doctor = $this->makeDoctor(); + $item = $this->serviceItemOf($doctor); + $session = $this->sessionOf($doctor); + + self::assertSame('doctor', $item->getEntityType()); + self::assertSame($doctor->getId(), $item->getEntityId()); + self::assertSame($doctor->getId(), $session->getEntityId()); + } + + /** ✅ در محیط خودی، جست‌وجو با uuid موجودیت را می‌دهد. */ + public function testALookupByUuidSucceedsInsideItsOwnEnvironment(): void + { + $doctor = $this->makeDoctor(); + $uuid = $this->serviceItemOf($doctor)->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId()); + + self::assertNotNull($this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $uuid])); + } + + /** + * ❌ همان جست‌وجو در محیط دیگر تهی برمی‌گردد — **بدون هیچ گارد دستی**. + * این همان نشتی مالی فاز ۷ است که حالا در سطح داده بسته شده. + */ + public function testTheSameLookupReturnsNothingInAnotherEnvironment(): void + { + $doctor = $this->makeDoctor(); + $uuid = $this->serviceItemOf($doctor)->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNull($this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $uuid])); + } + + /** ❌ همین قاعده برای مراجعه — که ۷ نقطهٔ جست‌وجو با uuid دارد. */ + public function testASessionOfAnotherEnvironmentIsInvisible(): void + { + $doctor = $this->makeDoctor(); + $uuid = $this->sessionOf($doctor)->getUuid(); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNull($this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $uuid])); + } + + /** ⚠️ مرزی: نوهٔ ریشه (پرداختِ مراجعه) هم محیطش را از همان زنجیره می‌گیرد. */ + public function testAGrandchildTakesTheEnvironmentOfTheChainToo(): void + { + $doctor = $this->makeDoctor(); + $session = $this->sessionOf($doctor); + $payment = new SessionPayment($session, 'cash', 50_000); + $this->em->persist($payment); + $this->em->flush(); + $uuid = $payment->getUuid(); + + self::assertSame($doctor->getId(), $payment->getEntityId()); + + $this->em->clear(); + $this->enableFilterFor('doctor', $doctor->getId() + 1000); + + self::assertNull($this->em->getRepository(SessionPayment::class)->findOneBy(['uuid' => $uuid])); + } +}