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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+149
@@ -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)가 담당한다.
|
||||
Reference in New Issue
Block a user