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>
This commit is contained in:
hamed
2026-07-31 20:21:16 +03:30
co-authored by Claude Opus 5
parent 62f18b3c0d
commit aa6ea45a57
17 changed files with 811 additions and 6 deletions
+8 -1
View File
@@ -89,7 +89,14 @@ abstract class ApiTestCase extends WebTestCase
if ($attempt >= 4) {
throw $e;
}
// The failed INSERT closed the EntityManager; reopen before retrying.
// 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);
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Availability\Picker\FirstAvailablePicker;
use App\Appointment\Availability\Picker\LeastGapPicker;
use App\Appointment\Availability\Picker\LeastLoadedPicker;
use App\Appointment\Availability\Picker\PickContext;
use App\Appointment\Availability\Picker\ResourcePickerRegistry;
use App\Appointment\Availability\Picker\SameAsPreviousPicker;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Shared\Time\TimeInterval;
use App\Tests\ApiTestCase;
/**
* استراتژی‌های ترتیب منابع — تسک ۰۶.
*
* استراتژی **مرتب می‌کند، انتخاب نمی‌کند**؛ پس هر تست فقط ترتیب خروجی را می‌سنجد و
* هیچ‌کدام نباید کاندیدی را حذف کند.
*/
class ResourcePickerTest extends ApiTestCase
{
private int $idCursor = 0;
/** منبعی با شناسهٔ واقعی، چون استراتژی‌ها با `getId()` کار می‌کنند. */
private function resource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource
{
$resource = new ClinicResource($address, $type, $name);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
/** @return array{0: DoctorAddress, 1: ResourceType} */
private function branch(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new \App\Clinic\Entity\Clinic($user);
$clinic->setName('کلینیک استراتژی ' . (++$this->idCursor));
$this->em->persist($clinic);
$this->em->flush();
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبه');
$this->em->persist($address);
$type = new ResourceType('clinic', (int) $clinic->getId(), 'operator', 'اپراتور');
$this->em->persist($type);
$this->em->flush();
return [$address, $type];
}
private function namesOf(array $ordered): array
{
return array_map(static fn (ClinicResource $r): string => $r->getName(), $ordered);
}
public function testFirstAvailableOrdersByName(): void
{
[$address, $type] = $this->branch();
$candidates = [
$this->resource($address, $type, 'ج'),
$this->resource($address, $type, 'الف'),
$this->resource($address, $type, 'ب'),
];
$ordered = (new FirstAvailablePicker())->order(
$candidates,
new PickContext([], [], time()),
);
self::assertSame(['الف', 'ب', 'ج'], $this->namesOf($ordered));
}
/** ⭐ منبعی که کمترین وقت مرده جا می‌گذارد، اول امتحان می‌شود. */
public function testLeastGapPrefersTheTightestWindow(): void
{
[$address, $type] = $this->branch();
$tight = $this->resource($address, $type, 'دقیق');
$loose = $this->resource($address, $type, 'گشاد');
$start = 1_800_000_000;
$end = $start + 3600;
$free = [
// پنجرهٔ دقیقاً به اندازهٔ نوبت — هیچ وقتی هدر نمی‌رود.
(int) $tight->getId() => [new TimeInterval($start, $end)],
// پنجرهٔ چهارساعته — سه ساعت خالی می‌ماند.
(int) $loose->getId() => [new TimeInterval($start - 3600, $end + 7200)],
];
$ordered = (new LeastGapPicker())->order(
[$loose, $tight],
new PickContext($free, [new TimeInterval($start, $end)], $start),
);
self::assertSame(['دقیق', 'گشاد'], $this->namesOf($ordered));
}
/** پخش بار عکسِ کمترین‌شکاف عمل می‌کند — و هر دو درست‌اند. */
public function testLeastLoadedPrefersTheEmptiestResource(): void
{
[$address, $type] = $this->branch();
$busy = $this->resource($address, $type, 'پرکار');
$free = $this->resource($address, $type, 'خلوت');
$start = 1_800_000_000;
$windows = [
(int) $busy->getId() => [new TimeInterval($start, $start + 3600)],
(int) $free->getId() => [new TimeInterval($start, $start + 6 * 3600)],
];
$ordered = (new LeastLoadedPicker())->order(
[$busy, $free],
new PickContext($windows, [new TimeInterval($start, $start + 1800)], $start),
);
self::assertSame(['خلوت', 'پرکار'], $this->namesOf($ordered));
}
/** ⭐ ترجیح است نه فیلتر: منبع ترجیحی جلو می‌آید، بقیه حذف نمی‌شوند. */
public function testSameAsPreviousLiftsThePreferredResourceWithoutDroppingOthers(): void
{
[$address, $type] = $this->branch();
$other = $this->resource($address, $type, 'اپراتور دیگر');
$preferred = $this->resource($address, $type, 'اپراتور جلسهٔ قبل');
$start = 1_800_000_000;
$ordered = (new SameAsPreviousPicker())->order(
[$other, $preferred],
new PickContext([], [new TimeInterval($start, $start + 1800)], $start, [(int) $preferred->getId()]),
);
self::assertSame(['اپراتور جلسهٔ قبل', 'اپراتور دیگر'], $this->namesOf($ordered));
self::assertCount(2, $ordered, 'هیچ کاندیدی نباید حذف شود');
}
/** بدون ترجیح، همان ترتیب استراتژی پایه می‌ماند. */
public function testSameAsPreviousFallsBackWhenNothingIsPreferred(): void
{
[$address, $type] = $this->branch();
$a = $this->resource($address, $type, 'الف');
$b = $this->resource($address, $type, 'ب');
$start = 1_800_000_000;
$ordered = (new SameAsPreviousPicker())->order(
[$a, $b],
new PickContext([], [new TimeInterval($start, $start + 1800)], $start),
);
self::assertCount(2, $ordered);
}
// ── رجیستری ─────────────────────────────────────────────────────────────
public function testRegistryResolvesEveryStrategy(): void
{
$registry = static::getContainer()->get(ResourcePickerRegistry::class);
foreach (['first_available', 'least_gap', 'least_loaded', 'same_as_previous'] as $code) {
self::assertTrue($registry->has($code), $code);
self::assertSame($code, $registry->get($code)::code());
}
self::assertCount(4, $registry->describe());
}
/** ⭐ کلید ناشناخته نوبت‌دهی را نمی‌خواباند؛ به پیش‌فرض برمی‌گردد. */
public function testAnUnknownStrategyFallsBackToTheDefault(): void
{
$registry = static::getContainer()->get(ResourcePickerRegistry::class);
self::assertSame('first_available', $registry->get('nonsense')::code());
self::assertSame('first_available', $registry->get(null)::code());
}
/** ولی هنگام **ذخیره** رد می‌شود، وگرنه کاربر فکر می‌کند اعمال شده. */
public function testSavingAnUnknownStrategyIsRejected(): void
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($owner, 'دکتر استراتژی');
$this->em->persist($doctor);
$this->em->flush();
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'schedule' => [],
'meta' => ['booking_mode' => 'slot', 'resource_strategy' => 'nonsense'],
]);
self::assertSame(422, $this->responseCode());
}
public function testStrategyListIsExposedForThePanel(): void
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$body = $this->authJson('GET', '/api/v1/appointment-settings/resource-strategies', $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertCount(4, $body['data']);
self::assertContains('least_gap', array_column($body['data'], 'code'));
self::assertNotEmpty($body['data'][0]['label']);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Tests\Shared;
use App\Auth\Entity\User;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* ساختِ کاربر آزمون پس از تصادم شمارهٔ موبایل باید سالم ادامه بدهد.
*
* `db_test` هرگز ریست نمی‌شود و ده‌ها هزار کاربر دارد، پس قرعهٔ تصادفی گاهی تکراری
* می‌شود. INSERT شکست‌خورده EntityManager را **می‌بندد**، و گرفتن دوبارهٔ آن از
* کانتینر همان نمونهٔ بسته را برمی‌گرداند — تا وقتی رجیستری ریست نشود.
*
* تا پیش از این اصلاح، همین باعث می‌شد اجرای کامل به‌صورت متناوب روی یک تستِ
* **بی‌ربط** با «EntityManager is closed» بیفتد.
*/
class UserCollisionRetryTest extends ApiTestCase
{
public function testAClosedManagerIsReplacedNotReused(): void
{
$before = $this->em;
// بستن عمدی: درج تکراری روی کلید یکتای موبایل.
$existing = $this->createUser(['ROLE_USER']);
try {
$clash = new User($existing->getMobileNumber());
$clash->setRoles(['ROLE_USER']);
$clash->setStatus(1);
$this->em->persist($clash);
$this->em->flush();
self::fail('درج تکراری باید شکست بخورد');
} catch (\Throwable) {
// همان چیزی که در قرعهٔ تصادفی اتفاق می‌افتد.
}
self::assertFalse($before->isOpen(), 'INSERT شکست‌خورده باید EntityManager را ببندد');
static::getContainer()->get('doctrine')->resetManager();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
// کانتینر یک proxy برمی‌گرداند، پس هویت شیء عوض نمی‌شود؛ چیزی که اهمیت دارد
// این است که پشتِ همان proxy یک EntityManager **باز** نشسته باشد.
self::assertTrue($this->em->isOpen(), 'پس از ریست رجیستری باید مدیر باز باشد');
// و کار عادی باید ادامه پیدا کند.
$fresh = $this->createUser(['ROLE_USER']);
self::assertNotNull($fresh->getId());
}
}