The invitation flow never created an account for the invitee. accept() only looked up an existing doctor by mobile, so for a brand-new invitee it marked the invitation accepted and burned the token while leaving doctor_id NULL — no login, no clinic link, and every doctor-facing endpoint 404ing afterwards. - invite/accept now provision the users + doctors pair, claim the profile on accept, link it to the clinic, and SMS generated credentials when the user has no password. Existing passwords are never overwritten. - accept runs in one transaction so an invitation can no longer be marked accepted without its doctor profile and clinic link. - changeStatus accepts `pending`, refreshing the token and re-sending the SMS so reactivating a suspended invitation yields a link that actually works. Answered invitations are rejected with 409. - DELETE returns 200 with the standard envelope instead of a bodyless 204, which made the admin panel show a false error toast; api.ts also stops calling res.json() on empty responses. - The clinic-doctors settings page sent the active context uuid as the clinic uuid, so users holding both a doctor and a clinic context got 404 on every invitation action. It now always resolves the clinic context. - Adds app:invitations:repair to fix invitations already left orphaned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
7.2 KiB
PHP
167 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\ClinicInvitation;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* Covers the full clinic → doctor invitation lifecycle: inviting provisions a
|
|
* doctor profile, accepting creates login credentials and links the doctor to
|
|
* the clinic, and every admin action on an invitation stays reachable.
|
|
*/
|
|
class ClinicInvitationFlowTest extends ApiTestCase
|
|
{
|
|
private function createClinicOwner(): array
|
|
{
|
|
$owner = $this->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());
|
|
}
|
|
}
|