Files
Gnuboard7/tests/Feature/Menu/MenuListSortAndSearchTest.php
HeuJung b10e89bef0 fix(core,board,ecommerce,page,gdpr,ckeditor5,kginicis,admin_basic,basic): 목록 조회·검증 계층 정비 및 감사 지적 전건 처리
목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친
감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다.

검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다.
옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던
공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글
목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가
되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는
접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다.

ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로
로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에
사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다.

하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서
통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가
요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다.

정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다.
탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
2026-08-02 15:29:29 +09:00

184 lines
7.4 KiB
PHP

<?php
namespace Tests\Feature\Menu;
use App\Enums\MenuPermissionType;
use App\Models\Menu;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 메뉴 목록 정렬·검색 회귀 테스트 (#492 D-22 · D-23).
*
* 두 결함이 함께 있었다.
*
* 1. **정렬 무시(D-22)** — 컨트롤러가 "필터가 없으면" `filters` 를 받지 않는 메서드로
* 분기해 `sort_by`/`sort_order` 를 통째로 버렸다. 필터를 하나라도 걸어야 정렬이 먹었다.
* 2. **한글 검색 0건(D-23)** — `name` 은 다국어 JSON 컬럼이고 유니코드 이스케이프
* (`{"ko":"대시보드"}`)로 저장된다. 컬럼 전체에 LIKE 를 걸면 한글
* 검색어가 절대 매칭되지 않는다. 로케일 JSON 경로(`name->ko`)로 검색해야 한다.
*
* **HTTP 엔드포인트로 검증한다.** Service 를 직접 부르면 컨트롤러의 분기를 타지 않아
* D-22 가 남아 있어도 통과한다(실제로 처음 작성했을 때 그렇게 통과했다).
*
* 부하 설계: 관리자 1명 + 메뉴 3건을 INSERT 로 심고 한 메서드에서 두 결함을 모두 단언한다.
*/
class MenuListSortAndSearchTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
private int $roleId;
protected function setUp(): void
{
parent::setUp();
// permission 미들웨어는 권한 행이 실제로 존재해야 통과한다(is_super 만으로는 403).
// 이 테스트가 쓰는 읽기 권한 1건만 심어 픽스처를 최소로 유지한다.
$role = Role::create([
'identifier' => 'menu-reader',
'name' => ['ko' => '메뉴 조회자', 'en' => 'Menu Reader'],
'description' => ['ko' => '메뉴 조회 전용', 'en' => 'Menu read only'],
'is_active' => true,
]);
$permission = Permission::create([
'identifier' => 'core.menus.read',
'name' => ['ko' => '메뉴 조회', 'en' => 'Read Menus'],
'description' => ['ko' => '메뉴를 조회할 수 있습니다.', 'en' => 'Can read menus.'],
'extension_type' => 'core',
'extension_identifier' => 'core',
'type' => 'admin',
]);
$role->permissions()->attach($permission->id);
$this->roleId = $role->id;
$this->adminUser = User::factory()->create(['is_super' => true]);
$this->adminUser->roles()->attach($role->id);
}
/**
* 정렬·검색 판정용 메뉴를 INSERT 1회로 심습니다.
*
* `order` 순서와 이름 순서를 **일부러 어긋나게** 둔다 — 두 순서가 같으면 정렬이
* 무시돼도 기본 정렬(order)과 결과가 같아 결함을 통과시킨다.
*/
private function seedMenus(): void
{
$rows = [
['ko' => '대시보드', 'en' => 'Dashboard', 'slug' => 'reg-dashboard', 'order' => 1],
['ko' => '사용자 관리', 'en' => 'Users', 'slug' => 'reg-users', 'order' => 2],
['ko' => '게시판 관리', 'en' => 'Boards', 'slug' => 'reg-boards', 'order' => 3],
];
DB::table((new Menu)->getTable())->insert(array_map(fn ($r) => [
'name' => json_encode(['ko' => $r['ko'], 'en' => $r['en']]),
'slug' => $r['slug'],
'url' => '/'.$r['slug'],
'order' => $r['order'],
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
], $rows));
// 메뉴는 역할에 read 로 연결돼야 목록에 노출된다(`Menu::scopeAccessibleBy`).
// 연결 행도 INSERT 1회로 심는다.
$menuIds = Menu::whereIn('slug', array_column($rows, 'slug'))->pluck('id');
DB::table('role_menus')->insert($menuIds->map(fn ($id) => [
'menu_id' => $id,
'role_id' => $this->roleId,
'permission_type' => MenuPermissionType::Read->value,
'created_at' => now(),
'updated_at' => now(),
])->all());
}
/**
* 메뉴 목록 API 를 호출하고 이 테스트가 심은 메뉴의 slug 순서를 반환합니다.
*
* @param string $query 쿼리스트링 (앞의 ? 제외)
* @return array<int, string> slug 순서
*/
private function slugsFrom(string $query): array
{
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson('/api/admin/menus'.($query === '' ? '' : '?'.$query));
$response->assertOk();
$slugs = [];
$collect = function ($node) use (&$collect, &$slugs) {
if (! is_array($node)) {
return;
}
if (isset($node['slug']) && is_string($node['slug']) && str_starts_with($node['slug'], 'reg-')) {
$slugs[] = $node['slug'];
}
foreach ($node as $child) {
$collect($child);
}
};
$collect($response->json());
return $slugs;
}
/**
* 필터 없이도 정렬이 적용되고, 한글 검색이 다국어 JSON 컬럼에서 매칭되어야 한다.
*/
public function test_필터_없이도_정렬이_적용되고_한글검색이_매칭된다(): void
{
$this->seedMenus();
// (D-22) 필터를 하나도 걸지 않은 상태에서 이름 오름차순.
// 기대 순서는 이름순(게시판<대시보드<사용자 / Boards<Dashboard<Users)이고,
// 심어둔 order 순서(대시보드1·사용자2·게시판3)와 다르다 — 정렬이 버려지면
// order 순서가 나오므로 두 순서가 다른 것이 판정의 핵심이다.
$this->assertSame(
['reg-boards', 'reg-dashboard', 'reg-users'],
$this->slugsFrom('sort_by=name&sort_order=asc'),
'필터 없이 정렬만 요청하면 정렬이 무시됩니다 — 기본 order 순서로 되돌아간 것입니다.'
);
// 방향을 뒤집으면 순서도 뒤집혀야 한다 (한쪽만 보면 우연한 일치를 못 걸러낸다)
$this->assertSame(
['reg-users', 'reg-dashboard', 'reg-boards'],
$this->slugsFrom('sort_by=name&sort_order=desc'),
'정렬 방향이 반영되지 않았습니다.'
);
// (D-23) 한글 검색어가 JSON 이스케이프 저장값에서도 매칭되어야 한다
$this->assertSame(
['reg-dashboard'],
$this->slugsFrom('filters[0][field]=name&filters[0][value]=대시보드&filters[0][operator]=like'),
'한글 검색이 0건이면 JSON 컬럼 전체에 LIKE 를 건 것입니다 — 로케일 경로로 검색해야 합니다.'
);
// 'all' 필드 검색도 같은 경로를 타야 한다
$this->assertSame(
['reg-users'],
$this->slugsFrom('filters[0][field]=all&filters[0][value]=사용자&filters[0][operator]=like'),
"'all' 검색에서도 다국어 컬럼이 로케일 경로로 풀려야 합니다."
);
// 다른 로케일 값으로도 검색되어야 한다 (표시 로케일에만 묶이면 안 된다)
$this->assertSame(
['reg-boards'],
$this->slugsFrom('filters[0][field]=name&filters[0][value]=Boards&filters[0][operator]=like'),
'지원 로케일 전체를 대상으로 검색해야 합니다.'
);
}
}