feat: enhance search functionality to include parent names and related fields across categories

This commit is contained in:
hamed
2026-08-08 14:57:36 +03:30
parent 35181cd715
commit d74a351e5a
11 changed files with 250 additions and 21 deletions
+7 -7
View File
@@ -494,7 +494,7 @@ function CitiesTab() {
{ key: 'id', sortable: true, header: 'شناسه', render: (c) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{c.id}</span> },
{ key: 'name', header: 'نام', render: (c) => <b>{c.name}</b> },
{ key: 'site_name', header: 'نام سایت', render: (c) => c.site_name ? <span>{c.site_name}</span> : <span className="muted"></span> },
{ key: 'province_id', header: 'استان', render: (c) => c.province_id ? <span className="chip">{provinceMap[c.province_id] ?? `#${c.province_id}`}</span> : <span className="muted"></span> },
{ key: 'province_id', header: 'استان', render: (c) => c.province_id ? <span className="chip">{c.province_name ?? provinceMap[c.province_id] ?? `#${c.province_id}`}</span> : <span className="muted"></span> },
{ key: 'representation_id', header: 'نماینده', render: (c) => c.representation_id ? <span className="muted" style={{ fontSize: 12 }}>{representationMap[c.representation_id] ?? `#${c.representation_id}`}</span> : <span className="muted"></span> },
{ key: 'domain', header: 'دامنه', render: (c) => c.domain ? <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{c.domain}</span> : <span className="muted"></span> },
{ key: 'weight', header: 'ترتیب', render: (c) => <span className="muted">{c.weight}</span> },
@@ -504,7 +504,7 @@ function CitiesTab() {
return (
<>
<TabActions label="افزودن شهر" onClick={() => { 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'] })} />
<DataTable<City> 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="هیچ شهری یافت نشد"
<DataTable<City> 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) => (
<>
<button onClick={() => openEdit(c)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
@@ -668,7 +668,7 @@ function SpecialtiesTab() {
{ key: 'id', sortable: true, header: 'شناسه', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.id}</span> },
{ key: 'name', header: 'نام', render: (s) => <b>{s.name}</b> },
{ key: 'slug', header: 'slug', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.slug}</span> },
{ key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? <span className="chip">{parentMap[s.parent_id] ?? `#${s.parent_id}`}</span> : <span className="muted"></span> },
{ key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? <span className="chip">{s.parent_name ?? parentMap[s.parent_id] ?? `#${s.parent_id}`}</span> : <span className="muted"></span> },
{ key: 'weight', header: 'ترتیب', render: (s) => <span className="muted">{s.weight}</span> },
{ key: 'status', header: 'وضعیت', render: (s) => <SBadge status={s.status} /> },
];
@@ -676,7 +676,7 @@ function SpecialtiesTab() {
return (
<>
<TabActions label="افزودن تخصص" onClick={() => { 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'] }); }} />
<DataTable<SpecialtyFull> 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="هیچ تخصصی یافت نشد"
<DataTable<SpecialtyFull> 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) => (
<>
<button onClick={() => openEdit(s)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
@@ -793,7 +793,7 @@ function DoctorServicesTab() {
const columns: Column<DoctorService>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.id}</span> },
{ key: 'name', header: 'نام', render: (s) => <b>{s.name}</b> },
{ key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? <span className="badge violet"><span className="bdot" />{specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`}</span> : <span className="muted"></span> },
{ key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? <span className="badge violet"><span className="bdot" />{s.specialty_name ?? specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`}</span> : <span className="muted"></span> },
{ key: 'weight', header: 'ترتیب', render: (s) => <span className="muted">{s.weight}</span> },
{ key: 'status', header: 'وضعیت', render: (s) => <SBadge status={s.status} /> },
];
@@ -801,7 +801,7 @@ function DoctorServicesTab() {
return (
<>
<TabActions label="افزودن خدمت" onClick={() => { 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'] })} />
<DataTable<DoctorService> 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="هیچ خدمتی یافت نشد"
<DataTable<DoctorService> 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) => (
<>
<button onClick={() => openEdit(s)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
@@ -1066,7 +1066,7 @@ function TagsTab() {
return (
<>
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/tags/export" exportFile="tags.json" bundle="tags" entityLabel="تگ‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-tags'] })} />
<DataTable<Tag> 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="هیچ تگی یافت نشد"
<DataTable<Tag> 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) => (
<>
<button onClick={() => openEdit(t)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
+5
View File
@@ -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 {
+7 -2
View File
@@ -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 |
+2 -2
View File
@@ -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`.
---
+9 -2
View File
@@ -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 |
+1 -1
View File
@@ -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
@@ -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
);
}
@@ -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);
@@ -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
);
}
+1 -1
View File
@@ -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();
+194
View File
@@ -0,0 +1,194 @@
<?php
namespace App\Tests\Category;
use App\Tests\ApiTestCase;
/**
* search=… on the admin category lists must reach the parent, not just the row's
* own name — operators look a child up by the name of the thing that owns it
* ("all services under Dermatology"), and by slug when they paste one from a URL.
*
* Covers: /admin/specialties (parent specialty), /admin/doctor-services
* (specialty), /admin/cities (province).
*/
class AdminCategorySearchTest extends ApiTestCase
{
/** @return list<int> */
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());
}
}