fix(page): 페이지 목록 검색 범위·페이지당 기본값·긴 검색어 오류 정정

- 검색 조건 '전체'가 슬러그를 검색하도록 통합하고, 검색 대상을 제목·슬러그로
 한정(본문 제외 — UI '제목 또는 슬러그로 검색'과 정합). '제목' 선택 시 제목만 검색.
- 페이지당 표시 개수 기본값을 백엔드도 20으로 통일(화면 표시와 실제 조회 일치).
- 긴 검색어(공백 없는 한글 다수)가 FULLTEXT 토큰 한도를 넘겨 500을 유발하던 문제를
 검색어 100자 제한으로 422 처리. 검증 메시지 필드명을 '검색어'로 노출.
This commit is contained in:
chym1217 committed 2026-07-06 09:37:03 +09:00
1 parent bb654fb56c
commit 4c4f90a091
10 files changed
+238 -109

No files matched your search

@@ -62,4 +62,7 @@ return [
'array' => '順序情報の形式が正しくありません。',
],
],
'attributes' => [
'search' => '検索ワード',
],
];
+15 -10
View File
@@ -4,6 +4,21 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-01
### Fixed
- 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다.
- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다.
- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다.
- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다.
- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다.
- 페이지를 수정하거나 삭제한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다.
- 페이지 목록에서 여러 페이지를 한 번에 발행 / 미발행할 때 활동 이력에 발행 여부가 기록되지 않던 문제를 수정했습니다. 이제 일괄 처리에서도 페이지별로 발행 / 미발행 기록이 남습니다.
- 페이지 관리 목록에서 검색 조건 '전체'로 슬러그를 검색해도 결과가 나오지 않던 문제를 수정했습니다 — '전체'는 제목과 슬러그를 함께 검색하고, '제목'을 고르면 제목만 검색됩니다.
- 페이지 관리 목록의 페이지당 표시 개수 기본값이 화면 표시(20건)와 실제 조회(15건) 간에 어긋나던 문제를 수정했습니다 — 기본값을 20건으로 통일했습니다.
- 페이지 관리 목록에서 아주 긴 검색어를 입력하면 오류 화면이 뜨던 문제를 수정했습니다 — 검색어 길이를 100자로 제한해 안내 메시지로 처리합니다.
## [1.0.0] - 2026-07-01
### Changed
@@ -22,16 +37,6 @@
- 페이지 등록 / 수정 화면의 입력 행 컨테이너를 sirsoft-admin_basic 표준 시맨틱 (.row-stack) 과 정합 — 행 사이 옅은 회색 구분선(1px) 이 자동 적용되어 페이지 폼의 행 묶음 가독성 개선.
### Fixed
- 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
- 페이지를 수정하거나 삭제한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
- 페이지 목록에서 여러 페이지를 한 번에 발행 / 미발행할 때 활동 이력에 발행 여부가 기록되지 않던 문제를 수정했습니다. 이제 일괄 처리에서도 페이지별로 발행 / 미발행 기록이 남습니다. (#424 @gnuboard 님께서 제보해주셨습니다.)
## [1.0.0-beta.3] - 2026-05-11
### Changed
@@ -44,7 +44,7 @@ class PageController extends AdminBaseController
{
try {
$validated = $request->validated();
$perPage = $validated['per_page'] ?? 15;
$perPage = $validated['per_page'] ?? 20;
$pages = $this->pageService->getPages(
array_filter($validated, fn ($v) => $v !== null),
$perPage
@@ -11,6 +11,8 @@ class PageListRequest extends FormRequest
{
/**
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (실제 인가는 미들웨어 담당)
*/
public function authorize(): bool
{
@@ -26,11 +28,13 @@ class PageListRequest extends FormRequest
{
return [
'published' => ['nullable', 'boolean'],
'search' => ['nullable', 'string', 'max:255'],
// 공백 없는 긴 한글(약 140자+)은 FULLTEXT phrase 토큰 한도를 초과해
// 'Too many words in a FTS phrase'(191) → 500 을 유발하므로 100자로 제한한다.
'search' => ['nullable', 'string', 'max:100'],
'search_field' => ['nullable', 'string', 'in:all,title,slug'],
'filters' => ['nullable', 'array'],
'filters.*.field' => ['nullable', 'string', 'in:all,title,slug'],
'filters.*.value' => ['nullable', 'string', 'max:255'],
'filters.*.value' => ['nullable', 'string', 'max:100'],
'filters.*.operator' => ['nullable', 'string', 'in:like,eq,starts_with,ends_with'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
@@ -57,4 +61,20 @@ class PageListRequest extends FormRequest
'sort_order.in' => __('sirsoft-page::validation.sort_order.in'),
];
}
/**
* 검증 오류에 사용할 사용자 친화적 필드명
*
* 레이아웃은 검색어를 filters[0][value] 로 전송하므로, 내부 필드명이 그대로
* 노출되지 않도록 '검색어' 로 매핑한다. (search 파라미터 경로도 동일하게 통일)
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'search' => __('sirsoft-page::validation.attributes.search'),
'filters.*.value' => __('sirsoft-page::validation.attributes.search'),
];
}
}
@@ -17,7 +17,7 @@ interface PageRepositoryInterface
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator;
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
/**
* ID로 페이지를 조회합니다.
@@ -5,7 +5,9 @@ namespace Modules\Sirsoft\Page\Repositories;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
@@ -26,35 +28,17 @@ class PageRepository implements PageRepositoryInterface
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
// filters 배열에서 search/search_field 변환
$filters = $this->normalizeSearchFilters($filters);
// FULLTEXT 대상 검색인 경우 Scout 사용
if (! empty($filters['search'])) {
$searchField = $filters['search_field'] ?? 'all';
if (in_array($searchField, ['all', 'title'])) {
return Page::search($filters['search'])
->query(function ($query) use ($filters) {
$query->with(['creator', 'updater']);
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($query, 'sirsoft-page.pages.read');
// 발행 상태 필터
if (isset($filters['published']) && $filters['published'] !== '') {
$query->where('published', (bool) $filters['published']);
}
// 정렬
$this->applySorting($query, $filters);
})
->paginate($perPage);
}
}
// 검색 필드(title/slug/all)별로 대상 컬럼을 정확히 분리하기 위해
// 모든 검색을 applyFilters 통합 쿼리로 처리한다.
// - title: 제목만 (Scout searchableColumns 는 title+content 를 함께 검색하므로 사용하지 않음)
// - slug : 슬러그만
// - all : 제목 + 슬러그 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색')
// (Scout 콜백에 slug orWhere 를 넣으면 total 카운트가 부풀려지는 회귀가 있어 #225 에서 제거됨)
$query = Page::with(['creator', 'updater']);
// 권한 스코프 필터링
@@ -94,7 +78,7 @@ class PageRepository implements PageRepositoryInterface
* @param int $id 페이지 ID
* @return Page 페이지 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): Page
{
@@ -202,9 +186,8 @@ class PageRepository implements PageRepositoryInterface
* (Scout 엔진은 indexing 및 다른 검색에서 계속 사용됨).
*
* @param string $keyword 검색 키워드
* @return \Illuminate\Database\Eloquent\Builder
*/
private function buildKeywordQuery(string $keyword): \Illuminate\Database\Eloquent\Builder
private function buildKeywordQuery(string $keyword): Builder
{
return Page::query()
->published()
@@ -218,7 +201,7 @@ class PageRepository implements PageRepositoryInterface
/**
* 쿼리에 정렬을 적용합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건 (sort_by, sort_order 포함)
*/
private function applySorting($query, array $filters): void
@@ -236,7 +219,7 @@ class PageRepository implements PageRepositoryInterface
/**
* 쿼리에 필터를 적용합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건
*/
private function applyFilters($query, array $filters): void
@@ -255,12 +238,12 @@ class PageRepository implements PageRepositoryInterface
$query->where('slug', 'like', "%{$keyword}%");
} elseif ($searchField === 'title') {
$query->where(function ($q) use ($keyword) {
$this->applyTitleKeywordSearch($q, $keyword, titleOnly: true);
$this->applyTitleKeywordSearch($q, $keyword, includeSlug: false);
});
} else {
// all: slug + title 통합 검색
// all: 제목 + 슬러그 통합 검색 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색')
$query->where(function ($q) use ($keyword) {
$this->applyTitleKeywordSearch($q, $keyword);
$this->applyTitleKeywordSearch($q, $keyword, includeSlug: true);
});
}
}
@@ -269,24 +252,21 @@ class PageRepository implements PageRepositoryInterface
/**
* 제목(JSON) + 슬러그 키워드 검색을 쿼리에 적용합니다.
*
* title은 {"ko": "...", "en": "..."} 형태의 JSON 컬럼이므로
* JSON_UNQUOTE + LIKE 방식으로 각 로케일 값을 검색합니다.
* title은 {"ko": "...", "en": "..."} 형태의 JSON 컬럼이므로 FULLTEXT 로 검색한다.
* 검색 대상은 제목·슬러그이며 본문(content)은 포함하지 않는다
* (검색 UI 안내 '제목 또는 슬러그로 검색' 및 검색 필드 옵션 전체/제목/슬러그와 일치).
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param string $keyword 검색 키워드
* @param bool $titleOnly 제목만 검색 여부 (false 시 slug도 포함)
* @param bool $includeSlug 슬러그도 함께 검색할지 여부 (전체 검색 시 true)
*/
private function applyTitleKeywordSearch($query, string $keyword, bool $titleOnly = false): void
private function applyTitleKeywordSearch($query, string $keyword, bool $includeSlug = false): void
{
if (! $titleOnly) {
if ($includeSlug) {
$query->where('slug', 'like', "%{$keyword}%");
}
DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or');
if (! $titleOnly) {
DatabaseFulltextEngine::whereFulltext($query, 'content', $keyword, 'or');
}
}
/**
@@ -32,7 +32,7 @@ class PageService
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function getPages(array $filters = [], int $perPage = 15): LengthAwarePaginator
public function getPages(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$filters = HookManager::applyFilters('sirsoft-page.page.filter_list_query', $filters);
@@ -237,7 +237,7 @@ class PageService
$page = $this->pageRepository->update($page, $updateData);
// 페이지별 after_publish 발화 → 활동로그 per-item 기록 (이슈 #424-19)
// 페이지별 after_publish 발화 → 활동로그 per-item 기록
HookManager::doAction('sirsoft-page.page.after_publish', $page, $published);
$count++;
@@ -59,6 +59,11 @@ return [
'max' => 'The search query must not exceed :max characters.',
],
// user-friendly attribute names shown in validation errors
'attributes' => [
'search' => 'search query',
],
// search_field validation messages
'search_field' => [
'in' => 'The search field must be one of: all, title, slug.',
@@ -59,6 +59,11 @@ return [
'max' => '검색어는 최대 :max자까지 입력 가능합니다.',
],
// 검증 오류에 표시할 사용자 친화적 필드명
'attributes' => [
'search' => '검색어',
],
// search_field 검증 메시지
'search_field' => [
'in' => '검색 필드는 all, title, slug 중 하나여야 합니다.',
@@ -11,27 +11,11 @@ use App\Helpers\PermissionHelper;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Search\Engines\DatabaseFulltextEngine;
use Laravel\Scout\EngineManager;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageAttachment;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
/**
* LIKE fallback 전용 Scout 엔진
*
* MySQL FULLTEXT는 트랜잭션 내 미커밋 데이터를 인덱싱하지 않으므로,
* 테스트에서 Scout 파이프라인 전체를 검증하기 위해 LIKE fallback을 강제합니다.
*/
class LikeFallbackEngine extends DatabaseFulltextEngine
{
public static function supportsFulltext(): bool
{
return false;
}
}
/**
* 관리자 페이지 관리 API 테스트
*
@@ -123,12 +107,13 @@ class PageControllerTest extends FeatureTestCase
/**
* 검색어로 목록을 조회할 수 있는지 확인
*
* 검색 동작은 슬러그(LIKE) 매칭으로 검증한다 — 슬러그 검색은 InnoDB FULLTEXT 가
* 트랜잭션 내 미커밋 데이터를 보장하지 않는 가시성 문제와 무관하게 안정적이다.
* (제목/본문 FULLTEXT 컬럼 범위 분리는 test_admin_search_title_excludes_content 가 담당)
*/
public function test_admin_can_search_pages(): void
{
// Scout 엔진을 LIKE fallback으로 교체 (트랜잭션 호환)
$this->swapScoutEngineToLikeFallback();
Page::factory()->create([
'slug' => 'test-search-target',
'title' => ['ko' => '검색대상페이지', 'en' => 'Search Target'],
@@ -136,11 +121,158 @@ class PageControllerTest extends FeatureTestCase
'updated_by' => $this->adminUser->id,
]);
// 전체(all) 검색에 슬러그 토큰을 넣으면 결과에 포함되어야 한다
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=검색대상');
->getJson('/api/modules/sirsoft-page/admin/pages?search=search-target&search_field=all');
$response->assertStatus(200);
$this->assertGreaterThanOrEqual(1, $response->json('data.meta.total'));
$slugs = collect($response->json('data.data'))->pluck('slug')->all();
$this->assertContains('test-search-target', $slugs);
}
/**
* 검색조건 '전체'(all)로 슬러그를 검색하면 결과에 포함되는지 확인
*
* 회귀: 기존에는 all 검색이 Scout(제목·본문 FULLTEXT) 경로로만 처리되어
* 슬러그가 검색 범위에서 누락됨. all 검색에도 슬러그 LIKE 가 포함되어야 한다.
*
* 슬러그 매칭은 LIKE 기반이라 테스트 트랜잭션의 미커밋 데이터도 검증 가능하다.
* (제목·본문 FULLTEXT 매칭은 별도 title 단독 검색 테스트가 담당)
*/
public function test_admin_search_all_includes_slug(): void
{
// 제목/본문에 없는 고유 슬러그 토큰 (운영 시더 데이터와 충돌 회피)
Page::factory()->create([
'slug' => 'test-zzslugonly-target',
'title' => ['ko' => '제목없음표제', 'en' => 'Heading Only'],
'content' => ['ko' => '본문내용', 'en' => 'Body'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// search_field 미지정 = all. 제목/본문에 없는 슬러그 토큰으로 검색
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zzslugonly');
$response->assertStatus(200);
$slugs = collect($response->json('data.data'))->pluck('slug')->all();
$this->assertContains('test-zzslugonly-target', $slugs);
}
/**
* '전체'(all) 검색 결과의 total 이 실제 행 수와 일치하는지 확인 (#225 회귀 가드)
*
* 과거 Scout 콜백 내 orWhere('slug') 가 total 카운트를 부풀렸던 회귀(#225)를
* all 검색 통합 후에도 재발하지 않는지 검증한다.
* 고유 슬러그 토큰으로 결과를 1건으로 한정해 total ↔ 행 수 정합을 본다.
*/
public function test_admin_search_all_total_matches_rows(): void
{
Page::factory()->create([
'slug' => 'test-zztotalguard-one',
'title' => ['ko' => '집계가드대상', 'en' => 'Count Guard'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zztotalguard');
$response->assertStatus(200);
$total = $response->json('data.meta.total');
$rowCount = count($response->json('data.data'));
// 단일 페이지 결과: total 과 실제 반환 행 수가 일치해야 함 (부풀림 없음)
$this->assertSame(1, $total);
$this->assertSame(1, $rowCount);
}
/**
* per_page 미지정 시 기본값이 20 인지 확인
*
* 회귀: 백엔드 기본값이 15 였으나 프론트 셀렉트는 20 을 표시해
* '표시 20 / 실제 조회 15' 불일치 발생. 양쪽을 20 으로 통일한다.
*/
public function test_admin_list_default_per_page_is_20(): void
{
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages');
$response->assertStatus(200);
$this->assertSame(20, $response->json('data.meta.per_page'));
}
/**
* 본문(content)에만 있는 단어는 제목/전체 검색 어느 쪽에서도 검색되지 않는지 확인 (E4)
*
* 검색 대상은 제목·슬러그이며 본문은 포함하지 않는다 (UI '제목 또는 슬러그로 검색',
* 검색 필드 옵션 전체/제목/슬러그와 일치). '제목'뿐 아니라 '전체' 검색도 본문을 제외한다.
*
* 검증 범위: '본문 단어가 검색에 누출되지 않음'(항상 0건이라 트랜잭션 FULLTEXT 가시성과 무관하게 안정적).
*/
public function test_admin_search_excludes_content(): void
{
Page::factory()->create([
'slug' => 'test-content-scope',
'title' => ['ko' => '제목쪽고유단어', 'en' => 'Heading'],
'content' => ['ko' => '본문쪽고유단어', 'en' => 'Body'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// 본문에만 있는 단어는 '제목' 검색에서 0건
$this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=본문쪽고유단어&search_field=title')
->assertStatus(200)
->assertJsonPath('data.meta.total', 0);
// 본문에만 있는 단어는 '전체' 검색에서도 0건 (본문은 검색 대상 아님)
$this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=본문쪽고유단어&search_field=all')
->assertStatus(200)
->assertJsonPath('data.meta.total', 0);
}
/**
* 검색조건 '전체'(all)는 제목 외에 슬러그도 검색해 title 보다 넓은 범위인지 확인 (E4 대조)
*
* '전체' 검색 = 제목 + 슬러그. title 검색이 제목만 보더라도, all 검색은 슬러그까지 포함해야 한다.
*/
public function test_admin_search_all_wider_than_title(): void
{
Page::factory()->create([
'slug' => 'test-allscope-zzslugword',
'title' => ['ko' => '무관한제목', 'en' => 'x'],
'content' => ['ko' => '본문', 'en' => 'y'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// all 검색: 슬러그 토큰으로 잡혀야 함 (제목엔 없음)
$all = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zzslugword&search_field=all');
$all->assertStatus(200);
$this->assertGreaterThanOrEqual(1, $all->json('data.meta.total'));
// title 검색: 같은 슬러그 토큰은 제목에 없으므로 0건 (범위가 좁음을 대조)
$title = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zzslugword&search_field=title');
$title->assertStatus(200);
$this->assertSame(0, $title->json('data.meta.total'));
}
/**
* 검색어가 최대 길이를 초과하면 500 이 아닌 422 검증 오류를 반환하는지 확인 (E5)
*
* 회귀: 공백 없는 긴 한글(140자+)이 FULLTEXT phrase 토큰 한도를 초과해
* 'Too many words in a FTS phrase'(191) → 500 을 유발했다.
* search max 를 100 으로 제한해 긴 입력을 422 로 사전 차단한다.
*/
public function test_admin_search_too_long_returns_422(): void
{
// 레이아웃이 실제로 쓰는 filters[0][value] 경로로 긴 검색어 전송 → 500 이 아닌 422
$this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?filters[0][field]=all&filters[0][value]='.str_repeat('가', 200))
->assertStatus(422);
}
// ─── 상세 조회 (show) ──────────────────────────────
@@ -182,7 +314,7 @@ class PageControllerTest extends FeatureTestCase
}
/**
* 관리자 상세 조회 시 첨부 URL이 관리자 라우트를 가리키는지 확인 (이슈 #424 6-1)
* 관리자 상세 조회 시 첨부 URL이 관리자 라우트를 가리키는지 확인
*
* 첨부 URL이 공개 라우트(발행 가드 있음)를 가리키면 미발행 페이지의
* 첨부가 관리자에게도 404로 차단된다. 관리자 응답은 발행 가드 없는
@@ -218,7 +350,7 @@ class PageControllerTest extends FeatureTestCase
}
/**
* 공개 상세 조회 시 첨부 URL이 공개 라우트를 유지하는지 확인 (이슈 #424 6-1)
* 공개 상세 조회 시 첨부 URL이 공개 라우트를 유지하는지 확인
*
* 공개 응답에 admin URL(발행 가드 없음)이 섞이면 미발행 첨부가
* 노출되는 보안 회귀가 된다. 공개 응답은 공개 라우트만 반환해야 한다.
@@ -697,7 +829,7 @@ class PageControllerTest extends FeatureTestCase
}
/**
* 페이지 삭제 후 동일 슬러그로 재생성할 수 있는지 확인 (#424-20 회귀)
* 페이지 삭제 후 동일 슬러그로 재생성할 수 있는지 확인 (회귀)
*
* soft delete 시절에는 삭제된 페이지의 slug 가 잔존하여 동일 slug 재생성 시
* Rule::unique 가 422 를 반환했다. hard delete 전환으로 재생성이 성공해야 한다.
@@ -738,7 +870,7 @@ class PageControllerTest extends FeatureTestCase
}
/**
* 페이지 삭제 후 슬러그 중복 체크와 생성 검증 결과가 일치하는지 확인 (#424-20 회귀)
* 페이지 삭제 후 슬러그 중복 체크와 생성 검증 결과가 일치하는지 확인 (회귀)
*
* 삭제 후 check-slug 는 "사용 가능", 생성도 성공해야 한다 (두 경로 정합).
*/
@@ -778,7 +910,7 @@ class PageControllerTest extends FeatureTestCase
}
/**
* 첨부가 있는 페이지를 물리 삭제해도 삭제 흐름(첨부 정리 + 활동로그 기록)이 정상 완료되는지 확인 (#424-20)
* 첨부가 있는 페이지를 물리 삭제해도 삭제 흐름(첨부 정리 + 활동로그 기록)이 정상 완료되는지 확인
*
* 활동로그 리스너는 삭제되는 페이지를 loggable 로 참조한다. hard delete 후 loggable 행이
* 사라지더라도 삭제 응답이 200 이고 페이지/첨부 행이 모두 물리 삭제되어야 한다.
@@ -882,7 +1014,7 @@ class PageControllerTest extends FeatureTestCase
->assertJsonPath('success', true)
->assertJsonPath('data.count', 3);
// 성공 메시지의 :count 플레이스홀더가 실제 건수로 치환되는지 확인 (이슈 #424-19 회귀)
// 성공 메시지의 :count 플레이스홀더가 실제 건수로 치환되는지 확인 (회귀)
$message = $response->json('message');
$this->assertStringNotContainsString(':count', $message, '메시지에 :count 플레이스홀더가 치환되지 않고 남았습니다.');
$this->assertStringContainsString('3', $message, '메시지에 변경 건수(3)가 포함되어야 합니다.');
@@ -1019,7 +1151,7 @@ class PageControllerTest extends FeatureTestCase
]);
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?' . http_build_query([
->getJson('/api/modules/sirsoft-page/admin/pages?'.http_build_query([
'filters' => [
['field' => 'slug', 'value' => 'filters-target', 'operator' => 'like'],
],
@@ -1240,7 +1372,7 @@ class PageControllerTest extends FeatureTestCase
{
// 별도 역할을 생성하여 read 권한만 부여 (admin 역할과 분리)
$readOnlyRole = Role::create([
'identifier' => 'test_page_read_only_' . uniqid(),
'identifier' => 'test_page_read_only_'.uniqid(),
'name' => ['ko' => '읽기전용', 'en' => 'Read Only'],
'is_active' => true,
]);
@@ -1512,25 +1644,4 @@ class PageControllerTest extends FeatureTestCase
return $user;
}
/**
* Scout 엔진을 LIKE fallback 모드로 교체합니다.
*
* MySQL FULLTEXT는 트랜잭션 내 미커밋 데이터를 검색하지 못하므로,
* Scout 파이프라인 전체(Model::search → EngineManager → performSearch → 쿼리)를
* 검증하기 위해 LIKE fallback 엔진으로 교체합니다.
*
* @return void
*/
private function swapScoutEngineToLikeFallback(): void
{
$manager = $this->app->make(EngineManager::class);
$manager->extend('mysql-fulltext', fn () => new LikeFallbackEngine());
// EngineManager 캐시된 드라이버 인스턴스 초기화
$reflection = new \ReflectionClass($manager);
$property = $reflection->getProperty('drivers');
$property->setAccessible(true);
$property->setValue($manager, []);
}
}