Files
clinicpro/tests/ClinicInvitation/ClinicInvitationFlowTest.php
T
hamedandClaude Opus 4.8 3a23aa242e fix(clinic-invitation): provision doctor accounts and repair panel actions
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>
2026-07-18 09:14:13 +03:30

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