createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($owner); $clinic->setName('کلینیک تست'); $this->em->persist($clinic); $this->em->flush(); return [$owner, $clinic]; } private function invitedMobile(): string { return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT); } public function testInviteCreatesDoctorProfileWithoutLinkingClinic(): void { [$owner, $clinic] = $this->createClinicOwner(); $mobile = $this->invitedMobile(); $res = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $mobile, 'name' => 'دکتر تست', ]); self::assertSame(201, $this->responseCode()); self::assertNotNull($res['data']['doctor'], 'invite must provision a doctor profile'); $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]); self::assertNotNull($doctor); self::assertSame('unclaimed', $doctor->getOwnerStatus()); self::assertNull($doctor->getUser()->getPasswordHash(), 'no password before acceptance'); $this->em->refresh($clinic); self::assertFalse($clinic->getDoctors()->contains($doctor), 'clinic link happens only on accept'); } public function testAcceptLinksDoctorAndIssuesLoginCredentials(): void { [$owner, $clinic] = $this->createClinicOwner(); $mobile = $this->invitedMobile(); $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $mobile, 'name' => 'دکتر تست', ]); $inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]); $this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept"); self::assertSame(200, $this->responseCode()); $this->em->clear(); $inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]); $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]); $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); self::assertSame(ClinicDoctorInvitation::STATUS_ACCEPTED, $inv->getStatus()); self::assertSame($doctor->getId(), $inv->getDoctor()?->getId()); self::assertSame('claimed', $doctor->getOwnerStatus()); self::assertNotNull($user->getPasswordHash(), 'accepted doctor must be able to log in'); self::assertContains('ROLE_DOCTOR', $user->getRoles()); self::assertTrue($inv->getClinic()->getDoctors()->contains($doctor)); } public function testAcceptDoesNotOverwriteExistingPassword(): void { [$owner, $clinic] = $this->createClinicOwner(); $existing = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $existing->setPasswordHash('$2y$13$alreadySetHashValueForTesting.aaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); $this->em->flush(); $mobile = $existing->getMobileNumber(); $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $mobile, ]); $inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]); $this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept"); self::assertSame(200, $this->responseCode()); $this->em->clear(); $reloaded = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); self::assertStringStartsWith('$2y$13$alreadySet', $reloaded->getPasswordHash()); } public function testSuspendAndReactivateInvitation(): void { [$owner, $clinic] = $this->createClinicOwner(); $mobile = $this->invitedMobile(); $created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $mobile, ]); $invUuid = $created['data']['uuid']; $this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$invUuid}/status", $owner, ['status' => 'suspended']); self::assertSame(200, $this->responseCode()); $res = $this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$invUuid}/status", $owner, ['status' => 'pending']); self::assertSame(200, $this->responseCode()); self::assertSame('pending', $res['data']['status']); $this->em->clear(); $inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['uuid' => $invUuid]); self::assertTrue($inv->isUsable(), 'reactivated invitation must have a fresh usable token'); } public function testInvalidStatusIsRejected(): void { [$owner, $clinic] = $this->createClinicOwner(); $created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $this->invitedMobile(), ]); $this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}/status", $owner, ['status' => 'bogus']); self::assertSame(422, $this->responseCode()); } public function testDeleteInvitationReturnsJsonBody(): void { [$owner, $clinic] = $this->createClinicOwner(); $created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $this->invitedMobile(), ]); $res = $this->authJson('DELETE', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}", $owner); self::assertSame(200, $this->responseCode()); self::assertTrue($res['success']); $this->authJson('DELETE', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}", $owner); self::assertSame(404, $this->responseCode()); } public function testAcceptedInvitationCannotReturnToPending(): void { [$owner, $clinic] = $this->createClinicOwner(); $mobile = $this->invitedMobile(); $created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [ 'mobile' => $mobile, ]); $inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]); $this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept"); $this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}/status", $owner, ['status' => 'pending']); self::assertSame(409, $this->responseCode()); } }