Phase 3 of the tenant-marking series. The same concept was written four ways, and the Doctrine filter arriving in phase 4 keys on the field name — so the tables using a different spelling would have been skipped silently, which is exactly the leak this work exists to prevent. - discount_rules: owner_type/owner_id renamed to entity_type/entity_id. Pure rename, no data moves. - doctor_secretaries: owner_type plus a nullable clinic_id replaced by the shared pair. The environment now comes from the clinic argument alone, so the inconsistent combination (owner_type='clinic', clinic_id=NULL) can no longer be constructed, and the redundant constructor parameter is gone. - user_active_context: added db_type, so resolving an environment is one lookup instead of "try clinics, then try doctors". Filled from the type already present in available_contexts. - entity_type is VARCHAR(10) in all twenty tenant tables; four of them were 20. Behaviour change, the only one in this series: the doctor_secretaries unique key went from (doctor_id, secretary_id, owner_type) to (doctor_id, secretary_id, entity_type, entity_id). With clinic_id outside the key, one secretary could not be assigned to the same doctor in two clinics — the second row collided on owner_type='clinic'. The duplicate check in SecretaryController had the same blind spot and would have rejected the request before the database saw it; both are fixed together. Correcting an assumption from the phase-3 plan: mobile_verification_otp.entity_type really is a tenant pair. NotificationMobileController validates the target against ['doctor','clinic'] and stores that entity's id, so the column was normalised with the rest rather than treated as unrelated. TenantOwnedTrait gained assignTenantPair() for callers that resolved the pair as scalars and hold no entity — building an EntityContext from scalars would produce one where isClinic() is true but ->clinic is null, breaking consumers silently. tests/ApiTestCase::createUser now retries on a duplicate mobile. db_test is never reset and already holds ~38k users, so the 9-digit random draw collided often enough to fail unrelated tests a few percent of runs. Tests: 830 passing. PHPStan reports no new errors on the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
7.1 KiB
PHP
213 lines
7.1 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\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 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 closed the EntityManager; reopen before retrying.
|
|
$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;
|
|
}
|
|
|
|
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()));
|
|
}
|
|
}
|