diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e73358d..e34eaa56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,18 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [7.0.1] - 2026-07-01 +## [7.0.1] - 2026-07-02 ### Changed - 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다. +### Fixed + +- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다. +- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다. +- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.) + ## [7.0.0] - 2026-07-01 ### Added diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 25d1b806..fb381c94 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -806,6 +806,7 @@ class CoreUpdateCommand extends Command // 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용). $handoffPayload = null; $stepsExecuted = null; + $stepsDiscovered = null; while (! feof($pipes[1])) { $line = fgets($pipes[1]); if ($line !== false) { @@ -837,7 +838,13 @@ class CoreUpdateCommand extends Command $decoded = json_decode($json, true); if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) { $stepsExecuted = $decoded['count']; - $log('[spawn] 실행된 step 수: '.$stepsExecuted); + // discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은 + // null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다. + if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) { + $stepsDiscovered = $decoded['discovered']; + } + $log('[spawn] 실행된 step 수: '.$stepsExecuted + .($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : '')); continue; } @@ -852,6 +859,50 @@ class CoreUpdateCommand extends Command fclose($pipes[2]); $exitCode = proc_close($process); + return $this->handleSpawnExit( + $exitCode, + $handoffPayload, + $stepsExecuted, + $stepsDiscovered, + $fromVersion, + $toVersion, + $log, + ); + } + + /** + * spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다. + * + * 판정 규칙 (exit=0 경우): + * - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast. + * 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. + * - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로 + * 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A). + * - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이 + * 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). fromfailSpawnWithMode( 'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심', @@ -878,9 +927,27 @@ class CoreUpdateCommand extends Command ); } - if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) { + // executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B). + // 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다. + if ($stepsExecuted === 0 && $stepsDiscovered === 0) { + $log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})"); + + return true; + } + + // executed=0 + (discovered>0 또는 discovered 신호 부재) + from0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A). + // discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지. + if ($stepsExecuted === 0 + && $stepsDiscovered !== 0 + && version_compare($fromVersion, $toVersion, '<')) { return $this->failSpawnWithMode( - sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion), + sprintf( + 'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)', + $fromVersion, + $toVersion, + $stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '', + ), $log, $fromVersion, $toVersion, diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 19c8f690..9f7ff3bc 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -164,6 +164,7 @@ class ExecuteUpgradeStepsCommand extends Command } $stepsExecuted = 0; + $stepsDiscovered = 0; try { $service->runUpgradeSteps( @@ -174,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command $stepsExecuted++; }, $force, + function (int $discovered) use (&$stepsDiscovered): void { + $stepsDiscovered = $discovered; + }, ); } catch (UpgradeHandoffException $e) { // 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다. @@ -221,6 +225,7 @@ class ExecuteUpgradeStepsCommand extends Command // mode=fallback 시 in-process 진행 (`spawn_failure_mode`). $this->line('[STEPS_EXECUTED] '.json_encode([ 'count' => $stepsExecuted, + 'discovered' => $stepsDiscovered, 'fromVersion' => $from, 'toVersion' => $to, ], JSON_UNESCAPED_UNICODE)); diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php index 67db687d..e29877b2 100644 --- a/app/Extension/Helpers/FilePermissionHelper.php +++ b/app/Extension/Helpers/FilePermissionHelper.php @@ -53,15 +53,25 @@ class FilePermissionHelper $destPath = $destination.DIRECTORY_SEPARATOR.$itemName; - // symlink 자체 보존: target 추적 없이 동일 symlink 재생성. + // symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성. // SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다. - // 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상. - if ($item->isLink()) { - if (static::copySymlink($item->getPathname(), $destPath)) { + // 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상. + // + // Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두 + // false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면 + // junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다. + // isReparsePoint() 로 junction 도 이 분기에 포함시킨다. + if ($item->isLink() || static::isReparsePoint($item->getPathname())) { + if (static::copyLink($item->getPathname(), $destPath)) { continue; } // 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through. + // 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가 + // 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다. // 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능. + if (! $item->isLink()) { + continue; + } } if ($item->isDir()) { @@ -79,25 +89,63 @@ class FilePermissionHelper } /** - * symlink 자체를 보존 복사합니다 (target 미추적). + * 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다. * - * 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성. - * readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through. + * Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` / + * `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로 + * target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는 + * `public/storage` 가 이 형태다. * - * Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는 - * 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로 - * fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동 - * 명령으로 symlink 를 회복할 수 있다. + * 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction + * 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음" + * 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지 + * 않는 경로를 배제한다. * - * @param string $source 소스 symlink 경로 - * @param string $destination 대상 symlink 경로 - * @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백) + * 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다. + * junction 은 Windows 전용이므로 비-Windows 에서는 항상 false. + * + * @param string $path 검사할 경로 + * @return bool junction 등 isLink() 미인식 reparse point 여부 */ - protected static function copySymlink(string $source, string $destination): bool + protected static function isReparsePoint(string $path): bool + { + if (PHP_OS_FAMILY !== 'Windows') { + return false; + } + + // 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님. + if (is_link($path) || is_file($path) || is_dir($path)) { + return false; + } + + // 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point). + return @readlink($path) !== false; + } + + /** + * symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적). + * + * 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성. + * readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through. + * + * 재생성 전략: + * - 일반 symlink: `symlink()` + * - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백. + * Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은 + * 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다. + * + * 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후 + * `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다. + * + * @param string $source 소스 링크(symlink/junction) 경로 + * @param string $destination 대상 링크 경로 + * @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백) + */ + protected static function copyLink(string $source, string $destination): bool { $target = @readlink($source); if ($target === false) { - Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]); + Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]); return false; } @@ -109,17 +157,29 @@ class FilePermissionHelper File::deleteDirectory($destination); } - if (! @symlink($target, $destination)) { - Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [ - 'source' => $source, - 'destination' => $destination, - 'target' => $target, - ]); - - return false; + if (@symlink($target, $destination)) { + return true; } - return true; + // symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성. + if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) { + exec( + 'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1', + $out, + $code + ); + if ($code === 0 && is_dir($destination)) { + return true; + } + } + + Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [ + 'source' => $source, + 'destination' => $destination, + 'target' => $target, + ]); + + return false; } /** @@ -157,11 +217,19 @@ class FilePermissionHelper // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { - // symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후 - // 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고 - // 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다. + // symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir() + // 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는 + // 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다. + // + // junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크 + // 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다. if (is_link($destItem->getPathname())) { @unlink($destItem->getPathname()); + } elseif (static::isReparsePoint($destItem->getPathname())) { + // junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도. + if (! @rmdir($destItem->getPathname())) { + @unlink($destItem->getPathname()); + } } elseif ($destItem->isDir()) { File::deleteDirectory($destItem->getPathname()); } else { diff --git a/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php b/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php index f8af3027..d53ac9a9 100644 --- a/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php +++ b/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php @@ -187,7 +187,7 @@ class AdminTemplateAssetController extends AdminBaseController /** * 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환. * - * PO admin 환경이 다크 테마면 `` 가 조상으로 남아, 프리뷰 + * 관리자 admin 환경이 다크 테마면 `` 가 조상으로 남아, 프리뷰 * 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가 * 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의 * 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다. diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index b5662e2a..f3f3fc9e 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Settings\RegenerateAppKeyRequest; +use App\Http\Requests\Settings\RestoreSettingsRequest; use App\Http\Requests\Settings\SaveSettingsRequest; use App\Http\Requests\Settings\TestDriverConnectionRequest; use App\Http\Requests\Settings\TestMailRequest; @@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester; use App\Services\DriverRegistryService; use App\Services\SettingsService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; /** @@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController $systemInfo = $this->settingsService->getSystemInfo(); return $this->success('common.success', $systemInfo); - } catch (\Exception $e) { + } catch (\Throwable $e) { + Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]); + return $this->error('common.error_occurred', 500, $e->getMessage()); } } @@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController /** * 백업에서 설정을 복원합니다. * + * @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로) * @return JsonResponse 복원 결과 JSON 응답 */ - public function restore(Request $request): JsonResponse + public function restore(RestoreSettingsRequest $request): JsonResponse { try { - $backupPath = $request->input('backup_path'); - - if (empty($backupPath)) { - return $this->error('settings.backup_path_required', 422); - } + $backupPath = $request->validated('backup_path'); $result = $this->settingsService->restoreSettings($backupPath); diff --git a/app/Http/Requests/Settings/RestoreSettingsRequest.php b/app/Http/Requests/Settings/RestoreSettingsRequest.php new file mode 100644 index 00000000..2be11e66 --- /dev/null +++ b/app/Http/Requests/Settings/RestoreSettingsRequest.php @@ -0,0 +1,55 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'backup_path' => 'required|string', + ]; + + // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 + return HookManager::applyFilters('core.settings.restore_validation_rules', $rules, $this); + } + + /** + * 검증 오류 메시지를 반환합니다. + * + * @return array + */ + public function messages(): array + { + return [ + 'backup_path.required' => __('settings.backup_path_required'), + ]; + } +} diff --git a/app/Models/TemplateLayoutAttachment.php b/app/Models/TemplateLayoutAttachment.php index a7b8b0a8..853125c4 100644 --- a/app/Models/TemplateLayoutAttachment.php +++ b/app/Models/TemplateLayoutAttachment.php @@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)을 표현한다. 파일 자체는 * 코어 StorageInterface 로 저장되고 본 모델은 저장 위치(disk/path)와 메타데이터를 * 보관한다. 템플릿 삭제 시 행은 cascadeOnDelete 로 삭제되나, 스토리지 파일 - * 실삭제는 Service 가 명시적으로 수행한다(CLAUDE.md "DB CASCADE 의존 삭제 금지"). + * 실삭제는 Service 가 명시적으로 수행한다(코어 규정: DB CASCADE 의존 삭제 금지). * * @property int $id 첨부 파일 ID * @property int $template_id 소속 템플릿 ID diff --git a/app/Seo/Editor/BroadcastCatalogService.php b/app/Seo/Editor/BroadcastCatalogService.php index 0d66d907..e1ecd889 100644 --- a/app/Seo/Editor/BroadcastCatalogService.php +++ b/app/Seo/Editor/BroadcastCatalogService.php @@ -11,7 +11,7 @@ use App\Contracts\Extension\PluginManagerInterface; * 데이터소스 종류=websocket 편집 시 채널을 "구독 가능한 목록 기준 검색 드롭다운"으로 * 제시하기 위해, "지금 이 설치본에서 구독 가능한 채널"을 런타임 수집한다. 코어/확장이 * `getChannels()` 오버라이드로 등록한 채널명을 출처(core/module/plugin)와 함께 모은다 - * (CLAUDE.md 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인 + * (코어 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인 * `plugin.{id}.*`). * * 이벤트는 `HookManager::broadcast($channel, $eventName, $payload)` 호출 시점에 이름이 diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index 57f6dc6e..b3523eff 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -1558,8 +1558,9 @@ class CoreUpdateService * @param string $toVersion 종료 버전 * @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달) * @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함 + * @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달) */ - public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void + public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void { // 부모 in-process fallback 진입 시 stale 메모리 가드. // @@ -1597,6 +1598,9 @@ class CoreUpdateService $upgradesPath = base_path('upgrades'); if (! File::isDirectory($upgradesPath)) { + // upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료. + $onDiscovered?->__invoke(0); + return; } @@ -1656,6 +1660,12 @@ class CoreUpdateService uksort($steps, 'version_compare'); + // 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다. + // 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은 + // 있는데 실행이 0건(비정상 — gnuboard/g7#28 silent skip)" 을 구분한다. onStep 은 실제 실행 + // 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다. + $onDiscovered?->__invoke(count($steps)); + $context = new UpgradeContext($fromVersion, $toVersion); foreach ($steps as $version => $step) { diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index d9fb5e12..dfce003f 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -822,30 +822,74 @@ class SettingsService private function buildSystemInfo(): array { return [ - 'os_info' => php_uname('s').' '.php_uname('r'), + // php_uname / ini_get 은 disable_functions 로 차단될 수 있어 개별 격리한다. + 'os_info' => $this->safeSystemProbe('os_info', fn () => php_uname('s').' '.php_uname('r'), __('common.unknown')), 'web_server' => $_SERVER['SERVER_SOFTWARE'] ?? __('common.unknown'), 'php_version' => PHP_VERSION, - 'mysql_version' => $this->getDatabaseInfo(), + 'mysql_version' => $this->safeSystemProbe('mysql_version', fn () => $this->getDatabaseInfo(), __('common.unknown')), 'g7_version' => config('app.version', '1.0.0'), 'g7_release_year' => config('app.release_year', '2026'), 'laravel_version' => app()->version(), 'environment' => app()->environment(), - 'cpu_info' => $this->getCpuInfo(), - 'memory_usage' => $this->getMemoryUsage(), - 'disk_usage' => $this->getDiskUsage(), - 'php_memory_limit' => ini_get('memory_limit'), - 'max_execution_time' => ini_get('max_execution_time').__('settings.seconds'), - 'upload_max_filesize' => ini_get('upload_max_filesize'), + 'cpu_info' => $this->safeSystemProbe('cpu_info', fn () => $this->getCpuInfo(), __('common.unknown')), + 'memory_usage' => $this->safeSystemProbe('memory_usage', fn () => $this->getMemoryUsage(), $this->unknownUsage()), + 'disk_usage' => $this->safeSystemProbe('disk_usage', fn () => $this->getDiskUsage(), $this->unknownUsage()), + 'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')), + 'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')), + 'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')), 'install_path' => base_path(), 'config_path' => storage_path('app/settings'), 'log_path' => storage_path('logs'), 'upload_path' => storage_path('app/public'), - 'php_extensions' => $this->getPhpExtensions(), - 'database_config' => $this->getDatabaseConfig(), + 'php_extensions' => $this->safeSystemProbe('php_extensions', fn () => $this->getPhpExtensions(), ['required' => [], 'optional' => []]), + 'database_config' => $this->safeSystemProbe('database_config', fn () => $this->getDatabaseConfig(), ['has_read_write_split' => false, 'write' => [], 'read' => []]), 'timezone' => config('app.timezone'), ]; } + /** + * 시스템 정보 probe 를 안전하게 실행합니다. + * + * probe 가 예외(ErrorException·Error·disable_functions 로 인한 Error 포함)를 + * 던지면 전파하지 않고 폴백값을 반환하고 경고 로그를 남깁니다. 개별 항목 수집 + * 실패가 system-info API 전체를 500 으로 만들지 않도록 격리합니다. + * + * @param string $label 실패 로그 식별용 항목명 (예: 'cpu_info') + * @param callable $cb 실행할 probe 콜백 + * @param mixed $fallback 실패 시 반환할 폴백 값 + * @return mixed probe 결과 또는 폴백 + */ + private function safeSystemProbe(string $label, callable $cb, mixed $fallback): mixed + { + try { + return $cb(); + } catch (\Throwable $e) { + Log::warning('시스템 정보 수집 실패', [ + 'item' => $label, + 'error' => $e->getMessage(), + ]); + + return $fallback; + } + } + + /** + * 메모리/디스크 사용량 조회 실패 시 사용할 폴백 배열을 반환합니다. + * + * total/used/free/percentage 형식을 memory_usage·disk_usage 와 동일하게 유지합니다. + * + * @return array 사용량 폴백 배열 + */ + private function unknownUsage(): array + { + return [ + 'total' => __('common.unknown'), + 'used' => __('common.unknown'), + 'free' => __('common.unknown'), + 'percentage' => 0, + ]; + } + /** * 시스템 캐시를 정리합니다. * @@ -949,11 +993,16 @@ class SettingsService try { $connection = DB::connection(); $driver = $connection->getDriverName(); - $version = $connection->select('SELECT VERSION() as version')[0]->version ?? 'Unknown'; + $version = $connection->select('SELECT VERSION() as version')[0]->version ?? __('common.unknown'); return ucfirst($driver).' '.$version; - } catch (\Exception $e) { - return 'Unknown'; + } catch (\Throwable $e) { + Log::warning('시스템 정보 수집 실패', [ + 'item' => 'mysql_version', + 'error' => $e->getMessage(), + ]); + + return __('common.unknown'); } } @@ -999,12 +1048,7 @@ class SettingsService } if ($total <= 0) { - return [ - 'total' => __('common.unknown'), - 'used' => __('common.unknown'), - 'free' => __('common.unknown'), - 'percentage' => 0, - ]; + return $this->unknownUsage(); } $used = max(0, $total - $free); @@ -1034,12 +1078,7 @@ class SettingsService $free = is_numeric($freeRaw) ? (int) $freeRaw : 0; if ($total <= 0) { - return [ - 'total' => __('common.unknown'), - 'used' => __('common.unknown'), - 'free' => __('common.unknown'), - 'percentage' => 0, - ]; + return $this->unknownUsage(); } $used = max(0, $total - $free); @@ -1077,7 +1116,7 @@ class SettingsService * * @return string CPU 정보 문자열 */ - private function getCpuInfo(): string + protected function getCpuInfo(): string { if (PHP_OS_FAMILY === 'Windows') { $output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1'); diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php index 929e68b0..d9596476 100644 --- a/app/Services/TemplateLayoutAttachmentService.php +++ b/app/Services/TemplateLayoutAttachmentService.php @@ -18,7 +18,7 @@ use Illuminate\Support\Str; * 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다. * 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고, * 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다. - * (Storage::disk() 직접 호출 금지 — CLAUDE.md 스토리지 규칙) + * (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙) */ class TemplateLayoutAttachmentService { @@ -114,7 +114,7 @@ class TemplateLayoutAttachmentService * 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제. * * DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다 - * (CLAUDE.md "DB CASCADE 의존 삭제 금지" — 파일 정리 보장). + * (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장). * * @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일 * @return bool 삭제 성공 여부 diff --git a/docs/extension/editor-spec.md b/docs/extension/editor-spec.md index 0aa3d3b3..1abeee75 100644 --- a/docs/extension/editor-spec.md +++ b/docs/extension/editor-spec.md @@ -136,7 +136,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄 ### actionRecipes — 친화 명칭 → 핸들러 JSON -`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다. +`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 코어 핸들러 규칙의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다. `build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`). diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md index d4cd3c44..8bd2dd88 100644 --- a/docs/testing/e2e-testing.md +++ b/docs/testing/e2e-testing.md @@ -219,7 +219,7 @@ test('unauthenticated_401 × no_token', async ({ page }) => { `audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증. -## §8. 회귀 테스트 4단계 (CLAUDE.md 의무 절차 준수) +## §8. 회귀 테스트 4단계 버그 수정 시 다음 4단계를 스킵 불가: @@ -236,7 +236,7 @@ E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리: - **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정 - **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정 -- **데이터 구조 불일치** (양쪽 모두 오래됨) → PO 보고 + 보류 +- **데이터 구조 불일치** (양쪽 모두 오래됨) → 보고 + 보류 상세: `testing-guide.md` "무관 에러 처리 분기". diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 3526455f..e0c432a2 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-02 + +### Added + +- 설정 복원 관련 안내 메시지 일본어 번역 추가 (`settings.backup_path_required`·`restore_success`·`restore_failed`·`restore_error`) — 백업 경로 미입력 안내와 설정 복원 성공/실패 결과가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index 561e3038..30137ecb 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -145,4 +145,8 @@ return [ 'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。', 'composer_failed_with_output' => 'composer installの実行に失敗しました。:output', ], + 'backup_path_required' => 'バックアップパスを入力してください。', + 'restore_success' => '設定が正常に復元されました。', + 'restore_failed' => '設定の復元に失敗しました。', + 'restore_error' => '設定の復元中にエラーが発生しました。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index fc7b6dca..4517babf 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "scope": "core", "target_identifier": null, diff --git a/lang/en/settings.php b/lang/en/settings.php index 100a4379..0756b246 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -21,6 +21,10 @@ return [ 'backup_success' => 'Database backup started successfully.', 'backup_failed' => 'Failed to start database backup.', 'backup_error' => 'An error occurred while backing up database.', + 'backup_path_required' => 'Please enter a backup path.', + 'restore_success' => 'Settings restored successfully.', + 'restore_failed' => 'Failed to restore settings.', + 'restore_error' => 'An error occurred while restoring settings.', 'save_individual_failed' => 'Settings save failed: :error', // App key related messages diff --git a/lang/ko/settings.php b/lang/ko/settings.php index f5e4efbd..4c49dd4f 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -21,6 +21,10 @@ return [ 'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.', 'backup_failed' => '데이터베이스 백업에 실패했습니다.', 'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.', + 'backup_path_required' => '백업 경로를 입력해주세요.', + 'restore_success' => '설정이 성공적으로 복원되었습니다.', + 'restore_failed' => '설정 복원에 실패했습니다.', + 'restore_error' => '설정 복원 중 오류가 발생했습니다.', 'save_individual_failed' => '설정 저장에 실패했습니다: :error', // 앱 키 관련 메시지 diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx index f9523fa9..d5ce0ef0 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx @@ -968,7 +968,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => { it('admin-card 안 4 섹션 description 이 .card-description 시맨틱이고 card-title 의 직계 sibling 이다 (#408)', () => { // _tab_report_policy.json 의 4 섹션 (자동 숨김, abuse_prevention, permissions, notification) // description 이 stack 안에 묻혀있으면 .card-title:has(+ .card-description) 셀렉터 불발 → - // card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (PO 보고 #408) + // card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (보고 #408) // // 기대: admin-card 직계 자식 순서가 [card-title, card-description, stack, ...] 이고, // card-description 의 className 에 'card-description' 시맨틱 자산이 포함되어야 함. @@ -1045,7 +1045,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => { }); it('board layout 의 모든 .card-description 은 Div 태그여야 한다 — Span/P 금지 (#408)', () => { - // PO 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML + // 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML // → div.card-description 만 허용. const layouts: Array<[string, any]> = [ ['mainLayout', mainLayout], diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-comment-cascade-visibility.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-comment-cascade-visibility.test.tsx index ac24c7b5..6ade3505 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-comment-cascade-visibility.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-comment-cascade-visibility.test.tsx @@ -5,7 +5,7 @@ * 게시글 삭제로 함께 숨겨진(cascade) 댓글이 사용자 직접 삭제 댓글과 동일하게 * "삭제된 댓글입니다" 로 마스킹되던 회귀의 방지. * - * 정책(PO 확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다. + * 정책(확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다. * 백엔드(CommentResource)가 is_cascade_deleted 플래그를 내려주고, * 레이아웃은 그 플래그로 분기를 결정한다. * diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin_dashboard_extension.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin_dashboard_extension.test.tsx index b03b0aa8..0e1c61f0 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin_dashboard_extension.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin_dashboard_extension.test.tsx @@ -265,7 +265,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => { // 카드는 Pending/Review 신고만 표시한다 (ReportRepository::getPendingAcrossBoards). // 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다. // 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다. - // PO 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로". + // 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로". const statusBadge = findById(communityExt as any, 'report_status_badge'); expect(statusBadge).not.toBeNull(); expect(statusBadge.if).toBe('{{!!report?.status_label}}'); diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-copy-modal-info.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-copy-modal-info.test.tsx index b3eb3593..4cc3a93d 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-copy-modal-info.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-copy-modal-info.test.tsx @@ -2,7 +2,7 @@ * @file board-copy-modal-info.test.tsx * @description 게시판 복사 확인 모달 - 안내 박스(배지 박스 + 불릿 3항목) 레이아웃 JSON 구조 검증 * - * 이슈 #413-16 후속(PO 요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 / + * 이슈 #413-16 후속(요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 / * 새 슬러그 확인" 3항목으로 구조화. 안내 박스가 테두리+배경 컨테이너 안에 불릿 3개를 * 포함하는지 JSON 트리로 검증한다 (DOM 비의존 구조 검증). */ diff --git a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php index b8cc8348..7628b880 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php @@ -216,7 +216,7 @@ class CommentRepository implements CommentRepositoryInterface * 재귀적으로 모든 하위 자손 수를 계산합니다. * * tombstone(삭제) 댓글은 트리 구조 유지를 위해 노드로는 남기지만, - * "답글 N" 집계 모수에서는 제외한다(PO 확정). 단, 삭제 댓글의 + * "답글 N" 집계 모수에서는 제외한다(확정 정책). 단, 삭제 댓글의 * 살아있는 하위 자손은 계속 카운트해 누락되지 않게 한다. * * @param Collection $grouped parent_id로 그룹화된 댓글 컬렉션 diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/DeletedPostDetailVisibilityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/DeletedPostDetailVisibilityTest.php index e74e7fa8..69f0ef59 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/DeletedPostDetailVisibilityTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/DeletedPostDetailVisibilityTest.php @@ -21,7 +21,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase; * 증상2 — cascade 로 함께 숨겨진 댓글이 user 선삭제 댓글과 동일하게 * "삭제된 댓글입니다" 로 마스킹됨 (cascade/user 구분 신호 부재) * - * 정책(PO 확정): + * 정책(확정): * - 첨부: 관리 권한자에게는 댓글과 동일하게 cascade 삭제분도 노출 * - cascade 댓글: 관리 권한자에게는 마스킹 없이 원문 노출 (is_cascade_deleted 신호 제공) * - user 선삭제분: 기존대로 마스킹 유지 diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostDeleteCascadeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostDeleteCascadeTest.php index 920a5f84..d7b1d192 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostDeleteCascadeTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostDeleteCascadeTest.php @@ -132,7 +132,7 @@ class PostDeleteCascadeTest extends BoardTestCase } /** - * 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (PO 우려 차단). + * 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (우려 차단). * * @effects cascaded_comment_restored_on_post_restore,cascaded_attachment_restored_on_post_restore,user_pre_deleted_comment_stays_trashed,user_pre_deleted_attachment_stays_trashed */ diff --git a/modules/_bundled/sirsoft-board/tests/Feature/PostSearchSecretPreviewTest.php b/modules/_bundled/sirsoft-board/tests/Feature/PostSearchSecretPreviewTest.php index 6ff0040d..2585e0d5 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/PostSearchSecretPreviewTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/PostSearchSecretPreviewTest.php @@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test; * * 게시판 내부 검색은 별도 엔드포인트가 아니라 목록 조회(index)에 search 필터를 얹은 것이다. * 비밀글(is_secret)·블라인드(status=blinded) 게시글은 목록/검색 응답에서 본문 요약이 - * 권한과 무관하게 빈 문자열로 차단되어야 한다 (PO 확정: 목록 미리보기는 권한자도 숨김). + * 권한과 무관하게 빈 문자열로 차단되어야 한다 (확정 정책: 목록 미리보기는 권한자도 숨김). * * 제목은 현행 유지(노출), 차단 대상은 content_preview 뿐. * 상세 본문(content) 권한자 노출은 기존 정책(PostBlindedAccessControlTest)이 보장 — 본 테스트 범위 밖. @@ -237,7 +237,7 @@ class PostSearchSecretPreviewTest extends BoardTestCase /** * 비밀글 미리보기는 작성자 본인에게도 목록에서는 차단된다(빈 문자열). - * (PO 확정: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출) + * (확정 정책: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출) * * @scenario viewer=author * diff --git a/modules/_bundled/sirsoft-board/tests/Unit/BoardDashboardServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/BoardDashboardServiceTest.php index 4ad8085e..7501bc64 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/BoardDashboardServiceTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/BoardDashboardServiceTest.php @@ -183,7 +183,7 @@ class BoardDashboardServiceTest extends ModuleTestCase // FormatsBoardDate 표준형 규칙: // - 24시간 이상 차이: 올해는 'MM-DD', 작년 이전은 'YY-MM-DD' // 캡션이 게시판 날짜 표시 규칙과 일관되어야 한다는 회귀 가드. - // (게시글 created_at 표시와 동일 컨벤션 — PO 시각 검증 피드백) + // (게시글 created_at 표시와 동일 컨벤션 — 시각 검증 피드백) $today = CarbonImmutable::today(); // 어제 자정에 마지막 스케줄러가 돈 상황을 시뮬레이션 $yesterdayBoundary = $today->subDay()->setTime(0, 0, 0); @@ -223,7 +223,7 @@ class BoardDashboardServiceTest extends ModuleTestCase public function test_get_recent_posts_excludes_replies(): void { // 대시보드 "최신 게시글" 카드는 본문 글만 보여줘야 한다 (답글 Re: 제외). - // PO 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것. + // 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것. // PostRepository::getRecentAcrossBoards 가 whereNull('parent_id') 누락했던 회귀 가드. $parent = $this->makePost('원글'); $reply = $this->makePost('Re: 원글'); diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-attachment-download-actor.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-attachment-download-actor.yaml index 46e92833..885a7041 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-attachment-download-actor.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-attachment-download-actor.yaml @@ -17,7 +17,7 @@ description: | (composite/ImageGallery.tsx 의 downloadAuthenticatedFile 과 동일 패턴). · 레이아웃 카드 3곳 전환: 사용자 _post_attachments(권한 분기 보존), 관리자 _post_card_content / _reply_card_content(무분기) 의 → Div + click 핸들러. - - PO 정책 확정: + - 정책 확정: · 범위 = 게시판만 3곳 (페이지 첨부는 별도). 비회원 = NULL 유지(현행). 성공 토스트 없음. · 서버(라우트/컨트롤러/서비스) 무변경 — optional.sanctum 유지가 비회원 NULL 정책과 정합. diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-blinded-original-access.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-blinded-original-access.yaml index 48f42830..fc25c682 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-blinded-original-access.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-blinded-original-access.yaml @@ -16,7 +16,7 @@ description: | canViewBlindedContent(관리자 OR 작성자 본인) 게이트 추가, 권한 없으면 null · CommentResource::getFilteredContent — 동일 결함 → 동일 게이트 추가 · PostResource::toListArray — 블라인드 글 content_preview 를 빈 문자열로 차단 - - PO 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출. + - 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출. - 비밀글(canViewSecretContent)과 달리 비밀번호 검증·posts.read-secret 은 인정하지 않음 (블라인드는 운영 제재이므로 비밀번호로 해제되지 않음). diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-clone.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-clone.yaml index 31ac4bd4..84ac0e08 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-clone.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-clone.yaml @@ -13,7 +13,7 @@ description: | identifier 는 stripBoardScopeRoles 로 제거 (attachRoles 의 전역 whereIn 으로 인한 교차 누수 차단) · 새 게시판 manager/step 역할은 생성 시 injectBoardRolesToPermissions 가 자동 주입 · 비-스코프 역할(member 등) 은 그대로 보존 - - PO 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독) + - 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독) - role 조회 로직은 BoardResource::getBoardRoleData 를 public static 으로 공유 (SSoT) axes: diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-comment-deleted-parent-child-tree.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-comment-deleted-parent-child-tree.yaml index 9b317f6d..5c550187 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-comment-deleted-parent-child-tree.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-comment-deleted-parent-child-tree.yaml @@ -16,7 +16,7 @@ description: | 고아 자식의 조상 체인을 따라 누락된 삭제 부모를 일괄 IN 조회(N+1 회피)로 tombstone 노드 복구. 자식 없는 삭제 부모는 고아 자식이 없어 자연 제외. · countDescendants() 보정 — tombstone(삭제) 댓글은 "답글 N" 집계 모수에서 제외 - (트리 노드로는 유지, 살아있는 자손은 계속 카운트). PO 확정. + (트리 노드로는 유지, 살아있는 자손은 계속 카운트). 확정 정책. · content 권한·표시(tombstone 안내 + 관리자 원문 토글)는 #413-50 기구현 재사용. · withTrashed=true(manager del_cmt 토글) 경로는 이미 전체 노출 → 고아 복구 비적용. diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-delete-children-force-delete.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-delete-children-force-delete.yaml index 8da9ee96..9822a08f 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-delete-children-force-delete.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-delete-children-force-delete.yaml @@ -15,7 +15,7 @@ description: | (b) 저장 경로는 slug 기준({slug}/...)인데 삭제는 board_id 기준 디렉토리를 지우려 해 경로 불일치로 아무 파일도 안 지워짐. - 조치 (PO 결정 B안): + 조치 (결정 B안): · Repository 3종(Post/Comment/Attachment)에 forceDeleteByBoardId 신설 (기존 softDeleteByBoardId 의 delete → forceDelete). 인터페이스 + 구현. · BoardService::deleteBoard() 의 자식 정리를 force 로 교체. diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-search-secret-content-preview.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-search-secret-content-preview.yaml index f356e057..3e51e0f7 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-search-secret-content-preview.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-search-secret-content-preview.yaml @@ -14,7 +14,7 @@ description: | 미처리 → 비로그인·제3자도 게시판 내부 검색/목록 응답에서 비밀글 본문 요약 열람 가능 (보안 결함). - 수정: PostResource::getMaskedContentPreviewForList 신설 — status=Blinded 또는 is_secret 이면 권한 무관 빈 문자열. toListArray 가 이 헬퍼를 사용. - - PO 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출). + - 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출). DB select 심층방어는 미포함(응답 계층 차단만 — 노출 경계 차단 관례). - 통합검색(/api/search)은 쿼리에서 published+non-secret 만 검색하므로 누수 없음(무관). diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/post-delete-cascade.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/post-delete-cascade.yaml index 4f14c034..b249eb63 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/post-delete-cascade.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/post-delete-cascade.yaml @@ -5,7 +5,7 @@ description: | 정리되지 않아, 게시글만 trashed 되고 댓글·첨부는 살아남아 목록/상세에서 고아로 노출되던 문제를 수정한다. 동시에 게시글 복원 시 cascade 로 지워진 항목만 선택 복원하여, 삭제 이전에 사용자가 직접 지운 항목이 게시글 복원과 함께 부활하는 - 문제(PO 우려)를 차단한다. + 문제(우려)를 차단한다. 근본 원인: · PostService::deletePost() 가 게시글 자신만 soft delete 하고 댓글·첨부는 @@ -14,7 +14,7 @@ description: | · 첨부 테이블(board_attachments)에는 cascade 와 사용자 삭제를 구분할 trigger_type 컬럼 자체가 없었다. - 조치 (PO 결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원): + 조치 (결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원): · board_comments.trigger_type enum 에 'cascade' 추가 (마이그레이션). · board_attachments 에 trigger_type enum 컬럼 신설 (+ board_id/post_id/trigger_type 복합 인덱스). diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts index 18fad849..308a7acb 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts @@ -1,4 +1,4 @@ -// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(PO 환경)로 수행. +// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(검수 환경)로 수행. /** * bulkUpdateHandlers 테스트 * diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderPartialCancelBadge.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderPartialCancelBadge.test.ts index 30f8c3d4..d2916189 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderPartialCancelBadge.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminOrderPartialCancelBadge.test.ts @@ -4,7 +4,7 @@ * 배경: * - partial_cancelled 주문상태 제거 후, 일부 취소 사실은 파생 플래그 is_partially_cancelled 로 표시한다. * - DataGrid order_status 컬럼 cellChildren 에 StatusBadge + 보조 배지를 평면 형제로 두면 - * 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(PO 발견). + * 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(검수 발견). * → 둘을 flex-col 묶음 Div 로 감싸 상태 배지 아래(두 줄)에 보조 배지를 배치한다. * DataGrid 는 동일 columns 정의를 목록·카드 양쪽에 재사용하므로 한 곳 수정으로 둘 다 적용된다. * diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx index a1dd5b63..121d99c6 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx @@ -406,7 +406,7 @@ describe('DataGrid (_transactions_table.json)', () => { expect(edit).toBeTruthy(); // 비적립계 또는 권한없음이면 비활성 — Resource 가 can_edit = can_manage && isEarning 으로 계산 expect(edit.disabledField).toBe('abilities.can_edit'); - // 원장 불변: 삭제 액션은 존재하지 않는다 (PO 확정) + // 원장 불변: 삭제 액션은 존재하지 않는다 (정책 확정) const ids = grid.props.rowActions.map((a: any) => a.id); expect(ids).not.toContain('delete'); expect(ids).not.toContain('manual'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx index 693905be..38668dc4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx @@ -3,7 +3,7 @@ * * @description * - 마일리지 설정은 이커머스 환경설정(admin_ecommerce_settings.json)의 서브탭으로 통합됨 - * (별도 독립 화면 아님 — PO 합의사항) + * (별도 독립 화면 아님 — 합의사항) * - 환경설정 메인: mileage 탭 등록 + 탭 콘텐츠 partial(_tab_mileage.json) 참조 + 탭 if 가드 * - 탭 콘텐츠 partial 5종(탭/기본/통화테이블/통화카드/유효기간/소멸알림) 구조 검증 * - 카드 4종(기본/통화/유효기간/소멸알림), 토글 3종, 적립시점 기본=구매확정 @@ -180,7 +180,7 @@ describe('마일리지 탭 콘텐츠 (_tab_mileage.json)', () => { }); it('마일리지 내역 보기 버튼이 기본 설정 카드 헤더에 있고 내역 화면으로 이동한다', () => { - // 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (PO 요청) + // 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (요청) const btn = findById(basicCard, 'view_transactions_button'); expect(btn, '내역 보기 버튼이 기본 설정 카드에 없다').toBeTruthy(); expect(btn.actions[0].handler).toBe('navigate'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mileage_user_injection.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mileage_user_injection.test.tsx index f7d1de5d..4809601a 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mileage_user_injection.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mileage_user_injection.test.tsx @@ -102,7 +102,7 @@ describe('마이페이지 프로필 내 마일리지 카드 주입 (mypage-profi it('사용자 화면(마이페이지) 카드는 ExtensionBadge 를 사용하지 않는다 (회귀 차단)', () => { // ExtensionBadge 는 관리자 템플릿(sirsoft-admin_basic)에만 존재하는 컴포넌트로, // 사용자 템플릿(sirsoft-basic)에는 미등록이라 사용자 화면에 주입 시 "컴포넌트를 찾을 수 없습니다" 렌더 실패. - // PO 정책: 사용자 화면에서 ExtensionBadge 사용 금지. + // 정책: 사용자 화면에서 ExtensionBadge 사용 금지. const json = jsonOf(mypageCard); expect(json).not.toContain('ExtensionBadge'); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productFormLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productFormLayouts.test.tsx index 7c985641..1db68b5c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productFormLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productFormLayouts.test.tsx @@ -258,7 +258,7 @@ describe('productFormLayouts', () => { * "Failed to execute action: navigate" 오류 토스트가 추가로 발생하고 * 목록 페이지로 이동하지 않음 (성공 토스트 + 오류 토스트 2건 동시 노출). * 원인: onSuccess 시퀀스의 navigate 핸들러가 params.url 로 경로를 전달했으나, - * G7 navigate 핸들러는 params.path 만 인식한다 (CLAUDE.md "navigate path 필수"). + * G7 navigate 핸들러는 params.path 만 인식한다 (코어 규정: navigate path 필수). * url 키는 무시되어 finalPath=undefined → navigate throw → 오류 토스트. * 동일 모듈의 다른 navigate(주문 상세/브랜드 목록 등)는 모두 path 사용. * 해결: onSuccess navigate 의 params.url → params.path 로 정정. @@ -527,7 +527,7 @@ describe('productFormLayouts', () => { findNodeById(otherInfoPartial, 'field_max_purchase_qty'), ]); // $event.target.value 는 허용하되, parseInt($event)/Number($event) 같은 - // 이벤트 객체 직접 숫자화 패턴은 금지 (CLAUDE.md 데이터 바인딩 규정) + // 이벤트 객체 직접 숫자화 패턴은 금지 (코어 데이터 바인딩 규정) expect(serialized).not.toMatch(/parseInt\(\$event\)/); expect(serialized).not.toMatch(/Number\(\$event\)/); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx index a8dd5a66..c66cc957 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/shippingCountryLayouts.test.tsx @@ -95,7 +95,7 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션 }); it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => { - // PO 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제. + // 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제. expect(root.if).toBeUndefined(); const cls = root.props?.className ?? ''; expect(cls).toContain("'hidden'"); @@ -160,7 +160,7 @@ describe('header-currency-selector-admin.json — 관리자 헤더 2섹션', () }); it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => { - // PO 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다. + // 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다. // 항상 마운트하되 className 에 기본 hidden(display:none) 을 깔고 조건 충족 시에만 해제한다. expect(root.if).toBeUndefined(); const cls = root.props?.className ?? ''; diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts index fe6c88f6..e2ada8ab 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/handlers/shippingPolicyFormHandlers.ts @@ -1201,7 +1201,7 @@ export function testShippingApiHandler( G7Core.state.set?.({ apiTestLoading: true, apiTestResult: null }); // apiCall 구조: URL 은 액션 top-level target, method/body 는 params, - // onSuccess/onError 도 top-level (CLAUDE.md apiCall 규약). 백엔드 test-api-call + // onSuccess/onError 도 top-level (코어 apiCall 규약). 백엔드 test-api-call // 엔드포인트가 사용자가 입력한 설정으로 외부 API 를 대신 호출하고 결과를 서빙한다. G7Core.dispatch({ handler: 'apiCall', diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json index c2e7e4dd..d3c81786 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json @@ -421,7 +421,7 @@ ] }, { - "comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, PO 2026-06-22).", + "comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).", "type": "basic", "name": "Div", "if": "{{order.data?.payment?.requires_pg_cancellation === true}}", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Enums/OrderStatusEnum.php b/modules/_bundled/sirsoft-ecommerce/src/Enums/OrderStatusEnum.php index bd4c848e..0c5f7ca9 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Enums/OrderStatusEnum.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Enums/OrderStatusEnum.php @@ -339,7 +339,7 @@ enum OrderStatusEnum: string * - 취소(CANCELLED) 진입은 이 목록에 포함하지 않는다(canTransitionTo 가 별도 통과 처리). * - 현재 상태가 CANCELLED 면 판매 반영 상태(salesEligibleStatuses)로만 복원 허용 * (취소된 주문/옵션을 다시 판매 진행 상태로 되살리는 reactivation 운영 기능 — 재고 재차감 동반. - * 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. PO 2026-06-23). + * 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. 2026-06-23). * * @return array */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/MileageTransactionResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/MileageTransactionResource.php index fc453e02..22b746a7 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/MileageTransactionResource.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/MileageTransactionResource.php @@ -116,7 +116,7 @@ class MileageTransactionResource extends BaseApiResource * 행 레벨 능력을 계산합니다. * * 표준 can_manage 에 더해, 행 편집 메뉴('사유·기간 변경') 게이팅용 can_edit 를 노출합니다. - * can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — PO 합의 C-2). + * can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — 합의 C-2). * * @param Request $request HTTP 요청 * @return array 능력 불리언 맵 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php index 8e416396..0dd22248 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/MileageTransactionListener.php @@ -175,7 +175,7 @@ class MileageTransactionListener implements HookListenerInterface // 취소 전이: 기적립 건 회수. // 취소는 옵션 단위로 처리되므로 회수도 옵션(CANCELLED) 기준이다 — 부분취소든 전체취소든 - // 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). PO 2026-06-22 + // 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). 2026-06-22 if ($newStatus === OrderStatusEnum::CANCELLED) { $this->mileageService->cancelEarnForOption($order, $option); } @@ -186,7 +186,7 @@ class MileageTransactionListener implements HookListenerInterface * * mileage.enabled 토글은 주문 당시 적립액 "계산"(OrderCalculationService) 단계에서만 작용한다. * 리스너는 이미 계산·저장된 subtotal_earned_points_amount 를 실행할 뿐이므로 enabled 를 재검사하지 않는다. - * (저장값이 0 이면 Service 가 자연 no-op — PO 확정: 계산 완료된 적립은 토글로 막지 않음) + * (저장값이 0 이면 Service 가 자연 no-op — 정책 확정: 계산 완료된 적립은 토글로 막지 않음) * * @param Order $order 주문 * @param OrderOption $option 주문옵션 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/UserMileageCleanupListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/UserMileageCleanupListener.php index 2093c51f..27bcaa22 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/UserMileageCleanupListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/UserMileageCleanupListener.php @@ -7,12 +7,12 @@ use App\Models\User; use Modules\Sirsoft\Ecommerce\Services\UserMileageService; /** - * 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (PO 확정 #8) + * 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (정책 확정 #8) * * 코어 UserService 의 탈퇴(before_withdraw)·삭제(before_delete) 훅을 구독해, * 활성 lot 을 expired 거래로 소멸 기록(감사 흐름 보존)한 뒤 원장·잔액 캐시 행을 명시 삭제합니다. * 탈퇴는 soft 상태 변경이라 cascadeOnDelete 가 발화하지 않으므로 명시 정리가 필수입니다 - * (CLAUDE.md "DB CASCADE 의존 삭제 금지"). cascade 는 삭제 경로의 안전망으로만 둡니다. + * (코어 규정: DB CASCADE 의존 삭제 금지). cascade 는 삭제 경로의 안전망으로만 둡니다. */ class UserMileageCleanupListener implements HookListenerInterface { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php index 7137e98b..807545fd 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Order.php @@ -526,7 +526,7 @@ class Order extends Model * * 부분취소는 별도 주문 상태(partial_cancelled)가 아니라 "취소된 옵션이 있으면서 * 잔여 활성 옵션도 존재"하는 파생 상태다. 주문 상태(order_status)는 잔여 활성 옵션 기준으로 - * 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거). + * 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (2026-06-22, partial_cancelled 제거). * * @return bool 일부 옵션만 취소된 주문이면 true */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php index 05fd4916..4321afe7 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php @@ -664,7 +664,7 @@ class OrderAdjustmentService { $updates = []; - // 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (PO 확정) + // 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (정책 확정) $currencySnapshot = $order->currency_snapshot; // 재계산 결과에서 옵션별 금액 반영 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php index c4019f5a..f3ecca4f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCalculationService.php @@ -118,7 +118,7 @@ class OrderCalculationService // 쿠폰 조합 가능 여부 검증 (is_combinable) — 전체 슬롯 통합 1회 (A15/MP06) // 슬롯별 독립 검증은 "상품별(false) 1개 + 주문(true) 1개" 같은 슬롯 교차 조합을 // 각 슬롯 count=1 이라 놓친다. 적용 대상 전체 쿠폰을 한 집합으로 모아 1회 검증한다. - // 스냅샷 모드(환불 재계산)에서도 동일 검증한다(PO 2026-06-19 확정). + // 스냅샷 모드(환불 재계산)에서도 동일 검증한다(2026-06-19 확정). $allAppliedCoupons = $this->collectAllCouponsForCombination($coupons, $itemCouponIssues); $combinationViolations = $this->findNonCombinableViolations($allAppliedCoupons); foreach ($combinationViolations as $violationIssue) { @@ -874,7 +874,7 @@ class OrderCalculationService // 마일리지 기능 비활성 시 주문 시점 적립 "계산" 자체를 수행하지 않는다 (전부 0). // mileage_value/mileage_type 은 상품 옵션(상품 등록 당시) 속성이므로, - // 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (PO 확정). + // 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (정책 확정). if (! (bool) $this->settingsService->getSetting('mileage.enabled', false)) { $pointsPerItem[$optionId] = 0; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php index 7b2adcf7..7be48aec 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderCancellationService.php @@ -683,7 +683,7 @@ class OrderCancellationService } // 부분취소는 별도 주문 상태(partial_cancelled)를 두지 않는다 — 옵션은 이미 CANCELLED 로 전이됐고, // 주문 상태는 아래 syncParentOrderStatus 가 "취소 제외 잔여 활성 옵션" 기준으로 파생 결정한다. - // (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — PO 2026-06-22 + // (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — 2026-06-22 $order->update($updateData); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php index 234b8f7c..e4338d52 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php @@ -603,7 +603,7 @@ class OrderOptionService * * 부분취소는 별도 주문 상태가 아니라 "취소 옵션 + 잔여 활성 옵션 공존" 파생 상태다. * 따라서 주문 상태는 항상 잔여 활성 옵션 기준으로 결정하고, 부분취소 여부는 - * Order::isPartiallyCancelled() 파생 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거). + * Order::isPartiallyCancelled() 파생 플래그로 노출한다 (2026-06-22, partial_cancelled 제거). * * @param int $orderId 동기화할 주문 ID */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/SignupCurrencyResolver.php b/modules/_bundled/sirsoft-ecommerce/src/Services/SignupCurrencyResolver.php index d7253360..0cbf4896 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/SignupCurrencyResolver.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/SignupCurrencyResolver.php @@ -7,7 +7,7 @@ namespace Modules\Sirsoft\Ecommerce\Services; * * 유저 locale 을 환경설정의 통화별 언어(currencies[].locales) 매핑과 대조해 부여 통화를 결정한다. * - * 매칭 우선순위(D-SIGNUP, PO 확정): + * 매칭 우선순위(D-SIGNUP, 정책 확정): * 1) locale 이 매칭되는 통화가 1개 → 그 통화 * 2) 여러 통화에 중복 매칭 → 매칭된 통화 중 is_default 통화 우선 * 3) 매칭 없음/모호 → is_default 통화 → 그래도 없으면 default_currency 폴백 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php index ab76f02f..2460eecb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php @@ -731,7 +731,7 @@ class UserMileageService } /** - * 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — PO 확정 #8) + * 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — 정책 확정 #8) * * 활성 lot 을 expired 거래로 소멸 기록(활동로그에 금액·통화·회원ID 잔존)합니다. * 거래/캐시 행은 이후 cascade 또는 명시 삭제로 함께 사라집니다. @@ -764,7 +764,7 @@ class UserMileageService } /** - * 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (PO 확정 #8). + * 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (정책 확정 #8). * * 활성 lot 을 expired 거래로 소멸 기록(활동로그 보존)한 뒤, 원장·잔액 캐시 행을 * 동일 트랜잭션에서 삭제합니다. CASCADE 는 안전망으로만 두고 명시 삭제합니다 @@ -986,7 +986,7 @@ class UserMileageService * 마일리지 사용이 가능한지 여부를 반환합니다. * * 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면 - * 마일리지 사용을 허용하지 않는다(PO 정책). 마일리지는 base_currency 기준으로 사용/정산되므로 + * 마일리지 사용을 허용하지 않는다(정책). 마일리지는 base_currency 기준으로 사용/정산되므로 * base 규칙이 없으면 사용 단위·최소 사용액·한도를 알 수 없어 사용 자체가 불가하다. * * @return bool 기본 통화 마일리지 사용 규칙이 설정되어 있으면 true diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Identity/CheckoutVerificationGuardTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Identity/CheckoutVerificationGuardTest.php index 210926f2..b86a05c6 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Identity/CheckoutVerificationGuardTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Identity/CheckoutVerificationGuardTest.php @@ -164,7 +164,7 @@ class CheckoutVerificationGuardTest extends ModuleTestCase * * 기존 G1(훅 발화)·코어 가드(enforce 예외)·리스너 구독 테스트는 각 조각만 따로 검증한다. * 이 테스트는 그 셋이 실제 HTTP 흐름에서 연결되어 최종적으로 428 이 나오는지를 통합 검증한다. - * (PO 검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드) + * (검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드) * * @scenario policy=checkout, enabled=on, actor=member, verified_state=unverified, path=http_order_create * diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md index e97a015a..6eadf69f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md @@ -120,7 +120,7 @@ await page.route('https://api.tosspayments.com/v1/payments/confirm', route => ro ``` 분기별 가맹점 측 처리(주문 상태 전환, 재고 차감, 알림 발송 등) 가 정확한지 검증. -실 카드 결제 자동화는 권장하지 않음 — PO 수동 검증. +실 카드 결제 자동화는 권장하지 않음 — 수동 검증. ## 참고 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/OrderStatusTransitionTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/OrderStatusTransitionTest.php index bd4b8e68..dd952863 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/OrderStatusTransitionTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Enums/OrderStatusTransitionTest.php @@ -134,7 +134,7 @@ class OrderStatusTransitionTest extends ModuleTestCase /** * 취소 → 판매 반영 상태 복원(reactivation)은 허용, 결제 전 상태 복귀는 차단됨을 명시 단언합니다. - * (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. PO 2026-06-23) + * (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. 2026-06-23) */ public function test_cancelled_reactivation_to_sales_status_allowed_but_pre_payment_blocked(): void { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/UserMileageCleanupListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/UserMileageCleanupListenerTest.php index 58c6e94f..935f858c 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/UserMileageCleanupListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/UserMileageCleanupListenerTest.php @@ -11,7 +11,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInt use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; /** - * UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — PO 확정 #8) + * UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — 정책 확정 #8) */ class UserMileageCleanupListenerTest extends ModuleTestCase { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderRepositoryTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderRepositoryTest.php index 8d9cac78..6d0fe886 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderRepositoryTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderRepositoryTest.php @@ -482,7 +482,7 @@ class OrderRepositoryTest extends ModuleTestCase } /** - * 주문대기(pending_order)는 통계에서 숨김 유지된다 (PO 확정, listHiddenValues). + * 주문대기(pending_order)는 통계에서 숨김 유지된다 (정책 확정, listHiddenValues). */ public function test_user_statistics_hides_pending_order(): void { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/OrderResourceFieldsTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/OrderResourceFieldsTest.php index 296632c6..42dc4d6a 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/OrderResourceFieldsTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Resources/OrderResourceFieldsTest.php @@ -367,7 +367,7 @@ class OrderResourceFieldsTest extends ModuleTestCase /** * OrderResource 의 is_partially_cancelled 파생 플래그가 "취소 옵션 ∧ 잔여 활성 옵션" 일 때만 true 인지 확인. - * (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — PO 2026-06-22.) + * (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — 2026-06-22.) */ public function test_order_resource_exposes_is_partially_cancelled_flag(): void { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageBaseCurrencyGateTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageBaseCurrencyGateTest.php index 7d0c7543..7e43138f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageBaseCurrencyGateTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageBaseCurrencyGateTest.php @@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; * 마일리지 기본 통화 사용 규칙 게이트 테스트 (M2) * * 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면 - * 마일리지 사용이 불가해야 한다(PO 정책). + * 마일리지 사용이 불가해야 한다(정책). * - isMileageUsable(): base 규칙 존재 시 true, 없으면 false * - getMaxUsable(): base 규칙 없으면 0 * - validateUsage(): base 규칙 없으면 MileageValidationException diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php index 70e13c42..b2885dc8 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php @@ -6820,7 +6820,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase } /** - * §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (PO 확정). + * §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (정책 확정). * * 결함: buildOptionUpdates 가 plain subtotal_* 만 갱신하고 mc_subtotal_* 는 부분취소 후 stale. */ @@ -6869,7 +6869,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase } // ================================================================ - // §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (PO 추가 요구) + // §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (추가 요구) // OrderCalculation 테스트 수준의 정확값 검증. 각 케이스는 적립 재안분값이 // 환불 우선순위에 무관(우선순위는 refund split 만 변경)함을 함께 단언한다. // ================================================================ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php index f5d0ca6f..6112fa41 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php @@ -1208,7 +1208,7 @@ class OrderProcessingServiceTest extends ModuleTestCase } /** - * PO 검증 4조합: base/order 통화 전 경우의 수. + * 검증 4조합: base/order 통화 전 경우의 수. * * @return array */ diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderServiceStockRedeductTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderServiceStockRedeductTest.php index cad63e79..ca65eaa3 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderServiceStockRedeductTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderServiceStockRedeductTest.php @@ -98,7 +98,7 @@ class OrderServiceStockRedeductTest extends ModuleTestCase public function test_update_to_non_sales_status_is_blocked_by_transition_rule(): void { // 전이 규칙 도입(A30) 이후: 취소 → 결제 전 상태(결제대기) 복귀는 전이 게이트가 차단한다. - // (취소 복원은 판매 반영 상태로만 허용 — PO 2026-06-23.) + // (취소 복원은 판매 반영 상태로만 허용 — 2026-06-23.) // 차단되므로 update 가 OrderProcessingException 을 던지고, 재차감은 물론 상태/재고 모두 불변. [$order, $option] = $this->createCancelledOrderWithRestoredStock(stock: 8, quantity: 3); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml index 3d7c8a2d..5c8c833e 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml @@ -19,7 +19,7 @@ description: | shouldConvertToFullCancel 이 담당(프론트 isFullCancel 휴리스틱 제거). - U20③: 취소 차단 메시지를 쿠폰 단정 없이 일반화. - U20④: cancelled_at native 컬럼에 전체·부분취소 모두 기록 + Resource 노출(cancelled_at_formatted). - - 부분취소 모델 변경(PO 2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라 + - 부분취소 모델 변경(2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라 "취소 옵션 + 잔여 활성 옵션 공존"이며, 주문 상태는 잔여 활성 옵션 기준으로 파생(syncParentOrderStatus). 잔여 항목 재취소는 진행 상태(payment_complete 등)가 취소 가능 목록에 있어 자연 허용. 잔여 전량 취소 시 CANCELLED 전이. "일부 취소됨" 표시는 파생 플래그 is_partially_cancelled(취소옵션∧활성옵션)로 노출(목록·상세 보조 배지). diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp04-order-status-transition.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp04-order-status-transition.yaml index 5c0f7dd3..f8e4c344 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp04-order-status-transition.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp04-order-status-transition.yaml @@ -17,7 +17,7 @@ description: | (SHIPPING_HOLD↔PREPARING, SHIPPING→{SHIPPING_READY,PREPARING}, DELIVERED→SHIPPING)만 추가 허용. - CONFIRMED 되돌림 전부 금지. - CANCELLED 발 전이는 판매 반영 상태(salesEligibleStatuses)로의 복원(reactivation)만 허용 - (재고 재차감 동반 운영 기능, PO 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단. + (재고 재차감 동반 운영 기능, 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단. - 취소(CANCELLED) 진입은 게이트가 통과 처리(OrderCancellationService 전담, 이중 관리 방지). - 3경로 모두 FormRequest withValidator 1차 차단(422 + 다국어) + Service 진입부 2차 도메인 불변식 가드. - 일괄은 all-or-nothing: 1건이라도 위반이면 전체 422, DB 무변경. diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-base-currency-policy.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-base-currency-policy.yaml index a47fd8e6..6aff77ea 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-base-currency-policy.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-base-currency-policy.yaml @@ -2,7 +2,7 @@ # cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일). # base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/ # StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다. -feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — PO 신규) +feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규) description: | 기본통화 = 모든 금액 테이블의 base(진실값). 비-base 선택 시 base 정수 × 스냅샷 환율로 PG 청구·통지·환불. diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-signup-currency.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-signup-currency.yaml index f7b5d8ab..e21a7306 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-signup-currency.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-signup-currency.yaml @@ -2,7 +2,7 @@ # cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일). # 가입 통화 부여 axis 는 PHPUnit(AssignDefaultCurrencyOnRegisterListener/StoreEcommerceSettingsRequest # locales 검증)·Vitest(통화별 언어 Tag UI)가 커버하며 green 이다. -feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — PO 신규) +feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — 신규) description: | 가입/회원생성 시 브라우저/locale 기반 기본통화 부여(환경설정>언어/통화의 통화별 언어 Tag 매핑 기준). diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-user-currency.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-user-currency.yaml index 53a347bb..c2fe781e 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-user-currency.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp08-user-currency.yaml @@ -2,7 +2,7 @@ # cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일). # 유저별 영속 통화 axis 는 PHPUnit(EcommerceUserProfile/Repository/통화 저장 컨트롤러)· # Vitest(설정 UI)·Playwright(설치 게이트)가 커버하며 green 이다. -feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — PO 신규) +feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — 신규) description: | 유저별 통화 = 커머스 책임. 코어 users 미변경, 모듈 ecommerce_user_profiles 테이블 + layout_extensions. diff --git a/playwright.config.ts b/playwright.config.ts index 2ba2c116..a77f2396 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,7 @@ /** * Playwright 코어 E2E 설정 (G7 코어 영역) * - * 위치 규약 (CLAUDE.md 코어/확장 분리 원칙): + * 위치 규약 (코어/확장 분리 원칙): * - 코어 spec : tests/Playwright/specs/ * - 모듈 spec : modules/_bundled/{id}/tests/Playwright/specs/ (모듈 자체 config) * - 플러그인 : plugins/_bundled/{id}/tests/Playwright/specs/ (플러그인 자체 config) diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index f48b0376..f7a3a964 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -490,7 +490,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au const modalText = JSON.stringify(publishModal); it('발행 버튼은 isPublishing 동안 spinner + "발행 중..." 라벨 노출 + 양쪽 버튼 disabled', () => { - // PO 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가 + // 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가 // 발행 진행 상태를 알 수 없었음. _local.isPublishing 플래그로 발행 중 시각 피드백 + // 양쪽 버튼 disabled (취소 버튼도 발행 중엔 막아 도중 닫힘 방지). // JSON.stringify 기본 출력은 공백 없는 형식 (콜론 뒤 공백 없음). @@ -516,7 +516,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); }); - it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (PO 피드백: 토글/박스 제거)', () => { + it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (피드백: 토글/박스 제거)', () => { const box = findById(root, 'policy_version_guide_box'); expect(box).not.toBeNull(); const serialized = JSON.stringify(box); @@ -666,7 +666,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(layoutJson).toContain('partials/_shared/_policy_version_snapshot_modal.json'); }); - it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (PO 피드백 — 평문 + 명시 액션 분리)', () => { + it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (피드백 — 평문 + 명시 액션 분리)', () => { // v배지는 평문 Span (text 'v...' 으로 항상 표시), 본문 보기 Button 은 if 분기 + sequence expect(layoutJson).toContain('"text":"v{{gdprPolicyVersionCurrent?.data?.data?.version ?? \'?\'}}"'); expect(layoutJson).toContain('"if":"{{gdprPolicyVersionCurrent?.data?.data?.version}}"'); @@ -683,7 +683,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(layoutJson).toContain('"viewingPolicyVersion":"{{row?.version}}"'); }); - it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (PO 피드백: 토글/박스 제거)', () => { + it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (피드백: 토글/박스 제거)', () => { const box = findById(root, 'policy_version_guide_box'); expect(box).not.toBeNull(); // wrapper 는 className 없음 (라벨 + hint 두 줄을 단순 그룹핑) diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx index 97a2cf2f..70f8bbfc 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx @@ -84,13 +84,13 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { // 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `.data.` 한 단계 경로. // 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고. // 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장. - // 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — PO 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합. + // 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합. expect(banner?.if).toBe( '{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}' ); }); - it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (PO 결정 — 배너 통합)', () => { + it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (결정 — 배너 통합)', () => { // 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해 // 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합. const modal = findById(root, 'gdpr_needs_renewal_modal'); @@ -114,7 +114,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(json).toContain('|renewed='); }); - it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (PO 결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => { + it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => { const guestBtn = findById(root, 'gdpr_keep_consent_button_guest'); expect(guestBtn).toBeNull(); }); @@ -143,12 +143,12 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { it('기본(bottom_bar) 위치는 풀폭 팝업 카드 (bottom-4 left-4 right-4 rounded-lg)', () => { // 회귀 가드: 이전엔 화면 하단에 딱 붙는 띠(bottom-0)였지만, - // PO 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨. + // 설계 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨. expect(className).toContain('bottom-4 left-4 right-4 rounded-lg'); }); it('top_bar 분기는 제거되어 있다', () => { - // 회귀 가드: PO 결정으로 banner_position 옵션에서 top_bar 제거됨. + // 회귀 가드: 설계 결정으로 banner_position 옵션에서 top_bar 제거됨. expect(className).not.toContain("'top_bar'"); }); @@ -170,9 +170,9 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { } }); - it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (PO 의도: 다른 팝업과 동일 외형)', () => { + it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (의도: 다른 팝업과 동일 외형)', () => { // 회귀 가드: 이전 구현은 외곽에 bg-black/40 dim 을 깔았으나 자식 박스가 dim 위에 떠야 하는 - // 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. PO 가 dim 강제 모달이 아닌 + // 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. dim 강제 모달이 아닌 // 단순 중앙 배치만 원하므로 dim 클래스 자체를 제거. expect(className).not.toContain('bg-black/40'); expect(className).not.toContain('bg-black/50'); @@ -280,7 +280,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { }); it('배너 4버튼은 gdprBannerSubmittingAction 식별자 패턴으로 클릭한 버튼만 spinner + 다른 버튼은 원래 라벨 유지', () => { - // 회귀 가드: PO 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어 + // 회귀 가드: 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어 // 어느 버튼을 클릭했는지 시각적으로 알 수 없음. 식별자 string 으로 변경하여 클릭한 버튼만 // 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지. // 4 action 식별자: accept_all / reject_all / save_selection / renew_consent @@ -365,7 +365,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { }); it('"자세히 보기" 토글 + 펼침 영역이 통째로 제거되었다 (카테고리 description 한 줄로 일원화)', () => { - // PO 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과 + // 설계 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과 // 의미상 중복 → "자세히 보기" 토글 자체를 없애고, 카테고리 description 을 사용자 친화 // 표현으로 갈아끼움 (시더 plugin.php / CookieCategoryService 의 기본값 갱신). expect(text).not.toContain('banner.category_details_expand'); @@ -398,7 +398,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { }); describe('카테고리 카드 — 임의값 회귀 가드 (#14 글씨 키우기)', () => { - // PO 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아 + // 설계 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아 // 가독성 저하. 임의값 text-[10px] 11건을 모두 표준 text-xs 로 통일. it('cookie_banner.json 전체에서 text-[10px] 임의값이 사용되지 않는다', () => { const fullText = serializeForSearch(extension); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx index cb216211..89d81463 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/gdpr_consent_log.test.tsx @@ -192,7 +192,7 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용 /** * 작업 2 (B-3 옵션 X) — policy_version 컬럼이 평문 Span + 본문 보기 Button 분리. - * PO 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경. + * 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경. */ describe('작업 2: policy_version 컬럼 → 평문 + 본문 보기 Button', () => { const layoutJson = JSON.stringify(layout); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx index 9dad8627..5a1173b7 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx @@ -79,7 +79,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); it('카드 헤더(타이틀 + 구분선) 다음 형제 위치에 배치 (헤더 내부 X)', () => { - // PO UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출. + // UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출. // 헤더 내부(border-b 위)에 두면 타이틀과 같은 영역으로 보여 시각적 혼잡 → 헤더 외부로 분리. const header = findById(card ?? null, 'gdpr_card_header'); const headerChildren = (header?.children ?? []) as AnyNode[]; @@ -129,7 +129,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); it('가운데 점(·) 구분자 미사용 (자연어 한 줄 패턴이라 구분자 불필요)', () => { - // 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, PO 결정으로 자연어 한 줄로 재설계. + // 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, 설계 결정으로 자연어 한 줄로 재설계. const children = (meta?.children ?? []) as AnyNode[]; const separator = children.find((c) => String(c.text ?? '') === '·'); expect(separator).toBeUndefined(); @@ -223,7 +223,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal'); it('모달 본문에 raw key (cookie_functional 등) 대신 사용자 친화 description 노출', () => { - // PO 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거. + // 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거. // 라벨 + description 2줄 구조로 변경됨. const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal'); const text = serializeForSearch(modal); @@ -329,7 +329,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 }); describe('정책 갱신 amber 안내 박스 (마이페이지 카드 내 needs_renewal 안내)', () => { - // PO 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지. + // 설계 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지. // 조건: gdprMeConsents.data.needs_renewal === true 일 때만 노출. // 본문: 현 정책 버전 안내 (#23 informed consent) + 필수 쿠키 면제 hint // 액션: 「전체 항목 다시 동의」 (POST /consent/renew-all) diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php index 1a91c3ab..1a6df9a1 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Admin/GdprAdminSettingsControllerTest.php @@ -295,7 +295,7 @@ class GdprAdminSettingsControllerTest extends PluginTestCase * 노출하여 사용자가 어느 필드를 의미하는지 인지하기 어려움. FormRequest::attributes() 메서드로 * 각 필드에 lang 의 사용자 친화 라벨을 매핑하여 메시지 안에 노출되도록 함. * - * 검토 #10d — PO 가 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가. + * 검토 #10d — 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가. */ public function test_update_validation_messages_use_friendly_attribute_names(): void { diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Listeners/GdprAuthConsentListenerTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Listeners/GdprAuthConsentListenerTest.php index 69e48147..ea9bc7b9 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Listeners/GdprAuthConsentListenerTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Listeners/GdprAuthConsentListenerTest.php @@ -75,7 +75,7 @@ class GdprAuthConsentListenerTest extends PluginTestCase * * 실제 훅 체인: HookManager::doAction('core.auth.record_consents', User, array, string $agreedAt, string $ip) * 를 발화했을 때 listener 가 TypeError 없이 받고 폼 동의를 status 에 반영해야 함. - * 회귀 발견 시점: 2026-05-08 PO 가입 시 Argument #3 ($agreedAt) must be of type Carbon\Carbon, string given. + * 회귀 발견 시점: 2026-05-08 관리자 가입 시 Argument #3 ($agreedAt) must be of type Carbon\Carbon, string given. * * @return void */ @@ -139,7 +139,7 @@ class GdprAuthConsentListenerTest extends PluginTestCase * Listener 메서드는 현재 HTTP 요청 컨텍스트 (`request()->ip()`) + Carbon 인스턴스에 * 의존한다. HookListenerRegistrar 의 기본 동작은 큐 디스패치인데, 큐 잡은 별도 컨텍스트라 * 쿠키 소실 + Carbon → string 직렬화 mismatch 발생 → 폼 동의 기록 실패 - * (실제 회귀 — 2026-05-08 PO 가입 시도 시 발견). + * (실제 회귀 — 2026-05-08 관리자 가입 시도 시 발견). */ public function test_subscribed_hook_forces_sync_to_preserve_request_context(): void { diff --git a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/MobileCallbackRouteTest.php b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/MobileCallbackRouteTest.php index 2163fcb2..04baa0c7 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/MobileCallbackRouteTest.php +++ b/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/MobileCallbackRouteTest.php @@ -9,7 +9,7 @@ class MobileCallbackRouteTest extends PluginTestCase /** * KG 이니시스 모바일 표준결제는 P_NEXT_URL 로 POST 콜백을 보낸다. * 라우트가 GET 만 허용하면 405 Method Not Allowed 회귀가 발생한다. - * 가상계좌 결제 시 PO 운영 검증으로 발견된 회귀. + * 가상계좌 결제 시 운영 검증으로 발견된 회귀. */ public function test_mobile_callback_route_accepts_post(): void { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/identityProviderInicisExtension.test.ts b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/identityProviderInicisExtension.test.ts index 60a2e6b4..d37a9f83 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/identityProviderInicisExtension.test.ts +++ b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/identityProviderInicisExtension.test.ts @@ -3,7 +3,7 @@ * * 본 회차에서 추가한 두 가지 회귀 차단 포인트: * 1. 모달 헤더 제목/설명에 `purpose === 'inicis.adult_verification'` 분기 두 쌍 존재 - * 2. if 표현식이 CLAUDE.md 규정 (`{{식}}` 한 쌍 안에 비교 식 전체) 부합 + * 2. if 표현식이 코어 규정 (`{{식}}` 한 쌍 안에 비교 식 전체) 부합 * * 레이아웃 JSON 파일을 직접 읽어 구조를 단언하는 가벼운 단위 테스트. * 코어 DynamicRenderer 환경 셋업 없이도 회귀 차단 가능. @@ -128,7 +128,7 @@ describe('identity_provider_inicis Extension Point — 본 회차 회귀 차단' expect(texts).toContain('$t:sirsoft-verification_kginicis.modal.description'); }); - it('모든 purpose 분기 if 표현식이 CLAUDE.md 규정 (식 전체를 `{{}}` 한 쌍에) 부합', () => { + it('모든 purpose 분기 if 표현식이 코어 규정 (식 전체를 `{{}}` 한 쌍에) 부합', () => { const purposeIfs: string[] = []; for (const root of spec.components) { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/layouts/pluginSettingsErrorBinding.test.tsx b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/layouts/pluginSettingsErrorBinding.test.tsx index fd7ba10a..558d7707 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/layouts/pluginSettingsErrorBinding.test.tsx +++ b/plugins/_bundled/sirsoft-verification_kginicis/resources/js/__tests__/layouts/pluginSettingsErrorBinding.test.tsx @@ -5,7 +5,7 @@ * 배경: onError 의 errors 바인딩이 `{{$error.errors}}` ($ 접두사) 였을 때 ActionDispatcher * 컨텍스트 변수명과 불일치하여 _local.errors 가 null 로 남고, validation_error 박스가 * 렌더되지 않는 silent failure 회귀가 발생했다. 올바른 컨텍스트 변수명은 `{{error.errors}}`. - * (CLAUDE.md 데이터 바인딩 규칙: onSuccess/onError 컨텍스트는 $ 접두사 없음) + * (코어 데이터 바인딩 규칙: onSuccess/onError 컨텍스트는 $ 접두사 없음) */ import { describe, it, expect } from 'vitest'; diff --git a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/duplicate-block.yaml b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/duplicate-block.yaml index d2552a56..78bb6533 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/duplicate-block.yaml +++ b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/duplicate-block.yaml @@ -110,7 +110,7 @@ design_rationale: | 신규 가입자는 가입 시점에 record 생성되므로 시간 흐를수록 자연 수렴. - 외국인 (userDi 빈값) 통과 정책 — KG이니시스가 외국인 본인확인 시 DI 발급 못 하는 케이스 대응. - PO 결정에 따라 향후 차단 정책 전환 가능 (별도 이슈). + 정책 결정에 따라 향후 차단 정책 전환 가능 (별도 이슈). - PII 미노출 — IdentityDuplicateException / verify failure 메시지에 이메일/이름 절대 포함하지 않음. heads-up 공격 방어. i18n 키로만 처리. diff --git a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/extension-point.yaml b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/extension-point.yaml index 234938d1..4f6c97d3 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/extension-point.yaml +++ b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/extension-point.yaml @@ -155,7 +155,7 @@ design_rationale: | 주석 명시), 모달 닫힘은 명시적 closeModal 책임. 코어 모달 partial 의 verify 버튼이 이미 동일 sequence 패턴 사용 — 코어 default 와 일관성. 2. 사용자 안내 강화 — 기존 "본인인증 시작" 버튼만으로는 사용자가 "왜 이게 뜨지" 의문을 가질 수 - 있어 (PO 피드백), 사전 안내 카드 (아이콘 + 제목 + 설명 + 3단계 절차 안내) 로 확장. Phase E + 있어 (사용자 피드백), 사전 안내 카드 (아이콘 + 제목 + 설명 + 3단계 절차 안내) 로 확장. Phase E 폐기 사유 #1 의 "사용자 제스처 확보" 본질은 그대로 유지. 3. 진행 중 상태 표시 — 팝업이 열린 후 모달이 정적 상태로 남아있어 사용자가 "지금 뭘 해야 하지" 혼란을 느낄 수 있음. providerInProgress 플래그로 슬롯 if 분기하여 "팝업창에서 진행 중" 스피너 @@ -164,7 +164,7 @@ design_rationale: | providerInProgress 만 false 로 set → 모달은 열린 채 시작 버튼이 재활성화되어 사용자가 다시 시도 가능. 강제 cancelled 처리 시 사용자가 모달 처음부터 다시 열어야 하는 부담 회피. - 옵션 비교 — Phase E′-Redesign 의사결정 (PO 와 검토): + 옵션 비교 — Phase E′-Redesign 의사결정 (검토): - 옵션 1 (사전 안내 모달 + redirect): 페이지 전환 1회 발생, 데스크톱 UX 무거움 - 옵션 2 (모달 + 팝업 = 본 안): 모달 안에서 완결, 모바일 일반 브라우저 (Safari/Chrome) 도 동일 흐름 으로 동작 (window.open 이 새 탭으로 열려도 opener 유지 → bridge postMessage 동작) @@ -178,7 +178,7 @@ design_rationale: | - 코어 무수정 + 템플릿 minimal 수정 (모달 partial 에 if 분기 1줄 + 액션 버튼 if 조건 보강) - 모달 닫힘 결함만 fix 하면 작업 완결 - PO 피드백 반영 (sirsoft-basic 1.0.0-beta.6): + 피드백 반영 (sirsoft-basic 1.0.0-beta.6): 1. 외부 plugin 흐름에서 코어 모달의 [코드 재전송] / [확인] 버튼 숨김 — 외부 plugin 은 자기 흐름 안에서 verify 까지 책임지므로 코어 액션이 불필요. 코어 mail/SMS 등 코드 발송형 흐름은 기존대로 노출 (회귀 없음). diff --git a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/install-default-purpose-provider.yaml b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/install-default-purpose-provider.yaml index 0bbc47be..fc12e7b6 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/install-default-purpose-provider.yaml +++ b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/install-default-purpose-provider.yaml @@ -22,7 +22,7 @@ test_files: - plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Identity/InicisInstallDefaultPurposeProviderTest.php design_rationale: | - PO 결정: + 설계 결정: - 설치 시 1회만 세팅(install 훅), 활성화/업그레이드 시 재적용하지 않음. - 운영자 변경 가능(초기값 성격) — 코어 settings 에 저장하므로 환경설정 UI (본인인증 > 프로바이더 탭, 목적별 매핑)에서 그대로 변경된다. diff --git a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/partial-data-integrity.yaml b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/partial-data-integrity.yaml index 54d44392..afaefe92 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/partial-data-integrity.yaml +++ b/plugins/_bundled/sirsoft-verification_kginicis/tests/scenarios/partial-data-integrity.yaml @@ -48,7 +48,7 @@ rules_layer_coverage: coverage: name/phone/birthday nullable 변경 마이그레이션에 down() (NOT NULL 복귀) + 한국어 comment 포함. design_rationale: | - PO 결정 (부분 데이터 처리 방향): + 설계 결정 (부분 데이터 처리 방향): - 차단 대상 = 신원 핵심값(이름·생년월일·휴대폰·설정 기준 식별값). Chrome MCP 실측(2026-06-23, 아이핀 채널)에서 이름·휴대폰·생년월일·CI·DI 가 모두 응답에 채워짐을 확인 → 휴대폰도 항상 오는 핵심값으로 포함. 법규(CI/DI 는 주민/외국인등록번호 기반 → 정상 본인확인이면 제공)·코드(식별값 @@ -61,7 +61,7 @@ design_rationale: | 휴대폰은 신원 핵심값이라 누락 시 차단 대상 (위 incomplete_identity 케이스에 포함). - 토큰 재발급 가드(isVerified enum 비교)는 1번 PR, verify 실패 toast 매핑은 2번 PR 에서 처리됨. 본 항목은 3번의 신원 가드 + 정합 저장 + INCOMPLETE_IDENTITY toast 1줄 보강. - - (저장 원자성 보강) 토큰 발급(log update)과 PII 저장을 한 단위로 묶는다 (PO 결정 A안). PII 저장이 + - (저장 원자성 보강) 토큰 발급(log update)과 PII 저장을 한 단위로 묶는다 (설계 결정 A안). PII 저장이 실패하면 토큰 발급까지 롤백하고 STORAGE_FAILED 로 인증을 실패시켜 "인증 성공=PII 저장됨" 무결성을 보장한다. 로그인 record upsert 는 DB 트랜잭션으로 묶고, 비로그인 cache stash 실패는 트랜잭션 콜백을 예외 종료시켜 log update 를 롤백한다. 비로그인 가입 backfill(after_register listener)은 record 가 diff --git a/resources/js/core/template-engine/CHANGELOG.md b/resources/js/core/template-engine/CHANGELOG.md index c6da28e9..3d7f0643 100644 --- a/resources/js/core/template-engine/CHANGELOG.md +++ b/resources/js/core/template-engine/CHANGELOG.md @@ -5,7 +5,7 @@ > > 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) -## [engine-v1.52.0] - 2026-07-01 +## [engine-v1.52.0] - 2026-07-02 ### Changed @@ -16,7 +16,7 @@ - `devtools-entry.ts` + `vite.config.devtools.js`(신규) — DevTools 번들 엔트리/빌드. React external → `window.React` 단일 인스턴스, `G7DevToolsCore` 는 shim(`devtools/__runtime-shims/`)으로 메인 번들과 공유(재번들 0바이트, 싱글톤 동일성). - `core:build` — 엔진 + 편집기 + DevTools 3개 번들 순차 빌드. `--watch` 는 3개 `vite build --watch` 병렬. -## [engine-v1.51.0] - 2026-07-01 +## [engine-v1.51.0] - 2026-07-02 ### Changed diff --git a/resources/js/core/template-engine/layout-editor/__tests__/components/PlaceholderChipInput.test.tsx b/resources/js/core/template-engine/layout-editor/__tests__/components/PlaceholderChipInput.test.tsx index 5fd2b0a1..504a7a43 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/components/PlaceholderChipInput.test.tsx +++ b/resources/js/core/template-engine/layout-editor/__tests__/components/PlaceholderChipInput.test.tsx @@ -273,7 +273,7 @@ describe('insertChipInValue — 커서 위치 자리표시 삽입', () => { }); }); -describe('타이핑 안전성 — 비제어 평문(글자 뒤섞임 결함 회귀, PO)', () => { +describe('타이핑 안전성 — 비제어 평문(글자 뒤섞임 결함 회귀)', () => { it('평문 타이핑(연속 입력) 시 평문 span 을 React 가 덮어쓰지 않는다(비제어)', () => { // 결함 재현 방지: controlled 였다면 onChange→value 갱신→리렌더로 span textContent 가 // 매 입력마다 재설정되어 커서가 튀고 글자 순서가 깨졌다("하나둘"→"퉁둘낭나한하"). diff --git a/resources/js/core/template-engine/layout-editor/__tests__/components/TableEditor.test.tsx b/resources/js/core/template-engine/layout-editor/__tests__/components/TableEditor.test.tsx index b5af39b1..819447a0 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/components/TableEditor.test.tsx +++ b/resources/js/core/template-engine/layout-editor/__tests__/components/TableEditor.test.tsx @@ -278,7 +278,7 @@ describe('셀 테두리/텍스트', () => { colors: [{ value: 'red', swatch: '#ef4444', token: 'border-red-500' }], }; const paramsCat = { ...params, cellBorder: catalog }; - // 셀에 두께(border)를 미리 부여 → 색 피커가 즉시 노출(앵커 셀 className 기반). 실제 PO + // 셀에 두께(border)를 미리 부여 → 색 피커가 즉시 노출(앵커 셀 className 기반). 실제 운영자 // 흐름(얇게 선택된 상태)과 동일. (단위 테스트의 onPatchNode mock 은 node prop 을 // 되먹이지 않아 두께 클릭→리렌더로는 색 피커가 안 떠 별도 셀 className 으로 노출.) const bc = (text: string): EditorNode => ({ type: 'basic', name: 'Td', text, props: { className: 'border' } }); diff --git a/resources/js/core/template-engine/layout-editor/__tests__/layouts/condition-toggle-render.test.tsx b/resources/js/core/template-engine/layout-editor/__tests__/layouts/condition-toggle-render.test.tsx index f975c738..73875846 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/layouts/condition-toggle-render.test.tsx +++ b/resources/js/core/template-engine/layout-editor/__tests__/layouts/condition-toggle-render.test.tsx @@ -4,7 +4,7 @@ * * ConditionBuilder 가 combineConditions 로 만든 단일 `{{ }}` 식이 실제 런타임 * `evaluateStringCondition`(→ optional chaining 식이라 `evaluateExpression` 진입, - * CLAUDE.md `if` 평가 규칙)으로 샘플 상태에 따라 표시/숨김 토글되는지 검증한다. + * 코어 `if` 평가 규칙)으로 샘플 상태에 따라 표시/숨김 토글되는지 검증한다. * * 단위 시뮬레이션이 아닌 실제 평가 엔진 라운드트립 — 합성식의 문법 정합성을 잠근다. */ diff --git a/resources/js/core/template-engine/layout-editor/__tests__/spec/actionRecipeEngine.test.ts b/resources/js/core/template-engine/layout-editor/__tests__/spec/actionRecipeEngine.test.ts index 9a82e283..cd1fc47a 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/spec/actionRecipeEngine.test.ts +++ b/resources/js/core/template-engine/layout-editor/__tests__/spec/actionRecipeEngine.test.ts @@ -1,7 +1,7 @@ /** * actionRecipeEngine.test.ts — 친화 액션 레시피 → 핸들러 JSON DataProvider * - * CLAUDE.md "CRITICAL RULES — API/핸들러 호출" 전수 회귀 가드 포함: + * 코어 핸들러 규칙(API/핸들러 호출) 전수 회귀 가드 포함: * - apiCall(api 아님)/navigate(nav 아님)/setState+target/toast(showToast 아님)/ * refetchDataSource.dataSourceId(id 아님). * - apiCall 의 target/onSuccess/onError 는 액션 top-level. @@ -70,7 +70,7 @@ describe('normalizeActionRecipes', () => { }); }); -describe('buildAction — CLAUDE.md 핸들러 규칙 준수', () => { +describe('buildAction — 코어 핸들러 규칙 준수', () => { const recipes = normalizeActionRecipes(RECIPES); const byId = (id: string) => recipes.find((r) => r.id === id)!; @@ -273,7 +273,7 @@ describe('requestPgPayment — placeholder 핸들러 build/match 라운드트립 // 회귀 — "+동작 추가" 로 결제 진입을 고르면 ActionAddPicker 가 buildAction(recipe, {}) 로 빈 값 // 빌드한다. placeholder 핸들러({{paymentHandler}})가 빈 값에서 undefined 로 떨어져 사라지면 - // handler 없는 액션이 되어 "알 수 없는 동작"(advanced)으로 강등된다(PO 제보). 빈 빌드에서도 + // handler 없는 액션이 되어 "알 수 없는 동작"(advanced)으로 강등된다(제보 회귀). 빈 빌드에서도 // handler placeholder 는 보존돼야 추가 직후 친화 카드로 매칭되고 사용자가 칩으로 채울 수 있다. it('빈 값 빌드(+동작 추가) — placeholder 핸들러가 보존되어 handler 가 사라지지 않는다', () => { const recipe = recipes.find((r) => r.id === 'requestPgPayment')!; @@ -290,14 +290,14 @@ describe('requestPgPayment — placeholder 핸들러 build/match 라운드트립 expect(m?.recipeId).toBe('requestPgPayment'); }); - // 회귀(PO 제보) — 핸들러 필드를 데이터 칩으로 바꾸고 결제 데이터는 비워두면, buildAction 이 + // 회귀(제보) — 핸들러 필드를 데이터 칩으로 바꾸고 결제 데이터는 비워두면, buildAction 이 // 미입력 required pgPaymentData 를 떨궈 params 가 사라진다. 그러면 fingerprint(pgPaymentData) - // 가 깨져 친화 카드가 [고급]으로 강등됐다(PO: "변경하니 고급으로 넘어간다"). required placeholder + // 가 깨져 친화 카드가 [고급]으로 강등됐다("변경하니 고급으로 넘어간다"). required placeholder // param 은 빈 값이어도 토큰을 보존해 fingerprint 를 유지하고, 핸들러를 어떤 칩으로 바꿔도 친화 // 카드가 유지돼야 한다(핸들러 필드 바인딩 가능성 ↔ greedy 가드 공존). it('핸들러를 임의 칩으로 바꾸고 데이터는 비워도 requestPgPayment 친화 카드 유지 (고급 강등 금지)', () => { const recipe = recipes.find((r) => r.id === 'requestPgPayment')!; - // 핸들러만 데이터 칩으로 채우고 pgPaymentData 는 미입력(빈 값) — PO 시나리오. + // 핸들러만 데이터 칩으로 채우고 pgPaymentData 는 미입력(빈 값) — 제보 시나리오. const action = buildAction(recipe, { paymentHandler: '{{checkoutData?.data?.temp_order_id}}' }); // required pgPaymentData 가 토큰으로 보존돼 params 가 살아있어야 한다. expect(action.handler).toBe('{{checkoutData?.data?.temp_order_id}}'); diff --git a/resources/js/core/template-engine/layout-editor/__tests__/spec/conditionRecipeEngine.test.ts b/resources/js/core/template-engine/layout-editor/__tests__/spec/conditionRecipeEngine.test.ts index a468e05a..96869158 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/spec/conditionRecipeEngine.test.ts +++ b/resources/js/core/template-engine/layout-editor/__tests__/spec/conditionRecipeEngine.test.ts @@ -1,7 +1,7 @@ /** * conditionRecipeEngine.test.ts — 친화 조건 → if 식 합성 DataProvider * - * CLAUDE.md `if` 규칙 회귀 가드: + * 코어 `if` 규칙 회귀 가드: * - 최종 결과 전체가 단일 `{{ }}` 한 쌍 — 중첩 보간 `{{ {{x}} }}` 미발생. * - 옵셔널 체이닝(`?.`) / 배열 길이 `(arr?.length ?? 0)` 형태 보존. * - 로그인 판정 경로 보존(임의 식 금지). @@ -65,7 +65,7 @@ describe('buildClauseExpr — {paramKey} 치환', () => { }); }); -describe('combineConditions — 단일 {{ }} 합성 (CLAUDE.md if 규칙)', () => { +describe('combineConditions — 단일 {{ }} 합성 (코어 if 규칙)', () => { it('단일 조건 → {{ 식 }}', () => { const r = combineConditions([{ operator: 'isLoggedIn', params: {} }], 'and', ops); expect(r).toBe('{{ _global?.currentUser?.uuid }}'); diff --git a/resources/js/core/template-engine/layout-editor/__tests__/spec/editorGapCoverage.test.ts b/resources/js/core/template-engine/layout-editor/__tests__/spec/editorGapCoverage.test.ts index 75e21d67..b0732c70 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/spec/editorGapCoverage.test.ts +++ b/resources/js/core/template-engine/layout-editor/__tests__/spec/editorGapCoverage.test.ts @@ -9,7 +9,7 @@ * * 가드 항목: * 1. 신규 6개 admin composite 가 nesting.draggable 등재 + capability 보유. - * 2. 그 6개 tsx 가 editorAttrs 를 시각 루트에 spread (선택/클릭 가능 — PO 가 직접 + * 2. 그 6개 tsx 가 editorAttrs 를 시각 루트에 spread (선택/클릭 가능 — 검수자가 직접 * 발견한 AdminSidebar/AdminFooter 선택 불가 결함의 회귀 가드). * 3. SectionHeader 가 componentPalette 에 등재 (팔레트 추가 가능). * 4. ecommerce editor-spec 에 신규 admin 화면 5개 states 그룹 등록 (상태 토글 노출). @@ -83,7 +83,7 @@ describe('편집기 갭 보강 — 신규 admin composite 편집 가능성', () describe('편집기 갭 보강 — editorAttrs 시각 루트 spread (선택/클릭 가능)', () => { // base 골격/위젯이 편집기 주입 editorAttrs 를 시각 루트에 흘려야 클릭이 그 노드에 닿는다. - // (PO 가 base 편집 모드에서 AdminSidebar/AdminFooter 클릭 시 부모 Div 만 선택되던 결함) + // (검수자가 base 편집 모드에서 AdminSidebar/AdminFooter 클릭 시 부모 Div 만 선택되던 결함) const FILES: Record = { AdminSidebar: `${ADMIN}/src/components/composite/AdminSidebar.tsx`, AdminFooter: `${ADMIN}/src/components/composite/AdminFooter.tsx`, diff --git a/resources/js/core/template-engine/layout-editor/__tests__/spec/expressionValueTree.test.ts b/resources/js/core/template-engine/layout-editor/__tests__/spec/expressionValueTree.test.ts index 743f653b..a0d6946e 100644 --- a/resources/js/core/template-engine/layout-editor/__tests__/spec/expressionValueTree.test.ts +++ b/resources/js/core/template-engine/layout-editor/__tests__/spec/expressionValueTree.test.ts @@ -283,7 +283,7 @@ describe('디그레이드 — 손상 0 폴백', () => { }); describe('hasDecomposableExpressionSegment — 다중 세그먼트 + 표현식 조각', () => { - // 양 템플릿 실측 9건(다중 세그먼트 + 진짜 삼항 분기 토큰) — PO board/form 제목 케이스 포함. + // 양 템플릿 실측 9건(다중 세그먼트 + 진짜 삼항 분기 토큰) — 실측 board/form 제목 케이스 포함. const REAL_MULTISEGMENT = [ "{{route.id ? '$t:board.edit_post' : '$t:board.new_post'}} - {{form_meta?.data?.board?.name || ''}}", "{{_local.collapsedReplies?.[comment?.id] === false ? '$t:board.hide_replies' : '$t:board.show_replies'}} ({{comment?.replies_count}})", @@ -324,7 +324,7 @@ describe('previewSegments — 접힌 미리보기 토큰("한 값만 해석")', expect(toks[1]).toEqual({ kind: 'ellipsis', text: '⋯' }); }); - it('PO board/form 다중 세그먼트 → 게시글 수정⋯ + 평문 + 데이터 칩', () => { + it('실측 board/form 다중 세그먼트 → 게시글 수정⋯ + 평문 + 데이터 칩', () => { const toks = previewSegments( "{{route.id ? '$t:board.edit_post' : '$t:board.new_post'}} - {{form_meta?.data?.board?.name || ''}}", resolve, @@ -606,7 +606,7 @@ describe('extractFirstLeafText / reduceExpressionToPlain — 표현식 → 일 // og.title `{{route.id ? '$t:edit': product.title}}` 류에서 첫 결과(then)가 // 데이터인 경우. 미리보기 "기본 값 → 🔗 product.title" 과 적용이 일치해야 한다. - it('조건식 첫 분기가 데이터면 그 데이터 바인딩으로 복구(PO og.title 케이스)', () => { + it('조건식 첫 분기가 데이터면 그 데이터 바인딩으로 복구(실측 og.title 케이스)', () => { expect(reduceExpressionToPlain("{{route.id ? product.title : '$t:shop.new'}}")).toBe('{{product.title}}'); }); diff --git a/resources/js/core/template-engine/layout-editor/components/PreviewCanvas.tsx b/resources/js/core/template-engine/layout-editor/components/PreviewCanvas.tsx index 82b192b5..34d08e34 100644 --- a/resources/js/core/template-engine/layout-editor/components/PreviewCanvas.tsx +++ b/resources/js/core/template-engine/layout-editor/components/PreviewCanvas.tsx @@ -1060,7 +1060,7 @@ export function PreviewCanvas(props: PreviewCanvasProps = {}): React.ReactElemen >
{ - // shape 안전 바인딩(`{{src?.path ?? ''}}`) — 데이터 미도착 시 런타임 에러 방지(CLAUDE.md + // shape 안전 바인딩(`{{src?.path ?? ''}}`) — 데이터 미도착 시 런타임 에러 방지(코어 // fallback 필수). buildBindingExpression 이 scalar 폴백(`?? ''`)을 붙인다. onPick(buildBindingExpression(c.sourceId, c.path, 'scalar')); }} diff --git a/resources/js/core/template-engine/layout-editor/components/property-controls/I18nTextField.tsx b/resources/js/core/template-engine/layout-editor/components/property-controls/I18nTextField.tsx index d8bc030a..b61b9042 100644 --- a/resources/js/core/template-engine/layout-editor/components/property-controls/I18nTextField.tsx +++ b/resources/js/core/template-engine/layout-editor/components/property-controls/I18nTextField.tsx @@ -829,7 +829,7 @@ function PlainInsertDataButton({ return (
{/* `+` 버튼은 항상 자리 유지(피커가 열려도 플로우 폭 불변). 피커는 공용 FloatingDropdown 으로 - 버튼 아래 띄우되 위치 자동 보정(flip/clamp)으로 좁은 입력 행 어디서든 잘리지 않는다(PO + 버튼 아래 띄우되 위치 자동 보정(flip/clamp)으로 좁은 입력 행 어디서든 잘리지 않는다(검수 2026-06-13 — 하드코딩 right:0 으로 좌측 쏠려 가려지던 결함 → 범용 위치 보정으로 통일). */}