feat(practice-domain): add practice domains and let a clinic select one
A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.
Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.
Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\PracticeDomain;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\PracticeDomain\Entity\PracticeDomain;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* `practice_domains` سراسری است و `code` در کل جدول یکتاست، و db_test هم هرگز ریست
|
||||
* نمیشود — پس هر تست کد خودش را میسازد و ادعاهایش «عضویت» را میسنجند نه برابریِ
|
||||
* کل فهرست.
|
||||
*/
|
||||
class PracticeDomainTest extends ApiTestCase
|
||||
{
|
||||
private function uniqueCode(string $prefix): string
|
||||
{
|
||||
return $prefix . '_' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
private function newDomain(string $code, string $name, bool $active = true): PracticeDomain
|
||||
{
|
||||
$domain = new PracticeDomain($code, $name);
|
||||
$domain->setActive($active);
|
||||
$this->em->persist($domain);
|
||||
$this->em->flush();
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
public function testAdminCreatesADomainAndOthersCanListIt(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->uniqueCode('beauty');
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/practice-domains', $admin, [
|
||||
'code' => $code,
|
||||
'name' => 'کلینیک زیبایی',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame($code, $created['data']['code']);
|
||||
|
||||
$clinicUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$list = $this->authJson('GET', '/api/v1/practice-domains', $clinicUser);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains($code, array_column($list['data'], 'code'));
|
||||
}
|
||||
|
||||
public function testDuplicateCodeIsRejected(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->uniqueCode('beauty');
|
||||
$this->newDomain($code, 'کلینیک زیبایی');
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/practice-domains', $admin, [
|
||||
'code' => $code,
|
||||
'name' => 'تکراری',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('code', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testInvalidCodeIsRejected(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/practice-domains', $admin, [
|
||||
'code' => 'Beauty Clinic',
|
||||
'name' => 'x',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('code', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testNonAdminCannotCreateOrUpdate(): void
|
||||
{
|
||||
$clinicUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$domain = $this->newDomain($this->uniqueCode('beauty'), 'کلینیک زیبایی');
|
||||
|
||||
$this->authJson('POST', '/api/v1/practice-domains', $clinicUser, [
|
||||
'code' => $this->uniqueCode('dental'),
|
||||
'name' => 'دندانپزشکی',
|
||||
]);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/practice-domain/' . $domain->getUuid(), $clinicUser, ['name' => 'nope']);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
/** کد لنگرِ TreatmentWorkflow است؛ ویرایشش workflow را بیصدا از کار میاندازد. */
|
||||
public function testCodeCannotBeChanged(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->uniqueCode('beauty');
|
||||
$domain = $this->newDomain($code, 'کلینیک زیبایی');
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/practice-domain/' . $domain->getUuid(), $admin, [
|
||||
'code' => 'hijacked',
|
||||
'name' => 'نام تازه',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($code, $body['data']['code']);
|
||||
self::assertSame('نام تازه', $body['data']['name']);
|
||||
}
|
||||
|
||||
/** مدیر کلینیک نباید حوزهای را ببیند که پلتفرم بازنشستهاش کرده. */
|
||||
public function testInactiveDomainIsHiddenFromNonAdmins(): void
|
||||
{
|
||||
$liveCode = $this->uniqueCode('beauty');
|
||||
$retiredCode = $this->uniqueCode('retired');
|
||||
$this->newDomain($liveCode, 'کلینیک زیبایی');
|
||||
$this->newDomain($retiredCode, 'بازنشسته', false);
|
||||
|
||||
$clinicUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$codes = array_column($this->authJson('GET', '/api/v1/practice-domains', $clinicUser)['data'], 'code');
|
||||
self::assertContains($liveCode, $codes);
|
||||
self::assertNotContains($retiredCode, $codes);
|
||||
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$adminCodes = array_column($this->authJson('GET', '/api/v1/practice-domains', $admin)['data'], 'code');
|
||||
self::assertContains($retiredCode, $adminCodes);
|
||||
}
|
||||
|
||||
public function testClinicPatchAssignsClearsAndRejectsUnknownDomain(): void
|
||||
{
|
||||
$domain = $this->newDomain($this->uniqueCode('beauty'), 'کلینیک زیبایی');
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک آزمون');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$uuid = $clinic->getUuid();
|
||||
$uri = '/api/v1/clinic/' . $uuid;
|
||||
$domainId = $domain->getId();
|
||||
// درخواستِ کرنل روی EntityManager دیگری مینویسد، پس identity map محلی کهنه
|
||||
// میماند و بدون clear همان نمونهٔ قبلی برمیگردد نه وضعیت واقعیِ دیتابیس.
|
||||
$reload = function () use ($uuid): ?Clinic {
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
};
|
||||
|
||||
$this->authJson('PATCH', $uri, $owner, ['practice_domain_uuid' => $domain->getUuid()]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($domainId, $reload()?->getPracticeDomain()?->getId());
|
||||
|
||||
// کلید نبودن یعنی «دست نزن»
|
||||
$this->authJson('PATCH', $uri, $owner, ['info' => 'توضیح']);
|
||||
self::assertSame($domainId, $reload()?->getPracticeDomain()?->getId());
|
||||
|
||||
// رشتهٔ خالی یعنی «پاک کن»
|
||||
$this->authJson('PATCH', $uri, $owner, ['practice_domain_uuid' => '']);
|
||||
self::assertNull($reload()?->getPracticeDomain());
|
||||
|
||||
// uuid ناشناس بیصدا رد نمیشود
|
||||
$body = $this->authJson('PATCH', $uri, $owner, [
|
||||
'practice_domain_uuid' => '00000000-0000-0000-0000-000000000000',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('practice_domain_uuid', $body['errors'][0]['field']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user