createUser() drew a random mobile and recovered from a collision by catching the unique-constraint violation and calling resetManager(). That hands back a brand-new EntityManager, which detaches every entity the running test had built so far; its next flush died with "Multiple non-persisted new entities were found", always in a different test and never reproducible in isolation. The number is now checked before the insert, so the collision never reaches the database and the manager stays open. testParentIdAddsNoQueryPerSpecialty counted queries on the first request of each size, so one-shot per-process caches — site config, subscription plan, Doctrine metadata — landed inside the count or not depending on which tests had run before it. Both requests are now warmed first; the assertion measures steady-state growth, which is what it was always about. The 23 PHPUnit notices were all one complaint: doubles created with createMock() that never had an expectation. The ones that only stub return values became createStub(); in SmsServiceLookupOnlyTest the provider and the bus got the expectations they were missing, since "dispatch does not touch the provider" and "sendNow does not enqueue" are exactly what that suite is there to prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
318 lines
13 KiB
PHP
318 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\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.
|
|
//
|
|
// شماره پیش از insert بررسی میشود، نه بعد از شکستنِ قید یکتا. برخوردِ واقعی
|
|
// EntityManager را میبست و راه ترمیمش `resetManager()` بود — که مدیر تازهای
|
|
// میسازد و همهٔ موجودیتهایی را که تستِ جاری تا آن لحظه ساخته بود detach
|
|
// میکند. اولین flush بعدی با «Multiple non-persisted new entities were found»
|
|
// میترکید؛ همان خطای تصادفی که هر بار روی تستِ دیگری میافتاد و در اجرای تکی
|
|
// هرگز تکرار نمیشد. حالا برخورد اصلاً به دیتابیس نمیرسد.
|
|
for ($attempt = 0; ; $attempt++) {
|
|
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
|
|
|
if ($this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]) === null) {
|
|
return $this->persistUser($mobile, $roles);
|
|
}
|
|
|
|
if ($attempt >= 20) {
|
|
throw new \RuntimeException('could not draw a free test mobile number after 20 attempts');
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|