Files
clinicpro/tests/ApiTestCase.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

239 lines
8.3 KiB
PHP

<?php
namespace App\Tests;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\DateOverride;
use App\Appointment\Entity\WeeklySchedule;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Context\EntityContext;
use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Base class for functional API tests.
*
* Provides a booted KernelBrowser, the EntityManager, and helpers to create
* users and issue JWTs so tests can hit authenticated /api and /oauth endpoints.
* Runs against the dedicated db_test database (see .env.test / doctrine when@test).
*/
abstract class ApiTestCase extends WebTestCase
{
/**
* محیطِ رکوردهایی که موضوعِ تست، محیطشان نیست. عمداً یک ثابت است تا هیچ تستی
* تصادفاً با محیطِ واقعیِ تستِ دیگری برخورد نکند.
*/
protected const TENANTLESS_TEST_ENTITY_ID = 1;
protected KernelBrowser $client;
protected EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = static::createClient();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->ensureFreePlan();
}
/**
* The «free» plan is SubscriptionService::getEffectivePlan's fallback for any
* tenant without a paid subscription. Without it every hasFeature() is false
* and the patient / service / insurance endpoints answer 403 instead of doing
* their job. db_test is never reset, so the insert is idempotent.
*
* Mirrors the row shipped in the dev database.
*/
private function ensureFreePlan(): void
{
$repo = $this->em->getRepository(SubscriptionPlan::class);
if ($repo->findOneBy(['name' => 'free']) !== null) {
return;
}
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
'patient_records' => true,
'services' => true,
'sms_panel' => true,
'insurance' => true,
]));
$this->em->flush();
}
/**
* Persist a user with the given roles. Mobile is randomised per test to
* avoid unique-constraint clashes across cases without a full DB reset.
*/
protected function createUser(array $roles = ['ROLE_USER'], ?string $mobile = null): User
{
if ($mobile !== null) {
return $this->persistUser($mobile, $roles);
}
// 9 random digits after 09 (full ^09\d{9}$ space). db_test is never reset and
// already holds tens of thousands of users, so a draw does collide now and
// then; retry rather than fail an unrelated test on a birthday collision.
for ($attempt = 0; ; $attempt++) {
try {
return $this->persistUser(
'09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
$roles,
);
} catch (UniqueConstraintViolationException $e) {
if ($attempt >= 4) {
throw $e;
}
// The failed INSERT closed the EntityManager; reopen before retrying.
$this->em = static::getContainer()->get(EntityManagerInterface::class);
}
}
}
private function persistUser(string $mobile, array $roles): User
{
$user = new User($mobile);
$user->setRoles($roles);
$user->setStatus(1);
$this->em->persist($user);
$this->em->flush();
return $user;
}
/**
* A booking with its owning tenant already assigned — the test-side mirror of
* what the booking controllers do after resolving the environment. Constructing
* an Appointment without it fails at flush, since entity_type/entity_id are NOT
* NULL and have no default.
*/
protected function newAppointment(
Doctor $doctor,
User $patient,
int $slotStart,
int $slotEnd,
?Clinic $clinic = null,
): Appointment {
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
$appointment->setClinic($clinic);
$appointment->assignTenant(EntityContext::forBooking($doctor, $clinic));
return $appointment;
}
/**
* A weekly schedule with its owning tenant assigned. The doctor is flushed
* first when it has no id yet: the tenant pair stores scalars, so the owner
* must already exist in the database.
*/
protected function newWeeklySchedule(Doctor $doctor, array $setting, ?Clinic $clinic = null): WeeklySchedule
{
$this->flushIfNew($doctor, $clinic);
$schedule = new WeeklySchedule($doctor, $setting, $clinic);
$schedule->assignTenant(EntityContext::forBooking($doctor, $clinic));
return $schedule;
}
/**
* محیط پرداخت را می‌گذارد: از نوبتش اگر داشته باشد، وگرنه محیطِ ثابتی که
* موضوع تست نیست. مثل بقیهٔ جدول‌های محیط‌دار، ستون‌ها NOT NULL‌اند و پرداختِ
* بی‌محیط سرِ flush می‌شکند — همان رفتاری که کد واقعی هم دارد.
*/
protected function stampTenant(Payment $payment): Payment
{
$appointment = $payment->getAppointment();
if ($appointment !== null) {
$payment->assignTenant(EntityContext::forBooking($appointment->getDoctor(), $appointment->getClinic()));
return $payment;
}
$payment->assignTenantPair(EntityContext::TYPE_DOCTOR, self::TENANTLESS_TEST_ENTITY_ID);
return $payment;
}
protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride
{
$this->flushIfNew($doctor, $clinic);
$override = new DateOverride($doctor, $date, $active, $clinic);
$override->assignTenant(EntityContext::forBooking($doctor, $clinic));
return $override;
}
/**
* The tenant pair stores scalars, so the owner needs a database id before it
* can be assigned. persist() on an already-managed entity is a no-op, which
* makes this safe for owners the test never persisted itself.
*/
private function flushIfNew(Doctor $doctor, ?Clinic $clinic): void
{
$pending = array_filter(
[$doctor, $clinic],
static fn(?object $owner) => $owner !== null && $owner->getId() === null,
);
if ($pending === []) {
return;
}
foreach ($pending as $owner) {
$this->em->persist($owner);
}
$this->em->flush();
}
protected function jwtFor(User $user): string
{
return static::getContainer()
->get(JWTTokenManagerInterface::class)
->create($user);
}
/**
* Issue an authenticated JSON request and return the decoded response body.
*/
protected function authJson(string $method, string $uri, User $user, array $body = []): array
{
$this->client->request(
$method,
$uri,
server: [
'HTTP_AUTHORIZATION' => 'Bearer ' . $this->jwtFor($user),
'CONTENT_TYPE' => 'application/json',
],
content: $body ? json_encode($body) : null,
);
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
}
protected function responseCode(): int
{
return $this->client->getResponse()->getStatusCode();
}
/**
* Count the SQL queries executed while running $fn. Used to assert that a
* list endpoint's query count does not grow with the number of rows (N+1).
*/
protected function countQueries(callable $fn): int
{
$holder = static::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$fn();
return array_sum(array_map('count', $holder->getData()));
}
}