feat(tenant): give a tenant pair to the children reachable by a request uuid

Phase 7 concluded that aggregate children needed no column of their own,
because every repository query anchors to its root. That was true of the
repositories, and it missed the case where the anchor never happens:

    $item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']);

A lookup by uuid is itself an unanchored query, and TenantFilter cannot help
when the table has no column to filter on. All three leaks phase 7 found had
exactly this shape, including the one that put another environment's service
price on a patient's invoice.

Measuring which children are actually loaded that way gives eight of the
twenty-five — service_items (15 call sites), patient_sessions (7),
session_payments, patient_notes, patient_calls, patient_messages,
patient_attachments, patient_medical_records. They now carry their own pair
and leave AGGREGATE_CHILDREN; the other seventeen are only ever traversed
from their root and stay as they were.

The pair is derived from the root inside the constructor rather than passed
in, so no creation site can forget it and the value has one source. A root
never changes environment, so the copy is written once and cannot drift.

This is defence at the data layer rather than at the entry point: a forgotten
guard now returns nothing instead of another environment's row. The existing
TenantOwnershipChecker guards stay as the outer layer.

Verified against an imported production database: 8 tables backfilled, zero
rows unmatched, zero rows inconsistent with their root. Dropping the column
again turns the leak test red.

Tests: 911 backend (+5). PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 16:50:51 +03:30
co-authored by Claude Opus 5
parent 74d2034158
commit a95ee9a618
12 changed files with 323 additions and 18 deletions
+32 -2
View File
@@ -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` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند |
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Phase 8: give a tenant pair to the aggregate children that are loaded straight
* from a request uuid.
*
* The phase-7 audit concluded that aggregate children needed no column of their
* own because every repository query anchors to its root. That was true of the
* repositories — and missed the case where the anchor never happens:
*
* $item = $this->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;
}
}
+5 -8
View File
@@ -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; }
+5
View File
@@ -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;
+5
View File
@@ -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();
@@ -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();
+5
View File
@@ -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();
+5
View File
@@ -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();
+5
View File
@@ -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();
+5
View File
@@ -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();
+4 -8
View File
@@ -80,23 +80,19 @@ final class GlobalTables
* ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون
* JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
*
* فرزندی که uuidش از خودِ درخواست می‌آید نباید اینجا بماند: چنین جست‌وجویی
* ذاتاً بی‌لنگر است و تور ایمنی ندارد. هشت مورد از این دست جفت محیط خودشان را
* گرفتند (فاز ۸)؛ باقی‌مانده‌ها فقط از ریشه پیمایش می‌شوند.
*
* @var array<class-string, class-string> فرزند => ریشه
*/
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,
@@ -0,0 +1,153 @@
<?php
namespace App\Tests\Shared;
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\Patient\Entity\SessionPayment;
use App\Shared\Tenant\TenantFilter;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* فرزندی که uuidش از خودِ درخواست می‌آید، جفت محیط **خودش** را دارد.
*
* فاز ۷ نتیجه گرفت فرزندان aggregate ستون لازم ندارند چون هر کوئری repository به
* ریشه لنگر می‌زند. آن حرف دربارهٔ repositoryها درست بود و یک حالت را ندید:
*
* $item = $this->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]));
}
}