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