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:
hamed
2026-08-06 16:06:29 +03:30
co-authored by Claude Opus 5
parent 1d43475724
commit 85985b04a0
13 changed files with 736 additions and 23 deletions
+168
View File
@@ -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']);
}
}