fix(core,board,page,ecommerce,basic,pay): FULLTEXT 게이트·검색 실패 표면화와 페이지네이션/접두사 결함 정비

- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고,
 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더
- 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록)
- 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝,
 결제 3플러그인 컨트롤러 51지점 모델 파생 전환)
- audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase
 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
This commit is contained in:
HeuJung
2026-08-17 03:02:26 +09:00
parent 3ccfb24417
commit 7f457a05b3
77 changed files with 2578 additions and 526 deletions
@@ -8,6 +8,7 @@
### Added
- 통합검색에서 검색이 실패했을 때 "검색 결과가 없습니다" 와 구분되는 "검색 중 오류" 안내를 표시합니다. 실패한 항목만 오류로 표시되고 나머지 항목의 결과는 정상 표시됩니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 공개 자산 스토리지(직접 URL/CDN)를 사용하는 사이트에서도 첨부 이미지 미리보기와 다운로드가 그 주소를 그대로 사용해 정상 동작합니다.
### Fixed
@@ -39,6 +39,10 @@
"pages_in_all": "No pages found",
"suggestion": "Try a different search term"
},
"failed": {
"title": "An error occurred while searching",
"suggestion": "Please try again in a moment"
},
"page_published_at": "Published: {{date}}",
"view_more": "View more",
"view_all": "View all",
@@ -39,6 +39,10 @@
"pages_in_all": "페이지 검색 결과가 없습니다",
"suggestion": "다른 검색어로 시도해 보세요"
},
"failed": {
"title": "검색 중 오류가 발생했습니다",
"suggestion": "잠시 후 다시 시도해 주세요"
},
"page_published_at": "발행일: {{date}}",
"view_more": "더보기",
"view_all": "전체보기",
@@ -26,10 +26,10 @@
]
},
{
"comment": "게시글 탭 - 결과 없을 때",
"comment": "게시글 탭 - 결과 없을 때 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{query?.type === 'posts' && (searchResults?.data?.posts_count ?? 0) === 0}}",
"if": "{{query?.type === 'posts' && (searchResults?.data?.posts_count ?? 0) === 0 && !(searchResults?.data?.categories_failed?.posts ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
@@ -44,6 +44,25 @@
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.empty.suggestion" }
]
},
{
"comment": "게시글 탭 - 검색 실패",
"type": "basic",
"name": "Div",
"if": "{{query?.type === 'posts' && (searchResults?.data?.categories_failed?.posts ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
]
},
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.failed.title" },
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.failed.suggestion" }
]
},
{
"comment": "상품 탭 - 결과 있을 때",
"type": "basic",
@@ -62,10 +81,10 @@
]
},
{
"comment": "상품 탭 - 결과 없을 때",
"comment": "상품 탭 - 결과 없을 때 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{query?.type === 'products' && (searchResults?.data?.products_count ?? 0) === 0}}",
"if": "{{query?.type === 'products' && (searchResults?.data?.products_count ?? 0) === 0 && !(searchResults?.data?.categories_failed?.products ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
@@ -80,6 +99,25 @@
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.empty.suggestion" }
]
},
{
"comment": "상품 탭 - 검색 실패",
"type": "basic",
"name": "Div",
"if": "{{query?.type === 'products' && (searchResults?.data?.categories_failed?.products ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
]
},
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.failed.title" },
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.failed.suggestion" }
]
},
{
"comment": "페이지 탭 - 결과 있을 때",
"type": "basic",
@@ -90,10 +128,10 @@
]
},
{
"comment": "페이지 탭 - 결과 없을 때",
"comment": "페이지 탭 - 결과 없을 때 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{query?.type === 'pages' && (searchResults?.data?.pages_count ?? 0) === 0}}",
"if": "{{query?.type === 'pages' && (searchResults?.data?.pages_count ?? 0) === 0 && !(searchResults?.data?.categories_failed?.pages ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
@@ -107,6 +145,25 @@
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.empty.pages" },
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.empty.suggestion" }
]
},
{
"comment": "페이지 탭 - 검색 실패",
"type": "basic",
"name": "Div",
"if": "{{query?.type === 'pages' && (searchResults?.data?.categories_failed?.pages ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
]
},
{ "type": "basic", "name": "H3", "props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" }, "text": "$t:search.failed.title" },
{ "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:search.failed.suggestion" }
]
}
]
}
@@ -34,10 +34,10 @@
]
},
{
"comment": "상한 초과 안내 — 검색어를 좁히면 정확한 건수와 마지막 페이지를 볼 수 있다",
"comment": "상한 초과 안내 — 검색어를 좁히면 정확한 건수와 마지막 페이지를 볼 수 있다. 검색 실패도 total_is_exact=false 를 싣지만(실패한 0건을 '정확한 0건'으로 말하지 않기 위함) 그때는 검색어를 좁혀도 해소되지 않으므로 이 조치 안내를 그리지 않는다",
"type": "basic",
"name": "P",
"if": "{{(searchResults?.data?.total_is_exact ?? true) === false}}",
"if": "{{(searchResults?.data?.total_is_exact ?? true) === false && !(searchResults?.data?.search_failed ?? false)}}",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:search.refine_query_hint"
}
@@ -114,11 +114,40 @@
}
]
},
{
"comment": "전체 탭 검색 실패 — 결과가 0건이고 실패가 있으면 '결과 없음' 대신 오류 안내 (키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all' && (searchResults?.data?.search_failed ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "mb-5 text-red-300 dark:text-red-500/60" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "4x" } }
]
},
{
"type": "basic",
"name": "H3",
"props": { "className": "text-lg font-medium text-gray-900 dark:text-white mb-2" },
"text": "$t:search.failed.title"
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "$t:search.failed.suggestion"
}
]
},
{
"comment": "검색 결과 없음 (전체 탭)",
"type": "basic",
"name": "Div",
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all'}}",
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all' && !(searchResults?.data?.search_failed ?? false)}}",
"props": { "className": "py-16 text-center" },
"children": [
{
@@ -96,14 +96,33 @@
]
},
{
"comment": "결과 없음 안내",
"comment": "결과 없음 안내 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{!searchResults?.data?.pages?.items || searchResults?.data?.pages?.items?.length === 0}}",
"if": "{{(!searchResults?.data?.pages?.items || searchResults?.data?.pages?.items?.length === 0) && !(searchResults?.data?.categories_failed?.pages ?? false)}}",
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
"children": [
{ "type": "basic", "name": "Span", "text": "$t:search.empty.pages_in_all" }
]
},
{
"comment": "검색 실패 안내",
"type": "basic",
"name": "Div",
"if": "{{searchResults?.data?.categories_failed?.pages ?? false}}",
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-center gap-2" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-red-500 dark:text-red-400" } },
{ "type": "basic", "name": "Span", "text": "$t:search.failed.title" }
]
},
{ "type": "basic", "name": "P", "props": { "className": "mt-1 text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:search.failed.suggestion" }
]
}
]
}
@@ -138,14 +138,33 @@
]
},
{
"comment": "결과 없음 안내",
"comment": "결과 없음 안내 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{!searchResults?.data?.posts?.items || searchResults?.data?.posts?.items?.length === 0}}",
"if": "{{(!searchResults?.data?.posts?.items || searchResults?.data?.posts?.items?.length === 0) && !(searchResults?.data?.categories_failed?.posts ?? false)}}",
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
"children": [
{ "type": "basic", "name": "Span", "text": "$t:search.empty.posts_in_all" }
]
},
{
"comment": "검색 실패 안내",
"type": "basic",
"name": "Div",
"if": "{{searchResults?.data?.categories_failed?.posts ?? false}}",
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-center gap-2" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-red-500 dark:text-red-400" } },
{ "type": "basic", "name": "Span", "text": "$t:search.failed.title" }
]
},
{ "type": "basic", "name": "P", "props": { "className": "mt-1 text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:search.failed.suggestion" }
]
}
]
}
@@ -59,14 +59,33 @@
]
},
{
"comment": "결과 없음 안내",
"comment": "결과 없음 안내 (검색 실패는 결과 없음이 아니다 — categories_failed 로 구분, 키 부재(구버전 코어)는 종전 렌더 유지)",
"type": "basic",
"name": "Div",
"if": "{{!searchResults?.data?.products || searchResults?.data?.products?.length === 0}}",
"if": "{{(!searchResults?.data?.products || searchResults?.data?.products?.length === 0) && !(searchResults?.data?.categories_failed?.products ?? false)}}",
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
"children": [
{ "type": "basic", "name": "Span", "text": "$t:search.empty.products_in_all" }
]
},
{
"comment": "검색 실패 안내",
"type": "basic",
"name": "Div",
"if": "{{searchResults?.data?.categories_failed?.products ?? false}}",
"props": { "className": "px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-center gap-2" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-red-500 dark:text-red-400" } },
{ "type": "basic", "name": "Span", "text": "$t:search.failed.title" }
]
},
{ "type": "basic", "name": "P", "props": { "className": "mt-1 text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:search.failed.suggestion" }
]
}
]
}
@@ -0,0 +1,422 @@
/**
* @file search-failed-state.test.tsx
* @description 통합검색 실패 표면화 렌더링 테스트 (공개 이슈 #103)
*
* 카테고리 검색이 예외로 실패하면(`categories_failed` / `search_failed`) 화면은
* "검색 결과가 없습니다" 가 아니라 "검색 중 오류" 안내를 그려야 한다.
*
* 실제 레이아웃 JSON 을 import 해서 렌더한다 — 손으로 쓴 조각만 검증하면 실파일이
* 규약을 어겨도 green 인 채로 유출된다. 부재 단언은 baseline 케이스에서 그 문구의
* 존재를 먼저 확정한 뒤에만 의미를 갖는다.
*
* @vitest-environment jsdom
*/
import React from 'react';
import { describe, it, expect, beforeEach } from 'vitest';
import {
createLayoutTest,
screen,
} from '@/core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
// 실제 레이아웃 JSON — failed 분기 회귀 고정
import searchResultsPartial from '../../../layouts/partials/search/_search_results.json';
import searchStatesPartial from '../../../layouts/partials/search/_search_states.json';
import postsSectionPartial from '../../../layouts/partials/search/posts/_section.json';
import productsSectionPartial from '../../../layouts/partials/search/products/_section.json';
import pagesSectionPartial from '../../../layouts/partials/search/pages/_section.json';
// 실제 ko 언어 파일 — 문구 단언이 실키와 어긋나면 red
import koSearch from '../../../lang/partial/ko/search.json';
// ========== 테스트용 컴포넌트 정의 ==========
const TestDiv: React.FC<{ className?: string; children?: React.ReactNode }> = ({ className, children }) => (
<div className={className}>{children}</div>
);
const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
<span>{children ?? text}</span>
);
const TestP: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
<p>{children ?? text}</p>
);
const TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
<h3>{children ?? text}</h3>
);
const TestButton: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ children, text }) => (
<button type="button">{children ?? text}</button>
);
const TestIcon: React.FC<{ name?: string }> = ({ name }) => <i data-icon={name} />;
const TestHtmlContent: React.FC<{ content?: string }> = ({ content }) => (
<div dangerouslySetInnerHTML={{ __html: content ?? '' }} />
);
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
H4: { component: TestH3, metadata: { name: 'H4', type: 'basic' } },
Ul: { component: TestDiv, metadata: { name: 'Ul', type: 'basic' } },
Li: { component: TestDiv, metadata: { name: 'Li', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
HtmlContent: { component: TestHtmlContent, metadata: { name: 'HtmlContent', type: 'composite' } },
};
return registry;
}
// ========== partial 참조 인라인 해석 ==========
/**
* 서버측 partial 해석을 테스트에서 재현합니다.
*
* `{ "partial": "..." }` 노드를 매핑된 실제 JSON 으로 치환하고, 매핑이 없는 참조는
* 빈 Div 로 둡니다(이 테스트의 단언 대상 분기가 아니라는 뜻).
*
* @param node 레이아웃 JSON 노드
* @param map partial 경로 => 실제 JSON
* @returns 치환된 사본
*/
function resolvePartials(node: unknown, map: Record<string, unknown>): unknown {
if (Array.isArray(node)) {
return node.map((n) => resolvePartials(n, map));
}
if (!node || typeof node !== 'object') return node;
const obj = node as Record<string, unknown>;
if (typeof obj.partial === 'string') {
const target = map[obj.partial];
return target
? resolvePartials(JSON.parse(JSON.stringify(target)), map)
: { type: 'basic', name: 'Div' };
}
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
out[key] = resolvePartials(value, map);
}
return out;
}
const PARTIAL_MAP: Record<string, unknown> = {
'partials/search/posts/_section.json': postsSectionPartial,
'partials/search/products/_section.json': productsSectionPartial,
'partials/search/pages/_section.json': pagesSectionPartial,
};
/**
* 실제 partial JSON 을 렌더 가능한 레이아웃으로 감쌉니다.
*
* @param partial 레이아웃 partial JSON
* @returns 레이아웃 정의
*/
function wrapLayout(partial: unknown) {
return {
version: '1.0.0',
layout_name: 'search_failed_probe',
state: {},
components: [resolvePartials(JSON.parse(JSON.stringify(partial)), PARTIAL_MAP)],
};
}
// ========== 데이터 헬퍼 ==========
const FAILED_TITLE = (koSearch as any).failed?.title ?? '$t:search.failed.title';
/**
* 검색 응답 데이터를 만듭니다.
*
* @param overrides 덮어쓸 필드
* @returns searchResults 데이터소스 값
*/
function searchData(overrides: Record<string, unknown> = {}) {
return {
searchResults: {
data: {
q: '문의',
total: 0,
posts_count: 0,
products_count: 0,
pages_count: 0,
posts: { items: [] },
products: [],
pages: { items: [] },
categories_failed: { posts: false, products: false, pages: false },
search_failed: false,
...overrides,
},
},
};
}
const RENDER_OPTIONS = (queryParams: Record<string, string>, data: Record<string, unknown>) => ({
componentRegistry: ComponentRegistry.getInstance(),
queryParams,
initialData: data,
translations: { search: koSearch },
});
// ========== 테스트 케이스 ==========
describe('통합검색 실패 표면화 렌더링 (#103)', () => {
beforeEach(() => {
setupTestRegistry();
});
describe('_search_results.json 카테고리 탭 분기', () => {
const tabCases: Array<[string, string, string]> = [
['posts', 'posts', (koSearch as any).empty.posts],
['products', 'products', (koSearch as any).empty.products],
['pages', 'pages', (koSearch as any).empty.pages],
];
it.each(tabCases)(
'%s 탭: 실패가 아니면 기존 "결과 없음" 문구가 렌더된다 (baseline)',
async (_label, type, emptyText) => {
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
{ q: '문의', type },
searchData()
));
await testUtils.render();
// 존재 확정 — 이 문구가 없으면 아래 실패 케이스의 부재 단언이 무의미하다
expect(screen.getByText(emptyText)).toBeInTheDocument();
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
testUtils.cleanup();
}
);
/**
* @scenario category=posts, scope=category_tab, failure=single
* @effects error_notice_instead_of_empty
*/
/**
* @scenario category=products, scope=category_tab, failure=single
* @effects error_notice_instead_of_empty
*/
/**
* @scenario category=pages, scope=category_tab, failure=single
* @effects error_notice_instead_of_empty
*/
it.each(tabCases)(
'%s 탭: 카테고리 실패 시 "결과 없음" 대신 오류 안내가 렌더된다',
async (_label, type, emptyText) => {
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
{ q: '문의', type },
searchData({
categories_failed: { posts: false, products: false, pages: false, [type]: true },
search_failed: true,
})
));
await testUtils.render();
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(emptyText)).not.toBeInTheDocument();
testUtils.cleanup();
}
);
/**
* @scenario category=posts, scope=category_tab, failure=all
* @effects error_notice_instead_of_empty
*/
/**
* @scenario category=products, scope=category_tab, failure=all
* @effects error_notice_instead_of_empty
*/
/**
* @scenario category=pages, scope=category_tab, failure=all
* @effects error_notice_instead_of_empty
*/
it.each(tabCases)(
'%s 탭: 전 카테고리 실패 시에도 그 탭이 오류 안내를 렌더한다',
async (_label, type, emptyText) => {
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
{ q: '문의', type },
searchData({
categories_failed: { posts: true, products: true, pages: true },
search_failed: true,
})
));
await testUtils.render();
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(emptyText)).not.toBeInTheDocument();
testUtils.cleanup();
}
);
});
describe('전체 탭 섹션 (partial 인라인 해석)', () => {
const sectionCases: Array<[string, string, string]> = [
// [실패 카테고리, 그 카테고리의 in_all 빈 문구 키, 정상 렌더를 확인할 다른 카테고리의 빈 문구 키]
['posts', 'posts_in_all', 'pages_in_all'],
['products', 'products_in_all', 'pages_in_all'],
['pages', 'pages_in_all', 'posts_in_all'],
];
/**
* @scenario category=posts, scope=all_tab, failure=single
* @effects error_notice_instead_of_empty, unaffected_category_renders_normally
*/
/**
* @scenario category=products, scope=all_tab, failure=single
* @effects error_notice_instead_of_empty, unaffected_category_renders_normally
*/
/**
* @scenario category=pages, scope=all_tab, failure=single
* @effects error_notice_instead_of_empty, unaffected_category_renders_normally
*/
it.each(sectionCases)(
'%s 섹션만 실패하면 그 섹션만 오류 안내를 그리고 다른 섹션은 정상 렌더된다',
async (failedCategory, failedEmptyKey, normalEmptyKey) => {
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
searchData({
total: 3,
products_count: failedCategory === 'products' ? 0 : 3,
categories_failed: { posts: false, products: false, pages: false, [failedCategory]: true },
search_failed: true,
})
));
await testUtils.render();
// 실패 섹션: 오류 안내 (in_all 빈 문구 대신)
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
expect(screen.queryByText((koSearch as any).empty[failedEmptyKey])).not.toBeInTheDocument();
// 실패하지 않은 섹션은 종전대로 빈 문구를 그린다
expect(screen.getByText((koSearch as any).empty[normalEmptyKey])).toBeInTheDocument();
testUtils.cleanup();
}
);
it('실패가 없으면 posts 섹션은 종전대로 빈 문구를 그린다 (baseline)', async () => {
const testUtils = createLayoutTest(wrapLayout(searchResultsPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
searchData({ total: 3, products_count: 3 })
));
await testUtils.render();
expect(screen.getByText((koSearch as any).empty.posts_in_all)).toBeInTheDocument();
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
testUtils.cleanup();
});
});
describe('_search_states.json 전체 탭', () => {
it('전체 탭 0건 + 실패 없음이면 "검색 결과가 없습니다" 를 그린다 (baseline)', async () => {
const testUtils = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
searchData()
));
await testUtils.render();
expect(screen.getByText((koSearch as any).empty.all)).toBeInTheDocument();
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
testUtils.cleanup();
});
/**
* @scenario category=posts, scope=all_tab, failure=all
* @effects error_notice_instead_of_empty
*/
/**
* @scenario category=products, scope=all_tab, failure=all
* @effects error_notice_instead_of_empty
*/
/**
* @scenario category=pages, scope=all_tab, failure=all
* @effects error_notice_instead_of_empty
*/
it('전체 탭 0건 + 실패 존재면 "결과 없음" 대신 오류 안내를 그린다', async () => {
const testUtils = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
searchData({
categories_failed: { posts: true, products: true, pages: true },
search_failed: true,
})
));
await testUtils.render();
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
expect(screen.queryByText((koSearch as any).empty.all)).not.toBeInTheDocument();
testUtils.cleanup();
});
/**
* 실패 페이로드는 "정확한 0건" 이라고 말하지 않으려고 total_is_exact=false 를 싣는다.
* 그런데 그 값은 원래 "총 건수 상한 초과" 신호이기도 해서, 가드가 없으면 실패 화면에
* "검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있습니다" 라는 상한 초과용
* 조치 안내가 오류 안내와 나란히 렌더된다 — 검색어를 좁혀도 서버 오류는 해소되지 않으므로
* 사용자에게 잘못된 조치를 지시하게 된다.
*
* @scenario category=posts, scope=all_tab, failure=all
* @effects error_notice_instead_of_empty
*/
it('전체 탭 실패 시 상한 초과용 "검색어를 좁히세요" 안내를 그리지 않는다', async () => {
const refineHint = (koSearch as any).refine_query_hint;
expect(refineHint).toBeTruthy();
// 존재 확정: 실패가 아닌 상한 초과 상황에서는 그 안내가 실제로 렌더된다.
const exceeded = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
searchData({ total: 10000, total_is_exact: false })
));
await exceeded.render();
expect(screen.getByText(refineHint)).toBeInTheDocument();
exceeded.cleanup();
// 실패 상황에서는 같은 안내가 사라져야 한다.
const failed = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
searchData({
total_is_exact: false,
categories_failed: { posts: true, products: true, pages: true },
search_failed: true,
})
));
await failed.render();
expect(screen.getByText(FAILED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(refineHint)).not.toBeInTheDocument();
failed.cleanup();
});
it('실패 키가 없는 구버전 응답에서는 종전 렌더가 유지된다 (하위호환)', async () => {
const data = searchData();
delete (data.searchResults as any).data.categories_failed;
delete (data.searchResults as any).data.search_failed;
const testUtils = createLayoutTest(wrapLayout(searchStatesPartial), RENDER_OPTIONS(
{ q: '문의', type: 'all' },
data
));
await testUtils.render();
expect(screen.getByText((koSearch as any).empty.all)).toBeInTheDocument();
expect(screen.queryByText(FAILED_TITLE)).not.toBeInTheDocument();
testUtils.cleanup();
});
});
});
@@ -0,0 +1,149 @@
/**
* 통합검색 카테고리 실패 표면화 — 브라우저 렌더 검증 (공개 이슈 #103)
*
* 배경:
* - 카테고리 검색이 서버에서 예외로 실패하면 종전에는 HTTP 200 + "검색 결과가 없습니다"
* 로 위장됐다. 수정 후에는 응답의 `categories_failed`/`search_failed` 를 근거로
* 화면이 "검색 중 오류" 안내를 그려야 한다.
* - 라이브 DB 에 임의 예외를 만들지 않는다 — `page.route` 로 실패 응답을 주입한다.
* (인덱스 부재 축은 LIKE 폴백으로 흡수되므로 Chrome MCP 라이브 시나리오가 담당)
*
* 축 마킹은 아래 태그에 `키=값, 키=값` 한 줄로 적는다 — 매니페스트 대조기가 읽는
* 형식이 그것뿐이라, 다른 이름의 태그에 적으면 조합이 조용히 0건으로 집계된다.
* 설명문에도 그 태그 이름을 리터럴로 쓰지 않는다(docblock 의 첫 매치를 가로챈다).
* 아래 파일 단위 마킹이 기본축이고, 개별 test 의 라인 마킹이 나머지 조합을 채운다.
*
* @scenario category=posts, scope=all_tab, failure=single
* @effects failed_flag_in_response,
* error_notice_instead_of_empty,
* unaffected_category_renders_normally
*/
import { test, expect, type Page } from '@playwright/test';
/** 실패 안내 문구 (ko/en — 사이트 로케일에 무관하게 매칭) */
const FAILED_TITLE = /검색 중 오류가 발생했습니다|An error occurred while searching/;
/** "결과 없음" 문구 (ko/en) */
const EMPTY_ALL = /^검색 결과가 없습니다\.$|^No results found\.$/;
/**
* 검색 응답 데이터 골격을 만듭니다 (buildResponse 가 조립하는 실제 키 집합의 부분집합).
*
* @param overrides 덮어쓸 필드
* @returns 응답 data 값
*/
function searchData(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
q: '문의',
total: 0,
all_count: 0,
all_count_is_exact: true,
total_relation: 'exact',
total_is_exact: true,
result_cap: 10000,
posts_count: 0,
products_count: 0,
pages_count: 0,
posts: { items: [], available_boards: [] },
products: [],
pages: { items: [] },
counts_are_exact: { posts: true, products: true, pages: true },
categories_failed: { posts: false, products: false, pages: false },
search_failed: false,
...overrides,
};
}
/**
* `/api/search` 응답을 fixture 로 대체합니다.
*
* @param page Playwright 페이지
* @param data 응답 data 값
*/
async function mockSearchResponse(page: Page, data: Record<string, unknown>): Promise<void> {
await page.route('**/api/search**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, message: 'ok', data }),
});
});
}
test.describe('통합검색 카테고리 실패 표면화 (#103)', () => {
test('posts 만 실패한 전체 탭 — posts 섹션은 오류 안내, 다른 카테고리는 정상 렌더', async ({ page }) => {
await mockSearchResponse(page, searchData({
total: 1,
all_count: 1,
pages_count: 1,
pages: {
items: [{
id: 1,
title: '이용약관',
title_highlighted: '이용약관',
content_preview: '본 약관은 문의 안내를 포함합니다',
content_preview_highlighted: '본 약관은 문의 안내를 포함합니다',
published_at: '2026-02-01',
url: '/page/terms',
}],
},
counts_are_exact: { posts: false, products: true, pages: true },
categories_failed: { posts: true, products: false, pages: false },
search_failed: true,
}));
await page.goto('/search?q=문의');
// posts 섹션: 오류 안내가 렌더된다 ("결과 없음" 이 아니라)
await expect(page.getByText(FAILED_TITLE).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/게시글 검색 결과가 없습니다|No posts found/)).toHaveCount(0);
// 실패하지 않은 pages 카테고리는 결과를 정상 렌더한다
// ('이용약관' 은 사이트 네비의 페이지 링크와도 매칭되므로 고유한 미리보기 문구로 단언)
await expect(page.getByText('본 약관은 문의 안내를 포함합니다').first()).toBeVisible();
});
// @scenario category=posts, scope=category_tab, failure=single
// @effects failed_flag_in_response, error_notice_instead_of_empty
test('posts 탭 실패 — 탭 화면이 "결과 없음" 대신 오류 안내를 그린다', async ({ page }) => {
await mockSearchResponse(page, searchData({
counts_are_exact: { posts: false, products: true, pages: true },
categories_failed: { posts: true, products: false, pages: false },
search_failed: true,
}));
await page.goto('/search?q=문의&type=posts');
await expect(page.getByText(FAILED_TITLE).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/게시글 검색 결과가 없습니다|No posts found/)).toHaveCount(0);
});
// 이 한 케이스가 세 카테고리 동시 실패를 덮으므로 카테고리 축을 각각 마킹한다.
// @scenario category=posts, scope=all_tab, failure=all
// @effects failed_flag_in_response, error_notice_instead_of_empty
// @scenario category=products, scope=all_tab, failure=all
// @effects failed_flag_in_response, error_notice_instead_of_empty
// @scenario category=pages, scope=all_tab, failure=all
// @effects failed_flag_in_response, error_notice_instead_of_empty
test('전 카테고리 실패한 전체 탭 — 전역 오류 안내를 그린다', async ({ page }) => {
await mockSearchResponse(page, searchData({
counts_are_exact: { posts: false, products: false, pages: false },
categories_failed: { posts: true, products: true, pages: true },
search_failed: true,
}));
await page.goto('/search?q=문의');
await expect(page.getByText(FAILED_TITLE).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(EMPTY_ALL)).toHaveCount(0);
});
test('실패 없는 0건 응답 — 종전 "결과 없음" 렌더가 유지된다 (회귀 가드)', async ({ page }) => {
await mockSearchResponse(page, searchData());
await page.goto('/search?q=문의');
await expect(page.getByText(EMPTY_ALL).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(FAILED_TITLE)).toHaveCount(0);
});
});
@@ -0,0 +1,52 @@
feature: 통합검색 카테고리 실패 표면화 (공개 이슈 #103)
description: |
카테고리 검색이 서버 예외로 실패하면 종전에는 리스너 catch 가 결과 키를 설정하지
않은 채 삼켜 HTTP 200 + "검색 결과가 없습니다" 로 위장됐다. 수정 후 계약:
- 서버: 실패 카테고리는 `SearchCategoryPayload::failed()` 페이로드(failed=true,
total_is_exact=false)로 내보내고, 코어(PublicSearchController)가
`categories_failed`/`search_failed` 를 counts_are_exact 와 동형으로 일괄 조립한다.
예외 스택은 Log::error context 로 남는다.
- FULLTEXT 인덱스 부재는 실패가 아니다 — 드라이버가 지원해도 대상 테이블·컬럼 조합을
커버하는 인덱스가 없으면 MATCH 대신 LIKE 폴백으로 내려가고(1191 예방), 그 사실을
테이블+컬럼 조합당 프로세스 1회 warning 으로 남긴다.
- 화면(sirsoft-basic): `categories_failed`/`search_failed` 를 근거로 "검색 결과가
없습니다" 와 구분되는 "검색 중 오류" 안내를 그린다. 키 부재(구버전 코어) 조합에서는
`?? false` 기본값으로 종전 렌더를 유지한다.
axes:
category: [posts, products, pages] # 실패 카테고리
scope: [all_tab, category_tab] # 전체 탭 섹션 / 카테고리 탭 전면
failure: [single, all] # 단일 카테고리 실패 / 전 카테고리 실패
effects:
- failed_flag_in_response # 응답에 categories_failed / search_failed / 카테고리 failed 키
- error_notice_instead_of_empty # "결과 없음" 대신 오류 안내 렌더
- unaffected_category_renders_normally # 실패하지 않은 카테고리는 종전 렌더 유지
- exception_stack_logged # 리스너 catch 가 exception 인스턴스를 Log::error context 로 남김
- like_fallback_on_missing_index # 인덱스 부재 시 MATCH 대신 LIKE 폴백 (1191 예방)
- missing_index_fallback_logged_once # 인덱스 부재 폴백 warning 이 조합당 1회
test_files:
- tests/Unit/Search/DatabaseFulltextEngineIndexGateTest.php
- tests/Feature/Api/Public/PublicSearchControllerFailureFlagTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/search-failed-state.test.tsx
- templates/_bundled/sirsoft-basic/tests/Playwright/specs/search-category-failure.spec.ts
validation:
audit_rule: test-scenario-coverage
note: |
axes cross product(3×2×2=12) 중 실제로 화면이 갈라지는 조합만 브라우저 계층이
담당한다 — 카테고리 축의 실패 렌더 분기 구분은 Vitest(search-failed-state)가 3분기
× (탭/섹션) 를 전수 커버하고, Playwright 는 (posts,all_tab,single) /
(posts,category_tab,single) / (all_tab,all) + 회귀 가드(무실패)를 라이브 렌더로
고정한다. products/pages 의 category_tab 실패 렌더는 같은 레이아웃 분기 구조의
복제이므로 Vitest 계층이 등가 커버한다.
like_fallback_on_missing_index / missing_index_fallback_logged_once 는 DB 인덱스
상태가 전제라 브라우저 fixture 로 재현하지 않는다 — PHPUnit(IndexGateTest) +
Chrome MCP 라이브 매트릭스(인덱스 제거/복원 T4-a/T6)가 담당한다.