Files
clinicpro/tests/ApiTestCase.php
T
hamed 47323daa27 feat: add RichTextEditor component for rich text editing in articles
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
2026-08-08 11:40:17 +03:30

323 lines
13 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).
*/
use App\Doctor\Entity\DoctorAddress;
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();
// هر تستی که عمداً یا تصادفی یک قید یکتا را می‌شکند، EntityManager را می‌بندد
// و آن نمونهٔ بسته به تست بعدی ارث می‌رسد — چون کانتینر همان را برمی‌گرداند.
// نتیجه‌اش خطای «EntityManager is closed» روی تستی کاملاً بی‌ربط بود که هر بار
// جای دیگری می‌افتاد و در اجرای زیرمجموعه هرگز تکرار نمی‌شد.
//
// ریست اینجا ارزان است و تضمین می‌کند شروع هر تست مستقل از خرابیِ تست قبلی باشد.
$registry = static::getContainer()->get('doctrine');
if (!$registry->getManager()->isOpen()) {
$registry->resetManager();
}
$this->em = static::getContainer()->get(EntityManagerInterface::class);
// بستنِ manager تنها راهِ آلوده‌شدنِ تستِ بعدی نیست. تستی که موجودیتی را
// `persist()` می‌کند و بی‌`flush()` تمام می‌شود — یا درخواستِ کرنلی که
// ارجاع‌هایش را نیمه‌کاره رها می‌کند — همان unit of work را برای تستِ بعدی
// به ارث می‌گذارد. آنجا اولین `flush()` با «A new entity was found through
// the relationship …» می‌شکند؛ خطایی که همیشه جای دیگری می‌افتد و در اجرای
// تکی هرگز تکرار نمی‌شود.
//
// `clear()` نه `resetManager()`: همان نمونه می‌ماند، پس هیچ ارجاعی به
// managerِ مرده نمی‌رسد؛ فقط identity map خالی می‌شود.
$this->em->clear();
$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, except for max_resources: the
* fixture grants an unlimited resource quota so that the dozens of suites which
* merely need a room or a device are not rewritten into subscription tests. The
* real per-plan quota is exercised explicitly by ResourceQuotaTest, which lowers
* it with setPlanResourceQuota().
*/
private function ensureFreePlan(): void
{
$repo = $this->em->getRepository(SubscriptionPlan::class);
$plan = $repo->findOneBy(['name' => 'free']);
if ($plan === null) {
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
'patient_records' => true,
'services' => true,
'sms_panel' => true,
'insurance' => true,
], SubscriptionPlan::UNLIMITED));
$this->em->flush();
return;
}
// db_test is never reset, so a quota lowered by a previous case must be undone.
if ($plan->getMaxResources() !== SubscriptionPlan::UNLIMITED) {
$plan->setMaxResources(SubscriptionPlan::UNLIMITED);
$this->em->flush();
}
}
/** سقف منابعِ پلن مؤثرِ تست‌ها؛ `SubscriptionPlan::UNLIMITED` یعنی بی‌نهایت. */
protected function setPlanResourceQuota(int $max, string $plan = 'free'): void
{
$entity = $this->em->getRepository(SubscriptionPlan::class)->findOneBy(['name' => $plan]);
self::assertNotNull($entity, sprintf('Plan "%s" is missing from the test database.', $plan));
$entity->setMaxResources($max);
$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 closes the EntityManager, and asking the container
// for it again hands back the *same closed instance* — Doctrine only
// builds a fresh one when the registry is reset. Without this the retry
// throws EntityManagerClosed, and every later test in the same process
// inherits a dead manager: the intermittent, always-somewhere-else
// failure that made full runs flaky.
static::getContainer()->get('doctrine')->resetManager();
$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()));
}
/**
* پزشکِ ناظرِ محیطِ همین آدرس — از وقتی ناظر روی منبع الزامی شد، هر ساختِ منبع
* یکی لازم دارد. برای کلینیک یک پزشک عضو ساخته می‌شود و برای مطب، خودِ پزشک.
*/
protected function supervisorFor(DoctorAddress $address): Doctor
{
if ($address->tenantEntityType() === 'doctor') {
return $this->em->getRepository(Doctor::class)->find($address->tenantEntityId());
}
$clinic = $this->em->getRepository(Clinic::class)->find($address->tenantEntityId());
foreach ($clinic->getDoctors() as $existing) {
return $existing;
}
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, 'پزشک ناظر');
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
$clinic->getDoctors()->add($doctor);
$this->em->flush();
return $doctor;
}
}