diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx
index ca1cc95b..b54a4ae7 100644
--- a/assets/admin/pages/CategoriesPage.tsx
+++ b/assets/admin/pages/CategoriesPage.tsx
@@ -494,7 +494,7 @@ function CitiesTab() {
{ key: 'id', sortable: true, header: 'شناسه', render: (c) => {c.id} },
{ key: 'name', header: 'نام', render: (c) => {c.name} },
{ key: 'site_name', header: 'نام سایت', render: (c) => c.site_name ? {c.site_name} : — },
- { key: 'province_id', header: 'استان', render: (c) => c.province_id ? {provinceMap[c.province_id] ?? `#${c.province_id}`} : — },
+ { key: 'province_id', header: 'استان', render: (c) => c.province_id ? {c.province_name ?? provinceMap[c.province_id] ?? `#${c.province_id}`} : — },
{ key: 'representation_id', header: 'نماینده', render: (c) => c.representation_id ? {representationMap[c.representation_id] ?? `#${c.representation_id}`} : — },
{ key: 'domain', header: 'دامنه', render: (c) => c.domain ? {c.domain} : — },
{ key: 'weight', header: 'ترتیب', render: (c) => {c.weight} },
@@ -504,7 +504,7 @@ function CitiesTab() {
return (
<>
{ reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/cities/export" exportFile="city.json" bundle="cities" entityLabel="شهرها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-cities'] })} />
- columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد"
+ columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام شهر، استان، نام سایت یا دامنه..." emptyMessage="هیچ شهری یافت نشد"
actions={(c) => (
<>
@@ -668,7 +668,7 @@ function SpecialtiesTab() {
{ key: 'id', sortable: true, header: 'شناسه', render: (s) => {s.id} },
{ key: 'name', header: 'نام', render: (s) => {s.name} },
{ key: 'slug', header: 'slug', render: (s) => {s.slug} },
- { key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? {parentMap[s.parent_id] ?? `#${s.parent_id}`} : — },
+ { key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? {s.parent_name ?? parentMap[s.parent_id] ?? `#${s.parent_id}`} : — },
{ key: 'weight', header: 'ترتیب', render: (s) => {s.weight} },
{ key: 'status', header: 'وضعیت', render: (s) => },
];
@@ -676,7 +676,7 @@ function SpecialtiesTab() {
return (
<>
{ reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/specialties/export" exportFile="specialties.json" bundle="specialties" entityLabel="تخصصها" onImported={() => { qc.invalidateQueries({ queryKey: ['admin-specialties'] }); qc.invalidateQueries({ queryKey: ['admin-specialties-roots'] }); }} />
- columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تخصصها..." emptyMessage="هیچ تخصصی یافت نشد"
+ columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام تخصص، تخصص والد یا slug..." emptyMessage="هیچ تخصصی یافت نشد"
actions={(s) => (
<>
@@ -793,7 +793,7 @@ function DoctorServicesTab() {
const columns: Column[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (s) => {s.id} },
{ key: 'name', header: 'نام', render: (s) => {s.name} },
- { key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? {specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`} : — },
+ { key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? {s.specialty_name ?? specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`} : — },
{ key: 'weight', header: 'ترتیب', render: (s) => {s.weight} },
{ key: 'status', header: 'وضعیت', render: (s) => },
];
@@ -801,7 +801,7 @@ function DoctorServicesTab() {
return (
<>
{ reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/doctor_services/export" exportFile="doctor-services.json" bundle="doctor_services" entityLabel="خدمات پزشک" onImported={() => qc.invalidateQueries({ queryKey: ['admin-doctor-services'] })} />
- columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در خدمات..." emptyMessage="هیچ خدمتی یافت نشد"
+ columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام خدمت، تخصص مرتبط یا slug..." emptyMessage="هیچ خدمتی یافت نشد"
actions={(s) => (
<>
@@ -1066,7 +1066,7 @@ function TagsTab() {
return (
<>
{ reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/tags/export" exportFile="tags.json" bundle="tags" entityLabel="تگها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-tags'] })} />
- columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تگها..." emptyMessage="هیچ تگی یافت نشد"
+ columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو با نام تگ یا slug..." emptyMessage="هیچ تگی یافت نشد"
actions={(t) => (
<>
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts
index d55d857a..0635ebf0 100644
--- a/assets/admin/types/index.ts
+++ b/assets/admin/types/index.ts
@@ -397,6 +397,7 @@ export interface City {
status: number;
weight: number;
province_id: number | null;
+ province_name?: string | null;
representation_id?: number | null;
contact_phone?: string | null;
email?: string | null;
@@ -417,6 +418,8 @@ export interface SpecialtyFull {
status: number;
weight: number;
parent_id: number | null;
+ /** فقط در لیست ادمین برمیگردد. */
+ parent_name?: string | null;
}
export interface DoctorService {
@@ -427,6 +430,8 @@ export interface DoctorService {
status: number;
weight: number;
specialty_id: number | null;
+ /** فقط در لیست ادمین برمیگردد. */
+ specialty_name?: string | null;
}
export interface Insurance {
diff --git a/docs/api/doctor-service.md b/docs/api/doctor-service.md
index ecfe1c5e..a539ebc5 100644
--- a/docs/api/doctor-service.md
+++ b/docs/api/doctor-service.md
@@ -53,17 +53,22 @@ List all services with pagination (admin view).
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
-| `search` | string | ❌ | Search in name |
+| `search` | string | ❌ | Matches `name`, `slug`, **or the related specialty's name** (case-insensitive `LIKE`) |
| `specialty_id` | integer | ❌ | Filter by specialty |
### Response `200`
```json
{
"success": true,
- "data": [ ... ],
+ "data": [
+ { "id": 7, "uuid": "…", "name": "بوتاکس", "slug": "botox", "status": 1, "weight": 0,
+ "specialty_id": 3, "specialty_name": "پوست و مو" }
+ ],
"meta": { "totalRecords": 30, "totalPages": 2, "currentPage": 1 }
}
```
+- `specialty_name` is **admin-list only** — the public `GET /api/v1/doctor-services` does not return it.
+- Services without a specialty are still listed (the join is a `LEFT JOIN`).
### Errors
| Code | HTTP | Description |
diff --git a/docs/api/location.md b/docs/api/location.md
index 5a70c542..1e20e343 100644
--- a/docs/api/location.md
+++ b/docs/api/location.md
@@ -88,11 +88,11 @@ List all cities with pagination (admin view).
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
-| `search` | string | ❌ | Search in name |
+| `search` | string | ❌ | Matches `name`, `site_name`, `domain`, **or the parent province's name** (case-insensitive `LIKE`) |
| `province_id` | integer | ❌ | Filter by province |
### Response `200`
-Paginated city list.
+Paginated city list. Each row already carries `province_name` alongside `province_id`.
---
diff --git a/docs/api/specialty.md b/docs/api/specialty.md
index 200834a5..05e45a94 100644
--- a/docs/api/specialty.md
+++ b/docs/api/specialty.md
@@ -86,16 +86,23 @@ List all specialties with pagination (admin view — includes inactive).
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
-| `search` | string | ❌ | Search in name |
+| `search` | string | ❌ | Matches `name`, `slug`, **or the parent specialty's name** (case-insensitive `LIKE`) |
+| `sort` / `order` | string | ❌ | `sort=id` with `order=asc\|desc`; otherwise ordered by weight then name |
### Response `200`
```json
{
"success": true,
- "data": [ ... ],
+ "data": [
+ { "id": 12, "uuid": "…", "name": "لیزر درمانی", "slug": "laser", "status": 1, "weight": 0,
+ "parent_id": 3, "parent_name": "پوست و مو" }
+ ],
"meta": { "totalRecords": 50, "totalPages": 3, "currentPage": 1 }
}
```
+- `parent_name` is **admin-list only** — the public `GET /api/v1/specialties` does not return it.
+ It exists because searching by parent can return a child whose parent is not on the current page,
+ so the client cannot resolve the name from its own list.
### Errors
| Code | HTTP | Description |
diff --git a/docs/api/tag.md b/docs/api/tag.md
index bb3c85bf..19ade7fe 100644
--- a/docs/api/tag.md
+++ b/docs/api/tag.md
@@ -36,7 +36,7 @@ List all tags with pagination (admin view — includes inactive).
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
-| `search` | string | ❌ | Search in name |
+| `search` | string | ❌ | Matches `name` or `slug` (case-insensitive `LIKE`). Tags have no parent. |
### Response `200`
```json
diff --git a/src/DoctorService/Controller/DoctorServiceController.php b/src/DoctorService/Controller/DoctorServiceController.php
index 631f6565..e5bde252 100644
--- a/src/DoctorService/Controller/DoctorServiceController.php
+++ b/src/DoctorService/Controller/DoctorServiceController.php
@@ -104,15 +104,19 @@ class DoctorServiceController extends BaseController
$search = trim((string) $request->query->get('search', ''));
$specialtyId = $request->query->get('specialty_id');
- $qb = $this->repo->createQueryBuilder('ds');
+ $qb = $this->repo->createQueryBuilder('ds')
+ ->leftJoin('ds.specialty', 'sp')
+ ->addSelect('sp');
if ($request->query->get('sort') === 'id') {
$qb->orderBy('ds.id', strtoupper((string) $request->query->get('order')) === 'DESC' ? 'DESC' : 'ASC');
} else {
$qb->orderBy('ds.weight', 'ASC')->addOrderBy('ds.name', 'ASC');
}
+ // نام تخصصِ والد هم جستجو میشود: خدمات را معمولاً ذیل تخصص میجویند.
if ($search !== '') {
- $qb->andWhere('ds.name LIKE :s')->setParameter('s', '%' . $search . '%');
+ $qb->andWhere('ds.name LIKE :s OR ds.slug LIKE :s OR sp.name LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
}
if ($specialtyId !== null && $specialtyId !== '') {
$qb->andWhere('ds.specialty = :sp')->setParameter('sp', (int) $specialtyId);
@@ -121,8 +125,13 @@ class DoctorServiceController extends BaseController
$total = (clone $qb)->select('COUNT(ds.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
+ // specialty_name فقط در لیست ادمین میآید — تخصص اینجا با join هیدریت شده و
+ // جستجو ممکن است خدمتی را برگرداند که تخصصش در صفحهٔ جاری نیست.
return $this->paginated(
- array_map(fn(DoctorService $ds) => $ds->toArray(), $rows),
+ array_map(
+ static fn(DoctorService $ds) => $ds->toArray() + ['specialty_name' => $ds->getSpecialty()?->getName()],
+ $rows
+ ),
(int) $total, $page, $limit
);
}
diff --git a/src/Location/Controller/LocationController.php b/src/Location/Controller/LocationController.php
index 9b1b911b..2669a0c3 100644
--- a/src/Location/Controller/LocationController.php
+++ b/src/Location/Controller/LocationController.php
@@ -200,8 +200,10 @@ class LocationController extends BaseController
$qb->orderBy('c.weight', 'ASC')->addOrderBy('c.name', 'ASC');
}
+ // نام استانِ والد هم جستجو میشود، بهعلاوهٔ نام سایت و دامنه که شناسهٔ عملی شهرند.
if ($search !== '') {
- $qb->andWhere('c.name LIKE :s')->setParameter('s', '%' . $search . '%');
+ $qb->andWhere('c.name LIKE :s OR c.siteName LIKE :s OR c.domain LIKE :s OR p.name LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
}
if ($provinceId !== null && $provinceId !== '') {
$qb->andWhere('c.province = :province')->setParameter('province', (int) $provinceId);
diff --git a/src/Specialty/Controller/SpecialtyController.php b/src/Specialty/Controller/SpecialtyController.php
index c1cb1443..3653cf59 100644
--- a/src/Specialty/Controller/SpecialtyController.php
+++ b/src/Specialty/Controller/SpecialtyController.php
@@ -138,15 +138,22 @@ class SpecialtyController extends BaseController
$qb->orderBy('s.weight', 'ASC')->addOrderBy('s.name', 'ASC');
}
+ // نام والد هم جستجو میشود: زیرتخصصها را معمولاً با نام تخصص مادر میجویند.
if ($search !== '') {
- $qb->andWhere('s.name LIKE :s')->setParameter('s', '%' . $search . '%');
+ $qb->andWhere('s.name LIKE :s OR s.slug LIKE :s OR p.name LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
}
$total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getResult();
+ // parent_name فقط در لیست ادمین میآید — والد اینجا با join هیدریت شده و
+ // جستجو ممکن است فرزندی را برگرداند که والدش در صفحهٔ جاری نیست.
return $this->paginated(
- array_map(fn(Specialty $s) => $s->toArray(), $rows),
+ array_map(
+ static fn(Specialty $s) => $s->toArray() + ['parent_name' => $s->getParent()?->getName()],
+ $rows
+ ),
(int) $total, $page, $limit
);
}
diff --git a/src/Tag/Controller/TagController.php b/src/Tag/Controller/TagController.php
index b30dfbab..80f98fcc 100644
--- a/src/Tag/Controller/TagController.php
+++ b/src/Tag/Controller/TagController.php
@@ -93,7 +93,7 @@ class TagController extends BaseController
$qb->orderBy('t.name', 'ASC');
}
if ($search !== '') {
- $qb->andWhere('t.name LIKE :s')->setParameter('s', '%' . $search . '%');
+ $qb->andWhere('t.name LIKE :s OR t.slug LIKE :s')->setParameter('s', '%' . $search . '%');
}
$total = (clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult();
diff --git a/tests/Category/AdminCategorySearchTest.php b/tests/Category/AdminCategorySearchTest.php
new file mode 100644
index 00000000..815b186f
--- /dev/null
+++ b/tests/Category/AdminCategorySearchTest.php
@@ -0,0 +1,194 @@
+ */
+ private function idsOf(array $body): array
+ {
+ return array_map(static fn(array $r) => (int) $r['id'], $body['data']);
+ }
+
+ private function createdId(array $body): int
+ {
+ return (int) $body['data']['data']['id'];
+ }
+
+ // ── specialties ───────────────────────────────────────────────────────────
+
+ public function testSpecialtySearchByParentNameReturnsChildren(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $marker = 'پوستآزمون' . uniqid();
+
+ $parentId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => $marker])
+ );
+ $childId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'لیزر درمانی ' . uniqid(), 'parent_id' => $parentId])
+ );
+ $unrelatedId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'قلب' . uniqid()])
+ );
+
+ $body = $this->authJson('GET', '/api/v1/admin/specialties?limit=100&search=' . urlencode($marker), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ $ids = $this->idsOf($body);
+ self::assertContains($childId, $ids, 'فرزند باید با نام والد پیدا شود');
+ self::assertContains($parentId, $ids, 'خودِ والد هم باید بماند');
+ self::assertNotContains($unrelatedId, $ids);
+ }
+
+ public function testSpecialtyRowCarriesParentName(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $marker = 'والدآزمون' . uniqid();
+
+ $parentId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => $marker])
+ );
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'زیرشاخه ' . uniqid(), 'parent_id' => $parentId]);
+
+ $body = $this->authJson('GET', '/api/v1/admin/specialties?limit=100&search=' . urlencode($marker), $admin);
+
+ $child = null;
+ foreach ($body['data'] as $row) {
+ if ((int) ($row['parent_id'] ?? 0) === $parentId) $child = $row;
+ }
+
+ self::assertNotNull($child);
+ self::assertSame($marker, $child['parent_name']);
+ }
+
+ public function testSpecialtySearchBySlugStillMatches(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $slug = 'slug-test-' . uniqid();
+
+ $id = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => 'نام دلخواه ' . uniqid(), 'slug' => $slug])
+ );
+
+ $body = $this->authJson('GET', '/api/v1/admin/specialties?limit=100&search=' . urlencode($slug), $admin);
+
+ self::assertContains($id, $this->idsOf($body));
+ }
+
+ // ── doctor services ───────────────────────────────────────────────────────
+
+ public function testDoctorServiceSearchBySpecialtyName(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $marker = 'تخصصآزمون' . uniqid();
+
+ $specialtyId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/specialty', $admin, ['name' => $marker])
+ );
+ $serviceId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/doctor-service', $admin, ['name' => 'بوتاکس ' . uniqid(), 'specialty_id' => $specialtyId])
+ );
+ $unrelatedId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/doctor-service', $admin, ['name' => 'اکو' . uniqid()])
+ );
+
+ $body = $this->authJson('GET', '/api/v1/admin/doctor-services?limit=100&search=' . urlencode($marker), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ $ids = $this->idsOf($body);
+ self::assertContains($serviceId, $ids);
+ self::assertNotContains($unrelatedId, $ids);
+
+ $row = null;
+ foreach ($body['data'] as $r) {
+ if ((int) $r['id'] === $serviceId) $row = $r;
+ }
+ self::assertSame($marker, $row['specialty_name']);
+ }
+
+ public function testDoctorServiceWithoutSpecialtyIsStillListed(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $marker = 'خدمتتنها' . uniqid();
+
+ $id = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/doctor-service', $admin, ['name' => $marker])
+ );
+
+ $body = $this->authJson('GET', '/api/v1/admin/doctor-services?limit=100&search=' . urlencode($marker), $admin);
+
+ $ids = $this->idsOf($body);
+ self::assertContains($id, $ids, 'LEFT JOIN نباید ردیفِ بدون تخصص را حذف کند');
+ self::assertSame(1, $body['meta']['totalRecords']);
+ }
+
+ // ── cities ────────────────────────────────────────────────────────────────
+
+ public function testCitySearchByProvinceName(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $marker = 'استانآزمون' . uniqid();
+
+ $provinceId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/province', $admin, ['name' => $marker])
+ );
+ $cityId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/city', $admin, ['name' => 'شهر یک ' . uniqid(), 'province_id' => $provinceId])
+ );
+ $unrelatedId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/city', $admin, ['name' => 'شهر دو' . uniqid()])
+ );
+
+ $body = $this->authJson('GET', '/api/v1/admin/cities?limit=100&search=' . urlencode($marker), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ $ids = $this->idsOf($body);
+ self::assertContains($cityId, $ids);
+ self::assertNotContains($unrelatedId, $ids);
+ }
+
+ public function testCitySearchByDomain(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+ $domain = 'search-test-' . uniqid() . '.example';
+
+ $cityId = $this->createdId(
+ $this->authJson('POST', '/api/v1/admin/city', $admin, ['name' => 'شهر دامنهدار ' . uniqid(), 'domain' => $domain])
+ );
+
+ $body = $this->authJson('GET', '/api/v1/admin/cities?limit=100&search=' . urlencode($domain), $admin);
+
+ self::assertContains($cityId, $this->idsOf($body));
+ }
+
+ // ── boundaries ────────────────────────────────────────────────────────────
+
+ public function testUnknownTermReturnsEmptyList(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+
+ $body = $this->authJson('GET', '/api/v1/admin/specialties?search=' . urlencode('چنینچیزینیست' . uniqid()), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ self::assertSame([], $body['data']);
+ self::assertSame(0, $body['meta']['totalRecords']);
+ }
+
+ public function testNonAdminForbidden(): void
+ {
+ $user = $this->createUser(['ROLE_USER']);
+ $this->authJson('GET', '/api/v1/admin/specialties?search=x', $user);
+ self::assertSame(403, $this->responseCode());
+ }
+}