fix(core,basic,ecommerce): 쇼핑 화면 호출 통합 원복·검색 주소 승계·라우트 캐시 갱신 및 상한값 설정 저장 정합
합의되지 않은 구조 변경을 되돌리고, 계획서 전수조사에서 드러난 결함을 처리했다. 쇼핑 화면 호출 통합(F4)은 API 쿼리 튜닝이 아니라 화면 구조 변경이라 원복했다. 레이아웃 9개를 develop 상태로 복원하고 전용 엔드포인트·컨트롤러·문서·테스트를 걷어냈다. 되돌리면 함께 사라지는 비-F4 수정 둘(페이저 has_more_pages 판정 3곳, Icon 크기 클래스 23줄)은 재적용했다. 성능 개선분은 전부 잔존한다. 통합검색 화면 상태를 전역 상태에서 URL 쿼리로 옮겼다. 종전에는 탭·필터·정렬·페이지가 주소에 없어 새로고침·뒤로가기에서 초기화되고 결과를 공유할 수도 없었다. 화면은 정상으로 보이고 콘솔 에러도 없어 코드만으로는 드러나지 않는 형태였다. 확장이 라우트를 추가해도 라우트 캐시가 걸린 사이트에서는 그 주소가 404 가 되던 구멍을 막았다. 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 예외도 경고도 남지 않는다. 같은 문제를 이미 푼 ConfigCacheHelper 를 미러링해 갱신 지점을 한 곳으로 모았고, 코어 업데이트가 캐시를 비우기만 하고 되살리지 않던 문제도 함께 고쳤다. 상품·페이지 검색이 커서 판정에 페이지 번호를 넘기지 않아, 커서 없이 깊은 페이지를 지목한 딥링크가 조용히 1 페이지를 돌려주던 결함을 고쳤다. 호출 지점을 손으로 열거하는 대신 저장소를 훑는 패리티 테스트로 다음 도메인의 재발을 막는다. 환경설정 고급 탭의 목록 상한값이 저장되지 않던 결함을 고쳤다. 화면·검증·읽기는 모두 있었는데 저장 시 카테고리 분류표가 손으로 열거돼 있어 그 값만 버려지고 있었다. 분류표를 설정 정의에서 도출하도록 바꿔 다음 카테고리가 합류해도 같은 일이 생기지 않게 했다. 범위를 벗어난 값의 안내에 내부 식별자가 노출되던 것도 함께 정리했다. 언어팩 재설치가 활성 팩을 자기 자신과의 슬롯 충돌로 오인해 강등하던 회귀와, CLI 설치가 HTTP 권한 컨텍스트 없이 자동 활성화되는지를 고정하는 테스트를 함께 담았다.
This commit is contained in:
@@ -914,6 +914,7 @@ BaseApiController (최상위)
|
||||
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
|
||||
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
|
||||
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
|
||||
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
|
||||
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
|
||||
필수: 모든 확장 작업은 Artisan 커맨드로 수행
|
||||
```
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
|
||||
#### 확장 업데이트
|
||||
|
||||
- 속도 최적화를 적용한 사이트에서, 확장을 설치·활성화·업데이트해도 그 확장이 새로 추가한 주소가 동작하지 않던 문제를 수정했습니다. 예전에는 최적화 시점에 저장해 둔 주소 목록만 사용해 새 주소가 목록에 없었고, 화면에는 오류 대신 "페이지를 찾을 수 없음"만 나와 원인을 알기 어려웠습니다. 이제 확장을 설치·활성화·비활성화·삭제·업데이트할 때와 코어 업데이트 후에 주소 목록이 자동으로 다시 만들어집니다.
|
||||
- 코어를 업데이트하면 주소 목록 최적화가 꺼진 채로 남던 문제를 함께 수정했습니다. 업데이트 도중 목록을 비우기만 하고 다시 만들지 않아, 관리자가 직접 '시스템 최적화'를 실행할 때까지 사이트가 최적화 없이 동작했습니다.
|
||||
- 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다.
|
||||
- 업데이트 전 표시되는 "수정된 레이아웃" 안내가 확인에 실패했을 때도 "수정된 레이아웃이 없습니다"라고 단언하던 문제를 수정했습니다. 네트워크가 끊긴 상태에서 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있었습니다. 이제 확인하지 못했다는 사실과 함께 '수정 유지' 권장 안내가 표시됩니다.
|
||||
- 템플릿 업데이트 안내가 그 템플릿에 등록된 모듈·플러그인 화면의 수정까지 자기 것으로 세던 문제를 수정했습니다. 실제로 교체·보존되는 대상은 템플릿 자신의 화면뿐이라 표시 건수와 실제 동작이 어긋났습니다.
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -543,6 +544,11 @@ class CoreUpdateCommand extends Command
|
||||
// 상태를 가드하고 실패를 안전하게 흡수한다.
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
// 라우트 캐시도 같은 이유로 되살린다. 코어 업데이트는 routes/*.php 와 vendor 를
|
||||
// 교체하므로 흐름 중간에 비우는 것이 맞지만, 비운 채로 끝내면 이후 모든 요청이
|
||||
// 라우트를 다시 등록한다. 재생성은 파일이 전부 안착한 이 지점에서만 안전하다.
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
$log('정리 완료');
|
||||
|
||||
$bar->finish();
|
||||
|
||||
@@ -12,6 +12,8 @@ use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -251,7 +253,10 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
|
||||
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
|
||||
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
|
||||
\App\Support\ConfigCacheHelper::rebuild();
|
||||
ConfigCacheHelper::rebuild();
|
||||
// 라우트 캐시도 같은 이유로 되살린다 — 업그레이드 스텝이나 번들 확장 업데이트가
|
||||
// 라우트를 바꿨을 수 있고, 비운 채로 끝내면 이후 모든 요청이 라우트를 재등록한다.
|
||||
RouteCacheHelper::rebuild();
|
||||
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
|
||||
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
|
||||
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
|
||||
|
||||
@@ -43,6 +43,7 @@ use App\Models\Template;
|
||||
use App\Providers\CoreServiceProvider;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Support\AssetUrl;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -517,6 +518,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
@@ -646,6 +648,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
@@ -788,6 +791,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 모듈 자체 캐시 전체 정리
|
||||
$this->flushModuleCache($module);
|
||||
@@ -968,6 +972,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 모듈 자체 캐시 전체 정리
|
||||
$this->flushModuleCache($module);
|
||||
@@ -4512,6 +4517,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
$this->clearAllTemplateLanguageCaches();
|
||||
$this->clearAllTemplateRoutesCaches();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
||||
|
||||
@@ -43,6 +43,7 @@ use App\Providers\CoreServiceProvider;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Support\AssetUrl;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -495,6 +496,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
@@ -627,6 +629,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
@@ -772,6 +775,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 플러그인 자체 캐시 전체 정리
|
||||
$this->flushPluginCache($plugin);
|
||||
@@ -996,6 +1000,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 플러그인 자체 캐시 전체 정리
|
||||
$this->flushPluginCache($plugin);
|
||||
@@ -4692,6 +4697,7 @@ class PluginManager implements PluginManagerInterface
|
||||
$this->clearAllTemplateLanguageCaches();
|
||||
$this->clearAllTemplateRoutesCaches();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
||||
|
||||
@@ -907,6 +907,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'),
|
||||
'advanced.geoip_license_key' => __('validation.attributes.geoip_license_key'),
|
||||
'advanced.geoip_auto_update_enabled' => __('validation.attributes.geoip_auto_update_enabled'),
|
||||
'advanced.pagination_result_cap' => __('validation.attributes.pagination_result_cap'),
|
||||
'advanced.pagination_max_page' => __('validation.attributes.pagination_max_page'),
|
||||
// drivers
|
||||
'drivers.storage_driver' => __('validation.attributes.storage_driver'),
|
||||
'drivers.s3_bucket' => __('validation.attributes.s3_bucket'),
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Pagination\BoundedTotalAware;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Resources\Traits\HasRowNumber;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use App\Support\Query\KeysetPaginator;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
@@ -120,6 +121,9 @@ abstract class BaseApiCollection extends ResourceCollection
|
||||
* 커서 방식은 총 건수와 페이지 번호를 계산하지 않습니다. 대신 앞뒤 이동 커서를
|
||||
* 그대로 실어 보내며, 화면은 이 값으로 이전/다음 버튼을 만듭니다.
|
||||
*
|
||||
* 인코딩은 {@see KeysetPaginator} 를 거칩니다. 커서 문자열의 형식은 디코딩과 짝을
|
||||
* 이뤄야 하므로, 한쪽만 여기서 직접 만들면 표준이 두 벌이 됩니다.
|
||||
*
|
||||
* @param CursorPaginator $resource 커서 페이지 결과
|
||||
* @return array<string, mixed> pagination 메타
|
||||
*/
|
||||
@@ -128,8 +132,8 @@ abstract class BaseApiCollection extends ResourceCollection
|
||||
return [
|
||||
'per_page' => $resource->perPage(),
|
||||
'has_more_pages' => $resource->hasMorePages(),
|
||||
'next_cursor' => $resource->nextCursor()?->encode(),
|
||||
'prev_cursor' => $resource->previousCursor()?->encode(),
|
||||
'next_cursor' => KeysetPaginator::nextCursor($resource),
|
||||
'prev_cursor' => KeysetPaginator::previousCursor($resource),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,21 +590,46 @@ class SettingsService
|
||||
}
|
||||
|
||||
/**
|
||||
* advanced 탭 설정을 cache와 debug 카테고리로 분리하여 저장합니다.
|
||||
* advanced 탭 설정의 카테고리별 필드 분류표를 만듭니다.
|
||||
*
|
||||
* 분류표는 스키마(`frontend_schema.*.merge_into === 'advanced'`)에서 도출합니다.
|
||||
* 손으로 열거하면 고급 탭에 카테고리가 새로 합류할 때 분류표가 뒤처지고, 그 값은
|
||||
* 어느 카테고리에도 담기지 않은 채 조용히 버려집니다(저장은 성공으로 보고됨).
|
||||
*
|
||||
* 아래 기본 목록은 스키마가 노출하지 않지만 고급 탭이 계속 저장해 온 레거시 필드
|
||||
* (cache 카테고리 전반, debug.log_level)를 보존하기 위한 것입니다.
|
||||
*
|
||||
* @return array<string, array<int, string>> 카테고리 → 원본 필드명 목록
|
||||
*/
|
||||
private function buildAdvancedCategoryFieldMap(): array
|
||||
{
|
||||
// (frontend_key → 원본 키 역변환 후의 키 기준)
|
||||
$map = [
|
||||
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
|
||||
'debug' => ['mode', 'sql_query_log', 'log_level'],
|
||||
];
|
||||
|
||||
foreach ($this->configRepository->getFrontendSchema() as $category => $categorySchema) {
|
||||
if (str_starts_with($category, '_') || ($categorySchema['merge_into'] ?? null) !== 'advanced') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields = array_keys($categorySchema['fields'] ?? []);
|
||||
$map[$category] = array_values(array_unique(array_merge($map[$category] ?? [], $fields)));
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* advanced 탭 설정을 소속 카테고리로 분리하여 저장합니다.
|
||||
*
|
||||
* @param array $settings 저장할 설정 배열
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
private function saveAdvancedSettings(array $settings): bool
|
||||
{
|
||||
// 각 카테고리에 속하는 원본 필드명 목록
|
||||
// (frontend_key → 원본 키 역변환 후의 키 기준)
|
||||
$categoryFieldMap = [
|
||||
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
|
||||
'debug' => ['mode', 'sql_query_log', 'log_level'],
|
||||
'core_update' => ['github_url', 'github_token'],
|
||||
'geoip' => ['feature_enabled', 'license_key', 'auto_update_enabled', 'last_updated_at'],
|
||||
];
|
||||
$categoryFieldMap = $this->buildAdvancedCategoryFieldMap();
|
||||
|
||||
// 설정을 카테고리별로 분류
|
||||
$categorized = array_fill_keys(array_keys($categoryFieldMap), []);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 라우트 캐시(bootstrap/cache/routes-v*.php) 재빌드 헬퍼.
|
||||
*
|
||||
* 라우트의 소스(코어 routes/*.php, 활성 모듈·플러그인의 routes/api.php)를 변경하는
|
||||
* 모든 라이프사이클 지점이 이 헬퍼를 호출해 "변경 반영 + 캐시 재최적화"를 일관되게
|
||||
* 수행한다. 산발적으로 route:clear 를 각 지점에 뿌리면 누락이 생기고(확장 설치·활성화는
|
||||
* clear 조차 하지 않았다), 반대로 clear 만 하면 한 번 비워진 캐시가 재생성되지 않아
|
||||
* 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
|
||||
*
|
||||
* 라우트 캐시는 훅 캐시와 성질이 다르다. 훅 캐시는 항목이 없으면 스캔 폴백으로 동작해
|
||||
* 낡아도 안전하지만, **라우트 캐시에는 폴백이 없다** — 캐시에 없는 라우트는 그대로 404 다.
|
||||
* 오류도 경고도 남지 않고 그 엔드포인트만 조용히 사라지므로, 확장을 설치한 쪽에서는
|
||||
* "내 코드는 분명히 있는데 라우트가 없다" 는 형태로만 관측된다.
|
||||
*
|
||||
* 정책: 환경 무관 항상 재생성 (`ConfigCacheHelper` 와 동일). route:cache 는 그 자체로
|
||||
* 부팅 비용을 절감하고, 라우트는 배포 단위로 고정되므로 항상 켜두는 것이 이득이다.
|
||||
* local 개발 시 라우트 파일 수정이 즉시 반영되지 않는 점은 개발자가 `php artisan route:clear`
|
||||
* 로 대응하는 개발자 책임 영역이며, 확장 라우트는 `{module|plugin}:update` 가 이 헬퍼를
|
||||
* 경유하므로 그 경로에서는 자동 반영된다.
|
||||
*/
|
||||
class RouteCacheHelper
|
||||
{
|
||||
/**
|
||||
* 라우트 캐시를 비우고 즉시 재생성합니다.
|
||||
*
|
||||
* `route:cache` 는 내부적으로 기존 캐시를 지운 뒤 새 애플리케이션 인스턴스를 부팅해
|
||||
* 라우트를 수집하므로, 방금 설치·활성화한 확장의 라우트도 함께 잡힌다.
|
||||
*
|
||||
* 설치 미완료(installer 실행 전) 환경에서는 불완전한 부팅을 캐시에 박제할 수 있으므로
|
||||
* clear 만 수행한다. 테스트 환경은 캐시 생성이 격리를 깨므로 동일하게 스킵한다.
|
||||
*
|
||||
* 재생성 실패(직렬화 불가한 클로저 라우트, 권한/디스크 등)는 치명적이지 않다 —
|
||||
* clear 로 stale 캐시는 이미 제거되어 다음 요청이 fresh 라우트로 안전하게 부팅된다.
|
||||
* 비어 있으면 느릴 뿐 정확하지만, 낡으면 방금 설치한 확장이 통째로 동작하지 않는다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function rebuild(): void
|
||||
{
|
||||
// 항상 stale 캐시부터 제거 (변경 반영 보장).
|
||||
Artisan::call('route:clear');
|
||||
|
||||
if (app()->environment('testing') || ! self::isInstalled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Artisan::call('route:cache');
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('라우트 캐시 재생성 실패 (route:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 캐시만 제거합니다 (재생성 없음).
|
||||
*
|
||||
* 재생성이 부적절한 특수 경로(예: 코어 업데이트 흐름 중간 — vendor 교체 중 상태를
|
||||
* 캐시에 구울 수 없다)를 위한 보조 진입점.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
Artisan::call('route:clear');
|
||||
}
|
||||
|
||||
/**
|
||||
* G7 설치 완료 여부를 확인합니다.
|
||||
*
|
||||
* @return bool 설치 완료 시 true
|
||||
*/
|
||||
private static function isInstalled(): bool
|
||||
{
|
||||
if (config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return file_exists(storage_path('app/g7_installed'));
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ g7_core_settings('drivers.queue_driver'); // dev 공유 drivers.json 의 datab
|
||||
2. sync 코드를 추가하지 않으면 → `g7_core_settings()` 만 사용 가능. `config()` 는 sync 되지 않은 키를 모르기 때문이다.
|
||||
3. testing 격리가 필요한 키 (드라이버, 외부 서비스 자격증명 등) 는 `! $isTestingEnv` 가드로 sync 를 차단한다. 이 키들은 `config()` 가 testing 격리 SSoT 다.
|
||||
4. 의미가 다른 키 (`app.timezone` 처럼) 는 sync 하지 않고 별도 키 (`app.default_user_timezone`) 로 분리한다.
|
||||
5. 고급 탭 화면에 얹을 카테고리는 `config/settings/defaults.json` 의 `frontend_schema.{카테고리}.merge_into` 를 `advanced` 로 선언한다. 저장 시 어느 카테고리 파일에 쓸지는 이 선언에서 도출되므로 별도 등록이 필요 없다. 선언이 없으면 화면·검증·읽기가 모두 정상인데 입력값만 저장되지 않고 버려진다 — 저장 응답은 성공이고 화면에도 값이 보여 실패 신호가 없으므로, 새 카테고리를 추가했으면 저장 후 `storage/app/settings/{카테고리}.json` 이 생성되는지 직접 확인한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -322,6 +322,48 @@ Route::prefix('products')->group(function () {
|
||||
|
||||
---
|
||||
|
||||
## 라우트 캐시
|
||||
|
||||
`php artisan route:cache` 를 적용한 사이트는 캐시 파일에 직렬화된 라우트만 서빙한다.
|
||||
따라서 **라우트 정의를 바꾸는 모든 지점은 캐시를 함께 갱신해야 한다.**
|
||||
|
||||
라우트 캐시는 훅 캐시와 성질이 다르다. 훅 캐시는 항목이 없으면 스캔으로 폴백해 낡아도
|
||||
안전하지만, **라우트 캐시에는 폴백이 없다** — 캐시에 없는 라우트는 그대로 404 다.
|
||||
예외도 경고도 남지 않고 그 엔드포인트만 조용히 사라지므로, 확장을 만든 쪽에서는
|
||||
"내 코드에는 분명히 있는데 라우트가 없다" 는 형태로만 관측된다.
|
||||
|
||||
### 갱신은 헬퍼 하나로만 한다
|
||||
|
||||
각 지점에 `route:clear` 를 흩어 놓으면 누락이 생기고, 반대로 비우기만 하면 한 번 비워진
|
||||
캐시가 재생성되지 않아 성능 이점이 영구히 사라진다. `App\Support\RouteCacheHelper` 가
|
||||
단일 해석 지점이다 (`ConfigCacheHelper` 와 동일한 정책).
|
||||
|
||||
| 메서드 | 동작 |
|
||||
|--------|------|
|
||||
| `RouteCacheHelper::rebuild()` | 비운 뒤 즉시 재생성. 테스트 환경·설치 미완료는 비우기까지만 |
|
||||
| `RouteCacheHelper::clear()` | 재생성 없이 비우기만 — 재생성이 부적절한 흐름 중간용 |
|
||||
|
||||
`rebuild()` 는 `route:cache` 를 호출하며, 이 커맨드는 새 애플리케이션을 부팅해 라우트를
|
||||
수집하므로 방금 설치·활성화한 확장의 라우트도 함께 잡힌다. 재생성이 실패하면(직렬화
|
||||
불가한 클로저 라우트 등) 비운 상태로 둔다 — 비어 있으면 느릴 뿐 정확하지만, 낡은 캐시는
|
||||
방금 설치한 확장을 통째로 없는 것으로 만든다.
|
||||
|
||||
### 갱신이 필요한 지점
|
||||
|
||||
| 지점 | 이유 |
|
||||
|------|------|
|
||||
| 확장 설치 / 활성화 / 비활성화 / 삭제 / 업데이트 | 확장 라우트 등록이 활성 목록에 게이트되어 있다 |
|
||||
| 코어 업데이트 · 업그레이드 스텝 | 코어 라우트 파일과 vendor 가 교체된다 |
|
||||
|
||||
코어 업데이트처럼 파일 교체가 진행 중인 흐름에서는 **중간에 비우고 끝에서 재생성**한다.
|
||||
교체 중 상태를 캐시에 구우면 안 되기 때문이며, config 캐시가 같은 형태로 처리된다.
|
||||
|
||||
템플릿 설치·활성화는 갱신 대상이 아니다 — 템플릿의 `routes.json` 은 프론트엔드 라우팅이라
|
||||
서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는
|
||||
`api/modules/{identifier}` 로 식별자에 고정).
|
||||
|
||||
---
|
||||
|
||||
## 개발 체크리스트
|
||||
|
||||
### 라우트 정의 시 확인사항
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 예약 작업에 등록할 수 없는 Artisan 명령·옵션을 안내하는 문구 일본어 번역 추가 — 왜 등록이 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)가 사유별로 표시됩니다.
|
||||
- 언어팩 설치 시 거부 사유를 안내하는 문구 일본어 번역 추가 — 허용되지 않는 파일 형식, 번역 배열이 아닌 PHP 파일(줄 번호 포함), 심볼릭 링크, 그리고 설치 직후 활성화에 필요한 권한 안내입니다.
|
||||
- 환경설정 고급 탭의 목록 상한 항목명(총 건수 집계 상한·페이지 번호 상한) 일본어 번역 추가 — 값이 범위를 벗어났을 때의 안내에 내부 식별자 대신 항목명이 표시됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1139,6 +1139,8 @@ return [
|
||||
'geoip_enabled' => 'GeoIP の使用',
|
||||
'geoip_license_key' => 'GeoIP ライセンスキー',
|
||||
'geoip_auto_update_enabled' => 'GeoIP 自動更新',
|
||||
'pagination_result_cap' => '総件数集計上限',
|
||||
'pagination_max_page' => 'ページ番号上限',
|
||||
'websocket_app_id' => 'WebSocket アプリ ID',
|
||||
'websocket_app_secret' => 'WebSocket アプリ シークレット',
|
||||
'websocket_verify_ssl' => 'WebSocket SSL 証明書の検証',
|
||||
|
||||
@@ -1103,8 +1103,7 @@
|
||||
"wishlist": "ウィッシュリスト",
|
||||
"mileage_balance": "マイレージ残高",
|
||||
"mileage_history": "マイレージ履歴",
|
||||
"reviewSettings": "レビューポリシー設定",
|
||||
"storefront": "ショッピング最初の画面統合データ"
|
||||
"reviewSettings": "レビューポリシー設定"
|
||||
},
|
||||
"provider": {
|
||||
"naver": "Naver",
|
||||
|
||||
@@ -1281,6 +1281,8 @@ return [
|
||||
'geoip_enabled' => 'GeoIP',
|
||||
'geoip_license_key' => 'GeoIP license key',
|
||||
'geoip_auto_update_enabled' => 'GeoIP auto update',
|
||||
'pagination_result_cap' => 'list total count cap',
|
||||
'pagination_max_page' => 'list maximum page number',
|
||||
// Driver settings (additional)
|
||||
'websocket_app_id' => 'WebSocket app ID',
|
||||
'websocket_app_secret' => 'WebSocket app secret',
|
||||
|
||||
@@ -1274,6 +1274,8 @@ return [
|
||||
'geoip_enabled' => 'GeoIP 사용',
|
||||
'geoip_license_key' => 'GeoIP 라이선스 키',
|
||||
'geoip_auto_update_enabled' => 'GeoIP 자동 업데이트',
|
||||
'pagination_result_cap' => '목록 총 건수 상한',
|
||||
'pagination_max_page' => '목록 최대 페이지 번호',
|
||||
// 드라이버 설정 (추가)
|
||||
'websocket_app_id' => '웹소켓 앱 ID',
|
||||
'websocket_app_secret' => '웹소켓 앱 시크릿',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Models\Comment;
|
||||
use Modules\Sirsoft\Board\Models\Post;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use Tests\Concerns\CountsQueries;
|
||||
@@ -122,4 +123,46 @@ class PostDetailQueryCountRegressionTest extends BoardTestCase
|
||||
'게시글 상세에서 게시판 테이블 SELECT 가 '.$boardSelects.'회 — 이미 조회한 게시판을 다시 찾고 있다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 딸린 데이터가 늘어도 상세 화면의 쿼리 수가 늘지 않는지 확인
|
||||
*
|
||||
* 앞의 두 단언은 "같은 행을 두 번 읽지 않는다" 만 본다. 그것만으로는 댓글처럼
|
||||
* 개수가 변하는 자식 데이터에 항목별 쿼리가 붙는 형태를 잡지 못한다 — 그 형태는
|
||||
* 글 행을 한 번만 읽으면서도 댓글 수만큼 쿼리를 낸다.
|
||||
*
|
||||
* 그래서 댓글을 3배로 늘려 놓고 같은 화면을 다시 재, 쿼리 수가 그대로인지 본다.
|
||||
*
|
||||
* @effects post_detail_query_count_does_not_grow_with_comment_count
|
||||
*/
|
||||
public function test_query_count_does_not_grow_with_comment_count(): void
|
||||
{
|
||||
$seedComments = function (int $count, string $prefix): void {
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
Comment::create([
|
||||
'board_id' => $this->board->id,
|
||||
'post_id' => $this->post->id,
|
||||
'content' => $prefix.' '.$i,
|
||||
'author_name' => '댓글쓴이',
|
||||
'ip_address' => '127.0.0.1',
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
$seedComments(3, '초기');
|
||||
|
||||
// 모집단 확인 — 상세 응답이 댓글을 싣지 않게 되면 아래 단언은 "댓글을 늘려도
|
||||
// 쿼리가 안 는다" 를 아무 근거 없이 통과시킨다. 댓글을 실제로 읽는지 먼저 고정한다.
|
||||
$commentReads = array_filter(
|
||||
$this->captureDetailQueries(),
|
||||
fn (string $sql) => str_contains($sql, '`'.DB::getTablePrefix().'board_comments`')
|
||||
);
|
||||
$this->assertNotEmpty($commentReads, '상세 응답이 댓글을 조회하지 않는다 — 측정 전제가 무너졌다');
|
||||
|
||||
$this->assertQueryCountStableAsDataGrows(
|
||||
measure: fn () => $this->captureDetailQueries(),
|
||||
grow: fn () => $seedComments(9, '추가'),
|
||||
context: '게시글 상세(댓글 3건 → 12건)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
- 마이페이지 주문내역 조회가 기본으로 주문마다 주문상품 전체를 내려주지 않습니다. 기본 응답에는 대표 상품 1건과 전체 개수만 실리며, 주문상품을 전부 나열하는 마이페이지 화면은 전량을 함께 요청하므로 보이는 내용은 종전과 동일합니다. 주문내역 조회를 직접 사용하는 외부 연동에서 상품 전체가 필요하면 주소 뒤에 `?with_items=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 통합 검색의 상품 결과를 최신순·가격순으로 볼 때 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 결과가 많아도 페이지를 넘길수록 느려지지 않습니다.
|
||||
- 분류 단건 조회가 화면에서 쓰지 않는 상품 수·하위 분류 수 집계를 기본으로 계산하지 않도록 바꿨습니다. 분류 순서 변경처럼 그 값을 읽지 않는 작업이 빨라집니다.
|
||||
- 쇼핑 첫 화면이 분류·상품목록·최근 본·인기·신상품을 각각 따로 요청하던 것을 한 번에 받도록 바꿨습니다. 화면에 보이는 내용은 동일하며 첫 화면이 뜨는 속도가 빨라집니다.
|
||||
- 상품 분류 화면도 같은 방식으로 바꿨습니다. 예전에는 분류 정보·분류 목록·상품 목록을 각각 요청했고 그중 분류 목록은 화면에서 쓰지 않았습니다. 이제 한 번의 요청으로 필요한 것만 받습니다. 없는 분류 주소로 들어오면 빈 화면 대신 없는 페이지로 처리됩니다.
|
||||
- 상품 목록의 판매량 정렬과 인기 상품 조회 방식을 개선했습니다. 예전에는 순서를 정하기 위해 전체 상품의 판매량을 매번 계산했지만, 이제 미리 묶어 둔 집계를 사용합니다. 상품이 많을수록 차이가 큽니다.
|
||||
- 상품 목록의 리뷰 개수·평점도 같은 방식으로 한 번에 집계합니다. 예전에는 상품 한 건마다 리뷰 표를 두 번씩 훑었습니다.
|
||||
- 주문·마일리지·쿠폰 발급내역·리뷰·1:1 문의 목록도 총 건수가 아주 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다.
|
||||
@@ -43,6 +41,7 @@
|
||||
- 플러그인으로 다른 검색엔진을 연결하면 상품·쿠폰·브랜드 검색에도 그 엔진이 적용됩니다. 이전에는 연결한 엔진과 무관하게 기본 방식으로만 검색됐습니다.
|
||||
- 상품 검색 결과의 총 건수가 아주 많으면 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다.
|
||||
- 기간으로 걸러 보는 목록(주문·쿠폰)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다.
|
||||
- 주문 상세에서 여러 옵션의 상태를 한꺼번에 바꿀 때, 대상 옵션을 한 번에 조회하도록 바꿨습니다. 예전에는 항목마다 같은 옵션을 다시 읽었고, 결제완료로 바꿀 때는 주문과 결제수단까지 주문 수만큼 되풀이해 읽었습니다. 한 번에 처리하는 항목이 많을수록 차이가 큽니다.
|
||||
- 주문 관리 화면 상단의 통계를 짧게 저장해 두고 재사용합니다. 주문이 바뀌면 즉시 새로 계산됩니다.
|
||||
- 상품 대량 수정(가격·재고)이 상품 수만큼 저장을 반복하던 것을 한 번에 처리하도록 바꿨습니다.
|
||||
- 결제 진행과 비회원 장바구니 병합에서 항목마다 반복하던 조회를 한 번에 처리하도록 바꿨습니다. 장바구니에 담긴 상품이 많을수록 차이가 큽니다.
|
||||
@@ -134,6 +133,7 @@
|
||||
- 해외 배송지를 수정하면 도시·주·우편번호가 안내 없이 비워지던 문제를 수정했습니다. 이번 수정에서 다시 입력하지 않은 항목은 저장된 값이 그대로 유지됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
|
||||
- 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
| [shipping-carriers.md](shipping-carriers.md) | `shipping-carriers` | 7 |
|
||||
| [shipping-country.md](shipping-country.md) | `shipping-country` | 2 |
|
||||
| [shipping-policies.md](shipping-policies.md) | `shipping-policies` | 11 |
|
||||
| [storefront.md](storefront.md) | `storefront` | 1 |
|
||||
| [users.md](users.md) | `users` | 2 |
|
||||
| [wishlist.md](wishlist.md) | `wishlist` | 3 |
|
||||
|
||||
|
||||
@@ -16,6 +16,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 목록 응답의 총 건수와 페이지 이동
|
||||
|
||||
상품 목록(관리자·공개 양쪽)은 총 건수를 상한까지만 셉니다. 상한을 넘으면 응답의
|
||||
`pagination` 에 정확도가 함께 실립니다 — `total_relation` 이 `at_least`, `total_is_exact`
|
||||
가 `false`, `result_cap` 이 적용된 상한값입니다. 이때 `last_page` 는 `null` 이고,
|
||||
`has_more_pages` 는 그대로 정확하므로 마지막 페이지 점프만 감춰지고 다음 페이지 이동은
|
||||
끝까지 열려 있습니다. 상한 이하이면 종전과 동일하게 `total` 이 정확한 값이고
|
||||
`total_relation` 은 `exact` 입니다.
|
||||
|
||||
같은 규약이 이 모듈의 다른 목록 응답(주문·쿠폰·마일리지·문의·리뷰 등)에도 적용됩니다.
|
||||
상세 규약은 [pagination.md](../../../../../docs/backend/pagination.md) 를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/admin/products
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.products.index -->
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# Storefront API 레퍼런스
|
||||
|
||||
> **소유**: module `sirsoft-ecommerce` · 쇼핑 첫 화면이 필요한 데이터를 한 번에 돌려주는 통합 엔드포인트입니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. GET /api/modules/sirsoft-ecommerce/storefront — 분류·상품목록·최근본·인기·신상품 한 번에 (`category_slug` 지정 시 그 분류의 상세까지)
|
||||
2. 각 묶음의 응답 형태는 개별 엔드포인트와 완전히 동일 (조립 위치만 서버로 이동)
|
||||
3. 개별 엔드포인트도 그대로 남아 다른 화면에서 계속 사용
|
||||
4. products 묶음은 총 건수 정확도 메타(total_relation / total_is_exact / result_cap) 포함
|
||||
5. 진열 묶음 개수를 0 으로 주면 그 묶음은 조회하지 않음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 왜 통합 엔드포인트인가
|
||||
|
||||
쇼핑 첫 화면은 분류·상품 목록·최근 본·인기·신상품 다섯 묶음을 한 화면에 함께 그립니다.
|
||||
이를 각각의 엔드포인트로 부르면 화면 하나에 요청이 다섯 번 나가고, 그 다섯 번이 전부 같은
|
||||
부팅(라우팅·미들웨어·권한 판정·설정 적재)을 되풀이합니다.
|
||||
|
||||
각 묶음의 응답 형태(리소스 계약)는 개별 엔드포인트와 같습니다 — 옮긴 것은 조립 위치뿐이므로,
|
||||
개별 엔드포인트를 쓰는 다른 화면은 영향을 받지 않습니다.
|
||||
|
||||
분류 화면도 같은 이유로 이 엔드포인트를 씁니다. 종전에는 분류 상세·분류 트리·상품 목록으로
|
||||
요청이 세 번 나갔고, 그중 분류 트리는 그 화면의 어떤 표현식도 읽지 않는 낭비였습니다.
|
||||
`category_slug` 를 주면 `category` 묶음에 그 분류의 상세가 함께 담겨 요청 한 번으로 끝납니다.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/modules/sirsoft-ecommerce/storefront
|
||||
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.storefront.index`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\StorefrontController@index`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-products.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| category_id | query | integer | 아니오 | — | 상품 목록 묶음의 카테고리 필터 (category 식별자) |
|
||||
| category_slug | query | string | 아니오 | max 100 | 상품 목록 묶음의 카테고리 필터 (URL 친화 식별자). **지정하면 `category` 묶음에 그 분류의 상세가 함께 담깁니다** — 없는 분류면 404 |
|
||||
| brand_id | query | integer | 아니오 | — | 상품 목록 묶음의 브랜드 필터 |
|
||||
| search | query | string | 아니오 | max 200 | 상품 목록 묶음의 검색어 |
|
||||
| sort | query | string | 아니오 | `latest`, `sales`, `price_asc`, `price_desc` | 상품 목록 묶음의 정렬 기준 |
|
||||
| min_price | query | integer | 아니오 | min 0 | 판매가 하한 |
|
||||
| max_price | query | integer | 아니오 | min 0 | 판매가 상한 |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 상품 목록 묶음의 페이지당 항목 수 |
|
||||
| page | query | integer | 아니오 | min 1 | 상품 목록 묶음의 페이지 번호 |
|
||||
| popular_limit | query | integer | 아니오 | min 0, max 50 | 인기 상품 개수 (0 이면 조회하지 않음, 미지정 시 8) |
|
||||
| new_limit | query | integer | 아니오 | min 0, max 50 | 신상품 개수 (0 이면 조회하지 않음, 미지정 시 8) |
|
||||
| recent_ids | query | string | 아니오 | max 500 | 최근 본 상품 ID 목록 (쉼표 구분, 비면 조회하지 않음) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.public_storefront_validation_rules`, `sirsoft-ecommerce.product.public_storefront_validation_messages`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/modules/sirsoft-ecommerce/storefront?page=1&per_page=12&sort=latest&popular_limit=8&new_limit=8&recent_ids=12%2C34 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 용도/설명 |
|
||||
| --- | --- | --- |
|
||||
| category | object\|null | 지금 보고 있는 분류의 상세. `GET /categories/{slug}` 의 `data` 와 동일한 형태. `category_slug` 를 주지 않으면 `null` |
|
||||
| categories | array | 공개 카테고리 트리. `GET /categories` 의 `data` 와 동일한 형태 |
|
||||
| products | object | 상품 목록. `GET /products` 의 `data` 와 동일한 형태 (`data[]` + `pagination`) |
|
||||
| recent_products | array | 최근 본 상품. `GET /products/recent` 의 `data` 와 동일한 형태 (`recent_ids` 가 비면 `[]`) |
|
||||
| popular_products | array | 인기 상품. `GET /products/popular` 의 `data` 와 동일한 형태 (`popular_limit=0` 이면 `[]`) |
|
||||
| new_products | array | 신상품. `GET /products/new` 의 `data` 와 동일한 형태 (`new_limit=0` 이면 `[]`) |
|
||||
|
||||
`products.pagination` 은 표준 페이지네이션 메타에 총 건수 정확도가 더해진 형태입니다.
|
||||
|
||||
| 필드 | 타입 | 용도/설명 |
|
||||
| --- | --- | --- |
|
||||
| current_page | integer | 현재 페이지 번호 |
|
||||
| per_page | integer | 페이지당 항목 수 |
|
||||
| from / to | integer\|null | 현재 페이지의 첫/마지막 행 번호 |
|
||||
| has_more_pages | boolean | 다음 페이지 존재 여부 (총 건수를 몰라도 정확) |
|
||||
| total | integer | 총 건수 (상한 초과 시 상한값 = 하한 보증) |
|
||||
| last_page | integer\|null | 마지막 페이지 번호. **총 건수가 부정확하면 `null`** |
|
||||
| total_relation | string | `exact` 또는 `at_least` |
|
||||
| total_is_exact | boolean | 총 건수가 정확한지 여부 |
|
||||
| result_cap | integer\|null | 총 건수 집계에 적용된 상한 (무제한이면 `null`) |
|
||||
|
||||
> 총 건수 상한과 페이지 이동의 관계는 [pagination.md](../../../../../docs/backend/pagination.md) 참조.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "상품 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"category": null,
|
||||
"categories": [],
|
||||
"products": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"per_page": 12,
|
||||
"from": null,
|
||||
"to": null,
|
||||
"has_more_pages": false,
|
||||
"total": 0,
|
||||
"last_page": 1,
|
||||
"total_relation": "exact",
|
||||
"total_is_exact": true,
|
||||
"result_cap": 10000
|
||||
}
|
||||
},
|
||||
"recent_products": [],
|
||||
"popular_products": [],
|
||||
"new_products": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**오류 응답**
|
||||
|
||||
| 상태 | 상황 |
|
||||
| --- | --- |
|
||||
| 404 | `category_slug` 가 가리키는 분류가 없거나 비활성 (200 + 빈 목록으로 내보내면 검색엔진이 없는 주소를 정상 페이지로 수집합니다) |
|
||||
| 401 | 비회원에게 `sirsoft-ecommerce.user-products.read` 권한이 없음 |
|
||||
| 403 | 인증 사용자에게 해당 권한이 없음 |
|
||||
| 422 | 파라미터 검증 실패 (범위를 벗어난 `popular_limit` 등) |
|
||||
| 500 | 조회 중 예외 — 응답 형태는 개별 상품 엔드포인트와 동일 |
|
||||
-181
@@ -1,181 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Public;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicStorefrontRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductListResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\PublicCategoryDetailResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\PublicCategoryResource;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CategoryService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
|
||||
/**
|
||||
* 쇼핑 첫 화면 통합 조회 컨트롤러
|
||||
*
|
||||
* 쇼핑 첫 화면은 분류·상품 목록·최근 본·인기·신상품 다섯 묶음을 한 화면에 함께 그린다.
|
||||
* 이를 각각의 엔드포인트로 부르면 화면 하나에 요청이 다섯 번 나가고, 그 다섯 번이 전부
|
||||
* 같은 부팅(라우팅·미들웨어·권한 판정·설정 적재)을 되풀이한다.
|
||||
*
|
||||
* 각 묶음의 응답 형태(리소스 계약)는 개별 엔드포인트와 완전히 같다 — 옮긴 것은 조립
|
||||
* 위치뿐이므로, 개별 엔드포인트도 그대로 남아 다른 화면에서 계속 쓰인다.
|
||||
*/
|
||||
class StorefrontController extends PublicBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private ProductService $productService,
|
||||
private CategoryService $categoryService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 쇼핑 첫 화면에 필요한 데이터를 한 번에 조회합니다.
|
||||
*
|
||||
* @param PublicStorefrontRequest $request 요청 데이터
|
||||
* @return JsonResponse 분류·상품 목록·최근 본·인기·신상품을 담은 JSON 응답
|
||||
*/
|
||||
public function index(PublicStorefrontRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('storefront.index');
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$category = $this->resolveFocusedCategory($validated);
|
||||
|
||||
if ($category === false) {
|
||||
return ResponseHelper::notFound(
|
||||
'messages.categories.not_found',
|
||||
[],
|
||||
'sirsoft-ecommerce'
|
||||
);
|
||||
}
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
'messages.products.fetch_success',
|
||||
[
|
||||
'category' => $category,
|
||||
'categories' => PublicCategoryResource::collection(
|
||||
$this->categoryService->getPublicCategoryTree()
|
||||
)->resolve(),
|
||||
'products' => $this->resolveProductList($request, $validated),
|
||||
'recent_products' => $this->resolveRecentProducts($validated),
|
||||
'popular_products' => $this->resolveDisplayGroup(
|
||||
(int) ($validated['popular_limit'] ?? 8),
|
||||
fn (int $limit) => $this->productService->getPopularProducts($limit)
|
||||
),
|
||||
'new_products' => $this->resolveDisplayGroup(
|
||||
(int) ($validated['new_limit'] ?? 8),
|
||||
fn (int $limit) => $this->productService->getNewProducts($limit)
|
||||
),
|
||||
]
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'messages.products.fetch_failed',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록 묶음을 조립합니다.
|
||||
*
|
||||
* 개별 상품 목록 엔드포인트와 같은 컬렉션을 쓰므로 페이지네이션 메타까지 형태가 같습니다.
|
||||
*
|
||||
* @param PublicStorefrontRequest $request 요청 객체
|
||||
* @param array<string, mixed> $validated 검증된 입력
|
||||
* @return array<string, mixed> 상품 목록 응답 데이터
|
||||
*/
|
||||
private function resolveProductList(PublicStorefrontRequest $request, array $validated): array
|
||||
{
|
||||
$listKeys = ['category_id', 'category_slug', 'brand_id', 'search', 'sort', 'min_price', 'max_price'];
|
||||
$filters = array_filter(
|
||||
$validated,
|
||||
fn ($value, $key) => in_array($key, $listKeys, true) && $value !== null && $value !== '',
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
|
||||
$perPage = (int) ($validated['per_page'] ?? 20);
|
||||
$collection = new ProductCollection($this->productService->getPublicList($filters, $perPage));
|
||||
|
||||
return $collection->toArray($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 분류 화면이 필요로 하는 단건 분류를 조립합니다.
|
||||
*
|
||||
* 분류 화면은 "지금 보고 있는 분류" 하나와 전체 분류 트리, 그 분류의 상품 목록을 함께
|
||||
* 그린다. 단건 분류를 여기서 함께 돌려주면 그 화면도 요청 한 번으로 끝난다.
|
||||
* `category_slug` 가 없으면(쇼핑 첫 화면) 조회하지 않는다.
|
||||
*
|
||||
* 응답 형태는 단건 분류 엔드포인트와 같은 리소스를 써서 완전히 일치시킨다 —
|
||||
* 옮긴 것은 조립 위치뿐이므로 화면 바인딩이 그대로 동작해야 한다.
|
||||
*
|
||||
* 반환값이 false 면 "분류를 지정했는데 그런 분류가 없다" 는 뜻이다. 이 경우 호출자가
|
||||
* 404 로 끊는다 — 200 으로 빈 화면을 내보내면 검색엔진이 존재하지 않는 분류 주소를
|
||||
* 정상 페이지로 수집한다.
|
||||
*
|
||||
* @param array<string, mixed> $validated 검증된 입력
|
||||
* @return array<string, mixed>|null|false 분류 응답 데이터 / 미지정 시 null / 대상 없음 시 false
|
||||
*/
|
||||
private function resolveFocusedCategory(array $validated): array|null|false
|
||||
{
|
||||
$slug = trim((string) ($validated['category_slug'] ?? ''));
|
||||
|
||||
if ($slug === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$category = $this->categoryService->getPublicCategoryBySlug($slug);
|
||||
|
||||
return $category ? (new PublicCategoryDetailResource($category))->resolve() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 본 상품 묶음을 조립합니다.
|
||||
*
|
||||
* 최근 본 상품은 클라이언트가 보관한 ID 목록으로만 정해지므로, 비어 있으면 조회하지 않습니다.
|
||||
*
|
||||
* @param array<string, mixed> $validated 검증된 입력
|
||||
* @return array<int, mixed> 상품 목록 (없으면 빈 배열)
|
||||
*/
|
||||
private function resolveRecentProducts(array $validated): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map(
|
||||
'intval',
|
||||
explode(',', (string) ($validated['recent_ids'] ?? ''))
|
||||
)));
|
||||
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ProductListResource::collection(
|
||||
$this->productService->getProductsByIds($ids)
|
||||
)->resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 진열 묶음(인기·신상품)을 조립합니다.
|
||||
*
|
||||
* 개수가 0 이면 그 묶음을 쓰지 않겠다는 뜻이므로 조회하지 않습니다.
|
||||
*
|
||||
* @param int $limit 조회 개수
|
||||
* @param callable $resolver 상품 조회 클로저
|
||||
* @return array<int, mixed> 상품 목록 (없으면 빈 배열)
|
||||
*/
|
||||
private function resolveDisplayGroup(int $limit, callable $resolver): array
|
||||
{
|
||||
if ($limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ProductListResource::collection($resolver($limit))->resolve();
|
||||
}
|
||||
}
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Public;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 쇼핑 첫 화면 통합 조회 요청 (공개)
|
||||
*
|
||||
* 상품 목록 필터에 더해 진열 묶음(인기/신상품/최근 본)의 개수와 최근 본 상품 ID 를 받습니다.
|
||||
* 개별 엔드포인트가 각각 쓰던 규칙을 그대로 합친 것이므로, 같은 값이면 같은 결과가 나옵니다.
|
||||
*/
|
||||
class PublicStorefrontRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 확인
|
||||
*
|
||||
* @return bool 항상 true (권한은 미들웨어 체인이 담당)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효성 검사 규칙
|
||||
*
|
||||
* @return array 검증 규칙
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
// 상품 목록 필터 (PublicProductListRequest 와 동일)
|
||||
'category_id' => ['nullable', 'integer'],
|
||||
'category_slug' => ['nullable', 'string', 'max:100'],
|
||||
'brand_id' => ['nullable', 'integer'],
|
||||
'search' => ['nullable', 'string', 'max:200'],
|
||||
'sort' => ['nullable', 'string', 'in:latest,sales,price_asc,price_desc'],
|
||||
'min_price' => ['nullable', 'integer', 'min:0'],
|
||||
'max_price' => ['nullable', 'integer', 'min:0'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
|
||||
// 진열 묶음
|
||||
'popular_limit' => ['nullable', 'integer', 'min:0', 'max:50'],
|
||||
'new_limit' => ['nullable', 'integer', 'min:0', 'max:50'],
|
||||
'recent_ids' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('sirsoft-ecommerce.product.public_storefront_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 에러 메시지 정의
|
||||
*
|
||||
* @return array<string, string> 검증 메시지
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
$messages = [
|
||||
'category_id.integer' => __('sirsoft-ecommerce::validation.public_product.category_id.integer'),
|
||||
'category_slug.string' => __('sirsoft-ecommerce::validation.public_product.category_slug.string'),
|
||||
'category_slug.max' => __('sirsoft-ecommerce::validation.public_product.category_slug.max'),
|
||||
'brand_id.integer' => __('sirsoft-ecommerce::validation.public_product.brand_id.integer'),
|
||||
'search.string' => __('sirsoft-ecommerce::validation.public_product.search.string'),
|
||||
'search.max' => __('sirsoft-ecommerce::validation.public_product.search.max'),
|
||||
'sort.in' => __('sirsoft-ecommerce::validation.public_product.sort.in'),
|
||||
'min_price.integer' => __('sirsoft-ecommerce::validation.public_product.min_price.integer'),
|
||||
'min_price.min' => __('sirsoft-ecommerce::validation.public_product.min_price.min'),
|
||||
'max_price.integer' => __('sirsoft-ecommerce::validation.public_product.max_price.integer'),
|
||||
'max_price.min' => __('sirsoft-ecommerce::validation.public_product.max_price.min'),
|
||||
'per_page.integer' => __('sirsoft-ecommerce::validation.public_product.per_page.integer'),
|
||||
'per_page.min' => __('sirsoft-ecommerce::validation.public_product.per_page.min'),
|
||||
'per_page.max' => __('sirsoft-ecommerce::validation.public_product.per_page.max'),
|
||||
'popular_limit.integer' => __('sirsoft-ecommerce::validation.public_product.limit.integer'),
|
||||
'popular_limit.max' => __('sirsoft-ecommerce::validation.public_product.limit.max'),
|
||||
'new_limit.integer' => __('sirsoft-ecommerce::validation.public_product.limit.integer'),
|
||||
'new_limit.max' => __('sirsoft-ecommerce::validation.public_product.limit.max'),
|
||||
'recent_ids.string' => __('sirsoft-ecommerce::validation.public_product.ids.string'),
|
||||
'recent_ids.max' => __('sirsoft-ecommerce::validation.public_product.ids.max'),
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('sirsoft-ecommerce.product.public_storefront_validation_messages', $messages, $this);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Enums\TotalRelation;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Search\SearchCategoryPayload;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
@@ -141,7 +141,9 @@ class SearchProductsListener implements HookListenerInterface
|
||||
->map(fn ($product) => $this->formatProductResult($product, $q))
|
||||
->toArray();
|
||||
|
||||
// 커서를 받았고 그 정렬을 커서로 처리할 수 있으면 키셋으로 응답한다.
|
||||
// 그 정렬을 커서로 처리할 수 있으면 키셋으로 응답한다. 페이지 번호를 함께 넘기는
|
||||
// 이유는 커서 없이 깊은 페이지를 직접 지목한 딥링크를 코어가 가려내야 하기
|
||||
// 때문이다 — 넘기지 않으면 기본값 1 이 적용돼 그 링크가 첫 페이지로 되돌아간다.
|
||||
// 전체 탭은 미리보기 몇 건뿐이라 깊은 페이지가 없어 대상이 아니다.
|
||||
$cursorPage = $isAllTab
|
||||
? null
|
||||
@@ -150,7 +152,8 @@ class SearchProductsListener implements HookListenerInterface
|
||||
$sort,
|
||||
$categoryId,
|
||||
$fetchPerPage,
|
||||
$context['cursor'] ?? null
|
||||
$context['cursor'] ?? null,
|
||||
(int) $page
|
||||
);
|
||||
|
||||
if ($cursorPage !== null) {
|
||||
|
||||
@@ -342,9 +342,20 @@ class OrderOptionService
|
||||
$changedCount = 0;
|
||||
$splitCount = 0;
|
||||
|
||||
DB::transaction(function () use ($items, $newStatus, $metadata, &$results, &$changedCount, &$splitCount) {
|
||||
// 대상 옵션을 1회 일괄 조회해 재사용한다 (항목마다 같은 행을 다시 읽지 않는다).
|
||||
// 같은 옵션이 두 번 이상 실린 요청은 앞선 변경이 이미 DB 에 반영된 뒤이므로
|
||||
// 적재본이 낡는다 — 그 경우에만 그 항목을 다시 읽어 종전 동작을 유지한다.
|
||||
$prefetchedOptions = $this->orderOptionRepository->findByIdsKeyed($optionIds);
|
||||
|
||||
DB::transaction(function () use ($items, $newStatus, $metadata, $prefetchedOptions, &$results, &$changedCount, &$splitCount) {
|
||||
$consumed = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$option = $this->orderOptionRepository->findOrFail($item['option_id']);
|
||||
$optionId = $item['option_id'];
|
||||
$option = isset($consumed[$optionId])
|
||||
? $this->orderOptionRepository->findOrFail($optionId)
|
||||
: ($prefetchedOptions->get($optionId) ?? $this->orderOptionRepository->findOrFail($optionId));
|
||||
$consumed[$optionId] = true;
|
||||
|
||||
$result = $this->changeStatusWithQuantity(
|
||||
$option,
|
||||
@@ -639,8 +650,12 @@ class OrderOptionService
|
||||
{
|
||||
$orderIds = $this->orderOptionRepository->getOrderIdsByOptionIds($optionIds);
|
||||
|
||||
// 주문과 결제수단을 1회 일괄 조회한다. 주문마다 조회하면 결제수단 지연 로드까지
|
||||
// 따라붙어 대상 주문 수만큼 쿼리가 두 배로 늘어난다.
|
||||
$orders = $this->orderRepository->findByIdsWithRelationsKeyed($orderIds, ['payment']);
|
||||
|
||||
foreach ($orderIds as $orderId) {
|
||||
$order = $this->orderRepository->find($orderId);
|
||||
$order = $orders->get($orderId);
|
||||
if (! $order) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use App\Extension\HookManager;
|
||||
use App\Search\SearchPagePolicy;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use Illuminate\Pagination\CursorPaginator;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\CursorPaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
||||
@@ -1849,7 +1849,7 @@ class ProductService
|
||||
* @param int|null $categoryId 카테고리 필터
|
||||
* @param int $offset 오프셋
|
||||
* @param int $limit 조회할 최대 항목 수
|
||||
* @return array{total: int, items: Collection}
|
||||
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
|
||||
*/
|
||||
public function searchByKeyword(string $keyword, string $sort = 'latest', ?int $categoryId = null, int $offset = 0, int $limit = 10): BoundedPage
|
||||
{
|
||||
@@ -1869,6 +1869,7 @@ class ProductService
|
||||
* @param int|null $categoryId 카테고리 필터
|
||||
* @param int $perPage 페이지당 항목 수
|
||||
* @param string|null $cursor 인코딩된 커서 (첫 페이지면 null)
|
||||
* @param int $page 요청 페이지 번호 (커서 없이 깊은 페이지를 지목했는지 판정용)
|
||||
* @return CursorPaginator|null 커서 페이지 결과 (커서 적용 불가 시 null)
|
||||
*/
|
||||
public function searchByKeywordWithCursor(
|
||||
@@ -1876,11 +1877,12 @@ class ProductService
|
||||
string $sort = 'latest',
|
||||
?int $categoryId = null,
|
||||
int $perPage = 10,
|
||||
?string $cursor = null
|
||||
?string $cursor = null,
|
||||
int $page = 1
|
||||
): ?CursorPaginator {
|
||||
$sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP);
|
||||
|
||||
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS)) {
|
||||
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS, $page)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductImageController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductInquiryController as PublicProductInquiryController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductReviewController as PublicProductReviewController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\PublicCouponController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\StorefrontController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Public\WishlistController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Shop\PaymentConfigController;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\User\OrderController as UserOrderController;
|
||||
@@ -74,13 +73,6 @@ Route::prefix('categories')->group(function () {
|
||||
->name('categories.show');
|
||||
});
|
||||
|
||||
// 쇼핑 첫 화면 통합 조회 API
|
||||
// GET /api/modules/sirsoft-ecommerce/storefront - 분류·상품목록·최근본·인기·신상품 한 번에 조회
|
||||
// (각 묶음의 응답 형태는 개별 엔드포인트와 동일 — 조립 위치만 서버로 옮긴 것)
|
||||
Route::get('storefront', [StorefrontController::class, 'index'])
|
||||
->middleware(['optional.sanctum', 'permission:user,sirsoft-ecommerce.user-products.read'])
|
||||
->name('storefront.index');
|
||||
|
||||
// 공개 상품 API
|
||||
// GET /api/modules/sirsoft-ecommerce/products - 공개 상품 목록 조회
|
||||
// GET /api/modules/sirsoft-ecommerce/products/popular - 인기 상품 조회
|
||||
|
||||
-66
@@ -5,10 +5,6 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Performance;
|
||||
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Category;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
@@ -109,68 +105,6 @@ class ListQueryCountRegressionTest extends ModuleTestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쇼핑 첫 화면(storefront): 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인
|
||||
*
|
||||
* 이 엔드포인트는 분류·상품·최근·인기·신상품 5묶음을 한 응답으로 합친다. 묶음마다
|
||||
* 관계가 달려 있어 어느 한 묶음이 관계 eager load 를 놓치면 그 자리에서 N+1 이 된다.
|
||||
* 응답을 통째로 재야 그 사실이 드러난다.
|
||||
*
|
||||
* @effects storefront_query_count_stable
|
||||
*/
|
||||
public function test_storefront_query_count_is_stable(): void
|
||||
{
|
||||
$this->seedProducts(5, 'SHOP-A');
|
||||
|
||||
// storefront 는 `permission:user,sirsoft-ecommerce.user-products.read` 로 가드된다.
|
||||
// 시드된 'user' 역할에 그 권한이 붙어 있는지는 앞선 테스트가 무엇을 했는지에 따라
|
||||
// 달라지므로(단독 실행은 통과, 연속 실행은 403), 전용 역할에 권한을 직접 부여한다.
|
||||
$this->actingAs($this->userWithProductReadPermission(), 'sanctum');
|
||||
|
||||
$this->assertQueryCountStableAsDataGrows(
|
||||
measure: function () {
|
||||
// 카테고리 트리 캐시가 살아 있으면 두 번째 측정이 그만큼 적게 나와
|
||||
// 판정이 무의미해진다. 매 측정 조건을 같게 맞춘다.
|
||||
Category::flushTreeCache();
|
||||
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/storefront');
|
||||
$response->assertOk();
|
||||
},
|
||||
grow: fn () => $this->seedProducts(10, 'SHOP-B'),
|
||||
context: '쇼핑 첫 화면(storefront)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 상품 조회 권한을 가진 사용자를 만듭니다.
|
||||
*
|
||||
* 전용 역할에 권한을 직접 붙여, 시드된 공용 역할의 상태에 의존하지 않게 한다.
|
||||
*
|
||||
* @return User 권한을 가진 사용자
|
||||
*/
|
||||
private function userWithProductReadPermission(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$role = Role::create([
|
||||
'identifier' => 'storefront-probe-'.$user->id,
|
||||
'name' => ['ko' => '진열 조회 테스트', 'en' => 'Storefront Probe'],
|
||||
]);
|
||||
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => 'sirsoft-ecommerce.user-products.read'],
|
||||
[
|
||||
'name' => ['ko' => '공개 상품 조회', 'en' => 'Read public products'],
|
||||
'type' => PermissionType::User,
|
||||
]
|
||||
);
|
||||
|
||||
$role->permissions()->syncWithoutDetaching([$permission->id]);
|
||||
$user->roles()->attach($role->id);
|
||||
|
||||
return User::findOrFail($user->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리를 계층으로 만듭니다 (루트 1 + 자식 N-1).
|
||||
*
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Performance;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Tests\Concerns\CountsQueries;
|
||||
|
||||
/**
|
||||
* 주문 옵션 일괄 처리의 쿼리 수 회귀 테스트 (#519 E3)
|
||||
*
|
||||
* 일괄 상태 변경은 항목마다 옵션을 다시 조회했고, 결제완료 전이 가드는 주문마다
|
||||
* 다시 조회한 뒤 결제수단을 지연 로드했다. 둘 다 대상이 늘수록 조회가 함께 늘어난다.
|
||||
*
|
||||
* 전체 쿼리 수로는 재지 않는다 — 상태 UPDATE 는 항목마다 나는 것이 정상이라
|
||||
* 총량은 어차피 항목 수에 비례한다. 여기서 고정하는 것은 **쓰기 직전에 같은 행을
|
||||
* 한 건씩 다시 읽던 조회**이므로, 그 단일 행 조회의 형태를 직접 센다.
|
||||
*/
|
||||
class OrderOptionBulkQueryCountRegressionTest extends ModuleTestCase
|
||||
{
|
||||
use CountsQueries;
|
||||
|
||||
private OrderOptionService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(OrderOptionService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제완료 상태의 주문과 옵션 N건을 만든다.
|
||||
*
|
||||
* @param int $optionCount 생성할 옵션 수
|
||||
* @return Order 생성된 주문
|
||||
*/
|
||||
private function makeOrderWithOptions(int $optionCount): Order
|
||||
{
|
||||
$order = OrderFactory::new()->create([
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < $optionCount; $i++) {
|
||||
OrderOptionFactory::new()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'quantity' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문의 전 옵션을 대상으로 하는 일괄 변경 요청 페이로드를 만든다.
|
||||
*
|
||||
* @param Order $order 대상 주문
|
||||
* @return array<int, array{option_id: int, quantity: int}> 변경 항목 배열
|
||||
*/
|
||||
private function itemsFor(Order $order): array
|
||||
{
|
||||
return $order->options->map(fn ($option) => [
|
||||
'option_id' => $option->id,
|
||||
'quantity' => (int) $option->quantity,
|
||||
])->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본키 단일 행 조회(`where ...id = ? limit 1`)의 건수를 센다.
|
||||
*
|
||||
* @param array<int, string> $queries 실행된 SQL 목록
|
||||
* @param string $table 대상 테이블 이름 조각
|
||||
* @return int 단일 행 조회 건수
|
||||
*/
|
||||
private function countSingleRowLookups(array $queries, string $table): int
|
||||
{
|
||||
$pattern = '/^select .* from \S*'.preg_quote($table, '/').'\S* where \S*"?id"?\S* = \? limit 1$/i';
|
||||
|
||||
return count(array_filter(
|
||||
$queries,
|
||||
fn (string $sql) => (bool) preg_match($pattern, str_replace('`', '"', $sql))
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 상태 변경이 대상 옵션을 1회 일괄 조회해 재사용한다.
|
||||
*
|
||||
* 총 단일 행 조회 수로는 재지 않는다 — 옵션 단위 훅(`order_option.after_status_change`)이
|
||||
* 항목마다 발화하고 그 구독자들이 자기 대상을 읽으므로, 총량은 어느 구현에서든 항목 수에
|
||||
* 비례한다. 그 비례는 훅 설계상 정상이고 E3 의 대상이 아니다.
|
||||
*
|
||||
* E3 가 없앤 것은 **변경 대상을 확정하는 단계**에서 항목마다 같은 행을 한 건씩 다시 읽던
|
||||
* 조회다. 그래서 "첫 쓰기 이전에 단일 행 조회가 없고, 대상이 일괄 조회로 확정된다"는
|
||||
* 구조를 직접 고정한다. 루프 안에 단건 조회가 되살아나면 첫 항목의 조회가 첫 UPDATE 를
|
||||
* 앞서므로 이 단언이 깨진다.
|
||||
*
|
||||
* @scenario operation=bulk_change_with_quantity, item_count=6, target_status=shipping
|
||||
*
|
||||
* @effects bulk_change_resolves_targets_in_one_batched_query
|
||||
*/
|
||||
public function test_bulk_change_resolves_targets_in_one_batched_query(): void
|
||||
{
|
||||
$order = $this->makeOrderWithOptions(6);
|
||||
$items = $this->itemsFor($order);
|
||||
|
||||
$queries = $this->captureQueries(function () use ($items, $order) {
|
||||
$this->service->bulkChangeStatusWithQuantity(
|
||||
$items,
|
||||
OrderStatusEnum::SHIPPING,
|
||||
$order->id
|
||||
);
|
||||
});
|
||||
|
||||
$firstWriteAt = $this->indexOfFirstWrite($queries, 'order_options');
|
||||
$this->assertNotNull($firstWriteAt, '옵션 상태 UPDATE 가 실행되지 않았다 — 측정 전제가 무너졌다');
|
||||
|
||||
$beforeWrite = array_slice($queries, 0, $firstWriteAt);
|
||||
|
||||
$lookupsBeforeWrite = $this->countSingleRowLookups($beforeWrite, 'order_options');
|
||||
$this->assertSame(
|
||||
0,
|
||||
$lookupsBeforeWrite,
|
||||
"변경 대상을 확정하기 전에 옵션을 단건으로 {$lookupsBeforeWrite}회 조회했다. "
|
||||
.'항목마다 옵션을 다시 읽고 있다 — 대상 옵션을 1회 일괄 조회해 재사용한다'
|
||||
);
|
||||
|
||||
$batched = array_filter(
|
||||
$beforeWrite,
|
||||
fn (string $sql) => (bool) preg_match('/^select \* from \S*order_options\S* where \S*id\S* in \(/i', str_replace('`', '"', $sql))
|
||||
);
|
||||
$this->assertNotEmpty(
|
||||
$batched,
|
||||
'대상 옵션을 일괄 조회(`where id in (...)`)한 흔적이 없다 — 1회 일괄 조회로 확정해야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블에 대한 첫 쓰기(UPDATE) 위치를 찾는다.
|
||||
*
|
||||
* @param array<int, string> $queries 실행된 SQL 목록
|
||||
* @param string $table 대상 테이블 이름 조각
|
||||
* @return int|null 첫 UPDATE 의 인덱스 (없으면 null)
|
||||
*/
|
||||
private function indexOfFirstWrite(array $queries, string $table): ?int
|
||||
{
|
||||
foreach ($queries as $index => $sql) {
|
||||
if (preg_match('/^update \S*'.preg_quote($table, '/').'\S*/i', str_replace('`', '"', $sql))) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제완료 전이 가드가 주문마다 다시 읽지 않는다.
|
||||
*
|
||||
* 옵션 일괄변경은 단일 주문 범위지만, 가드는 옵션에서 주문을 역산하므로
|
||||
* 여러 주문에 걸친 내부/훅 호출에서 주문마다 조회 + 결제수단 지연 로드가 발생했다.
|
||||
*
|
||||
* @scenario operation=enforce_payment_complete_idv, order_count=1_vs_3, target_status=payment_complete
|
||||
*
|
||||
* @effects payment_complete_guard_query_count_does_not_grow_with_order_count
|
||||
*/
|
||||
public function test_payment_complete_guard_query_count_does_not_grow_with_order_count(): void
|
||||
{
|
||||
$single = $this->makeGuardTargets(1);
|
||||
$multiple = $this->makeGuardTargets(3);
|
||||
|
||||
$singleQueries = $this->countQueries(fn () => $this->invokeGuard($single));
|
||||
$multipleQueries = $this->countQueries(fn () => $this->invokeGuard($multiple));
|
||||
|
||||
$this->assertLessThanOrEqual(
|
||||
$singleQueries,
|
||||
$multipleQueries,
|
||||
"결제완료 가드의 쿼리 수가 주문 수에 비례한다 (1주문={$singleQueries} / 3주문={$multipleQueries}). "
|
||||
.'주문마다 다시 조회하고 결제수단을 지연 로드하고 있다 — 결제 관계까지 1회 일괄 조회한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가드 대상 옵션 ID 집합을 만든다 (주문마다 옵션 1건 + 결제 1건).
|
||||
*
|
||||
* @param int $orderCount 생성할 주문 수
|
||||
* @return array<int, int> 옵션 ID 배열
|
||||
*/
|
||||
private function makeGuardTargets(int $orderCount): array
|
||||
{
|
||||
$optionIds = [];
|
||||
|
||||
for ($i = 0; $i < $orderCount; $i++) {
|
||||
$order = OrderFactory::new()->create([
|
||||
'order_status' => OrderStatusEnum::PENDING_PAYMENT,
|
||||
]);
|
||||
OrderPaymentFactory::new()->create(['order_id' => $order->id]);
|
||||
$optionIds[] = OrderOptionFactory::new()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::PENDING_PAYMENT,
|
||||
'quantity' => 1,
|
||||
])->id;
|
||||
}
|
||||
|
||||
return $optionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* private 가드 메서드를 직접 호출한다.
|
||||
*
|
||||
* @param array<int, int> $optionIds 대상 옵션 ID 배열
|
||||
*/
|
||||
private function invokeGuard(array $optionIds): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this->service, 'enforcePaymentCompleteIdv');
|
||||
$method->invoke($this->service, $optionIds);
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Performance;
|
||||
|
||||
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Tests\Concerns\CountsQueries;
|
||||
|
||||
/**
|
||||
* 쇼핑 첫 화면 구성 조회의 쿼리 수 회귀 테스트 (#519)
|
||||
*
|
||||
* 쇼핑 첫 화면은 분류·상품 목록·최근 본·인기·신상품 다섯 조각으로 그려진다. 각 조각은
|
||||
* 독립 엔드포인트라 어느 하나에 N+1 이 들어와도 나머지는 멀쩡하고, 화면도 정상으로 보인다.
|
||||
* 응답 형태를 보는 테스트로는 드러나지 않으므로 **조각마다** 쿼리 수를 따로 센다.
|
||||
*
|
||||
* 특정 시점의 쿼리 수를 숫자로 박아 두면 관계 하나만 추가해도 깨져 유지되지 않는다.
|
||||
* 대신 **행 수를 늘려도 쿼리 수가 늘지 않는다** 를 단언한다 — 그것이 N+1 의 정의다.
|
||||
*
|
||||
* @scenario case=list_query_count
|
||||
*
|
||||
* @effects shop_home_popular_query_count_stable,
|
||||
* shop_home_new_query_count_stable,
|
||||
* shop_home_recent_query_count_stable
|
||||
*/
|
||||
class ShopHomeQueryCountRegressionTest extends ModuleTestCase
|
||||
{
|
||||
use CountsQueries;
|
||||
|
||||
/**
|
||||
* 진열 중인 상품을 원하는 수만큼 만듭니다.
|
||||
*
|
||||
* @param int $count 생성할 수
|
||||
* @param string $prefix 상품 코드 접두
|
||||
* @return array<int, int> 생성된 상품 ID 목록
|
||||
*/
|
||||
private function seedProducts(int $count, string $prefix): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$ids[] = Product::create([
|
||||
'product_code' => $prefix.'-'.$i,
|
||||
'name' => ['ko' => '상품 '.$prefix.$i],
|
||||
'selling_price' => 1000 + $i,
|
||||
'stock_quantity' => 10,
|
||||
'sales_status' => 'on_sale',
|
||||
'display_status' => 'visible',
|
||||
])->id;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 상품 섹션: 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인
|
||||
*
|
||||
* 판매량 집계가 상품마다 상관 서브쿼리로 나가면 여기서 드러난다.
|
||||
*
|
||||
* @effects shop_home_popular_query_count_stable
|
||||
*/
|
||||
public function test_popular_section_query_count_is_stable(): void
|
||||
{
|
||||
$repository = app(ProductRepositoryInterface::class);
|
||||
$this->seedProducts(5, 'HOME-POP-A');
|
||||
|
||||
$this->assertQueryCountStableAsDataGrows(
|
||||
measure: fn () => $repository->getPopularProducts(10),
|
||||
grow: fn () => $this->seedProducts(10, 'HOME-POP-B'),
|
||||
context: '쇼핑 첫 화면 인기 상품',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신상품 섹션: 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인
|
||||
*
|
||||
* @effects shop_home_new_query_count_stable
|
||||
*/
|
||||
public function test_new_section_query_count_is_stable(): void
|
||||
{
|
||||
$repository = app(ProductRepositoryInterface::class);
|
||||
$this->seedProducts(5, 'HOME-NEW-A');
|
||||
|
||||
$this->assertQueryCountStableAsDataGrows(
|
||||
measure: fn () => $repository->getNewProducts(10),
|
||||
grow: fn () => $this->seedProducts(10, 'HOME-NEW-B'),
|
||||
context: '쇼핑 첫 화면 신상품',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 본 상품 섹션: 지목한 상품 수가 늘어도 쿼리 수가 늘지 않는지 확인
|
||||
*
|
||||
* 이 섹션은 화면이 보관한 ID 목록으로 조회한다. ID 마다 단건 조회가 나가면
|
||||
* 목록이 길어질수록 쿼리가 그만큼 늘어난다.
|
||||
*
|
||||
* @effects shop_home_recent_query_count_stable
|
||||
*/
|
||||
public function test_recent_section_query_count_is_stable(): void
|
||||
{
|
||||
$repository = app(ProductRepositoryInterface::class);
|
||||
$ids = $this->seedProducts(5, 'HOME-RECENT-A');
|
||||
|
||||
// measure 와 grow 가 같은 목록을 봐야 한다. 화살표 함수는 정의 시점 값을 붙잡으므로
|
||||
// 참조로 받지 않으면 두 번째 측정이 늘어나기 전 목록을 다시 재게 된다.
|
||||
$this->assertQueryCountStableAsDataGrows(
|
||||
measure: function () use ($repository, &$ids) {
|
||||
return $repository->findByIds($ids);
|
||||
},
|
||||
grow: function () use (&$ids) {
|
||||
$ids = array_merge($ids, $this->seedProducts(10, 'HOME-RECENT-B'));
|
||||
},
|
||||
context: '쇼핑 첫 화면 최근 본 상품',
|
||||
);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Search;
|
||||
|
||||
use App\Search\SearchPagePolicy;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 상품 검색의 커서 딥링크 회귀 테스트 (#519)
|
||||
*
|
||||
* 커서 판정은 코어({@see SearchPagePolicy})가 소유하지만, 그 판정에 페이지
|
||||
* 번호가 도달하지 않으면 규칙이 무력해진다. 기본값 1 이 적용돼 **모든 요청이 첫 페이지로
|
||||
* 판정**되고, `?page=3` 딥링크가 조용히 1 페이지를 돌려준다.
|
||||
*
|
||||
* 응답은 200 이고 형태도 정상이라 기능 테스트로는 드러나지 않는다. 어느 경로를 타는지
|
||||
* 자체를 단언해야 회귀가 잡힌다.
|
||||
*
|
||||
* @scenario case=search_cursor_page_propagation
|
||||
*
|
||||
* @effects search_deep_page_link_keeps_offset,
|
||||
* search_first_page_starts_cursor
|
||||
*/
|
||||
class ProductSearchCursorDeepLinkTest extends ModuleTestCase
|
||||
{
|
||||
private ProductService $productService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->productService = app(ProductService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 없이 깊은 페이지를 지목한 요청이 offset 을 유지하는지 확인
|
||||
*
|
||||
* 커서로 바꿔 버리면 링크가 가리키던 자리를 잃고 첫 페이지가 열린다.
|
||||
*
|
||||
* @effects search_deep_page_link_keeps_offset
|
||||
*/
|
||||
public function test_deep_page_without_cursor_stays_on_offset(): void
|
||||
{
|
||||
// Given/When: 커서 없이 3 페이지를 직접 지목한 최신순 검색
|
||||
$result = $this->productService->searchByKeywordWithCursor(
|
||||
keyword: '커서키워드',
|
||||
sort: 'latest',
|
||||
categoryId: null,
|
||||
perPage: 10,
|
||||
cursor: null,
|
||||
page: 3
|
||||
);
|
||||
|
||||
// Then: 커서 경로를 타지 않는다 (호출자가 offset 경로로 내려간다)
|
||||
$this->assertNull(
|
||||
$result,
|
||||
'커서 없이 깊은 페이지를 지목한 요청은 offset 을 유지해야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 페이지는 커서가 없어도 커서 경로로 시작하는지 확인
|
||||
*
|
||||
* 서버는 커서 모드에서만 다음 커서를 내보낸다. 첫 페이지를 offset 으로 처리하면
|
||||
* 첫 커서가 만들어질 자리가 없어 화면이 영원히 offset 에 머문다.
|
||||
*
|
||||
* @effects search_first_page_starts_cursor
|
||||
*/
|
||||
public function test_first_page_starts_cursor_mode(): void
|
||||
{
|
||||
// Given/When: 커서 없는 1 페이지 최신순 검색
|
||||
$result = $this->productService->searchByKeywordWithCursor(
|
||||
keyword: '커서키워드',
|
||||
sort: 'latest',
|
||||
categoryId: null,
|
||||
perPage: 10,
|
||||
cursor: null,
|
||||
page: 1
|
||||
);
|
||||
|
||||
// Then: 커서 페이지네이터를 돌려준다
|
||||
$this->assertNotNull($result, '첫 페이지는 커서 시작점으로 읽어야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관련도순은 페이지 번호와 무관하게 offset 을 유지하는지 확인
|
||||
*
|
||||
* 계산값(`_ft_score`) 정렬은 WHERE 경계로 쓸 수 없다.
|
||||
*
|
||||
* @effects search_deep_page_link_keeps_offset
|
||||
*/
|
||||
public function test_relevance_sort_never_uses_cursor(): void
|
||||
{
|
||||
foreach ([1, 3] as $page) {
|
||||
$this->assertNull(
|
||||
$this->productService->searchByKeywordWithCursor(
|
||||
keyword: '커서키워드',
|
||||
sort: 'relevance',
|
||||
categoryId: null,
|
||||
perPage: 10,
|
||||
cursor: null,
|
||||
page: $page
|
||||
),
|
||||
$page.' 페이지 관련도순은 커서를 쓸 수 없어야 한다'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature;
|
||||
|
||||
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../ModuleTestCase.php';
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Models\Category;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 통합 조회의 단건 분류 응답 계약 테스트 (#519 F4)
|
||||
*
|
||||
* 분류 화면은 종전에 분류·분류트리·상품목록으로 요청을 세 번 냈다. 이를 통합 조회 하나로
|
||||
* 합치면서 단건 분류를 응답에 얹었다. 옮긴 것은 조립 위치뿐이므로, **개별 엔드포인트와
|
||||
* 같은 리소스·같은 키 집합**이어야 한다. 리소스가 어긋나면 화면 표현식이 조용히 빈 값을
|
||||
* 읽어 제목·설명·하위분류·이미지가 예외 없이 사라진다.
|
||||
*
|
||||
* @scenario case=storefront_category_contract
|
||||
*
|
||||
* @effects storefront_category_matches_single_endpoint,
|
||||
* storefront_without_slug_omits_category,
|
||||
* storefront_unknown_slug_returns_not_found
|
||||
*/
|
||||
class StorefrontCategoryContractTest extends ModuleTestCase
|
||||
{
|
||||
private Category $category;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->category = Category::create([
|
||||
'name' => ['ko' => '통합조회 분류'],
|
||||
'slug' => 'storefront-contract',
|
||||
'depth' => 0,
|
||||
'sort_order' => 1,
|
||||
'is_active' => true,
|
||||
'path' => '',
|
||||
]);
|
||||
|
||||
$this->category->update(['path' => (string) $this->category->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 조회의 분류가 단건 엔드포인트와 같은 키 집합이어야 함
|
||||
*
|
||||
* @effects storefront_category_matches_single_endpoint
|
||||
*/
|
||||
#[Test]
|
||||
public function storefront_category_matches_single_endpoint(): void
|
||||
{
|
||||
$single = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$this->category->slug);
|
||||
$single->assertOk();
|
||||
|
||||
$combined = $this->getJson(
|
||||
'/api/modules/sirsoft-ecommerce/storefront?category_slug='.$this->category->slug
|
||||
);
|
||||
$combined->assertOk();
|
||||
|
||||
$expected = $single->json('data');
|
||||
$actual = $combined->json('data.category');
|
||||
|
||||
$this->assertIsArray($actual, '통합 조회 응답에 분류가 없다 — 분류 화면이 제목조차 못 그린다.');
|
||||
|
||||
$missing = array_diff(array_keys($expected), array_keys($actual));
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missing,
|
||||
'단건 엔드포인트에는 있고 통합 조회에는 없는 필드: '.implode(', ', $missing)
|
||||
);
|
||||
|
||||
$this->assertSame($expected, $actual, '같은 분류인데 두 경로의 응답이 다르다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 분류를 지정하지 않으면 분류 키가 비어야 함 (쇼핑 첫 화면 경로)
|
||||
*
|
||||
* @effects storefront_without_slug_omits_category
|
||||
*/
|
||||
#[Test]
|
||||
public function storefront_without_slug_omits_category(): void
|
||||
{
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/storefront');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertNull($response->json('data.category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 없는 분류를 가리키면 404 여야 함
|
||||
*
|
||||
* 200 으로 빈 화면을 내보내면 검색엔진이 존재하지 않는 분류 주소를 정상 페이지로 수집한다.
|
||||
*
|
||||
* @effects storefront_unknown_slug_returns_not_found
|
||||
*/
|
||||
#[Test]
|
||||
public function storefront_unknown_slug_returns_not_found(): void
|
||||
{
|
||||
$response = $this->getJson(
|
||||
'/api/modules/sirsoft-ecommerce/storefront?category_slug=no-such-category'
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@
|
||||
- 페이지 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 항목의 본문까지 함께 읽어 느려졌지만, 이제 현재 페이지의 항목만 본문을 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 관리자 페이지 목록을 열 때 페이지 본문을 데이터베이스에서 읽지 않습니다. 목록은 제목·주소·발행 여부만 쓰는데 본문까지 함께 읽고 있었습니다. 본문이 긴 페이지가 많은 사이트일수록 목록을 여는 속도와 서버 메모리 사용이 개선되며, 화면에 보이는 내용은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 발행 페이지 목록과 통합검색의 페이지 탭 정렬에 필요한 색인을 추가했습니다. 기존 사이트도 업데이트 시 자동으로 반영됩니다.
|
||||
- 페이지 검색이 조건에 맞는 페이지 전체를 읽어 온 뒤 화면에 보일 만큼만 잘라 내던 방식을 바꿨습니다. 이제 필요한 페이지 분량만 조회하므로, 페이지가 많은 사이트에서 검색이 빨라지고 메모리 사용량이 줄어듭니다.
|
||||
- 검색 결과가 아주 많으면 총 건수를 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다.
|
||||
- 페이지 검색 탭의 배지 숫자도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 배지를 표시하지 않는 화면에서는 그 숫자를 세는 작업 자체를 건너뜁니다.
|
||||
- 관리자 페이지 목록의 총 건수도 일정 규모까지만 정확히 세고 그보다 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다.
|
||||
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
|
||||
@@ -39,13 +41,7 @@
|
||||
- 통합검색에서 페이지 제목으로 찾을 수 없던 문제를 수정했습니다. 한국어·영어 어느 쪽 제목으로도, 제목의 일부만 입력해도 검색됩니다.
|
||||
- 통합검색의 페이지 결과에서 같은 시각에 등록된 페이지가 여러 건일 때 목록 순서가 흔들리던 문제를 수정했습니다.
|
||||
- 페이지 관리 목록에서 뒤쪽 페이지를 보던 중 정렬을 바꾸면 이전 페이지 번호에 그대로 머물러 엉뚱한 구간이 보이던 문제를 수정했습니다. 이제 정렬을 바꾸면 첫 페이지부터 보여줍니다.
|
||||
|
||||
- 페이지 관리 목록에서 페이지가 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다.
|
||||
### Changed
|
||||
|
||||
- 페이지 검색이 조건에 맞는 페이지 전체를 읽어 온 뒤 화면에 보일 만큼만 잘라 내던 방식을 바꿨습니다. 이제 필요한 페이지 분량만 조회하므로, 페이지가 많은 사이트에서 검색이 빨라지고 메모리 사용량이 줄어듭니다.
|
||||
- 검색 결과가 아주 많으면 총 건수를 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다.
|
||||
- 발행 페이지 목록의 정렬 색인을 추가했습니다. 기존 사이트도 업데이트 시 자동으로 반영됩니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
|
||||
@@ -130,9 +130,11 @@ class SearchPagesListener implements HookListenerInterface
|
||||
|
||||
// 커서를 받았고 그 정렬을 커서로 처리할 수 있으면 키셋으로 응답한다.
|
||||
// 전체 탭은 미리보기 몇 건뿐이라 깊은 페이지가 없어 대상이 아니다.
|
||||
// 페이지 번호를 함께 넘겨 커서 없이 깊은 페이지를 지목한 딥링크를 코어가 가려내게
|
||||
// 한다 — 넘기지 않으면 기본값 1 이 적용돼 그 링크가 첫 페이지로 되돌아간다.
|
||||
$cursorPage = $isAllTab
|
||||
? null
|
||||
: $this->pageService->searchByKeywordWithCursor($q, $sort, $perPage, $context['cursor'] ?? null);
|
||||
: $this->pageService->searchByKeywordWithCursor($q, $sort, $perPage, $context['cursor'] ?? null, $pageNumber);
|
||||
|
||||
if ($cursorPage !== null) {
|
||||
$results['pages'] = SearchCategoryPayload::fromCursor(
|
||||
|
||||
@@ -424,17 +424,19 @@ class PageService
|
||||
* @param string $sort 정렬 옵션
|
||||
* @param int $perPage 페이지당 항목 수
|
||||
* @param string|null $cursor 인코딩된 커서 (첫 페이지면 null)
|
||||
* @param int $page 요청 페이지 번호 (커서 없이 깊은 페이지를 지목했는지 판정용)
|
||||
* @return CursorPaginator|null 커서 페이지 결과 (커서 적용 불가 시 null)
|
||||
*/
|
||||
public function searchByKeywordWithCursor(
|
||||
string $keyword,
|
||||
string $sort = 'latest',
|
||||
int $perPage = 10,
|
||||
?string $cursor = null
|
||||
?string $cursor = null,
|
||||
int $page = 1
|
||||
): ?CursorPaginator {
|
||||
$sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP);
|
||||
|
||||
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS)) {
|
||||
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS, $page)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Feature\Search;
|
||||
|
||||
use App\Search\SearchPagePolicy;
|
||||
use Modules\Sirsoft\Page\Services\PageService;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 페이지 검색의 커서 딥링크 회귀 테스트 (#519)
|
||||
*
|
||||
* 커서 판정은 코어({@see SearchPagePolicy})가 소유하지만, 그 판정에 페이지
|
||||
* 번호가 도달하지 않으면 규칙이 무력해진다. 기본값 1 이 적용돼 **모든 요청이 첫 페이지로
|
||||
* 판정**되고, `?page=3` 딥링크가 조용히 1 페이지를 돌려준다.
|
||||
*
|
||||
* 응답은 200 이고 형태도 정상이라 기능 테스트로는 드러나지 않는다.
|
||||
*
|
||||
* @scenario case=search_cursor_page_propagation
|
||||
*
|
||||
* @effects search_deep_page_link_keeps_offset,
|
||||
* search_first_page_starts_cursor
|
||||
*/
|
||||
class PageSearchCursorDeepLinkTest extends ModuleTestCase
|
||||
{
|
||||
private PageService $pageService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->pageService = app(PageService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 없이 깊은 페이지를 지목한 요청이 offset 을 유지하는지 확인
|
||||
*
|
||||
* @effects search_deep_page_link_keeps_offset
|
||||
*/
|
||||
public function test_deep_page_without_cursor_stays_on_offset(): void
|
||||
{
|
||||
// Given/When: 커서 없이 3 페이지를 직접 지목한 최신순 검색
|
||||
$result = $this->pageService->searchByKeywordWithCursor(
|
||||
keyword: '커서키워드',
|
||||
sort: 'latest',
|
||||
perPage: 10,
|
||||
cursor: null,
|
||||
page: 3
|
||||
);
|
||||
|
||||
// Then: 커서 경로를 타지 않는다
|
||||
$this->assertNull(
|
||||
$result,
|
||||
'커서 없이 깊은 페이지를 지목한 요청은 offset 을 유지해야 한다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 페이지는 커서가 없어도 커서 경로로 시작하는지 확인
|
||||
*
|
||||
* @effects search_first_page_starts_cursor
|
||||
*/
|
||||
public function test_first_page_starts_cursor_mode(): void
|
||||
{
|
||||
// Given/When: 커서 없는 1 페이지 최신순 검색
|
||||
$result = $this->pageService->searchByKeywordWithCursor(
|
||||
keyword: '커서키워드',
|
||||
sort: 'latest',
|
||||
perPage: 10,
|
||||
cursor: null,
|
||||
page: 1
|
||||
);
|
||||
|
||||
// Then: 커서 페이지네이터를 돌려준다
|
||||
$this->assertNotNull($result, '첫 페이지는 커서 시작점으로 읽어야 한다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관련도순은 페이지 번호와 무관하게 offset 을 유지하는지 확인
|
||||
*
|
||||
* @effects search_deep_page_link_keeps_offset
|
||||
*/
|
||||
public function test_relevance_sort_never_uses_cursor(): void
|
||||
{
|
||||
foreach ([1, 3] as $page) {
|
||||
$this->assertNull(
|
||||
$this->pageService->searchByKeywordWithCursor(
|
||||
keyword: '커서키워드',
|
||||
sort: 'relevance',
|
||||
perPage: 10,
|
||||
cursor: null,
|
||||
page: $page
|
||||
),
|
||||
$page.' 페이지 관련도순은 커서를 쓸 수 없어야 한다'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
- 거부 후 다시 동의하면 상태가 동의로 갱신되며, 거부·동의 이력은 모두 보존됩니다.
|
||||
- 마이페이지 「쿠키 동의 관리」에 「모두 동의」 버튼 추가 — 동의하지 않은 선택 항목을 한 번에 동의할 수 있습니다.
|
||||
- 자동 차단 정책 안내에 보호 범위 한계를 명시했습니다 — 이 자동 차단은 플러그인·모듈이 등록한 스크립트만 대상이며, 운영자가 템플릿 외부 스크립트 등으로 head 영역에 직접 삽입한 외부 스크립트는 보호 범위 밖임을 안내합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 정책 버전 발행 이력이 20건까지만 보이고 그보다 오래된 이력은 열어볼 방법이 없던 문제를 수정했습니다. 이력 아래에 이전·다음 이동과 총 건수 표시를 추가해 전체 이력을 확인할 수 있습니다.
|
||||
@@ -45,8 +46,8 @@
|
||||
- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용하고, 유효기간(쿠키 자체와 동일하게 1년)도 함께 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음).
|
||||
- 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다.
|
||||
- 관리자 환경설정의 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 문구가 빨간색(경고 색상)으로 표시되던 문제를 바로잡았습니다 — 일반 안내에도 경고 색상이 쓰여 어색하게 강조되던 부분을 다른 관리자 화면과 동일한 색상으로 정리했습니다.
|
||||
|
||||
- 「GDPR 동의 이력」과 정책 버전 발행 이력에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 페이지 이동이 막히던 문제를 수정했습니다. 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
- 총 건수를 끝까지 세지 못한 목록에서 페이지 이동 막대가 사라지거나 "1페이지뿐" 으로 잘못 표시되던 문제를 수정했습니다. 마이페이지의 주문·마일리지·위시리스트·알림함, 게시판 목록, 상품 리뷰·문의, 내가 쓴 글 목록이 해당합니다.
|
||||
- 검색 결과의 탭 배지가 끝까지 세지 못한 건수를 정확한 숫자처럼 보여주던 문제를 수정했습니다. 이제 그 경우 숫자 뒤에 "이상" 표시가 붙습니다.
|
||||
|
||||
#### 검색·봇 화면
|
||||
|
||||
- 통합 검색 결과의 상품 이름이 표시되지 않던 문제를 수정했습니다. 검색어 강조 표시 방식이 잘못 지정되어 상품 이름 자리가 비어 있었습니다.
|
||||
@@ -29,6 +30,7 @@
|
||||
|
||||
- 게시글 목록에서 글을 열어 이전글·다음글로 넘어가면 보고 있던 페이지 번호와 검색어·분류가 사라져, 이어서 '목록'을 누르면 1페이지로 되돌아가던 문제를 수정했습니다. 이제 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 글쓰기·수정 어디로 오가든, 글쓰기를 취소하고 돌아올 때도 보고 있던 목록 상태가 그대로 유지됩니다. 마이페이지 주문·마일리지 목록과 상품 목록(상품 상세에서 목록으로 돌아오는 경우 포함)도 동일하게 개선됐습니다. 검색 초기화와 탭 전환은 종전처럼 조건을 비웁니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 상품 목록에서 상품을 열고 상세의 탭(상세정보·리뷰·문의)을 누르면 보고 있던 페이지·정렬·검색어·분류가 주소에서 사라져, 이어서 '상품 목록'을 눌러도 1페이지로 되돌아가던 문제를 수정했습니다. 이제 탭을 오가도 목록 상태가 그대로 유지됩니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 통합 검색에서 탭(전체·게시글·상품·페이지)·정렬·게시판 필터·페이지를 바꿔도 주소가 그대로여서, 새로고침하거나 뒤로가기를 누르면 검색어만 남고 나머지가 처음 상태로 되돌아가던 문제를 수정했습니다. 이제 보고 있던 검색 결과를 주소로 그대로 공유할 수 있고, 뒤로가기로 직전에 보던 탭·페이지로 되돌아갑니다. 검색어를 새로 입력해 검색하면 종전처럼 조건이 초기화됩니다.
|
||||
|
||||
#### 장바구니·오류 안내
|
||||
- 마이페이지 알림 화면의 "읽지 않은 알림 N건" 이 실제 개수와 무관하게 항상 0건으로 표시되던 문제를 수정했습니다. 헤더 알림 아이콘의 개수와 동일하게 표시됩니다.
|
||||
@@ -56,7 +58,6 @@
|
||||
### Changed
|
||||
|
||||
- 옵션 담기 방식 개선에 따라 이 템플릿은 이커머스 모듈 1.0.5 이상이 필요합니다.
|
||||
- 쇼핑 첫 화면이 필요한 데이터를 한 번에 받도록 바꿨습니다. 화면에 보이는 내용은 동일하며 첫 화면이 뜨는 속도가 빨라집니다.
|
||||
- 검색 결과가 아주 많아 총 건수를 정확히 셀 수 없는 경우, "N건 이상" 으로 표시하고 검색어를 좁히면 정확한 건수를 볼 수 있다는 안내를 함께 보여 줍니다. 이때 마지막 페이지로 바로 뛰는 버튼은 감춰지고 다음 페이지 이동은 그대로 됩니다.
|
||||
- 페이지네이션에서 처음/마지막 버튼을 따로 켜고 끌 수 있게 했습니다.
|
||||
- 목록의 총 건수 정확도 표시를 사용하기 위해 이 템플릿은 이제 코어 7.0.6 이상이 필요합니다.
|
||||
|
||||
+14
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -19,7 +19,12 @@ describe('게시판 스킨 — 블라인드 아이콘 색 패리티', () => {
|
||||
'../../layouts/partials/board/types'
|
||||
);
|
||||
|
||||
const skins = ['basic', 'card', 'gallery'];
|
||||
// 스킨을 열거하지 않고 디렉토리에서 도출한다. 열거하면 스킨이 늘어날 때 그 스킨만
|
||||
// 조용히 검사 밖에 남아, 처음 이 결함이 갤러리에서만 났던 상황이 그대로 재현된다.
|
||||
const skins = readdirSync(typesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && existsSync(resolve(typesDir, entry.name, 'index.json')))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
/**
|
||||
* 스킨 레이아웃에서 eye-slash 아이콘의 className 을 모두 뽑아냅니다.
|
||||
@@ -35,6 +40,13 @@ describe('게시판 스킨 — 블라인드 아이콘 색 패리티', () => {
|
||||
return found;
|
||||
};
|
||||
|
||||
it('스킨 디렉토리에서 모집단이 도출된다', () => {
|
||||
// 디렉토리 스캔이 빗나가면 skins 가 비고, 아래 단언들은 루프를 돌지 않아 전부 통과한다.
|
||||
// 모집단 자체를 먼저 고정해 그 조용한 통과를 막는다.
|
||||
expect(skins.length, '발견된 게시판 스킨').toBeGreaterThanOrEqual(3);
|
||||
expect(skins).toContain('gallery');
|
||||
});
|
||||
|
||||
it('모든 스킨에 블라인드 아이콘이 존재한다', () => {
|
||||
// 모집단이 비면 아래 단언이 조용히 통과한다.
|
||||
for (const skin of skins) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* 통합검색 화면 상태의 SSoT 계약 (#519)
|
||||
*
|
||||
* 탭·게시판필터·정렬·페이지·커서를 전역 상태(`_global.search*`)에만 두면 주소가 바뀌지
|
||||
* 않는다. 그러면 새로고침·뒤로가기에서 전부 초기값으로 돌아가고, 보고 있던 결과를 주소로
|
||||
* 공유할 수도 없다. 화면은 정상으로 보이고 에러도 남지 않아 코드만 봐서는 드러나지 않는다.
|
||||
*
|
||||
* 이 계약은 다음 셋을 고정한다.
|
||||
* 1. 검색 데이터소스는 URL 쿼리를 읽는다 (전역 상태 아님)
|
||||
* 2. 검색 화면 어디에도 `_global.search*` 잔재가 없다
|
||||
* 3. 상태를 바꾸는 이동은 mergeQuery 로 나머지 상태를 승계한다
|
||||
* (검색어를 새로 실행하는 의도적 리셋만 예외이며, 그 사유가 코드에 적혀 있어야 한다)
|
||||
*
|
||||
* 파일을 열거하지 않고 검색 레이아웃 디렉토리를 훑는다. 열거하면 partial 이 늘어날 때
|
||||
* 그 파일만 조용히 검사 밖에 남는다.
|
||||
*/
|
||||
describe('통합검색 — 화면 상태의 SSoT 는 URL 쿼리', () => {
|
||||
const layoutsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../layouts');
|
||||
const indexPath = join(layoutsDir, 'search/index.json');
|
||||
|
||||
/** 디렉토리를 재귀로 훑어 .json 파일 경로를 모읍니다. */
|
||||
const collectJson = (dir: string): string[] => {
|
||||
const found: string[] = [];
|
||||
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
|
||||
if (statSync(full).isDirectory()) {
|
||||
found.push(...collectJson(full));
|
||||
} else if (entry.endsWith('.json')) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const searchFiles = [indexPath, ...collectJson(join(layoutsDir, 'partials/search'))].sort();
|
||||
const read = (path: string) => readFileSync(path, 'utf8');
|
||||
const rel = (path: string) => path.slice(layoutsDir.length + 1).replace(/\\/g, '/');
|
||||
|
||||
/** JSON 안의 모든 액션 노드를 깊이 우선으로 평탄화합니다. */
|
||||
const collectActions = (node: unknown, out: any[] = []): any[] => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) collectActions(child, out);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>;
|
||||
|
||||
if (typeof record.handler === 'string') out.push(record);
|
||||
|
||||
for (const value of Object.values(record)) collectActions(value, out);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
it('검색 레이아웃 모집단이 디렉토리에서 도출된다', () => {
|
||||
// 스캔이 빗나가면 모집단이 비고, 아래 단언들은 루프를 돌지 않아 전부 통과한다.
|
||||
expect(searchFiles.length, '발견된 검색 레이아웃 파일').toBeGreaterThanOrEqual(8);
|
||||
expect(searchFiles.map(rel)).toContain('search/index.json');
|
||||
expect(searchFiles.map(rel)).toContain('partials/search/_search_tabs.json');
|
||||
expect(searchFiles.map(rel)).toContain('partials/search/_search_filters.json');
|
||||
});
|
||||
|
||||
it('검색 데이터소스가 탭·필터·정렬·페이지·커서를 URL 쿼리에서 읽는다', () => {
|
||||
const layout = JSON.parse(read(indexPath));
|
||||
const source = layout.data_sources?.find((ds: any) => ds.id === 'searchResults');
|
||||
|
||||
expect(source, 'searchResults 데이터소스').toBeDefined();
|
||||
|
||||
for (const key of ['type', 'board_slug', 'sort', 'page', 'cursor']) {
|
||||
expect(source.endpoint, `${key} 바인딩`).toContain(`query?.${key}`);
|
||||
}
|
||||
|
||||
expect(source.endpoint, '엔드포인트에 남은 전역 상태').not.toMatch(/_global\.search/);
|
||||
});
|
||||
|
||||
it('검색 화면 어디에도 전역 검색 상태가 남아 있지 않다', () => {
|
||||
const leftovers = searchFiles.filter((path) =>
|
||||
/_global\??\.search(ActiveTab|SortBy|BoardFilter|Page|Cursor)|"search(ActiveTab|SortBy|BoardFilter|Page|Cursor)"/.test(read(path))
|
||||
);
|
||||
|
||||
expect(leftovers.map(rel), '전역 검색 상태 잔재').toEqual([]);
|
||||
});
|
||||
|
||||
it('상태를 바꾸는 이동은 mergeQuery 로 나머지 상태를 승계한다', () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const path of searchFiles) {
|
||||
for (const action of collectActions(JSON.parse(read(path)))) {
|
||||
if (action.handler !== 'navigate') continue;
|
||||
if (action.params?.path !== '/search') continue;
|
||||
if (action.params?.mergeQuery === true) continue;
|
||||
|
||||
// 의도적 리셋은 허용하되, 그 사유가 코드에 남아 있어야 한다.
|
||||
const declared = String(action.comment ?? '').includes(
|
||||
'audit:allow layout-list-context-navigate-merge-query'
|
||||
);
|
||||
|
||||
if (!declared) offenders.push(`${rel(path)} → ${JSON.stringify(action.params?.query ?? {})}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, 'mergeQuery 없이 검색 상태를 덮어쓰는 이동').toEqual([]);
|
||||
});
|
||||
|
||||
it('탭·필터·정렬을 바꾸는 이동은 페이지와 커서를 함께 비운다', () => {
|
||||
const facetKeys = ['type', 'board_slug', 'sort'];
|
||||
const checked: string[] = [];
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const path of searchFiles) {
|
||||
for (const action of collectActions(JSON.parse(read(path)))) {
|
||||
if (action.handler !== 'navigate') continue;
|
||||
if (action.params?.mergeQuery !== true) continue;
|
||||
|
||||
const query = action.params?.query ?? {};
|
||||
|
||||
if (!facetKeys.some((key) => key in query)) continue;
|
||||
|
||||
checked.push(rel(path));
|
||||
|
||||
// 결과 집합이 달라지므로 이전 페이지 번호와 커서를 그대로 들고 가면
|
||||
// 존재하지 않는 페이지나 남의 커서를 서버에 보내게 된다.
|
||||
if (query.page !== '' || query.cursor !== '') {
|
||||
offenders.push(`${rel(path)} → ${JSON.stringify(query)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 검사 대상이 하나도 없으면 위 단언은 아무것도 증명하지 못한다.
|
||||
expect(checked.length, '검사한 탭·필터·정렬 이동').toBeGreaterThanOrEqual(7);
|
||||
expect(offenders, '페이지·커서를 비우지 않는 이동').toEqual([]);
|
||||
});
|
||||
|
||||
it('페이지 이동은 이동 방향에 맞는 커서를 싣는다', () => {
|
||||
const layout = JSON.parse(read(indexPath));
|
||||
const pageChange = collectActions(layout).find((action: any) => action.event === 'onPageChange');
|
||||
|
||||
expect(pageChange, 'onPageChange 액션').toBeDefined();
|
||||
expect(pageChange.handler).toBe('navigate');
|
||||
expect(pageChange.params?.mergeQuery).toBe(true);
|
||||
expect(pageChange.params?.query?.page).toContain('$args[0]');
|
||||
|
||||
const cursor = String(pageChange.params?.query?.cursor ?? '');
|
||||
|
||||
expect(cursor, '앞으로 갈 때의 커서').toContain('next_cursor');
|
||||
expect(cursor, '뒤로 갈 때의 커서').toContain('prev_cursor');
|
||||
// 이동 전 페이지 번호는 URL 에서 읽어야 방향 판정이 성립한다.
|
||||
expect(cursor, '이동 방향 판정 기준').toContain('query?.page');
|
||||
});
|
||||
});
|
||||
+5
-9
@@ -5,7 +5,7 @@
|
||||
* 테스트 대상: templates/.../layouts/shop/index.json
|
||||
*
|
||||
* 검증 항목:
|
||||
* - 목록 데이터소스가 category_id 를 query.category 에 바인딩 (#519 이후 storefront 단일 요청)
|
||||
* - products 데이터소스가 category_id 를 query.category 에 바인딩
|
||||
* - 카테고리 버튼 navigate 가 /products + query.category = cat.id 로 이동 (slug 페이지 이동 금지)
|
||||
* - "전체" 버튼은 query 비움
|
||||
* - 선택 카테고리 active className 조건부 적용
|
||||
@@ -36,14 +36,10 @@ function findNodes(node: any, predicate: (n: any) => boolean, results: any[] = [
|
||||
}
|
||||
|
||||
describe('shop/index 카테고리 필터 (U6②)', () => {
|
||||
it('목록 데이터소스가 category_id 를 query.category 에 바인딩해야 함', () => {
|
||||
const storefront = indexLayout.data_sources.find((ds: any) => ds.id === 'storefront');
|
||||
expect(storefront).toBeDefined();
|
||||
expect(storefront.params.category_id).toBe("{{query.category ?? ''}}");
|
||||
});
|
||||
|
||||
it('화면이 목록을 한 번의 요청으로 받아야 함 (#519 — 종전 5요청 통합)', () => {
|
||||
expect(indexLayout.data_sources.map((ds: any) => ds.id)).toEqual(['storefront']);
|
||||
it('products 데이터소스가 category_id 를 query.category 에 바인딩해야 함', () => {
|
||||
const products = indexLayout.data_sources.find((ds: any) => ds.id === 'products');
|
||||
expect(products).toBeDefined();
|
||||
expect(products.params.category_id).toBe("{{query.category ?? ''}}");
|
||||
});
|
||||
|
||||
it('iteration 카테고리 버튼이 /products 로 이동하며 query.category = cat.id 를 설정해야 함', () => {
|
||||
|
||||
-296
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* @file shop-shared-partial-datasource-contract.test.tsx
|
||||
* @description 공유 partial 이 참조하는 데이터소스가 모든 포함 레이아웃에 존재하는지 전수 검사 (#519 회귀)
|
||||
*
|
||||
* 배경:
|
||||
* shop/index 를 단일 storefront 데이터소스로 통합하면서, 여러 레이아웃이 함께 쓰는
|
||||
* partial 을 index 전용 데이터소스 이름에 결합시켰다. 그 결과 shop/category 의 상품
|
||||
* 그리드와 shop/show 의 인기 상품 섹션이 예외도 404 도 없이 화면에서만 사라졌다.
|
||||
* `?? []` 폴백 때문에 런타임 오류가 나지 않아 어떤 정적 검사에도 걸리지 않는 유형이다.
|
||||
*
|
||||
* 검사 방식:
|
||||
* 특정 파일을 열거하지 않는다. layouts/**\/*.json 전수를 읽어
|
||||
* ① 템플릿 전체의 데이터소스 ID 집합을 만들고
|
||||
* ② partial 이 표현식에서 참조하는 루트 식별자 중 ①에 속한 것만 추려
|
||||
* ③ 그 partial 을 포함하는 모든 레이아웃이 해당 ID 를 실제로 정의하는지 확인한다.
|
||||
* 새 partial/레이아웃이 추가돼도 목록 갱신 없이 자동으로 검사 대상이 된다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const layoutsRoot = path.resolve(__dirname, '../../layouts');
|
||||
|
||||
/** layouts 하위 모든 JSON 을 재귀 수집한다 */
|
||||
function collectLayoutFiles(dir: string, acc: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) collectLayoutFiles(full, acc);
|
||||
else if (entry.name.endsWith('.json')) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** layouts 루트 기준 상대 경로 (partial 참조 문자열과 같은 형태) */
|
||||
function toRelative(file: string): string {
|
||||
return path.relative(layoutsRoot, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
const layoutFiles = collectLayoutFiles(layoutsRoot);
|
||||
const parsed = new Map<string, any>();
|
||||
for (const file of layoutFiles) {
|
||||
parsed.set(toRelative(file), JSON.parse(fs.readFileSync(file, 'utf-8')));
|
||||
}
|
||||
|
||||
/** 노드 트리 전체를 순회하며 문자열 값을 모은다 */
|
||||
function collectStrings(node: any, acc: string[] = []): string[] {
|
||||
if (typeof node === 'string') {
|
||||
acc.push(node);
|
||||
return acc;
|
||||
}
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
for (const value of Object.values(node)) collectStrings(value, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** 이 레이아웃이 정의한 데이터소스 ID */
|
||||
function dataSourceIds(layout: any): Set<string> {
|
||||
return new Set<string>((layout?.data_sources ?? []).map((ds: any) => ds.id).filter(Boolean));
|
||||
}
|
||||
|
||||
/** 상속 체인(extends)까지 포함해 이 레이아웃에서 참조 가능한 데이터소스 ID */
|
||||
function definedDataSources(entry: string, seen = new Set<string>()): Set<string> {
|
||||
const layout = parsed.get(entry);
|
||||
const ids = dataSourceIds(layout);
|
||||
const base = layout?.extends ?? layout?.meta?.extends;
|
||||
if (typeof base === 'string' && !seen.has(base)) {
|
||||
seen.add(base);
|
||||
const baseEntry = base.endsWith('.json') ? base : `${base}.json`;
|
||||
for (const id of definedDataSources(baseEntry, seen)) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** 이 레이아웃이 포함하는 partial 경로 (중첩 partial 은 재귀로 펼친다) */
|
||||
function directPartials(layout: any): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (typeof node.partial === 'string') found.push(node.partial);
|
||||
for (const value of Object.values(node)) {
|
||||
if (value && typeof value === 'object') walk(value);
|
||||
}
|
||||
};
|
||||
walk(layout);
|
||||
return found;
|
||||
}
|
||||
|
||||
function expandPartials(entry: string, seen = new Set<string>()): string[] {
|
||||
const layout = parsed.get(entry);
|
||||
if (!layout) return [];
|
||||
const result: string[] = [];
|
||||
for (const p of directPartials(layout)) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
result.push(p, ...expandPartials(p, seen));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 표현식에서 참조하는 루트 식별자 추출 (`a?.b`, `a.b`, `a[` 모두 a 로 환원) */
|
||||
function rootIdentifiers(text: string): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const bindings = text.match(/\{\{[\s\S]*?\}\}/g) ?? [];
|
||||
for (const binding of bindings) {
|
||||
// `$t:foo.bar` 는 다국어 키다 — 점 표기가 같아도 데이터 참조가 아니다
|
||||
const inner = binding.slice(2, -2).replace(/\$t:[\w.]+/g, '');
|
||||
for (const m of inner.matchAll(/(^|[^\w$.?])([A-Za-z_$][\w$]*)\s*(\??\.|\[)/g)) {
|
||||
ids.add(m[2]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* partial 안에서 자체적으로 이름이 묶이는 식별자.
|
||||
* 반복 렌더링 변수와 화살표 함수 파라미터는 데이터소스와 이름이 겹쳐도 참조가 아니다.
|
||||
*/
|
||||
function locallyBoundNames(partial: any): Set<string> {
|
||||
const bound = new Set<string>();
|
||||
const walk = (node: any) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node.iteration) {
|
||||
if (typeof node.iteration.item_var === 'string') bound.add(node.iteration.item_var);
|
||||
if (typeof node.iteration.index_var === 'string') bound.add(node.iteration.index_var);
|
||||
}
|
||||
for (const value of Object.values(node)) {
|
||||
if (value && typeof value === 'object') walk(value);
|
||||
}
|
||||
};
|
||||
walk(partial);
|
||||
|
||||
for (const text of collectStrings(partial)) {
|
||||
// (a, b) => ... / a => ...
|
||||
for (const m of text.matchAll(/\(([^()]*)\)\s*=>/g)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const name = part.trim().replace(/[^\w$].*$/, '');
|
||||
if (name) bound.add(name);
|
||||
}
|
||||
}
|
||||
for (const m of text.matchAll(/(^|[^\w$.])([A-Za-z_$][\w$]*)\s*=>/g)) bound.add(m[2]);
|
||||
// const x = ... (표현식 내부 지역 변수)
|
||||
for (const m of text.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)/g)) bound.add(m[1]);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
|
||||
/** 부모 레이아웃과 그 부모가 펼치는 모든 partial 에서 이름이 묶이는 식별자 */
|
||||
function boundNamesInTree(entry: string): Set<string> {
|
||||
const names = locallyBoundNames(parsed.get(entry));
|
||||
const base = parsed.get(entry)?.extends;
|
||||
if (typeof base === 'string') {
|
||||
const baseEntry = base.endsWith('.json') ? base : `${base}.json`;
|
||||
for (const n of locallyBoundNames(parsed.get(baseEntry))) names.add(n);
|
||||
for (const p of expandPartials(baseEntry)) {
|
||||
for (const n of locallyBoundNames(parsed.get(p))) names.add(n);
|
||||
}
|
||||
}
|
||||
for (const p of expandPartials(entry)) {
|
||||
for (const n of locallyBoundNames(parsed.get(p))) names.add(n);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// 템플릿 전체에서 한 번이라도 데이터소스 ID 로 쓰인 이름
|
||||
const allDataSourceIds = new Set<string>();
|
||||
for (const layout of parsed.values()) {
|
||||
for (const id of dataSourceIds(layout)) allDataSourceIds.add(id);
|
||||
}
|
||||
|
||||
// partial → 그 partial 을 (직접/간접) 포함하는 최상위 레이아웃 목록
|
||||
const includers = new Map<string, string[]>();
|
||||
for (const [entry, layout] of parsed.entries()) {
|
||||
if (layout?.meta?.is_partial) continue;
|
||||
for (const p of expandPartials(entry)) {
|
||||
if (!includers.has(p)) includers.set(p, []);
|
||||
includers.get(p)!.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 의도적으로 허용하는 조합.
|
||||
* 비회원 주문에는 회원 주소록이 없다 — 저장 주소 목록은 빈 상태 분기로 접히고 직접 입력 폼만 남는
|
||||
* 것이 설계된 동작이다. 조용히 넘기지 않고 여기에 남겨 사유를 코드에 기록한다.
|
||||
*/
|
||||
const ALLOWED_MISSING = new Set<string>([
|
||||
'partials/mypage/orders/_modal_change_address.json → shop/guest_order_show.json → userAddresses',
|
||||
]);
|
||||
|
||||
describe('공유 partial ↔ 데이터소스 계약 (#519)', () => {
|
||||
it('템플릿에 데이터소스와 partial 이 실제로 수집되어야 함 (스캔 자체의 공회전 방지)', () => {
|
||||
expect(allDataSourceIds.size).toBeGreaterThan(10);
|
||||
expect(includers.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('partial 이 참조하는 데이터소스는 그 partial 을 포함하는 모든 레이아웃에 정의되어야 함', () => {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const [partialPath, parents] of includers.entries()) {
|
||||
const partial = parsed.get(partialPath);
|
||||
if (!partial) continue;
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const text of collectStrings(partial)) {
|
||||
for (const id of rootIdentifiers(text)) {
|
||||
if (allDataSourceIds.has(id)) referenced.add(id);
|
||||
}
|
||||
}
|
||||
if (referenced.size === 0) continue;
|
||||
|
||||
for (const parent of parents) {
|
||||
const defined = definedDataSources(parent);
|
||||
// 함께 렌더되는 트리(부모 + 그 부모가 펼치는 모든 partial)에서 이름이 묶이면 참조가 아니다
|
||||
const bound = boundNamesInTree(parent);
|
||||
|
||||
for (const id of referenced) {
|
||||
if (defined.has(id) || bound.has(id)) continue;
|
||||
if (ALLOWED_MISSING.has(`${partialPath} → ${parent} → ${id}`)) continue;
|
||||
violations.push(`${partialPath} 가 "${id}" 를 참조하지만 ${parent} 에 그 데이터소스가 없음`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('쇼핑 섹션 partial 은 데이터소스 이름이 아니라 $computed 를 읽어야 함 (#519 F4)', () => {
|
||||
/**
|
||||
* 섹션 partial(최근 본 상품 / 인기 상품 / 신상품)은 여러 화면이 재사용한다.
|
||||
* 데이터소스 이름에 직결하면 그 이름을 가진 화면에서만 동작하고, 다른 화면에서는
|
||||
* `?? []` 폴백에 걸려 예외도 로그도 없이 섹션이 통째로 사라진다.
|
||||
* 그래서 값 전달은 부모가 정의한 $computed 로만 한다.
|
||||
*/
|
||||
const sectionPartials = Array.from(parsed.keys()).filter(
|
||||
(entry) => entry.startsWith('partials/shop/list/_') && entry.endsWith('_products.json')
|
||||
);
|
||||
|
||||
it('검사 대상 섹션 partial 이 실제로 수집되어야 함', () => {
|
||||
expect(sectionPartials.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it.each(sectionPartials)('%s 이 데이터소스 ID 를 직접 참조하지 않아야 함', (entry) => {
|
||||
const referenced = new Set<string>();
|
||||
for (const text of collectStrings(parsed.get(entry))) {
|
||||
for (const id of rootIdentifiers(text)) {
|
||||
if (allDataSourceIds.has(id)) referenced.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
expect(Array.from(referenced)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(sectionPartials)('%s 이 $computed 로 목록을 받아야 함', (entry) => {
|
||||
const texts = collectStrings(parsed.get(entry)).join('\n');
|
||||
|
||||
expect(texts).toContain('$computed.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('상품 그리드 페이저 — 총 건수가 부정확해도 다음 페이지가 열려 있어야 함 (#519)', () => {
|
||||
const grid = parsed.get('partials/shop/list/_product_grid.json');
|
||||
|
||||
/** 조건에 맞는 노드를 재귀 탐색 */
|
||||
function findNodes(node: any, predicate: (n: any) => boolean, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
if (predicate(node)) acc.push(node);
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) value.forEach((v) => findNodes(v, predicate, acc));
|
||||
else if (value && typeof value === 'object') findNodes(value, predicate, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
it('페이저 노출 조건이 last_page 단독 비교가 아니어야 함 (null 이면 접힌다)', () => {
|
||||
const pager = findNodes(grid, (n) => typeof n.comment === 'string' && n.comment.startsWith('페이지네이션'))[0];
|
||||
expect(pager).toBeDefined();
|
||||
expect(pager.if).toContain('has_more_pages');
|
||||
});
|
||||
|
||||
it('다음 버튼 비활성 판정이 has_more_pages 를 근거로 해야 함', () => {
|
||||
const next = findNodes(grid, (n) => n.comment === '다음 페이지 버튼')[0];
|
||||
expect(next).toBeDefined();
|
||||
expect(next.props.disabled).toContain('has_more_pages');
|
||||
// last_page 와 current_page 를 비교하는 종전 판정이 남아 있으면 null 에서 항상 비활성이 된다
|
||||
expect(next.props.disabled).not.toContain('last_page');
|
||||
});
|
||||
|
||||
it('총 건수가 부정확하면 마지막 페이지 번호를 단정하지 않아야 함', () => {
|
||||
const label = findNodes(grid, (n) => n.comment === '페이지 번호 표시')[0];
|
||||
expect(label).toBeDefined();
|
||||
expect(label.text).not.toMatch(/last_page\s*\?\?\s*1/);
|
||||
});
|
||||
});
|
||||
@@ -157,12 +157,17 @@ describe('단계 E — 통합검색 서브탭 게이트 상태 + posts/pages 본
|
||||
}
|
||||
});
|
||||
|
||||
it('각 서브탭 상태는 query.q + _global.searchActiveTab 을 패치한다 (게이트 경로)', () => {
|
||||
// 탭의 SSoT 가 전역 상태(`_global.searchActiveTab`)에서 URL 쿼리(`query.type`)로 바뀌었다(#519).
|
||||
// 편집기 상태 패치도 같은 경로로 줘야 캔버스에서 서브탭이 실제로 갈린다 — 옛 경로로 두면
|
||||
// 세 변종이 모두 'all' 탭으로 렌더되고, 그 사실이 화면상 오류 없이 조용히 지나간다.
|
||||
it('각 서브탭 상태는 query.q + query.type 을 패치한다 (게이트 경로)', () => {
|
||||
const map: Record<string, string> = { tab_posts: 'posts', tab_products: 'products', tab_pages: 'pages' };
|
||||
for (const [id, tab] of Object.entries(map)) {
|
||||
const init = item(g, id).initialState;
|
||||
expect(init?.query?.q).toBeTruthy();
|
||||
expect(init?.global?.searchActiveTab).toBe(tab);
|
||||
expect(init?.query?.type).toBe(tab);
|
||||
// 옛 경로가 남아 있으면 SSoT 가 둘이 된다.
|
||||
expect(init?.global?.searchActiveTab).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"kind": "route",
|
||||
"match": "/search"
|
||||
},
|
||||
"comment": "검색 결과 영역은 searchResults 데이터소스가 condition `{{query?.q}}` 로 게이트된다(본문 line 62). 검색어가 없으면 결과 영역 자체가 렌더되지 않아 두 상태가 동일해 보이므로, 두 변종 모두 query.q 를 주입해 결과 영역을 띄운 뒤 데이터 유무로 변종을 구분한다. — searchActiveTab(기본 'all') 게이트로 게시글/상품/페이지 서브탭 본체가 분기된다(_search_results.json). 각 서브탭 상태를 추가해 탭별 프레젠테이션을 캔버스에 노출(클릭 불가, 상태 패치로 시뮬레이션). all 탭 posts/pages 섹션 본체는 searchResults.data.{posts,pages}.items 바인딩이라 base 샘플에 해당 배열을 채워 렌더 보장.",
|
||||
"comment": "검색 결과 영역은 searchResults 데이터소스가 condition `{{query?.q}}` 로 게이트된다(본문 line 62). 검색어가 없으면 결과 영역 자체가 렌더되지 않아 두 상태가 동일해 보이므로, 두 변종 모두 query.q 를 주입해 결과 영역을 띄운 뒤 데이터 유무로 변종을 구분한다. — query.type(기본 'all') 게이트로 게시글/상품/페이지 서브탭 본체가 분기된다(_search_results.json). 탭은 전역 상태가 아니라 URL 쿼리가 SSoT 이므로 상태 패치도 query 로 준다. 각 서브탭 상태를 추가해 탭별 프레젠테이션을 캔버스에 노출(클릭 불가, 상태 패치로 시뮬레이션). all 탭 posts/pages 섹션 본체는 searchResults.data.{posts,pages}.items 바인딩이라 base 샘플에 해당 배열을 채워 렌더 보장.",
|
||||
"items": [
|
||||
{
|
||||
"id": "with_results",
|
||||
@@ -78,10 +78,8 @@
|
||||
"label": "$t:editor.state.search_tab_posts",
|
||||
"initialState": {
|
||||
"query": {
|
||||
"q": "티셔츠"
|
||||
},
|
||||
"global": {
|
||||
"searchActiveTab": "posts"
|
||||
"q": "티셔츠",
|
||||
"type": "posts"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -90,10 +88,8 @@
|
||||
"label": "$t:editor.state.search_tab_products",
|
||||
"initialState": {
|
||||
"query": {
|
||||
"q": "티셔츠"
|
||||
},
|
||||
"global": {
|
||||
"searchActiveTab": "products"
|
||||
"q": "티셔츠",
|
||||
"type": "products"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -102,10 +98,8 @@
|
||||
"label": "$t:editor.state.search_tab_pages",
|
||||
"initialState": {
|
||||
"query": {
|
||||
"q": "티셔츠"
|
||||
},
|
||||
"global": {
|
||||
"searchActiveTab": "pages"
|
||||
"q": "티셔츠",
|
||||
"type": "pages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,7 +1163,6 @@
|
||||
"searchResults": "Search Results",
|
||||
"shippingSettings": "Shipping Settings",
|
||||
"stats": "Stats",
|
||||
"storefront": "Storefront (combined)",
|
||||
"termsContent": "Terms Content",
|
||||
"tokenValidation": "Token Validation",
|
||||
"user": "User",
|
||||
|
||||
@@ -1163,7 +1163,6 @@
|
||||
"searchResults": "검색 결과",
|
||||
"shippingSettings": "배송 설정",
|
||||
"stats": "통계",
|
||||
"storefront": "쇼핑 첫 화면 통합 데이터",
|
||||
"termsContent": "이용약관 내용",
|
||||
"tokenValidation": "토큰 검증",
|
||||
"user": "사용자",
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
"meta": { "is_partial": true, "description": "검색 필터 (게시글/상품 탭)" },
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global?.searchActiveTab === 'posts' || _global?.searchActiveTab === 'products') && query?.q}}",
|
||||
"if": "{{(query?.type === 'posts' || query?.type === 'products') && query?.q}}",
|
||||
"props": { "className": "flex flex-wrap gap-2 mb-4" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"if": "{{_global?.searchActiveTab === 'posts'}}",
|
||||
"if": "{{query?.type === 'posts'}}",
|
||||
"props": {
|
||||
"name": "boardFilter",
|
||||
"className": "w-40 px-4 py-2.5 bg-gray-100 dark:bg-gray-700 border-0 rounded-xl text-gray-700 dark:text-gray-200 font-medium focus:ring-2 focus:ring-blue-500 focus:outline-none cursor-pointer",
|
||||
"value": "{{_global?.searchBoardFilter ?? ''}}"
|
||||
"value": "{{query?.board_slug ?? ''}}"
|
||||
},
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:search.filter.all_boards" },
|
||||
@@ -26,23 +26,21 @@
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색 안에서 범위만 좁히는 이동이라 mergeQuery 로 검색어·탭·정렬을 승계한다. 결과 집합이 달라지므로 페이지·커서는 빈 값으로 지운다 — '전체 게시판'(빈 값)을 고르면 board_slug 키 자체가 주소에서 사라진다",
|
||||
"type": "change",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchBoardFilter": "{{$event.target.value}}", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "board_slug": "{{$event.target.value}}", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"if": "{{_global?.searchActiveTab === 'posts'}}",
|
||||
"if": "{{query?.type === 'posts'}}",
|
||||
"props": {
|
||||
"name": "sortBy",
|
||||
"className": "w-32",
|
||||
"value": "{{_global?.searchSortBy ?? 'relevance'}}",
|
||||
"value": "{{query?.sort ?? 'relevance'}}",
|
||||
"options": [
|
||||
{ "value": "relevance", "label": "$t:search.sort.relevance" },
|
||||
{ "value": "latest", "label": "$t:search.sort.latest" },
|
||||
@@ -51,23 +49,21 @@
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 결과 집합의 순서만 바꾸는 이동이라 mergeQuery 로 검색어·탭·게시판필터를 승계한다. 순서가 바뀌면 지금 페이지의 내용이 달라지므로 페이지·커서는 빈 값으로 지운다",
|
||||
"type": "change",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchSortBy": "{{$event.target.value}}", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "sort": "{{$event.target.value}}", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"if": "{{_global?.searchActiveTab === 'products'}}",
|
||||
"if": "{{query?.type === 'products'}}",
|
||||
"props": {
|
||||
"name": "sortBy",
|
||||
"className": "w-32",
|
||||
"value": "{{_global?.searchSortBy ?? 'relevance'}}",
|
||||
"value": "{{query?.sort ?? 'relevance'}}",
|
||||
"options": [
|
||||
{ "value": "relevance", "label": "$t:search.sort.relevance" },
|
||||
{ "value": "latest", "label": "$t:search.sort.latest" },
|
||||
@@ -78,12 +74,10 @@
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 결과 집합의 순서만 바꾸는 이동이라 mergeQuery 로 검색어·탭·게시판필터를 승계한다. 순서가 바뀌면 지금 페이지의 내용이 달라지므로 페이지·커서는 빈 값으로 지운다",
|
||||
"type": "change",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchSortBy": "{{$event.target.value}}", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "sort": "{{$event.target.value}}", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -48,18 +48,12 @@
|
||||
"text": "$t:search.button",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 새 검색 실행 — 검색어를 바꾸면 결과 집합 자체가 달라지므로 이전 탭·정렬·페이지·커서를 물려받지 않는다. mergeQuery 가 없으므로 q 만 남고 나머지 키는 주소에서 사라진다.",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchPage": 1, "searchCursor": "" } },
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 새 검색 실행 — 검색어를 바꾸면 결과 집합 자체가 달라지므로 이전 페이지·필터를 물려받지 않는다.",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "query": { "q": "{{_local.q ?? ''}}" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"comment": "전체 탭: 섹션별 그룹핑",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global.searchActiveTab ?? 'all') === 'all' && (searchResults?.data?.total ?? 0) > 0}}",
|
||||
"if": "{{(query?.type ?? 'all') === 'all' && (searchResults?.data?.total ?? 0) > 0}}",
|
||||
"props": { "className": "space-y-6" },
|
||||
"children": [
|
||||
{ "partial": "partials/search/posts/_section.json" },
|
||||
@@ -20,7 +20,7 @@
|
||||
"comment": "게시글 탭 - 결과 있을 때",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.searchActiveTab === 'posts' && (searchResults?.data?.posts_count ?? 0) > 0}}",
|
||||
"if": "{{query?.type === 'posts' && (searchResults?.data?.posts_count ?? 0) > 0}}",
|
||||
"children": [
|
||||
{ "partial": "partials/search/posts/_list.json" }
|
||||
]
|
||||
@@ -29,7 +29,7 @@
|
||||
"comment": "게시글 탭 - 결과 없을 때",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.searchActiveTab === 'posts' && (searchResults?.data?.posts_count ?? 0) === 0}}",
|
||||
"if": "{{query?.type === 'posts' && (searchResults?.data?.posts_count ?? 0) === 0}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
@@ -48,7 +48,7 @@
|
||||
"comment": "상품 탭 - 결과 있을 때",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.searchActiveTab === 'products' && (searchResults?.data?.products_count ?? 0) > 0}}",
|
||||
"if": "{{query?.type === 'products' && (searchResults?.data?.products_count ?? 0) > 0}}",
|
||||
"props": { "className": "grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4" },
|
||||
"children": [
|
||||
{
|
||||
@@ -65,7 +65,7 @@
|
||||
"comment": "상품 탭 - 결과 없을 때",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.searchActiveTab === 'products' && (searchResults?.data?.products_count ?? 0) === 0}}",
|
||||
"if": "{{query?.type === 'products' && (searchResults?.data?.products_count ?? 0) === 0}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
@@ -84,7 +84,7 @@
|
||||
"comment": "페이지 탭 - 결과 있을 때",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.searchActiveTab === 'pages' && (searchResults?.data?.pages_count ?? 0) > 0}}",
|
||||
"if": "{{query?.type === 'pages' && (searchResults?.data?.pages_count ?? 0) > 0}}",
|
||||
"children": [
|
||||
{ "partial": "partials/search/pages/_section.json" }
|
||||
]
|
||||
@@ -93,7 +93,7 @@
|
||||
"comment": "페이지 탭 - 결과 없을 때",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_global.searchActiveTab === 'pages' && (searchResults?.data?.pages_count ?? 0) === 0}}",
|
||||
"if": "{{query?.type === 'pages' && (searchResults?.data?.pages_count ?? 0) === 0}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"comment": "검색 결과 없음 (전체 탭)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (_global.searchActiveTab ?? 'all') === 'all'}}",
|
||||
"if": "{{query?.q && searchResults?.data && (searchResults?.data?.total ?? 0) === 0 && (query?.type ?? 'all') === 'all'}}",
|
||||
"props": { "className": "py-16 text-center" },
|
||||
"children": [
|
||||
{
|
||||
|
||||
@@ -8,80 +8,72 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": { "className": "{{(_global.searchActiveTab ?? 'all') === 'all' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"props": { "className": "{{(query?.type ?? 'all') === 'all' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "grid", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.all" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{(_global.searchActiveTab ?? 'all') === 'all' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.all_count ?? searchResults.data.total ?? 0) | number}}{{(searchResults.data.all_count_is_exact ?? true) ? '' : '+'}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{(query?.type ?? 'all') === 'all' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.all_count ?? searchResults.data.total ?? 0) | number}}{{(searchResults.data.all_count_is_exact ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 보는 대상만 바꾸는 이동이라 mergeQuery 로 q 를 승계한다. 탭마다 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "all", "searchBoardFilter": "", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": { "className": "{{_global.searchActiveTab === 'posts' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"props": { "className": "{{query?.type === 'posts' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "file-text", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.posts" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'posts' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.posts_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.posts ?? true) ? '' : '+'}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{query?.type === 'posts' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.posts_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.posts ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 보는 대상만 바꾸는 이동이라 mergeQuery 로 q 를 승계한다. 탭마다 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "posts", "searchBoardFilter": "", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "posts", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": { "className": "{{_global.searchActiveTab === 'products' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"props": { "className": "{{query?.type === 'products' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "shopping-bag", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.products" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'products' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.products_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.products ?? true) ? '' : '+'}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{query?.type === 'products' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.products_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.products ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 보는 대상만 바꾸는 이동이라 mergeQuery 로 q 를 승계한다. 탭마다 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "products", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "products", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": { "className": "{{_global.searchActiveTab === 'pages' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"props": { "className": "{{query?.type === 'pages' ? 'px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white rounded-full font-medium' : 'px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full'}}" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Icon", "props": { "name": "file-alt", "size": "sm", "className": "mr-1.5 inline" } },
|
||||
{ "type": "basic", "name": "Span", "text": "$t:search.tabs.pages" },
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{_global.searchActiveTab === 'pages' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.pages_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.pages ?? true) ? '' : '+'}}" }
|
||||
{ "type": "basic", "name": "Span", "props": { "className": "ml-1.5 {{query?.type === 'pages' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}}" }, "text": "{{(searchResults.data.pages_count ?? 0) | number}}{{(searchResults.data.counts_are_exact?.pages ?? true) ? '' : '+'}}" }
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 보는 대상만 바꾸는 이동이라 mergeQuery 로 q 를 승계한다. 탭마다 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "pages", "searchPage": 1, "searchCursor": "" } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "pages", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"comment": "섹션 헤더 (전체 탭에서만 표시)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global.searchActiveTab ?? 'all') === 'all'}}",
|
||||
"if": "{{(query?.type ?? 'all') === 'all'}}",
|
||||
"props": { "className": "flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50" },
|
||||
"children": [
|
||||
{
|
||||
@@ -33,12 +33,10 @@
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 해당 탭 전체 목록으로 넘어가는 이동이라 mergeQuery 로 q 를 승계한다. 탭이 바뀌면 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "pages", "searchPage": 1 } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "pages", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,12 +32,10 @@
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 해당 탭 전체 목록으로 넘어가는 이동이라 mergeQuery 로 q 를 승계한다. 탭이 바뀌면 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "posts", "searchPage": 1 } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "posts", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,12 +32,10 @@
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색어 안에서 해당 탭 전체 목록으로 넘어가는 이동이라 mergeQuery 로 q 를 승계한다. 탭이 바뀌면 결과 집합이 달라지므로 게시판필터·페이지·커서는 빈 값으로 지운다",
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{ "handler": "setState", "params": { "target": "global", "searchActiveTab": "products", "searchPage": 1 } },
|
||||
{ "handler": "refetchDataSource", "params": { "dataSourceId": "searchResults" } }
|
||||
]
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/search", "mergeQuery": true, "query": { "type": "products", "board_slug": "", "page": "", "cursor": "" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "신상품 섹션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.newItems 를 읽는다 (다른 화면이 재사용해도 섹션이 조용히 비지 않는다)"
|
||||
"description": "신상품 섹션"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{($computed.newItems ?? []).length > 0}}",
|
||||
"if": "{{newProducts.data && newProducts.data.length > 0}}",
|
||||
"isolatedState": {
|
||||
"scrollIdx": 0
|
||||
},
|
||||
@@ -69,20 +69,20 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (($computed.newItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (($computed.newItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (newProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (newProducts.data.length)}}"
|
||||
},
|
||||
"responsive": {
|
||||
"0-639": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (($computed.newItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (($computed.newItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (newProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (newProducts.data.length)}}"
|
||||
}
|
||||
},
|
||||
"640-1023": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (($computed.newItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (($computed.newItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (newProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (newProducts.data.length)}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{$computed.newItems ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"iteration": { "source": "{{newProducts.data ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"props": {
|
||||
"id": "new-product-{{idx}}"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "인기 상품 섹션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.popularItems 를 읽는다 (shop/index 와 shop/show 가 함께 쓰는 partial)"
|
||||
"description": "인기 상품 섹션"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{($computed.popularItems ?? []).length > 0}}",
|
||||
"if": "{{popularProducts.data && popularProducts.data.length > 0}}",
|
||||
"isolatedState": {
|
||||
"scrollIdx": 0
|
||||
},
|
||||
@@ -69,20 +69,20 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (($computed.popularItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (($computed.popularItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (popularProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (popularProducts.data.length)}}"
|
||||
},
|
||||
"responsive": {
|
||||
"0-639": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (($computed.popularItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (($computed.popularItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (popularProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (popularProducts.data.length)}}"
|
||||
}
|
||||
},
|
||||
"640-1023": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (($computed.popularItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (($computed.popularItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (popularProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (popularProducts.data.length)}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{$computed.popularItems ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"iteration": { "source": "{{popularProducts.data ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"props": {
|
||||
"id": "popular-product-{{idx}}"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "상품 그리드 + 페이지네이션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.gridProducts / $computed.gridPagination 을 읽는다 (여러 레이아웃이 공유하는 partial)"
|
||||
"description": "상품 그리드 + 페이지네이션 (products 데이터소스 사용, route.path 기반 navigate)"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
@@ -18,7 +18,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "{{$computed.gridProducts ?? []}}",
|
||||
"source": "{{products?.data?.data ?? []}}",
|
||||
"item_var": "product"
|
||||
},
|
||||
"children": [
|
||||
@@ -39,7 +39,7 @@
|
||||
"comment": "빈 상태",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{($computed.gridProducts ?? []).length === 0}}",
|
||||
"if": "{{(products?.data?.data ?? []).length === 0}}",
|
||||
"props": {
|
||||
"className": "flex flex-col items-center justify-center py-20 px-4 my-16"
|
||||
},
|
||||
@@ -81,10 +81,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "페이지네이션 — 총 건수가 부정확하면 last_page 가 null 이므로 has_more_pages 로 판정한다",
|
||||
"comment": "페이지네이션 — 총 건수가 상한에 걸리면 last_page 가 null 로 온다. 그때 last_page 만 보고 판단하면 페이저가 통째로 사라져 1페이지 밖 상품에 도달할 방법이 없어지므로, has_more_pages 와 현재 페이지도 함께 본다",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(($computed.gridPagination?.last_page ?? 0) > 1) || ($computed.gridPagination?.has_more_pages ?? false) || (($computed.gridPagination?.current_page ?? 1) > 1)}}",
|
||||
"if": "{{((products?.data?.pagination?.last_page ?? 0) > 1) || (products?.data?.pagination?.has_more_pages ?? false) || ((products?.data?.pagination?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"className": "flex justify-center items-center gap-2 mb-12"
|
||||
},
|
||||
@@ -94,8 +94,8 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{($computed.gridPagination?.current_page ?? 1) <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'}}",
|
||||
"disabled": "{{($computed.gridPagination?.current_page ?? 1) <= 1}}"
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{(products?.data?.pagination?.current_page ?? 1) <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer'}}",
|
||||
"disabled": "{{(products?.data?.pagination?.current_page ?? 1) <= 1}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -115,28 +115,28 @@
|
||||
"path": "{{route.path}}",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": "{{($computed.gridPagination?.current_page ?? 1) - 1}}"
|
||||
"page": "{{(products?.data?.pagination?.current_page ?? 1) - 1}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "페이지 번호 표시",
|
||||
"comment": "페이지 번호 표시 — 마지막 페이지를 모를 때(총 건수 상한 초과) 1 로 채우면 '1 / 1' 로 굳어 끝난 것처럼 보인다. 그때는 현재 페이지만 표기한다",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "px-3 py-1 text-sm text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "{{$computed.gridPagination?.last_page ? (($computed.gridPagination?.current_page ?? 1) + ' / ' + $computed.gridPagination.last_page) : ($computed.gridPagination?.current_page ?? 1)}}"
|
||||
"text": "{{products?.data?.pagination?.last_page ? ((products?.data?.pagination?.current_page ?? 1) + ' / ' + products.data.pagination.last_page) : (products?.data?.pagination?.current_page ?? 1)}}"
|
||||
},
|
||||
{
|
||||
"comment": "다음 페이지 버튼",
|
||||
"comment": "다음 페이지 버튼 — 이동 가능 여부는 총 건수와 무관하게 has_more_pages 가 정확히 말해 준다. last_page 로 판정하면 그 값이 null 인 목록에서 첫 페이지부터 비활성이 된다",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{($computed.gridPagination?.has_more_pages ?? false) ? 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer' : 'opacity-50 cursor-not-allowed'}}",
|
||||
"disabled": "{{!($computed.gridPagination?.has_more_pages ?? false)}}"
|
||||
"className": "p-2 border border-gray-300 dark:border-gray-600 rounded-lg {{(products?.data?.pagination?.has_more_pages ?? false) ? 'hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer' : 'opacity-50 cursor-not-allowed'}}",
|
||||
"disabled": "{{!(products?.data?.pagination?.has_more_pages ?? false)}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -156,7 +156,7 @@
|
||||
"path": "{{route.path}}",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": "{{($computed.gridPagination?.current_page ?? 1) + 1}}"
|
||||
"page": "{{(products?.data?.pagination?.current_page ?? 1) + 1}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "최근 본 상품 섹션. 데이터소스 이름에 직접 결합하지 않고 부모 레이아웃이 정의한 $computed.recentItems 를 읽는다 (다른 화면이 재사용해도 섹션이 조용히 비지 않는다)"
|
||||
"description": "최근 본 상품 섹션"
|
||||
},
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{($computed.recentItems ?? []).length > 0}}",
|
||||
"if": "{{recentProducts.data && recentProducts.data.length > 0}}",
|
||||
"isolatedState": {
|
||||
"scrollIdx": 0
|
||||
},
|
||||
@@ -69,20 +69,20 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (($computed.recentItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (($computed.recentItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 4) >= (recentProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 4) >= (recentProducts.data.length)}}"
|
||||
},
|
||||
"responsive": {
|
||||
"0-639": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (($computed.recentItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (($computed.recentItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 2) >= (recentProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 2) >= (recentProducts.data.length)}}"
|
||||
}
|
||||
},
|
||||
"640-1023": {
|
||||
"props": {
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (($computed.recentItems ?? []).length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (($computed.recentItems ?? []).length)}}"
|
||||
"className": "{{(_isolated.scrollIdx + 3) >= (recentProducts.data.length) ? 'p-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed' : 'p-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700'}}",
|
||||
"disabled": "{{(_isolated.scrollIdx + 3) >= (recentProducts.data.length)}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,7 +125,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{$computed.recentItems ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"iteration": { "source": "{{recentProducts.data ?? []}}", "item_var": "product", "index_var": "idx" },
|
||||
"props": {
|
||||
"id": "recent-product-{{idx}}"
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "className": "text-sm font-semibold text-gray-700 dark:text-gray-300" },
|
||||
"text": "$t:shop.total_count|count={{storefront?.data?.products?.pagination?.total ?? 0}}"
|
||||
"text": "$t:shop.total_count|count={{products?.data?.pagination?.total ?? 0}}"
|
||||
},
|
||||
{
|
||||
"comment": "오른쪽: 초기화 버튼 + 정렬",
|
||||
|
||||
@@ -35,29 +35,19 @@
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"searchActiveTab": "all",
|
||||
"searchBoardFilter": "",
|
||||
"searchSortBy": "relevance",
|
||||
"searchPage": 1, "searchCursor": "",
|
||||
"_local": {
|
||||
"q": "{{query?.q || ''}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"initGlobal": {
|
||||
"searchActiveTab": "all",
|
||||
"searchBoardFilter": "",
|
||||
"searchSortBy": "relevance",
|
||||
"searchPage": 1, "searchCursor": ""
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "searchResults",
|
||||
"label_key": "$t:editor.data_source.searchResults",
|
||||
"type": "api",
|
||||
"comment": "cursor 는 커서로 넘길 수 있는 정렬(최신순 등)에서 이전/다음 이동 시 채워진다. 비어 있으면 서버가 첫 페이지로 읽고 다음 커서를 발급한다 — 관련도순은 계산값 정렬이라 서버가 커서를 발급하지 않고 page 로만 움직인다",
|
||||
"endpoint": "/api/search?q={{query?.q}}&type={{_global.searchActiveTab ?? 'all'}}&board_slug={{_global.searchBoardFilter}}&sort={{_global.searchSortBy ?? 'relevance'}}&page={{_global.searchPage ?? 1}}&cursor={{_global.searchCursor ?? ''}}&per_page={{(_global.searchActiveTab === 'products') ? 12 : 10}}",
|
||||
"comment": "탭·게시판필터·정렬·페이지·커서는 전역 상태가 아니라 URL 쿼리를 읽는다. 전역 상태에 두면 주소가 바뀌지 않아 새로고침·뒤로가기에서 전부 초기값으로 돌아가고 결과를 그대로 공유할 수도 없다. cursor 는 커서로 넘길 수 있는 정렬(최신순 등)에서 이전/다음 이동 시 채워진다. 비어 있으면 서버가 첫 페이지로 읽고 다음 커서를 발급한다 — 관련도순은 계산값 정렬이라 서버가 커서를 발급하지 않고 page 로만 움직인다",
|
||||
"endpoint": "/api/search?q={{query?.q}}&type={{query?.type ?? 'all'}}&board_slug={{query?.board_slug ?? ''}}&sort={{query?.sort ?? 'relevance'}}&page={{query?.page ?? 1}}&cursor={{query?.cursor ?? ''}}&per_page={{(query?.type === 'products') ? 12 : 10}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
@@ -151,41 +141,27 @@
|
||||
"comment": "페이지네이션 — 총 건수가 상한에 걸리면 last_page 가 null 로 온다. 그때는 페이지 번호 목록과 마지막 페이지 점프가 사라지고 이전/다음만 남으며, 다음 이동은 has_more_pages 로 끝까지 열려 있다",
|
||||
"type": "composite",
|
||||
"name": "Pagination",
|
||||
"if": "{{query?.q && (_global.searchActiveTab ?? 'all') !== 'all' && ((searchResults?.data?.last_page ?? 0) > 1 || searchResults?.data?.has_more_pages === true || (searchResults?.data?.current_page ?? 1) > 1)}}",
|
||||
"if": "{{query?.q && (query?.type ?? 'all') !== 'all' && ((searchResults?.data?.last_page ?? 0) > 1 || searchResults?.data?.has_more_pages === true || (searchResults?.data?.current_page ?? 1) > 1)}}",
|
||||
"props": {
|
||||
"comment": "커서로 넘길 때 서버는 페이지 번호를 세지 않으므로 표시용 번호는 화면이 센다",
|
||||
"currentPage": "{{_global.searchPage ?? searchResults?.data?.current_page ?? 1}}",
|
||||
"currentPage": "{{Number(query?.page ?? searchResults?.data?.current_page ?? 1)}}",
|
||||
"totalPages": "{{searchResults?.data?.last_page ?? null}}",
|
||||
"hasMorePages": "{{searchResults?.data?.has_more_pages ?? false}}",
|
||||
"className": "mt-6 flex justify-center"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"comment": "같은 검색 안에서의 페이지 이동이라 mergeQuery 로 검색어·탭·정렬·게시판필터를 그대로 승계한다. 커서는 이동 방향으로 정한다 — 이 시점의 query.page 는 아직 이동 전 값이라 앞뒤를 알 수 있고, 서버가 커서를 발급하지 않는 정렬(관련도순)에서는 빈 값이 되어 키가 지워지고 page 로만 움직인다",
|
||||
"event": "onPageChange",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "커서를 먼저 정한다 — 이 시점의 _global.searchPage 는 아직 이동 전 값이라 앞뒤 방향을 알 수 있다. 서버가 커서를 발급하지 않는 정렬(관련도순)에서는 빈 값이 되어 page 로만 움직인다",
|
||||
"handler": "setState",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"searchCursor": "{{$args[0] > (_global.searchPage ?? 1) ? (searchResults?.data?.next_cursor ?? '') : ($args[0] < (_global.searchPage ?? 1) ? (searchResults?.data?.prev_cursor ?? '') : '')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"searchPage": "{{$args[0]}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "searchResults"
|
||||
"path": "/search",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": "{{$args[0]}}",
|
||||
"cursor": "{{$args[0] > Number(query?.page ?? 1) ? (searchResults?.data?.next_cursor ?? '') : ($args[0] < Number(query?.page ?? 1) ? (searchResults?.data?.prev_cursor ?? '') : '')}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,24 +3,20 @@
|
||||
"layout_name": "shop/category",
|
||||
"permissions": [],
|
||||
"extends": "_user_base",
|
||||
"computed": {
|
||||
"gridProducts": "{{storefront?.data?.products?.data ?? []}}",
|
||||
"gridPagination": "{{storefront?.data?.products?.pagination ?? null}}"
|
||||
},
|
||||
"meta": {
|
||||
"title": "{{storefront?.data?.category?.name_localized ?? $t:shop.categories}}",
|
||||
"description": "{{storefront?.data?.category?.description_localized ?? ''}}",
|
||||
"title": "{{category?.data?.name_localized ?? $t:shop.categories}}",
|
||||
"description": "{{category?.data?.description_localized ?? ''}}",
|
||||
"seo": {
|
||||
"enabled": true,
|
||||
"extensions": [
|
||||
{ "type": "module", "id": "sirsoft-ecommerce" }
|
||||
],
|
||||
"data_sources": ["storefront"],
|
||||
"data_sources": ["category", "products"],
|
||||
"page_type": "category",
|
||||
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_category",
|
||||
"vars": {
|
||||
"category_name": "{{storefront?.data?.category?.name ?? ''}}",
|
||||
"category_description": "{{storefront?.data?.category?.description ?? ''}}",
|
||||
"category_name": "{{category.data.name ?? ''}}",
|
||||
"category_description": "{{category.data.description ?? ''}}",
|
||||
"site_name": "$core_settings:general.site_name",
|
||||
"shopBase": "$module_settings:sirsoft-ecommerce:basic_info.route_path"
|
||||
},
|
||||
@@ -28,48 +24,61 @@
|
||||
"changefreq": "weekly",
|
||||
"og": {
|
||||
"type": "website",
|
||||
"title": "{{_seo.category.title ?? storefront?.data?.category?.name ?? ''}}",
|
||||
"description": "{{_seo.category.description ?? storefront?.data?.category?.description ?? ''}}"
|
||||
"title": "{{_seo.category.title ?? category.data.name ?? ''}}",
|
||||
"description": "{{_seo.category.description ?? category.data.description ?? ''}}"
|
||||
},
|
||||
"structured_data": {
|
||||
"@type": "ItemList",
|
||||
"name": "{{_seo.category.title ?? storefront?.data?.category?.name ?? ''}}"
|
||||
"name": "{{_seo.category.title ?? category.data.name ?? ''}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "storefront",
|
||||
"label_key": "$t:editor.data_source.storefront",
|
||||
"comment": "보고 있는 분류와 그 분류의 상품 목록을 한 번에 받는다. 종전에는 분류·분류트리·상품목록으로 요청이 3번 나갔고, 그중 분류트리는 이 화면의 어떤 표현식도 읽지 않는 낭비였다. 각 묶음의 응답 형태는 개별 엔드포인트와 동일하다.",
|
||||
"id": "category",
|
||||
"label_key": "$t:editor.data_source.category",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/storefront",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/categories/{{route.slug}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "categories",
|
||||
"label_key": "$t:editor.data_source.categories",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/categories",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "products",
|
||||
"label_key": "$t:editor.data_source.products",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
"page": "{{query.page ?? 1}}",
|
||||
"per_page": 12,
|
||||
"category_slug": "{{route.slug}}",
|
||||
"sort": "{{query.sort ?? 'latest'}}",
|
||||
"search": "{{query.keyword ?? ''}}",
|
||||
"popular_limit": 0,
|
||||
"new_limit": 0
|
||||
"search": "{{query.keyword ?? ''}}"
|
||||
},
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": {
|
||||
"category": null,
|
||||
"products": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": null,
|
||||
"last_page": 1,
|
||||
"per_page": 12,
|
||||
"total": 0,
|
||||
"has_more_pages": false
|
||||
}
|
||||
}
|
||||
"total": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +126,7 @@
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{(storefront?.data?.category?.breadcrumb ?? []).length > 1 ? _global.shopBase + '/category/' + (storefront?.data?.category?.breadcrumb ?? []).slice(-2, -1)[0]?.slug : _global.shopBase + '/products'}}"
|
||||
"path": "{{(category?.data?.breadcrumb ?? []).length > 1 ? _global.shopBase + '/category/' + (category?.data?.breadcrumb ?? []).slice(-2, -1)[0]?.slug : _global.shopBase + '/products'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -125,7 +134,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{storefront?.data?.category?.name_localized ?? $t:shop.categories}}"
|
||||
"text": "{{category?.data?.name_localized ?? $t:shop.categories}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -140,11 +149,11 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"if": "{{!!storefront?.data?.category?.description_localized}}",
|
||||
"if": "{{!!category?.data?.description_localized}}",
|
||||
"props": {
|
||||
"className": "text-gray-600 dark:text-gray-400"
|
||||
},
|
||||
"text": "{{storefront?.data?.category?.description_localized ?? ''}}"
|
||||
"text": "{{category?.data?.description_localized ?? ''}}"
|
||||
},
|
||||
{
|
||||
"comment": "브레드크럼 (전체 > 경로 > 현재)",
|
||||
@@ -167,7 +176,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "{{storefront?.data?.category?.breadcrumb ?? []}}",
|
||||
"source": "{{category?.data?.breadcrumb ?? []}}",
|
||||
"item_var": "crumb"
|
||||
},
|
||||
"children": [
|
||||
@@ -219,7 +228,7 @@
|
||||
"comment": "카테고리 배너 이미지",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(storefront?.data?.category?.images ?? []).length > 0}}",
|
||||
"if": "{{(category?.data?.images ?? []).length > 0}}",
|
||||
"props": {
|
||||
"className": "mb-6 rounded-lg overflow-hidden"
|
||||
},
|
||||
@@ -228,8 +237,8 @@
|
||||
"type": "basic",
|
||||
"name": "Img",
|
||||
"props": {
|
||||
"src": "{{storefront?.data?.category?.images?.[0]?.download_url ?? ''}}",
|
||||
"alt": "{{storefront?.data?.category?.images?.[0]?.alt_text ?? storefront?.data?.category?.name_localized ?? ''}}",
|
||||
"src": "{{category?.data?.images?.[0]?.download_url ?? ''}}",
|
||||
"alt": "{{category?.data?.images?.[0]?.alt_text ?? category?.data?.name_localized ?? ''}}",
|
||||
"className": "w-full h-auto object-cover max-h-64"
|
||||
}
|
||||
}
|
||||
@@ -298,13 +307,13 @@
|
||||
"comment": "하위 카테고리 칩 (자식 카테고리가 있을 때만 표시)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(storefront?.data?.category?.children ?? []).length > 0}}",
|
||||
"if": "{{(category?.data?.children ?? []).length > 0}}",
|
||||
"props": { "className": "flex flex-wrap gap-2 mb-4" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": { "source": "{{storefront?.data?.category?.children ?? []}}", "item_var": "sub" },
|
||||
"iteration": { "source": "{{category?.data?.children ?? []}}", "item_var": "sub" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -335,7 +344,7 @@
|
||||
"props": {
|
||||
"className": "text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "$t:shop.total_count|count={{storefront?.data?.products?.pagination?.total ?? 0}}"
|
||||
"text": "$t:shop.total_count|count={{products?.data?.pagination?.total ?? 0}}"
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
|
||||
@@ -3,25 +3,13 @@
|
||||
"layout_name": "shop/index",
|
||||
"permissions": [],
|
||||
"extends": "_user_base",
|
||||
"computed": {
|
||||
"gridProducts": "{{storefront?.data?.products?.data ?? []}}",
|
||||
"gridPagination": "{{storefront?.data?.products?.pagination ?? null}}",
|
||||
"popularItems": "{{storefront?.data?.popular_products ?? []}}",
|
||||
"recentItems": "{{storefront?.data?.recent_products ?? []}}",
|
||||
"newItems": "{{storefront?.data?.new_products ?? []}}"
|
||||
},
|
||||
"meta": {
|
||||
"seo": {
|
||||
"enabled": true,
|
||||
"extensions": [
|
||||
{
|
||||
"type": "module",
|
||||
"id": "sirsoft-ecommerce"
|
||||
}
|
||||
],
|
||||
"data_sources": [
|
||||
"storefront"
|
||||
{ "type": "module", "id": "sirsoft-ecommerce" }
|
||||
],
|
||||
"data_sources": ["products", "categories", "popularProducts", "newProducts"],
|
||||
"page_type": "shop_index",
|
||||
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_shop_index",
|
||||
"vars": {
|
||||
@@ -74,11 +62,22 @@
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "storefront",
|
||||
"label_key": "$t:editor.data_source.storefront",
|
||||
"comment": "분류·상품목록·최근본·인기·신상품을 한 번에 받는다. 종전에는 같은 화면에 요청이 5번 나갔고 그 5번이 전부 같은 부팅을 되풀이했다. 각 묶음의 응답 형태는 개별 엔드포인트와 동일하다.",
|
||||
"id": "categories",
|
||||
"label_key": "$t:editor.data_source.categories",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/storefront",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/categories",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "products",
|
||||
"label_key": "$t:editor.data_source.products",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
@@ -86,29 +85,60 @@
|
||||
"per_page": 12,
|
||||
"category_id": "{{query.category ?? ''}}",
|
||||
"sort": "{{query.sort ?? 'latest'}}",
|
||||
"search": "{{query.keyword ?? ''}}",
|
||||
"popular_limit": 8,
|
||||
"new_limit": 8,
|
||||
"recent_ids": "{{(_global.recentProductIds ?? []).join(',')}}"
|
||||
"search": "{{query.keyword ?? ''}}"
|
||||
},
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": {
|
||||
"categories": [],
|
||||
"products": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 12,
|
||||
"total": 0
|
||||
}
|
||||
},
|
||||
"recent_products": [],
|
||||
"popular_products": [],
|
||||
"new_products": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "recentProducts",
|
||||
"label_key": "$t:editor.data_source.recentProducts",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/recent",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"params": {
|
||||
"ids": "{{(_global.recentProductIds ?? []).join(',')}}"
|
||||
},
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "popularProducts",
|
||||
"label_key": "$t:editor.data_source.popularProducts",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/popular?limit=8",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "newProducts",
|
||||
"label_key": "$t:editor.data_source.newProducts",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/new?limit=8",
|
||||
"method": "GET",
|
||||
"auth_mode": "optional",
|
||||
"auto_fetch": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": []
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -191,9 +221,7 @@
|
||||
"comment": "최상위 카테고리 버튼 (전체 + 1단계 카테고리)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-wrap gap-2 mb-4"
|
||||
},
|
||||
"props": { "className": "flex flex-wrap gap-2 mb-4" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -203,24 +231,13 @@
|
||||
},
|
||||
"text": "$t:shop.category.all",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 '전체' 선택 — 카테고리 필터를 푸는 의도적 리셋이므로 병합하지 않는다",
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{_global.shopBase}}/products",
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
{ "comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 '전체' 선택 — 카테고리 필터를 푸는 의도적 리셋이므로 병합하지 않는다", "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products", "query": {} } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "{{storefront?.data?.categories ?? []}}",
|
||||
"item_var": "cat"
|
||||
},
|
||||
"iteration": { "source": "{{categories?.data ?? []}}", "item_var": "cat" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -230,18 +247,7 @@
|
||||
},
|
||||
"text": "{{cat?.name_localized}}",
|
||||
"actions": [
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 전환 — 다른 목록으로의 이동이라 이전 카테고리의 검색·정렬 상태를 승계하지 않는다",
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{_global.shopBase}}/products",
|
||||
"query": {
|
||||
"category": "{{cat?.id}}",
|
||||
"page": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
{ "comment": "audit:allow layout-list-context-navigate-merge-query 카테고리 전환 — 다른 목록으로의 이동이라 이전 카테고리의 검색·정렬 상태를 승계하지 않는다", "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products", "query": { "category": "{{cat?.id}}", "page": "1" } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+17
-16
@@ -63,7 +63,7 @@ const pagesSectionFixture = {
|
||||
// 섹션 헤더 (all 탭에서만)
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
if: '{{(_global.searchActiveTab ?? "all") === "all"}}',
|
||||
if: '{{(query?.type ?? "all") === "all"}}',
|
||||
props: { 'data-testid': 'section-header' },
|
||||
children: [
|
||||
{ type: 'basic', name: 'Icon', props: { name: 'file-alt' } },
|
||||
@@ -80,11 +80,12 @@ const pagesSectionFixture = {
|
||||
actions: [
|
||||
{
|
||||
type: 'click',
|
||||
handler: 'sequence',
|
||||
actions: [
|
||||
{ handler: 'setState', params: { target: 'global', searchActiveTab: 'pages', searchPage: 1 } },
|
||||
{ handler: 'refetchDataSource', params: { dataSourceId: 'searchResults' } },
|
||||
],
|
||||
handler: 'navigate',
|
||||
params: {
|
||||
path: '/search',
|
||||
mergeQuery: true,
|
||||
query: { type: 'pages', board_slug: '', page: '', cursor: '' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -194,7 +195,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('all 탭에서 섹션 헤더가 표시된다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
initialData: { searchResults: { data: { pages_count: 0, pages: { items: [] } } } },
|
||||
});
|
||||
|
||||
@@ -208,7 +209,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('pages 탭에서는 섹션 헤더가 표시되지 않는다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'pages' } },
|
||||
queryParams: { type: 'pages' },
|
||||
initialData: { searchResults: { data: { pages_count: 3, pages: { items: [makeFakePageItem()] } } } },
|
||||
});
|
||||
|
||||
@@ -219,10 +220,10 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('searchActiveTab 미설정 시 all 탭으로 간주하여 헤더가 표시된다', async () => {
|
||||
it('query.type 미설정 시 all 탭으로 간주하여 헤더가 표시된다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: {} },
|
||||
queryParams: {},
|
||||
initialData: { searchResults: { data: { pages_count: 0, pages: { items: [] } } } },
|
||||
});
|
||||
|
||||
@@ -238,7 +239,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('pages_count가 0이면 전체보기 버튼이 표시되지 않는다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
initialData: { searchResults: { data: { pages_count: 0, pages: { items: [] } } } },
|
||||
});
|
||||
|
||||
@@ -252,7 +253,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('pages_count가 1 이상이면 전체보기 버튼이 표시된다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
initialData: { searchResults: { data: { pages_count: 3, pages: { items: [makeFakePageItem()] } } } },
|
||||
});
|
||||
|
||||
@@ -268,7 +269,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('아이템이 있으면 페이지 목록이 렌더링된다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
initialData: {
|
||||
searchResults: {
|
||||
data: {
|
||||
@@ -290,7 +291,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('아이템이 없으면 빈 상태 안내가 표시된다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
initialData: { searchResults: { data: { pages_count: 0, pages: { items: [] } } } },
|
||||
});
|
||||
|
||||
@@ -306,7 +307,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
const item = makeFakePageItem({ url: '/page/terms' });
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
initialData: { searchResults: { data: { pages_count: 1, pages: { items: [item] } } } },
|
||||
});
|
||||
|
||||
@@ -325,7 +326,7 @@ describe('partials/search/pages/_section.json 렌더링 (Issue #99)', () => {
|
||||
it('searchResults 데이터가 없어도 에러 없이 렌더링된다', async () => {
|
||||
const testUtils = createLayoutTest(pagesSectionFixture, {
|
||||
componentRegistry: registry,
|
||||
initialState: { _global: { searchActiveTab: 'all' } },
|
||||
queryParams: { type: 'all' },
|
||||
});
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Extension;
|
||||
|
||||
use App\Console\Commands\Core\CoreUpdateCommand;
|
||||
use App\Console\Commands\Core\ExecuteUpgradeStepsCommand;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 라우트 소스가 바뀌는 지점에서 라우트 캐시를 갱신하는지에 대한 회귀 테스트.
|
||||
*
|
||||
* 확장은 자기 라우트를 부팅 시 등록하지만, `route:cache` 가 걸린 사이트는 캐시에
|
||||
* 직렬화된 라우트만 서빙한다. 확장을 설치·업데이트해 라우트가 늘어도 캐시를 갱신하지
|
||||
* 않으면 그 라우트는 존재하지 않는 것과 같고, 오류 없이 404 만 난다.
|
||||
*
|
||||
* 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없다는 점이 이 회귀의 핵심이다.
|
||||
*/
|
||||
class ExtensionRouteCacheInvalidationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function 헬퍼는_언제나_stale_캐시를_먼저_제거한다(): void
|
||||
{
|
||||
Artisan::spy();
|
||||
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 재생성 여부와 무관하게 낡은 캐시는 남기지 않는다 — 낡은 캐시는
|
||||
// "방금 설치한 확장이 통째로 없는" 상태를 만든다.
|
||||
Artisan::shouldHaveReceived('call')->with('route:clear')->once();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 테스트_환경에서는_캐시를_생성하지_않는다(): void
|
||||
{
|
||||
Artisan::spy();
|
||||
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 캐시 생성은 같은 프로세스의 뒤따르는 테스트로 라우트가 새어 격리를 깬다.
|
||||
Artisan::shouldNotHaveReceived('call', ['route:cache']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clear_는_재생성_없이_비우기만_한다(): void
|
||||
{
|
||||
Artisan::spy();
|
||||
|
||||
RouteCacheHelper::clear();
|
||||
|
||||
Artisan::shouldHaveReceived('call')->with('route:clear')->once();
|
||||
Artisan::shouldNotHaveReceived('call', ['route:cache']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 라우트가_바뀌는_모든_지점이_갱신을_호출한다(): void
|
||||
{
|
||||
// 개별 지점을 손으로 열거하면 새 수명주기 메서드가 생겼을 때 그 지점만 조용히
|
||||
// 빠진다. 소스를 훑어 "라우트가 바뀌는 지점" 전부가 갱신을 부르는지 본다.
|
||||
$sites = [
|
||||
ModuleManager::class => ['installModule', 'activateModule', 'deactivateModule', 'uninstallModule', 'updateModule'],
|
||||
PluginManager::class => ['installPlugin', 'activatePlugin', 'deactivatePlugin', 'uninstallPlugin', 'updatePlugin'],
|
||||
];
|
||||
|
||||
foreach ($sites as $class => $methods) {
|
||||
foreach ($methods as $method) {
|
||||
$this->assertStringContainsString(
|
||||
'RouteCacheHelper::rebuild()',
|
||||
$this->methodSource($class, $method),
|
||||
$class.'::'.$method.' 이 라우트 캐시를 갱신하지 않는다 — '
|
||||
.'캐시가 걸린 사이트에서 이 조작 뒤 확장 라우트가 404 가 된다'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 코어_업데이트_흐름이_라우트_캐시를_되살린다(): void
|
||||
{
|
||||
// 코어 업데이트는 흐름 중간에 라우트 캐시를 비운다(vendor 교체 중 상태를 구울 수
|
||||
// 없으므로 옳다). 그러나 비운 채로 끝내면 그 사이트는 운영자가 수동으로
|
||||
// route:cache 를 돌릴 때까지 라우트 캐시 없이 동작한다 — config 가 같은 이유로
|
||||
// 흐름 끝에서 재생성되는 것과 대칭이어야 한다.
|
||||
foreach ([CoreUpdateCommand::class, ExecuteUpgradeStepsCommand::class] as $class) {
|
||||
$source = file_get_contents((new ReflectionClass($class))->getFileName());
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'RouteCacheHelper::rebuild()',
|
||||
$source,
|
||||
$class.' 가 흐름 끝에서 라우트 캐시를 되살리지 않는다'
|
||||
);
|
||||
|
||||
// config 와 같은 자리에서 함께 되살아나야 한다 — 한쪽만 되살리면
|
||||
// 그 사이트는 절반만 최적화된 상태로 남는다.
|
||||
$this->assertStringContainsString(
|
||||
'ConfigCacheHelper::rebuild()',
|
||||
$source,
|
||||
$class.' 의 config 재생성 지점이 사라졌다 (라우트 재생성의 기준점)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정한 메서드의 소스 본문을 반환합니다.
|
||||
*
|
||||
* @param string $class 대상 클래스
|
||||
* @param string $method 대상 메서드
|
||||
* @return string 메서드 본문 소스
|
||||
*/
|
||||
private function methodSource(string $class, string $method): string
|
||||
{
|
||||
$reflection = new ReflectionClass($class);
|
||||
$target = $reflection->getMethod($method);
|
||||
$lines = file($reflection->getFileName());
|
||||
|
||||
return implode('', array_slice(
|
||||
$lines,
|
||||
$target->getStartLine() - 1,
|
||||
$target->getEndLine() - $target->getStartLine() + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Services\SettingsService;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 고급 탭 저장이 병합 대상 카테고리를 빠짐없이 분류하는지 고정합니다.
|
||||
*
|
||||
* 고급 탭은 화면 하나가 여러 카테고리(cache/debug/core_update/geoip/pagination)를 함께
|
||||
* 편집하므로, 저장 시점에 어느 카테고리 소속인지 다시 분류해야 합니다. 이 분류표가
|
||||
* 스키마와 어긋나면 그 카테고리 값은 **어디에도 담기지 않고 조용히 버려집니다** —
|
||||
* 저장 응답은 200 이고 검증도 통과하므로 화면·API 어느 쪽에도 실패가 드러나지 않습니다.
|
||||
*
|
||||
* 실제 피해: `pagination`(목록 상한값)이 분류표에 없어 운영자가 상한을 바꿔도 반영되지
|
||||
* 않았고, `storage/app/settings/pagination.json` 이 만들어진 적이 없었습니다.
|
||||
*
|
||||
* 분류 대상은 손으로 열거하지 않고 스키마(`frontend_schema.*.merge_into === 'advanced'`)
|
||||
* 에서 도출합니다. 새 카테고리가 고급 탭에 합류해도 이 테스트가 자동으로 그것을 셉니다.
|
||||
*
|
||||
* @scenario case=advanced_tab_category_persistence
|
||||
*
|
||||
* @effects pagination_limits_persist_from_admin_settings
|
||||
* @effects advanced_tab_persists_every_merged_category
|
||||
*/
|
||||
class AdvancedTabCategoryPersistenceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 고급 탭에서 저장한 목록 상한값이 pagination 카테고리에 기록되는지 확인합니다.
|
||||
*
|
||||
* @effects pagination_limits_persist_from_admin_settings
|
||||
*/
|
||||
public function test_pagination_limits_saved_from_advanced_tab_are_persisted(): void
|
||||
{
|
||||
$saved = app(SettingsService::class)->saveSettings([
|
||||
'_tab' => 'advanced',
|
||||
'advanced' => [
|
||||
'pagination_result_cap' => 5000,
|
||||
'pagination_max_page' => 300,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($saved, '고급 탭 저장이 실패했습니다.');
|
||||
|
||||
$pagination = app(ConfigRepositoryInterface::class)->getCategory('pagination');
|
||||
|
||||
$this->assertSame(
|
||||
5000,
|
||||
$pagination['result_cap'] ?? null,
|
||||
'pagination.result_cap 이 저장되지 않았습니다 — 고급 탭 분류표에서 누락되면 값이 조용히 버려집니다.'
|
||||
);
|
||||
$this->assertSame(
|
||||
300,
|
||||
$pagination['max_page'] ?? null,
|
||||
'pagination.max_page 가 저장되지 않았습니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 탭에 병합되는 모든 카테고리가 저장 경로에서 분류되는지 확인합니다.
|
||||
*
|
||||
* 모집단은 스키마에서 도출하므로, 분류표가 뒤처지면 그 카테고리에서 실패합니다.
|
||||
*
|
||||
* @effects advanced_tab_persists_every_merged_category
|
||||
*/
|
||||
public function test_every_category_merged_into_advanced_is_persisted(): void
|
||||
{
|
||||
$repository = app(ConfigRepositoryInterface::class);
|
||||
$schema = $repository->getFrontendSchema();
|
||||
|
||||
$mergedCategories = [];
|
||||
foreach ($schema as $category => $categorySchema) {
|
||||
if (str_starts_with($category, '_') || ($categorySchema['merge_into'] ?? null) !== 'advanced') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mergedCategories[$category] = $categorySchema['fields'] ?? [];
|
||||
}
|
||||
|
||||
$this->assertNotEmpty(
|
||||
$mergedCategories,
|
||||
'고급 탭 병합 카테고리 모집단이 비었습니다 — 스키마 조회가 바뀌었는지 확인하세요.'
|
||||
);
|
||||
|
||||
$payload = [];
|
||||
$expected = [];
|
||||
|
||||
foreach ($mergedCategories as $category => $fields) {
|
||||
$this->assertNotEmpty($fields, "카테고리 {$category} 의 필드 목록이 비었습니다.");
|
||||
|
||||
foreach ($fields as $fieldName => $fieldSchema) {
|
||||
$value = $this->sampleValueFor($fieldSchema['type'] ?? 'string', $fieldName);
|
||||
$payload[$fieldSchema['frontend_key'] ?? $fieldName] = $value;
|
||||
$expected[$category][$fieldName] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$saved = app(SettingsService::class)->saveSettings([
|
||||
'_tab' => 'advanced',
|
||||
'advanced' => $payload,
|
||||
]);
|
||||
|
||||
$this->assertTrue($saved, '고급 탭 저장이 실패했습니다.');
|
||||
|
||||
foreach ($expected as $category => $fields) {
|
||||
$stored = $repository->getCategory($category);
|
||||
|
||||
foreach ($fields as $fieldName => $value) {
|
||||
$this->assertSame(
|
||||
$value,
|
||||
$stored[$fieldName] ?? null,
|
||||
"{$category}.{$fieldName} 이 저장되지 않았습니다 — 고급 탭 분류표에 이 카테고리가 없습니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 타입에 맞는 검사용 값을 만듭니다.
|
||||
*
|
||||
* @param string $type 스키마가 선언한 타입
|
||||
* @param string $fieldName 필드명 (문자열 값의 구분용)
|
||||
* @return mixed 검사용 값
|
||||
*/
|
||||
private function sampleValueFor(string $type, string $fieldName): mixed
|
||||
{
|
||||
return match ($type) {
|
||||
'integer' => 4242,
|
||||
'boolean' => true,
|
||||
default => 'advanced-tab-'.$fieldName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* E2E: 총 건수 상한 표기 계약 + 상품 그리드 페이저 (#519)
|
||||
*
|
||||
* 총 건수가 상한을 넘으면 서버가 `last_page: null` 을 보낸다. 화면이 그 값을 숫자로
|
||||
* 잘못 해석하면 페이저가 통째로 사라지거나 "1 / 1" 로 굳는다 — 응답은 정상이고 콘솔
|
||||
* 에러도 없어 API 테스트로는 드러나지 않는다. 브라우저에서만 드러나는 성질이라
|
||||
* 여기서 잡는다.
|
||||
*
|
||||
* @scenario case=bounded_total_and_product_grid_pager
|
||||
*
|
||||
* @effects search_pager_survives_null_last_page,
|
||||
* search_count_marks_inexact_total,
|
||||
* product_grid_pager_uses_has_more_pages
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
/**
|
||||
* 화면 진입 공통 대기.
|
||||
*
|
||||
* `networkidle` 은 폴링이 있는 화면에서 idle 이 되지 않으므로 쓰지 않는다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @param path 이동할 경로
|
||||
* @returns void
|
||||
*/
|
||||
async function gotoAndSettle(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await acceptCookieConsent(page);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 동의 배너를 처리한다.
|
||||
*
|
||||
* GDPR 사전 차단(preblocker)이 동의 전까지 데이터 요청을 막으므로, 배너를 남겨 둔 채로는
|
||||
* "화면이 어떤 API 를 부르는가" 를 관찰할 수 없다. 배너가 없는 사이트에서는 아무 일도 하지 않는다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @returns void
|
||||
*/
|
||||
async function acceptCookieConsent(page: Page): Promise<void> {
|
||||
const accept = page.getByRole('button', { name: /모두 동의|Accept all/i }).first();
|
||||
|
||||
if (await accept.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await accept.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// 쇼핑 목록 라우트는 '/shop' 이 아니라 '/shop/products' 다 (템플릿 routes.json 기준).
|
||||
const SHOP_LIST_PATH = '/shop/products';
|
||||
|
||||
test.describe('총 건수 상한과 페이지 이동', () => {
|
||||
// @scenario case=search_response_carries_accuracy_meta
|
||||
// @effects search_count_marks_inexact_total
|
||||
test('검색 응답이 총 건수 정확도를 함께 싣는다', async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/search?') && r.status() === 200,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
await gotoAndSettle(page, '/search?q=' + encodeURIComponent('테스트'));
|
||||
|
||||
const body = await (await responsePromise).json();
|
||||
const data = body?.data;
|
||||
|
||||
expect(data, '검색 응답에 data 가 없다').toBeTruthy();
|
||||
expect(data).toHaveProperty('total_is_exact');
|
||||
expect(data).toHaveProperty('total_relation');
|
||||
|
||||
// 정확도가 true 면 종전 표기 그대로, false 면 "이상" 표기가 붙어야 한다.
|
||||
// 표기 문구는 로케일에 따라 다르므로 둘 중 하나가 화면에 있는지로 판정한다.
|
||||
// 본문 전체를 대상으로 하므로 앵커(`$`)를 쓰지 않는다 — 뒤에 다른 문구가 따라온다.
|
||||
const suffix = data.total_is_exact
|
||||
? /\d+\s*건|\d+\s*results/i
|
||||
: /건 이상|\+ results|more than/i;
|
||||
|
||||
await expect(page.locator('body')).toContainText(suffix, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// @scenario case=search_pager_survives_null_last_page
|
||||
// @effects search_pager_survives_null_last_page
|
||||
test('마지막 페이지를 모르는 목록에서도 페이저가 사라지지 않는다', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
// 상한 초과 상태는 실데이터로 재현하기 어려우므로 응답을 가로채 주입한다.
|
||||
// last_page: null + has_more_pages: true 가 이 시나리오의 핵심 입력이다.
|
||||
await page.route('**/api/search?**', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
|
||||
if (body?.data) {
|
||||
body.data.last_page = null;
|
||||
body.data.has_more_pages = true;
|
||||
body.data.total_relation = 'at_least';
|
||||
body.data.total_is_exact = false;
|
||||
}
|
||||
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
await gotoAndSettle(page, '/search?q=' + encodeURIComponent('테스트'));
|
||||
|
||||
expect(errors, '검색 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
|
||||
|
||||
// "다음" 이동 수단이 실제로 화면에 남아 있어야 한다. 이것이 사라지면 상한 초과
|
||||
// 검색에서 2페이지 이후에 도달할 방법이 없어진다.
|
||||
const nextControl = page
|
||||
.locator('button:has(i.fa-chevron-right), a:has(i.fa-chevron-right)')
|
||||
.first();
|
||||
|
||||
await expect(nextControl, '마지막 페이지를 모른다는 이유로 "다음" 이동이 사라졌다')
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('상품 그리드 페이저', () => {
|
||||
// @scenario case=product_grid_pager_uses_has_more_pages
|
||||
// @effects product_grid_pager_uses_has_more_pages
|
||||
test('상품 그리드 페이저가 마지막 페이지를 몰라도 접히지 않는다', async ({ page }) => {
|
||||
await page.route('**/api/modules/sirsoft-ecommerce/products?**', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!body?.data?.pagination) {
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
|
||||
// 총 건수를 상한까지만 센 응답을 흉내 낸다 — last_page 는 계산할 수 없다
|
||||
body.data.pagination.last_page = null;
|
||||
body.data.pagination.has_more_pages = true;
|
||||
body.data.pagination.total_relation = 'at_least';
|
||||
body.data.pagination.total_is_exact = false;
|
||||
|
||||
return route.fulfill({ response, body: JSON.stringify(body) });
|
||||
});
|
||||
|
||||
await gotoAndSettle(page, SHOP_LIST_PATH);
|
||||
|
||||
// last_page 가 null 이라고 페이저가 통째로 사라지면 1페이지 밖 상품에 도달할 방법이 없다
|
||||
const nextButton = page.locator('button:has(i.fa-chevron-right)').last();
|
||||
await expect(nextButton).toBeVisible({ timeout: 15_000 });
|
||||
await expect(nextButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
/**
|
||||
* E2E: 총 건수 상한 표기 계약 + 쇼핑 첫 화면 단일 요청 (#519)
|
||||
*
|
||||
* 두 변경 모두 브라우저에서만 드러나는 성질을 갖는다.
|
||||
*
|
||||
* (1) 총 건수가 상한을 넘으면 서버가 `last_page: null` 을 보낸다. 화면이 그 값을
|
||||
* 숫자로 잘못 해석하면 페이저가 통째로 사라지거나 "1 / 1" 로 굳는다 —
|
||||
* 응답은 정상이고 콘솔 에러도 없어 API 테스트로는 드러나지 않는다.
|
||||
* (2) 쇼핑 첫 화면은 다섯 묶음을 한 번에 받도록 바꿨다. 레이아웃 바인딩을 한 곳이라도
|
||||
* 옛 경로에 남겨 두면 그 영역만 조용히 비어 렌더된다.
|
||||
*
|
||||
* @scenario case=bounded_total_and_single_storefront_request
|
||||
*
|
||||
* @effects search_pager_survives_null_last_page,
|
||||
* search_count_marks_inexact_total,
|
||||
* storefront_uses_single_request,
|
||||
* storefront_sections_render
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
/**
|
||||
* 화면 진입 공통 대기.
|
||||
*
|
||||
* `networkidle` 은 폴링이 있는 화면에서 idle 이 되지 않으므로 쓰지 않는다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @param path 이동할 경로
|
||||
* @returns void
|
||||
*/
|
||||
async function gotoAndSettle(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await acceptCookieConsent(page);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 동의 배너를 처리한다.
|
||||
*
|
||||
* GDPR 사전 차단(preblocker)이 동의 전까지 데이터 요청을 막으므로, 배너를 남겨 둔 채로는
|
||||
* "화면이 어떤 API 를 부르는가" 를 관찰할 수 없다. 배너가 없는 사이트에서는 아무 일도 하지 않는다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @returns void
|
||||
*/
|
||||
async function acceptCookieConsent(page: Page): Promise<void> {
|
||||
const accept = page.getByRole('button', { name: /모두 동의|Accept all/i }).first();
|
||||
|
||||
if (await accept.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await accept.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// 쇼핑 목록 라우트는 '/shop' 이 아니라 '/shop/products' 다 (템플릿 routes.json 기준).
|
||||
const SHOP_LIST_PATH = '/shop/products';
|
||||
|
||||
test.describe('총 건수 상한과 페이지 이동', () => {
|
||||
// @scenario case=search_response_carries_accuracy_meta
|
||||
// @effects search_count_marks_inexact_total
|
||||
test('검색 응답이 총 건수 정확도를 함께 싣는다', async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/search?') && r.status() === 200,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
await gotoAndSettle(page, '/search?q=' + encodeURIComponent('테스트'));
|
||||
|
||||
const body = await (await responsePromise).json();
|
||||
const data = body?.data;
|
||||
|
||||
expect(data, '검색 응답에 data 가 없다').toBeTruthy();
|
||||
expect(data).toHaveProperty('total_is_exact');
|
||||
expect(data).toHaveProperty('total_relation');
|
||||
|
||||
// 정확도가 true 면 종전 표기 그대로, false 면 "이상" 표기가 붙어야 한다.
|
||||
// 표기 문구는 로케일에 따라 다르므로 둘 중 하나가 화면에 있는지로 판정한다.
|
||||
// 본문 전체를 대상으로 하므로 앵커(`$`)를 쓰지 않는다 — 뒤에 다른 문구가 따라온다.
|
||||
const suffix = data.total_is_exact
|
||||
? /\d+\s*건|\d+\s*results/i
|
||||
: /건 이상|\+ results|more than/i;
|
||||
|
||||
await expect(page.locator('body')).toContainText(suffix, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// @scenario case=search_pager_survives_null_last_page
|
||||
// @effects search_pager_survives_null_last_page
|
||||
test('마지막 페이지를 모르는 목록에서도 페이저가 사라지지 않는다', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
// 상한 초과 상태는 실데이터로 재현하기 어려우므로 응답을 가로채 주입한다.
|
||||
// last_page: null + has_more_pages: true 가 이 시나리오의 핵심 입력이다.
|
||||
await page.route('**/api/search?**', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
|
||||
if (body?.data) {
|
||||
body.data.last_page = null;
|
||||
body.data.has_more_pages = true;
|
||||
body.data.total_relation = 'at_least';
|
||||
body.data.total_is_exact = false;
|
||||
}
|
||||
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
await gotoAndSettle(page, '/search?q=' + encodeURIComponent('테스트'));
|
||||
|
||||
expect(errors, '검색 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
|
||||
|
||||
// "다음" 이동 수단이 실제로 화면에 남아 있어야 한다. 이것이 사라지면 상한 초과
|
||||
// 검색에서 2페이지 이후에 도달할 방법이 없어진다.
|
||||
const nextControl = page
|
||||
.locator('button:has(i.fa-chevron-right), a:has(i.fa-chevron-right)')
|
||||
.first();
|
||||
|
||||
await expect(nextControl, '마지막 페이지를 모른다는 이유로 "다음" 이동이 사라졌다')
|
||||
.toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('쇼핑 첫 화면 단일 요청', () => {
|
||||
// @scenario case=storefront_uses_single_request
|
||||
// @effects storefront_uses_single_request
|
||||
test('분류·상품·진열 묶음을 한 번의 요청으로 받는다', async ({ page }) => {
|
||||
const productApiCalls: string[] = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (/\/api\/modules\/sirsoft-ecommerce\/(products|categories|storefront)/.test(url)) {
|
||||
productApiCalls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
const storefrontPromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/modules/sirsoft-ecommerce/storefront') && r.status() === 200,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
await gotoAndSettle(page, SHOP_LIST_PATH);
|
||||
|
||||
const body = await (await storefrontPromise).json();
|
||||
const data = body?.data;
|
||||
|
||||
expect(data, 'storefront 응답에 data 가 없다').toBeTruthy();
|
||||
for (const key of ['categories', 'products', 'recent_products', 'popular_products', 'new_products']) {
|
||||
expect(data, `storefront 응답에 ${key} 묶음이 없다`).toHaveProperty(key);
|
||||
}
|
||||
|
||||
// 개별 엔드포인트를 함께 부르고 있으면 통합의 의미가 없다.
|
||||
const legacyCalls = productApiCalls.filter(
|
||||
(url) => /\/(products\/(popular|new|recent)|categories)(\?|$)/.test(url),
|
||||
);
|
||||
expect(legacyCalls, `쇼핑 첫 화면이 개별 엔드포인트를 여전히 호출한다: ${legacyCalls.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// @scenario case=storefront_sections_render
|
||||
// @effects storefront_sections_render
|
||||
test('첫 화면의 각 영역이 비어 있지 않게 렌더된다', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
const storefrontPromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/modules/sirsoft-ecommerce/storefront') && r.status() === 200,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
await gotoAndSettle(page, SHOP_LIST_PATH);
|
||||
|
||||
expect(errors, '쇼핑 첫 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
|
||||
await expect(page.locator('h1')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 통합 응답이 실제로 담아 온 묶음은 화면에도 나타나야 한다. 바인딩 경로가
|
||||
// 하나라도 옛 이름에 남아 있으면 그 영역만 조용히 비므로, 응답에 항목이 있는
|
||||
// 묶음에 한해 그 이름이 화면에 렌더됐는지 본다 (빈 사이트에서는 건너뛴다).
|
||||
const data = (await (await storefrontPromise).json())?.data;
|
||||
|
||||
const sections: Array<[string, unknown]> = [
|
||||
['recent_products', data?.recent_products],
|
||||
['popular_products', data?.popular_products],
|
||||
['new_products', data?.new_products],
|
||||
];
|
||||
|
||||
for (const [key, items] of sections) {
|
||||
const list = Array.isArray(items) ? items : (items as { data?: unknown[] })?.data;
|
||||
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstName = (list[0] as { name_localized?: string; name?: string })?.name_localized
|
||||
?? (list[0] as { name?: string })?.name;
|
||||
|
||||
if (typeof firstName !== 'string' || firstName.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByText(firstName, { exact: false }).first(),
|
||||
`${key} 묶음이 응답에는 있는데 화면에 렌더되지 않았다 — 바인딩 경로가 어긋났다`,
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('공유 partial 을 쓰는 다른 화면 (#519 회귀)', () => {
|
||||
// 쇼핑 첫 화면을 단일 요청으로 통합하면서, 같은 partial 을 쓰는 분류 화면과 상품
|
||||
// 상세의 인기 상품 영역이 조용히 비었던 회귀다. `?? []` 폴백 때문에 예외도 404 도
|
||||
// 나지 않아 브라우저에서 눈으로 보는 것 말고는 드러나지 않는다.
|
||||
|
||||
// @scenario case=shared_partial_reads_parent_declared_names
|
||||
// @effects shared_partial_reads_parent_declared_names
|
||||
test('분류 화면이 상품 그리드 데이터를 실제로 받는다', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
const listRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (/\/api\/modules\/sirsoft-ecommerce\/(products|storefront)(\?|$|\/)/.test(url)) {
|
||||
listRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 분류 슬러그가 없는 사이트에서는 목록 화면으로 대체 확인한다
|
||||
await gotoAndSettle(page, '/shop/products');
|
||||
|
||||
expect(errors, '분류/목록 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
|
||||
|
||||
// 이 화면은 공유 partial 로 상품 그리드를 그린다. partial 이 부모가 선언한 이름을
|
||||
// 읽지 못하면 데이터 요청 자체가 나가지 않고 그리드만 조용히 빈다 — 요청이 실제로
|
||||
// 있었는지가 그 회귀를 잡는 신호다.
|
||||
expect(
|
||||
listRequests,
|
||||
'상품 목록/통합 응답 요청이 한 번도 나가지 않았다 — 공유 partial 이 부모가 선언한 데이터소스 이름을 읽지 못한다',
|
||||
).not.toEqual([]);
|
||||
|
||||
await expect(page.locator('h1')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
// @scenario case=storefront_grid_pager_uses_has_more_pages
|
||||
// @effects storefront_grid_pager_uses_has_more_pages
|
||||
test('상품 그리드 페이저가 마지막 페이지를 몰라도 접히지 않는다', async ({ page }) => {
|
||||
await page.route('**/api/modules/sirsoft-ecommerce/storefront**', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!body?.data?.products?.pagination) {
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
|
||||
// 총 건수를 상한까지만 센 응답을 흉내 낸다 — last_page 는 계산할 수 없다
|
||||
body.data.products.pagination.last_page = null;
|
||||
body.data.products.pagination.has_more_pages = true;
|
||||
body.data.products.pagination.total_relation = 'at_least';
|
||||
body.data.products.pagination.total_is_exact = false;
|
||||
|
||||
return route.fulfill({ response, body: JSON.stringify(body) });
|
||||
});
|
||||
|
||||
await gotoAndSettle(page, SHOP_LIST_PATH);
|
||||
|
||||
// last_page 가 null 이라고 페이저가 통째로 사라지면 1페이지 밖 상품에 도달할 방법이 없다
|
||||
const nextButton = page.locator('button:has(i.fa-chevron-right)').last();
|
||||
await expect(nextButton).toBeVisible({ timeout: 15_000 });
|
||||
await expect(nextButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* E2E: 통합검색 화면 상태의 주소 승계 (#519)
|
||||
*
|
||||
* 탭·게시판필터·정렬·페이지는 전역 상태가 아니라 URL 쿼리가 SSoT 여야 한다.
|
||||
* 전역 상태에만 두면 화면은 정상으로 보이고 콘솔 에러도 없지만, 주소가 바뀌지 않아
|
||||
*
|
||||
* - 새로고침하면 검색어만 남고 나머지가 초기값으로 돌아가고,
|
||||
* - 뒤로가기가 직전에 보던 탭·페이지로 되돌아가지 못하며,
|
||||
* - 보고 있던 결과를 주소로 공유할 수 없다.
|
||||
*
|
||||
* 셋 다 브라우저의 주소·히스토리를 실제로 움직여야만 드러나므로 단위 테스트로는
|
||||
* 잡히지 않는다. 레이아웃 계약 단위 검사는
|
||||
* `templates/_bundled/sirsoft-basic/__tests__/layouts/search-url-state-contract.test.ts` 가 맡는다.
|
||||
*
|
||||
* @scenario case=search_state_round_trips_through_url
|
||||
*
|
||||
* @effects search_state_persists_in_url,
|
||||
* search_state_survives_reload,
|
||||
* search_state_restores_on_back,
|
||||
* search_new_keyword_resets_state
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
const KEYWORD = '테스트';
|
||||
const SEARCH_PATH = '/search?q=' + encodeURIComponent(KEYWORD);
|
||||
|
||||
/**
|
||||
* 화면 진입 공통 대기.
|
||||
*
|
||||
* `networkidle` 은 폴링이 있는 화면에서 idle 이 되지 않으므로 쓰지 않는다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @param path 이동할 경로
|
||||
* @returns void
|
||||
*/
|
||||
async function gotoAndSettle(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await acceptCookieConsent(page);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키 동의 배너를 처리한다.
|
||||
*
|
||||
* 동의 전에는 사전 차단이 데이터 요청을 막아 검색 결과가 그려지지 않는다.
|
||||
* 배너가 없는 사이트에서는 아무 일도 하지 않는다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @returns void
|
||||
*/
|
||||
async function acceptCookieConsent(page: Page): Promise<void> {
|
||||
const accept = page.getByRole('button', { name: /모두 동의|Accept all/i }).first();
|
||||
|
||||
if (await accept.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await accept.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 탭 버튼을 반환합니다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @returns 게시글 탭 로케이터
|
||||
*/
|
||||
function postsTab(page: Page) {
|
||||
return page.getByRole('button', { name: /게시글|Posts/i }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 주소의 쿼리 파라미터를 읽습니다.
|
||||
*
|
||||
* @param page 대상 페이지
|
||||
* @returns 쿼리 파라미터 객체
|
||||
*/
|
||||
async function queryParams(page: Page): Promise<Record<string, string>> {
|
||||
const url = new URL(page.url());
|
||||
|
||||
return Object.fromEntries(url.searchParams.entries());
|
||||
}
|
||||
|
||||
test.describe('통합검색 화면 상태의 주소 승계', () => {
|
||||
// @scenario case=search_state_persists_in_url
|
||||
// @effects search_state_persists_in_url
|
||||
test('탭을 바꾸면 주소에 남고 검색어는 유지된다', async ({ page }) => {
|
||||
await gotoAndSettle(page, SEARCH_PATH);
|
||||
|
||||
const tab = postsTab(page);
|
||||
|
||||
// 탭이 없는 사이트(검색 결과 0건 등)에서는 이 축을 잴 수 없다.
|
||||
if (!(await tab.isVisible({ timeout: 5_000 }).catch(() => false))) {
|
||||
test.skip(true, '검색 탭이 화면에 없어 상태 승계를 측정할 수 없다');
|
||||
}
|
||||
|
||||
await tab.click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const params = await queryParams(page);
|
||||
|
||||
expect(params.type, '탭 전환이 주소에 반영되지 않았다').toBe('posts');
|
||||
expect(params.q, '탭을 바꿨더니 검색어가 주소에서 사라졌다').toBe(KEYWORD);
|
||||
// 결과 집합이 달라지므로 이전 페이지·커서는 따라오면 안 된다.
|
||||
expect(params.page, '탭 전환인데 이전 페이지 번호가 남았다').toBeUndefined();
|
||||
expect(params.cursor, '탭 전환인데 이전 커서가 남았다').toBeUndefined();
|
||||
});
|
||||
|
||||
// @scenario case=search_state_survives_reload
|
||||
// @effects search_state_survives_reload
|
||||
test('새로고침해도 보고 있던 탭이 유지된다', async ({ page }) => {
|
||||
await gotoAndSettle(page, SEARCH_PATH + '&type=posts');
|
||||
|
||||
const before = await queryParams(page);
|
||||
expect(before.type, '사전 조건: 주소에 탭이 실려 있어야 한다').toBe('posts');
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const after = await queryParams(page);
|
||||
|
||||
expect(after.type, '새로고침 후 탭이 초기값으로 돌아갔다').toBe('posts');
|
||||
expect(after.q, '새로고침 후 검색어가 사라졌다').toBe(KEYWORD);
|
||||
|
||||
// 주소만 남고 화면이 따라오지 않으면 의미가 없다 — 요청도 그 탭으로 나가야 한다.
|
||||
const request = await page
|
||||
.waitForRequest((r) => r.url().includes('/api/search?') && r.url().includes('type=posts'), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
expect(request, '새로고침 후 검색 요청이 주소의 탭을 반영하지 않았다').not.toBeNull();
|
||||
});
|
||||
|
||||
// @scenario case=search_state_restores_on_back
|
||||
// @effects search_state_restores_on_back
|
||||
test('뒤로가기가 직전에 보던 탭으로 되돌아간다', async ({ page }) => {
|
||||
await gotoAndSettle(page, SEARCH_PATH);
|
||||
|
||||
const tab = postsTab(page);
|
||||
|
||||
if (!(await tab.isVisible({ timeout: 5_000 }).catch(() => false))) {
|
||||
test.skip(true, '검색 탭이 화면에 없어 히스토리 왕복을 측정할 수 없다');
|
||||
}
|
||||
|
||||
await tab.click();
|
||||
await page.waitForTimeout(800);
|
||||
expect((await queryParams(page)).type, '사전 조건: 탭 전환이 주소에 반영돼야 한다').toBe('posts');
|
||||
|
||||
await page.goBack();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const params = await queryParams(page);
|
||||
|
||||
// 탭 전환이 history 항목을 만들지 않으면 뒤로가기가 검색 화면 자체를 떠난다.
|
||||
expect(params.q, '뒤로가기가 검색 화면을 벗어났다').toBe(KEYWORD);
|
||||
expect(params.type, '뒤로가기 후에도 바뀐 탭이 그대로다').toBeUndefined();
|
||||
});
|
||||
|
||||
// @scenario case=search_new_keyword_resets_state
|
||||
// @effects search_new_keyword_resets_state
|
||||
test('검색어를 새로 실행하면 이전 조건이 따라오지 않는다', async ({ page }) => {
|
||||
await gotoAndSettle(page, SEARCH_PATH + '&type=posts&sort=latest&page=2');
|
||||
|
||||
const input = page.locator('input[name="q"]').first();
|
||||
|
||||
if (!(await input.isVisible({ timeout: 5_000 }).catch(() => false))) {
|
||||
test.skip(true, '검색 입력란이 화면에 없어 재검색을 측정할 수 없다');
|
||||
}
|
||||
|
||||
await input.fill('다른검색어');
|
||||
await input.press('Enter');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const params = await queryParams(page);
|
||||
|
||||
expect(params.q, '새 검색어가 주소에 반영되지 않았다').toBe('다른검색어');
|
||||
// 의도적 리셋이다 — 결과 집합 자체가 달라지므로 이전 조건을 물려받지 않는다.
|
||||
expect(params.type, '새 검색인데 이전 탭이 따라왔다').toBeUndefined();
|
||||
expect(params.sort, '새 검색인데 이전 정렬이 따라왔다').toBeUndefined();
|
||||
expect(params.page, '새 검색인데 이전 페이지가 따라왔다').toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장이 부르는 다국어 키가 실제로 해석되는지 검사하는 패리티 테스트 (#519)
|
||||
*
|
||||
* 확장 코드가 네임스페이스 없이 `__('board.anonymous')` 처럼 부르면, 그 그룹은 코어
|
||||
* lang 에 없으므로 Laravel 이 **키 문자열 자체를 그대로 돌려준다**. 예외도 경고도 없고
|
||||
* 응답 코드도 200 이라, 화면에 원시 키가 노출되기 전까지 아무 곳에서도 드러나지 않는다.
|
||||
* (실제 사례: 통합검색 결과의 작성자 자리에 `board.anonymous` 가 그대로 찍혔다.)
|
||||
*
|
||||
* 확장 소유 그룹은 `vendor-extension::group.key` 로 불러야 하고, 네임스페이스 없는 키는
|
||||
* **코어 lang 그룹일 때만** 해석된다. 호출 지점을 손으로 열거하면 새 확장·새 호출이 같은
|
||||
* 함정을 다시 밟으므로, 저장소를 훑어 모든 호출을 검사한다.
|
||||
*
|
||||
* @scenario case=extension_translation_key_resolution
|
||||
*
|
||||
* @effects extension_non_namespaced_key_resolves_to_core_group
|
||||
*/
|
||||
class ExtensionTranslationKeyResolutionTest extends TestCase
|
||||
{
|
||||
/** 훑을 확장 소스 루트 */
|
||||
private const SOURCE_ROOTS = [
|
||||
'modules/_bundled',
|
||||
'plugins/_bundled',
|
||||
];
|
||||
|
||||
/** 네임스페이스 없는 그룹 키 호출 (`__('group.key')` / `trans('group.key')`) */
|
||||
private const KEY_PATTERN = "/\b(?:__|trans)\(\s*'([a-z][a-z0-9_]*)\.([a-zA-Z0-9_.\-]+)'/";
|
||||
|
||||
/**
|
||||
* 확장의 네임스페이스 없는 다국어 키가 전부 코어 그룹으로 해석되는지 확인
|
||||
*
|
||||
* @effects extension_non_namespaced_key_resolves_to_core_group
|
||||
*/
|
||||
public function test_every_non_namespaced_key_in_extensions_resolves_to_a_core_group(): void
|
||||
{
|
||||
$calls = $this->collectCalls();
|
||||
|
||||
// 스캐너가 아무것도 못 찾으면 초록은 아무 의미가 없다 — 모집단부터 단언한다.
|
||||
$this->assertNotEmpty(
|
||||
$calls,
|
||||
'확장에서 네임스페이스 없는 다국어 키 호출을 한 건도 찾지 못했다. 스캔 경로나 패턴이 잘못됐을 수 있다'
|
||||
);
|
||||
|
||||
$unresolvable = [];
|
||||
|
||||
foreach ($calls as $call) {
|
||||
if (! $this->isCoreGroup($call['group'])) {
|
||||
$unresolvable[] = $call['file'].':'.$call['line']." → {$call['group']}.{$call['key']}";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$unresolvable,
|
||||
"코어에 없는 그룹을 네임스페이스 없이 부르는 곳이 있다. 번역이 실패해도 예외가 나지 않고\n".
|
||||
"원시 키 문자열이 화면에 그대로 노출된다. `vendor-extension::group.key` 형태로 부를 것.\n".
|
||||
implode("\n", $unresolvable)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사기가 실제로 위반을 잡아내는지 합성 표본으로 확인
|
||||
*
|
||||
* 이 단언이 없으면 패턴이 아무것도 매칭하지 못하게 되어도 위 테스트는 계속 초록이다.
|
||||
*/
|
||||
public function test_detector_flags_a_key_whose_group_is_not_in_core(): void
|
||||
{
|
||||
$matches = $this->matchesIn("<?php \$name = __('board.anonymous');");
|
||||
|
||||
$this->assertCount(1, $matches, '합성 표본에서 호출을 찾지 못했다 — 패턴이 깨졌다');
|
||||
$this->assertSame('board', $matches[0]['group']);
|
||||
$this->assertFalse(
|
||||
$this->isCoreGroup($matches[0]['group']),
|
||||
'`board` 그룹이 코어 lang 에 생겼다면 이 표본을 다른 미해석 키로 바꿀 것'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 네임스페이스가 붙은 키는 검사 대상이 아님을 확인
|
||||
*/
|
||||
public function test_detector_ignores_namespaced_keys(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
[],
|
||||
$this->matchesIn("<?php \$name = __('sirsoft-board::messages.common.guest');")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 소스에서 네임스페이스 없는 키 호출을 모읍니다.
|
||||
*
|
||||
* @return array<int, array{file: string, line: int, group: string, key: string}>
|
||||
*/
|
||||
private function collectCalls(): array
|
||||
{
|
||||
$calls = [];
|
||||
|
||||
foreach ($this->phpFiles() as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file);
|
||||
$relative = str_replace('\\', '/', $relative);
|
||||
|
||||
foreach ($this->matchesIn($contents) as $match) {
|
||||
$calls[] = [
|
||||
'file' => $relative,
|
||||
'line' => $match['line'],
|
||||
'group' => $match['group'],
|
||||
'key' => $match['key'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 문자열에서 네임스페이스 없는 키 호출을 뽑습니다.
|
||||
*
|
||||
* @param string $contents PHP 소스
|
||||
* @return array<int, array{line: int, group: string, key: string}>
|
||||
*/
|
||||
private function matchesIn(string $contents): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
foreach (preg_split('/\R/', $contents) as $index => $line) {
|
||||
if (! preg_match_all(self::KEY_PATTERN, $line, $matches, PREG_SET_ORDER)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$found[] = [
|
||||
'line' => $index + 1,
|
||||
'group' => $match[1],
|
||||
'key' => $match[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹이 코어 lang 파일로 존재하는지 확인합니다.
|
||||
*
|
||||
* @param string $group 키의 첫 세그먼트
|
||||
* @return bool 코어 그룹 여부
|
||||
*/
|
||||
private function isCoreGroup(string $group): bool
|
||||
{
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
if (file_exists(base_path("lang/{$locale}/{$group}.php"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 소스의 PHP 파일 목록을 만듭니다.
|
||||
*
|
||||
* @return array<int, string> 파일 절대 경로
|
||||
*/
|
||||
private function phpFiles(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach (self::SOURCE_ROOTS as $root) {
|
||||
$path = base_path($root);
|
||||
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Search;
|
||||
|
||||
use App\Search\SearchPagePolicy;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 커서 판정에 페이지 번호가 도달하는지 검사하는 패리티 테스트 (#519)
|
||||
*
|
||||
* {@see SearchPagePolicy::usesCursor()} 는 커서가 없을 때 `page <= 1` 인지로
|
||||
* "시작점" 과 "깊은 페이지 딥링크" 를 가른다. 그래서 호출자가 페이지 번호를 넘기지 않으면
|
||||
* 기본값 1 이 적용되어 **모든 요청이 시작점으로 판정**된다. 그 결과 `?page=3` 딥링크가
|
||||
* 조용히 1 페이지를 돌려주고, 예외도 오류 응답도 남지 않는다.
|
||||
*
|
||||
* 호출 지점을 손으로 열거하면 검색에 새로 합류하는 도메인이 같은 함정을 다시 밟는다.
|
||||
* 여기서는 저장소를 훑어 **모든 호출 지점**이 페이지 번호를 넘기는지 확인한다.
|
||||
*
|
||||
* @scenario case=search_cursor_page_propagation
|
||||
*
|
||||
* @effects every_uses_cursor_call_forwards_page
|
||||
*/
|
||||
class SearchCursorPagePropagationParityTest extends TestCase
|
||||
{
|
||||
/** 판정 함수 호출 형태 */
|
||||
private const CALL_NEEDLE = 'SearchPagePolicy::usesCursor(';
|
||||
|
||||
/** 훑을 소스 루트 (선언 파일 자신은 제외) */
|
||||
private const SOURCE_ROOTS = [
|
||||
'app',
|
||||
'modules/_bundled',
|
||||
'plugins/_bundled',
|
||||
];
|
||||
|
||||
/**
|
||||
* 커서 판정을 호출하는 모든 곳이 페이지 번호를 함께 넘기는지 확인
|
||||
*
|
||||
* @effects every_uses_cursor_call_forwards_page
|
||||
*/
|
||||
public function test_every_uses_cursor_call_forwards_the_page_number(): void
|
||||
{
|
||||
$callSites = $this->collectCallSites();
|
||||
|
||||
// 스캐너가 아무것도 못 찾으면 초록은 아무 의미가 없다 — 모집단부터 단언한다.
|
||||
$this->assertNotEmpty(
|
||||
$callSites,
|
||||
self::CALL_NEEDLE.' 호출을 한 건도 찾지 못했다. 스캔 대상 경로가 잘못됐을 수 있다'
|
||||
);
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($callSites as $site) {
|
||||
if ($this->countArguments($site['arguments']) < 4 && ! str_contains($site['arguments'], 'page:')) {
|
||||
$missing[] = $site['file'].':'.$site['line'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missing,
|
||||
"커서 판정에 페이지 번호를 넘기지 않는 호출이 있다. 커서 없이 깊은 페이지를 지목한 요청이\n".
|
||||
"첫 페이지로 조용히 되돌아간다.\n".
|
||||
implode("\n", $missing)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소에서 커서 판정 호출 지점을 모읍니다.
|
||||
*
|
||||
* @return array<int, array{file: string, line: int, arguments: string}>
|
||||
*/
|
||||
private function collectCallSites(): array
|
||||
{
|
||||
$sites = [];
|
||||
|
||||
foreach ($this->phpFiles() as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if ($contents === false || ! str_contains($contents, self::CALL_NEEDLE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', substr($file, strlen(base_path()) + 1));
|
||||
|
||||
// 선언 파일과 문서 주석의 참조는 호출이 아니다.
|
||||
if (str_ends_with($relative, 'app/Search/SearchPagePolicy.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (explode("\n", $contents) as $index => $line) {
|
||||
$offset = strpos($line, self::CALL_NEEDLE);
|
||||
|
||||
if ($offset === false || $this->isComment($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sites[] = [
|
||||
'file' => $relative,
|
||||
'line' => $index + 1,
|
||||
'arguments' => $this->extractArguments($line, $offset + strlen(self::CALL_NEEDLE) - 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스캔 대상 PHP 파일 목록을 만듭니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function phpFiles(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach (self::SOURCE_ROOTS as $root) {
|
||||
$path = base_path($root);
|
||||
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $entry) {
|
||||
if ($entry->isFile() && $entry->getExtension() === 'php') {
|
||||
$files[] = $entry->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여는 괄호 위치에서 시작해 짝이 맞는 닫는 괄호까지의 인자 문자열을 잘라냅니다.
|
||||
*
|
||||
* @param string $line 호출이 있는 소스 한 줄
|
||||
* @param int $openParen 여는 괄호의 위치
|
||||
* @return string 괄호 안 인자 문자열
|
||||
*/
|
||||
private function extractArguments(string $line, int $openParen): string
|
||||
{
|
||||
$depth = 0;
|
||||
$length = strlen($line);
|
||||
|
||||
for ($i = $openParen; $i < $length; $i++) {
|
||||
if ($line[$i] === '(') {
|
||||
$depth++;
|
||||
} elseif ($line[$i] === ')') {
|
||||
$depth--;
|
||||
|
||||
if ($depth === 0) {
|
||||
return substr($line, $openParen + 1, $i - $openParen - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 여러 줄로 끊긴 호출은 그 줄에서 닫히지 않는다 — 남은 부분을 그대로 돌려준다.
|
||||
return substr($line, $openParen + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 괄호 중첩을 고려해 최상위 쉼표 기준으로 인자 수를 셉니다.
|
||||
*
|
||||
* @param string $arguments 괄호 안 인자 문자열
|
||||
* @return int 인자 수
|
||||
*/
|
||||
private function countArguments(string $arguments): int
|
||||
{
|
||||
if (trim($arguments) === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
$count = 1;
|
||||
$length = strlen($arguments);
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = $arguments[$i];
|
||||
|
||||
if ($char === '(' || $char === '[') {
|
||||
$depth++;
|
||||
} elseif ($char === ')' || $char === ']') {
|
||||
$depth--;
|
||||
} elseif ($char === ',' && $depth === 0) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 줄이 주석인지 판정합니다.
|
||||
*
|
||||
* @param string $line 소스 한 줄
|
||||
* @return bool 주석이면 true
|
||||
*/
|
||||
private function isComment(string $line): bool
|
||||
{
|
||||
$trimmed = ltrim($line);
|
||||
|
||||
return str_starts_with($trimmed, '*')
|
||||
|| str_starts_with($trimmed, '//')
|
||||
|| str_starts_with($trimmed, '/*');
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackRegistry;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
@@ -605,6 +606,98 @@ class LanguagePackServiceTest extends TestCase
|
||||
$this->service->activate($dependent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 팩을 같은 identifier 로 재설치해도 자기 자신을 슬롯 충돌로 오인해 강등하지 않는다.
|
||||
*
|
||||
* `finalizeInstall` 은 트랜잭션에서 항상 `installed` 로 기록하고 그 뒤 activate() 를
|
||||
* 호출한다. 이때 `findActiveForSlot` 이 `$existing?->id` 를 제외하지 않으면 이미 활성인
|
||||
* 자기 자신이 슬롯 점유자로 잡혀 `shouldActivate=false` 가 되고, 팩이 조용히 `installed`
|
||||
* 로 내려간다 — 의존하는 확장 언어팩이 `core_locale_missing` 으로 차단되어 인스톨러가
|
||||
* 멈추던 회귀다.
|
||||
*
|
||||
* 같은 회귀를 고정하는 `test_reinstall_active_pack_keeps_active_status` 는 실제 ja 번들
|
||||
* 산출물을 요구해 미빌드 환경에서 skip 된다. 활성화 경로가 activate() 경유로 바뀐 뒤에도
|
||||
* 이 가드가 환경에 따라 조용히 건너뛰어지지 않도록, 합성 번들 픽스처로 항상 실행되는
|
||||
* 판을 따로 둔다.
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects reinstalling_an_active_pack_does_not_demote_it
|
||||
*/
|
||||
public function test_finalize_install_does_not_demote_self_on_reinstall_of_active_pack(): void
|
||||
{
|
||||
$identifier = 'selfslot-core-ja';
|
||||
$bundledDir = $this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'selfslot', '1.0.0'));
|
||||
|
||||
try {
|
||||
$first = $this->service->installFromBundled($identifier, autoActivate: true);
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Active->value,
|
||||
$first->status,
|
||||
'전제 조건 붕괴 — 첫 설치가 활성화되지 않으면 강등 여부를 관측할 수 없다'
|
||||
);
|
||||
|
||||
$second = $this->service->installFromBundled($identifier, autoActivate: true);
|
||||
|
||||
$this->assertSame($first->id, $second->id, '같은 identifier 는 같은 행을 갱신해야 한다');
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Active->value,
|
||||
$second->status,
|
||||
'재설치가 자기 자신을 슬롯 충돌로 오인해 active → installed 로 강등했다 (회귀)'
|
||||
);
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Active->value,
|
||||
DB::table('language_packs')->where('identifier', $identifier)->value('status'),
|
||||
'반환값만 active 이고 DB 에는 강등된 값이 남았다'
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI 설치는 HTTP 활성화 권한 게이트의 영향을 받지 않는다.
|
||||
*
|
||||
* `auto_activate` 의 활성화 권한 검사는 FormRequest 계층(`RequiresActivationPermission`)
|
||||
* 이 소유한다. 이 검사가 Service 계층으로 내려가면 인증 컨텍스트가 없는 CLI 가 전부
|
||||
* 깨진다 — `language-pack:install` 과 `language-pack:provision` 은 `--no-activate` 를
|
||||
* 주지 않는 한 설치 직후 자동 활성화를 기본 동작으로 삼기 때문이다.
|
||||
*
|
||||
* Service 를 목으로 바꾸지 않고 실제 커맨드를 인증 없이 실행해 활성화까지 도달하는지
|
||||
* 고정한다. 게이트가 서비스 계층으로 새어 들어오면 여기서 red 가 된다.
|
||||
*
|
||||
* @scenario vector=finalize_install_activation, actor_permission=cli_system
|
||||
*
|
||||
* @effects cli_installation_is_unaffected_by_the_http_activation_gate
|
||||
*/
|
||||
public function test_cli_install_auto_activates_without_an_http_permission_context(): void
|
||||
{
|
||||
$identifier = 'cliact-core-ja';
|
||||
$bundledDir = $this->createBundledFixture($identifier, $this->coreJaManifest($identifier, 'cliact', '1.0.0'));
|
||||
|
||||
try {
|
||||
$this->assertFalse(
|
||||
Auth::check(),
|
||||
'전제 조건 붕괴 — 인증된 사용자가 있으면 CLI 컨텍스트를 재현한 것이 아니다'
|
||||
);
|
||||
|
||||
$this->artisan('language-pack:install', [
|
||||
'identifier' => $identifier,
|
||||
'--source' => 'bundled',
|
||||
])->assertExitCode(0);
|
||||
|
||||
$this->assertSame(
|
||||
LanguagePackStatus::Active->value,
|
||||
DB::table('language_packs')->where('identifier', $identifier)->value('status'),
|
||||
'CLI 설치가 자동 활성화되지 않았다 — 활성화 권한 검사가 Service 계층으로 내려왔을 가능성'
|
||||
);
|
||||
} finally {
|
||||
File::deleteDirectory($bundledDir);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 팩의 업데이트는 활성 상태를 유지한다 — activate() 경유 전환의 회귀 가드.
|
||||
*
|
||||
|
||||
@@ -29,7 +29,7 @@ axes:
|
||||
position: [first, middle, last]
|
||||
query_shape: [flat, grouped, deferred_join, fulltext]
|
||||
cap_source: [settings, config_fallback, hook_override, unlimited]
|
||||
surface: [core_repository, module_search, admin_datagrid, storefront_grid]
|
||||
surface: [core_repository, module_search, admin_datagrid, product_grid]
|
||||
|
||||
exclusions:
|
||||
- { navigation: keyset_cursor, query_shape: fulltext, reason: "관련도순은 계산값 정렬이라 커서 경계로 쓸 수 없다 — KeysetPaginator::supports 가 거부하고 offset 을 유지한다" }
|
||||
@@ -68,6 +68,11 @@ effects:
|
||||
- search_latest_sort_uses_cursor
|
||||
- search_relevance_sort_stays_on_offset
|
||||
- search_cursor_payload_keeps_offset_key_set
|
||||
# 판정 규칙을 코어가 소유해도 호출자가 페이지 번호를 넘기지 않으면 규칙이 무력해진다.
|
||||
# 기본값 1 이 적용돼 모든 요청이 첫 페이지로 판정되고 딥링크가 조용히 되돌아간다.
|
||||
- every_uses_cursor_call_forwards_page
|
||||
- search_deep_page_link_keeps_offset
|
||||
- search_first_page_starts_cursor
|
||||
# ── 집계 정확도 (P1) ──
|
||||
- aggregate_is_inexact_when_any_category_truncated
|
||||
- single_tab_accuracy_ignores_other_categories
|
||||
@@ -85,29 +90,39 @@ effects:
|
||||
# ── 화면 소비단 (P1) ──
|
||||
- datagrid_preserves_null_last_page
|
||||
- datagrid_shows_pager_when_only_has_more_pages_is_known
|
||||
- storefront_grid_pager_uses_has_more_pages
|
||||
- shared_partial_reads_parent_declared_names
|
||||
- product_grid_pager_uses_has_more_pages
|
||||
# ── 절단 노출 (P1) ──
|
||||
- comment_list_discloses_truncation
|
||||
- comment_count_reports_at_least_over_cap
|
||||
# ── 한계값 해석 (P1) ──
|
||||
- limits_resolve_settings_then_config_then_hook
|
||||
- zero_limit_means_unlimited
|
||||
# ── 한계값 설정 저장 (P1) ──
|
||||
# 상한값 해석은 관리자 설정을 1순위로 읽는다. 그런데 고급 탭 저장이 그 카테고리를
|
||||
# 분류하지 않으면 입력값이 어디에도 담기지 않고 버려진다 — 저장은 200 으로 성공하고
|
||||
# 검증도 통과하므로, 운영자에게는 "바꿨는데 안 바뀐다" 로만 나타난다.
|
||||
- pagination_limits_persist_from_admin_settings
|
||||
- advanced_tab_persists_every_merged_category
|
||||
# ── 확장 다국어 키 해석 (P1) ──
|
||||
# 네임스페이스 없는 키는 번역이 실패해도 예외가 나지 않고 원시 키가 화면에 그대로 나간다.
|
||||
- extension_non_namespaced_key_resolves_to_core_group
|
||||
# ── 종단(브라우저) 확인 (P1) ──
|
||||
# 위 항목들은 계약 단위이고, 아래 넷은 그 계약이 실제 화면까지 도달했는지를 본다.
|
||||
# 응답이 정상이어도 바인딩 경로가 어긋나면 화면만 조용히 비므로 API 테스트로는 안 잡힌다.
|
||||
- search_count_marks_inexact_total
|
||||
- search_pager_survives_null_last_page
|
||||
- storefront_uses_single_request
|
||||
- storefront_sections_render
|
||||
# ── 요청당 중복 조회 제거 (F3 / F4) ──
|
||||
# ── 검색 화면 상태의 주소 승계 (P1) ──
|
||||
# 탭·정렬·페이지가 전역 상태에만 있으면 주소가 바뀌지 않는다. 화면은 정상으로 보이고
|
||||
# 콘솔 에러도 없지만 새로고침·뒤로가기에서 조건이 통째로 초기화되고 공유도 불가능하다.
|
||||
- search_state_persists_in_url
|
||||
- search_state_survives_reload
|
||||
- search_state_restores_on_back
|
||||
- search_new_keyword_resets_state
|
||||
# ── 요청당 중복 조회 제거 (F3) ──
|
||||
# 같은 행을 두 번 읽는 것은 응답이 정상이라 어떤 기능 테스트에도 걸리지 않는다.
|
||||
# "몇 번 읽었는가" 를 직접 세야만 회귀가 드러난다.
|
||||
- post_detail_reads_post_row_once
|
||||
- post_detail_reads_board_row_once
|
||||
- storefront_category_matches_single_endpoint
|
||||
- storefront_without_slug_omits_category
|
||||
- storefront_unknown_slug_returns_not_found
|
||||
|
||||
# scale: 대용량 회귀 축. block-array of flow objects (내장 fallback 파서 제약 — flow 객체만 중첩 파싱)
|
||||
scale:
|
||||
@@ -126,6 +141,8 @@ test_files:
|
||||
- tests/Feature/Search/SearchAccuracyContractTest.php
|
||||
# 코어 — 커서 적용 판정 규칙 (도메인이 아니라 코어가 소유한다는 성질)
|
||||
- tests/Unit/Search/SearchPagePolicyTest.php
|
||||
# 코어 — 그 판정에 페이지 번호가 도달하는지 (호출 지점 전수 스캔)
|
||||
- tests/Unit/Search/SearchCursorPagePropagationParityTest.php
|
||||
# 코어 — 탭 배지 정확도가 카테고리마다 응답에 실리는지
|
||||
- tests/Feature/Search/SearchBadgeAccuracyTest.php
|
||||
# 게시판 — 검색이 커서 경로를 타는지 + 세 응답 형태의 키 집합이 같은지
|
||||
@@ -143,6 +160,10 @@ test_files:
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/ListQueryCountRegressionTest.php
|
||||
# 이커머스 — 상품 목록·공개 목록·카테고리 트리 쿼리 수 회귀
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/ListQueryCountRegressionTest.php
|
||||
# 이커머스 — 쇼핑 첫 화면 조각별(인기·신상품·최근 본) 쿼리 수 회귀
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Performance/ShopHomeQueryCountRegressionTest.php
|
||||
# 이커머스 — 상품 검색의 커서 딥링크 (커서 없는 깊은 페이지는 offset 유지)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Search/ProductSearchCursorDeepLinkTest.php
|
||||
# 이커머스 — 집계 캐시와 무효화 (낡은 수치 방지)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/ListCacheInvalidationTest.php
|
||||
# 이커머스 — 진열·판매 집계 색인이 옵티마이저에 실제로 잡히는지
|
||||
@@ -154,11 +175,17 @@ test_files:
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/Performance/ListQueryCountRegressionTest.php
|
||||
# 페이지 — 비활성 탭 배지의 정확도 전달
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
|
||||
# 화면 — 공유 partial 이 부모가 선언한 이름을 읽는지 + 페이저가 has_more_pages 로 열리는지
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/shop-shared-partial-datasource-contract.test.tsx
|
||||
# 페이지 — 페이지 검색의 커서 딥링크 (커서 없는 깊은 페이지는 offset 유지)
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/Search/PageSearchCursorDeepLinkTest.php
|
||||
# 중복 조회 — 상세에서 같은 글/게시판을 몇 번 읽는지
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/PostDetailQueryCountRegressionTest.php
|
||||
# 통합 조회의 단건 분류가 개별 엔드포인트와 같은 계약인지
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/StorefrontCategoryContractTest.php
|
||||
# 브라우저 — 상한 목록의 다음 이동 / 마지막 페이지 감춤 / 쇼핑 첫 화면 단일 요청
|
||||
- tests/Playwright/specs/pagination/bounded-total-and-storefront.spec.ts
|
||||
# 브라우저 — 상한 목록의 다음 이동 / 상품 그리드 페이저
|
||||
- tests/Playwright/specs/pagination/bounded-total-and-product-grid.spec.ts
|
||||
# 코어 — 고급 탭 저장이 병합 대상 카테고리(상한값 포함)를 빠짐없이 분류하는지
|
||||
- tests/Feature/Settings/AdvancedTabCategoryPersistenceTest.php
|
||||
# 코어 — 확장의 네임스페이스 없는 다국어 키가 해석되는지 (확장 소스 전수 스캔)
|
||||
- tests/Unit/ExtensionTranslationKeyResolutionTest.php
|
||||
# 화면 — 검색 상태의 SSoT 가 URL 쿼리인지 (레이아웃 전수 스캔)
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/search-url-state-contract.test.ts
|
||||
# 브라우저 — 검색 상태의 주소 승계 / 새로고침·뒤로가기 복원 / 재검색 리셋
|
||||
- tests/Playwright/specs/search/url-state.spec.ts
|
||||
|
||||
@@ -41,7 +41,7 @@ exclusions:
|
||||
- { vector: assert_security_rules, actor_permission: install_only, reason: "서비스 계층의 내용·형식 검사로 권한과 무관하게 모든 설치 경로에 동일 적용" }
|
||||
- { vector: assert_security_rules, actor_permission: manage_only, reason: "동일 — 권한별 분기 없음" }
|
||||
- { vector: assert_security_rules, actor_permission: install_and_manage, reason: "동일 — 권한별 분기 없음" }
|
||||
- { vector: install_from_untrusted_source, actor_permission: cli_system, reason: "CLI 는 FormRequest 를 거치지 않는다 — 권한 게이트를 서비스에 두지 않기로 한 설계의 직접 귀결이며 CLI 비파괴는 별도 커맨드 테스트가 담당" }
|
||||
- { vector: install_from_untrusted_source, actor_permission: cli_system, reason: "CLI 는 FormRequest 를 거치지 않는다 — 권한 게이트를 서비스에 두지 않기로 한 설계의 직접 귀결이다. CLI 비파괴 자체는 finalize_install_activation × cli_system 셀이 실 커맨드 실행으로 단언한다(cli_installation_is_unaffected_by_the_http_activation_gate)" }
|
||||
- { vector: install_from_bundled, actor_permission: manage_only, reason: "설치 권한이 없어 라우트 미들웨어에서 끊긴다 — 외부 소스 쪽에서 같은 경계를 이미 단언하므로 번들에서 반복하지 않는다" }
|
||||
- { vector: install_from_bundled, actor_permission: cli_system, reason: "동일 — FormRequest 미경유" }
|
||||
- { vector: finalize_install_activation, actor_permission: install_only, reason: "서비스 계층 활성화 경로로 HTTP 권한 컨텍스트가 없다 — 권한 판정은 FormRequest 계층이 소유" }
|
||||
@@ -72,6 +72,8 @@ effects:
|
||||
- activated_hook_fires_exactly_once_on_auto_activate
|
||||
- auto_activate_skipped_when_slot_occupied
|
||||
- auto_activate_goes_through_activate_and_enforces_dependencies
|
||||
- reinstalling_an_active_pack_does_not_demote_it
|
||||
- cli_installation_is_unaffected_by_the_http_activation_gate
|
||||
- update_of_an_active_pack_keeps_it_active
|
||||
- update_of_an_inactive_pack_does_not_promote_it
|
||||
|
||||
|
||||
Reference in New Issue
Block a user