311 lines
12 KiB
PHP
311 lines
12 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);
|
|
$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;
|
|
}
|
|
|
|
}
|