diff --git a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php index 3e6bb2d0..a83cc4ed 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php @@ -62,4 +62,7 @@ return [ 'array' => '順序情報の形式が正しくありません。', ], ], + 'attributes' => [ + 'search' => '検索ワード', + ], ]; diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 7b856326..74003cbd 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -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 diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php index fae71deb..27cfbb68 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php @@ -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 diff --git a/modules/_bundled/sirsoft-page/src/Http/Requests/PageListRequest.php b/modules/_bundled/sirsoft-page/src/Http/Requests/PageListRequest.php index 11375e40..b8fac237 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Requests/PageListRequest.php +++ b/modules/_bundled/sirsoft-page/src/Http/Requests/PageListRequest.php @@ -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 + */ + public function attributes(): array + { + return [ + 'search' => __('sirsoft-page::validation.attributes.search'), + 'filters.*.value' => __('sirsoft-page::validation.attributes.search'), + ]; + } } diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php index 7560151e..9abd3586 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php @@ -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로 페이지를 조회합니다. diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php index a7313e74..ac937438 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php @@ -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'); - } } /** diff --git a/modules/_bundled/sirsoft-page/src/Services/PageService.php b/modules/_bundled/sirsoft-page/src/Services/PageService.php index 5591994b..a96ce93f 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageService.php @@ -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++; diff --git a/modules/_bundled/sirsoft-page/src/lang/en/validation.php b/modules/_bundled/sirsoft-page/src/lang/en/validation.php index 23cd1bbc..6862cfdb 100644 --- a/modules/_bundled/sirsoft-page/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-page/src/lang/en/validation.php @@ -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.', diff --git a/modules/_bundled/sirsoft-page/src/lang/ko/validation.php b/modules/_bundled/sirsoft-page/src/lang/ko/validation.php index d043dedb..defdd4b4 100644 --- a/modules/_bundled/sirsoft-page/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-page/src/lang/ko/validation.php @@ -59,6 +59,11 @@ return [ 'max' => '검색어는 최대 :max자까지 입력 가능합니다.', ], + // 검증 오류에 표시할 사용자 친화적 필드명 + 'attributes' => [ + 'search' => '검색어', + ], + // search_field 검증 메시지 'search_field' => [ 'in' => '검색 필드는 all, title, slug 중 하나여야 합니다.', diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php index 00171291..b4e9e263 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php @@ -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, []); - } }