Files
clinicpro/tests/Shared/TenantSchemaCoverageTest.php
T
hamedandClaude Opus 5 c9d4348c46 feat(tenant): mark the financial tables with their owning environment
Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:06:28 +03:30

141 lines
5.8 KiB
PHP

<?php
namespace App\Tests\Shared;
use App\Shared\Tenant\GlobalTables;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* ضد رگرسیون برای فازهای بعدی: entity جدیدی که فردا اضافه شود و کسی محیطش را
* تعیین نکند، اینجا قرمز می‌شود — نه ماه‌ها بعد با یک نشت داده.
*
* شکست این تست یعنی «این کلاس طبقه‌بندی نشده»، نه «تست خراب است».
*/
class TenantSchemaCoverageTest extends ApiTestCase
{
/** @return class-string[] */
private function allEntityClasses(): array
{
$em = static::getContainer()->get(EntityManagerInterface::class);
return array_map(
static fn($meta) => $meta->getName(),
$em->getMetadataFactory()->getAllMetadata(),
);
}
private function isTenantOwned(string $class): bool
{
$em = static::getContainer()->get(EntityManagerInterface::class);
return $em->getClassMetadata($class)->hasField('entityType');
}
/**
* هر entity دقیقاً یکی از چهار وضعیت را دارد: جفت tenant، سراسری، فرزند
* aggregate، یا بدهی ثبت‌شده.
*/
public function testEveryEntityIsClassified(): void
{
$unclassified = array_values(array_filter(
$this->allEntityClasses(),
fn(string $class) => !$this->isTenantOwned($class)
&& !isset(GlobalTables::ENTITIES[$class])
&& !isset(GlobalTables::AGGREGATE_CHILDREN[$class])
&& !isset(GlobalTables::DEFERRED[$class]),
));
self::assertSame([], $unclassified, sprintf(
"این entityها نه جفت tenant دارند و نه در GlobalTables ثبت شده‌اند:\n%s",
implode("\n", $unclassified),
));
}
/** هیچ کلاسی نباید هم‌زمان در دو دستهٔ GlobalTables باشد. */
public function testClassificationsDoNotOverlap(): void
{
$buckets = [
'ENTITIES' => array_keys(GlobalTables::ENTITIES),
'AGGREGATE_CHILDREN' => array_keys(GlobalTables::AGGREGATE_CHILDREN),
'DEFERRED' => array_keys(GlobalTables::DEFERRED),
];
foreach ($buckets as $name => $classes) {
foreach ($buckets as $otherName => $otherClasses) {
if ($name === $otherName) {
continue;
}
self::assertSame(
[],
array_values(array_intersect($classes, $otherClasses)),
"{$name} و {$otherName} کلاس مشترک دارند",
);
}
}
}
/**
* هر فرزند aggregate باید زنجیره‌ای به یک ریشهٔ tenant-دار داشته باشد. بدون این،
* ثبت‌کردنش در whitelist فقط تست را ساکت می‌کند بی‌آنکه محیطی وجود داشته باشد.
*/
public function testEveryAggregateChildReachesATenantOwnedRoot(): void
{
foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) {
$chain = [$child];
$current = $child;
while (isset(GlobalTables::AGGREGATE_CHILDREN[$current])) {
$current = GlobalTables::AGGREGATE_CHILDREN[$current];
self::assertNotContains($current, $chain, 'زنجیرهٔ aggregate حلقه دارد: ' . implode(' → ', $chain));
$chain[] = $current;
}
self::assertTrue(
$this->isTenantOwned($current),
sprintf('ریشهٔ %s باید جفت tenant داشته باشد: %s', $child, implode(' → ', $chain)),
);
}
}
/** فرزندِ خودش نباید جفت tenant داشته باشد — وگرنه دو منبع حقیقت می‌شود. */
public function testAggregateChildrenDoNotCarryTheirOwnTenant(): void
{
foreach (array_keys(GlobalTables::AGGREGATE_CHILDREN) as $child) {
self::assertFalse(
$this->isTenantOwned($child),
"{$child} هم جفت tenant دارد هم فرزند aggregate ثبت شده — یکی را بردار",
);
}
}
/** هر ثبت باید کلاس موجود و دلیل ناتهی داشته باشد. */
public function testEveryClassificationIsRealAndJustified(): void
{
foreach ([GlobalTables::ENTITIES, GlobalTables::DEFERRED] as $bucket) {
foreach ($bucket as $class => $reason) {
self::assertTrue(class_exists($class), "کلاس ثبت‌شده وجود ندارد: {$class}");
self::assertNotSame('', trim($reason), "دلیل {$class} خالی است");
}
}
foreach (GlobalTables::AGGREGATE_CHILDREN as $child => $root) {
self::assertTrue(class_exists($child), "کلاس ثبت‌شده وجود ندارد: {$child}");
self::assertTrue(class_exists($root), "ریشهٔ ثبت‌شده وجود ندارد: {$root}");
}
}
/**
* فاز ۶ بدهی را صفر کرد. از «رشد نکن» به «صفر بمان»: افزودن دوباره یعنی جدولی
* بیرون از هر تضمینی مانده، و باید تصمیم آگاهانه باشد نه یک ردشدنِ بی‌صدا.
*/
public function testThereIsNoUnclassifiedDebtLeft(): void
{
self::assertSame(
[],
GlobalTables::DEFERRED,
'بدهی طبقه‌بندی باید صفر بماند؛ هر کلاس یا جفت محیط می‌گیرد یا با دلیل سراسری/فرزند aggregate می‌شود',
);
}
}