fix(page): 페이지 목록 검색 범위·페이지당 기본값·긴 검색어 오류 정정
- 검색 조건 '전체'가 슬러그를 검색하도록 통합하고, 검색 대상을 제목·슬러그로 한정(본문 제외 — UI '제목 또는 슬러그로 검색'과 정합). '제목' 선택 시 제목만 검색. - 페이지당 표시 개수 기본값을 백엔드도 20으로 통일(화면 표시와 실제 조회 일치). - 긴 검색어(공백 없는 한글 다수)가 FULLTEXT 토큰 한도를 넘겨 500을 유발하던 문제를 검색어 100자 제한으로 422 처리. 검증 메시지 필드명을 '검색어'로 노출.
This commit is contained in:
1 parent
bb654fb56c
commit
4c4f90a091
10 files changed
+238
-109
No files matched your search
@@ -62,4 +62,7 @@ return [
|
||||
'array' => '順序情報の形式が正しくありません。',
|
||||
],
|
||||
],
|
||||
'attributes' => [
|
||||
'search' => '検索ワード',
|
||||
],
|
||||
];
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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, []);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user