Files
clinicpro/tests/ApiTestCase.php
T
hamedandClaude Opus 5 aa6ea45a57 feat(availability): resource ordering strategies, and a real fix for the flaky suite
Strategies (task 06 debt, task 12 dependency)
- ResourcePicker orders candidates; it deliberately does not choose. Only the
  engine knows which resource actually fits this slot and which was already
  taken by another role, and a strategy that picked would have to duplicate
  both checks
- Four implementations behind a tagged iterator: first_available (name order,
  the previous behaviour and still the default because it is predictable),
  least_gap, least_loaded, same_as_previous
- least_gap and least_loaded are deliberate opposites and both are correct;
  choosing between them is a business decision, so it lives in settings
- same_as_previous lifts a course's preferred resource to the front and keeps
  everyone else behind it. A preference, not a filter: forcing the same
  operator would make the patient wait two weeks, which is worse than a
  different operator
- Availability accepts course_uuid to supply that preference, closing the
  dependency task 12 recorded against task 06
- An unknown strategy falls back at search time but is rejected at save time.
  Stale settings must not stop bookings; a user typing a wrong value must not
  believe it took effect

Test suite flake
createUser() retries on a mobile-number collision — db_test is never reset and
holds tens of thousands of users, so the random draw does collide. The failed
INSERT closes the EntityManager, and the retry asked the container for it
again, which hands back the *same closed instance*. So the retry threw, and
every later test in that process inherited a dead manager.

That is the intermittent "EntityManager is closed" on an unrelated,
always-different test that made roughly half of full runs red and never
reproduced in a subset. Resetting the registry gives a live manager back.
UserCollisionRetryTest pins it by closing the manager on purpose.

Two consecutive full runs are green: 1334 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 20:21:16 +03:30

246 lines
8.7 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 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()));
}
}