fix(core,engine): OFFSET 순회 중 필터 컬럼 변경으로 인한 처리 누락 수정

Laravel chunk/each 는 OFFSET 기반이라, 콜백이 행의 필터 소속을 바꾸면
(필터 컬럼을 채우거나 매칭 행을 삭제하면) 다음 조회가 그만큼 앞으로 밀려
미처리 행을 조용히 건너뛴다. 250건/청크 100 이면 정확히 100건이 남는다.
코어·번들 확장 전수 조사로 확정한 6곳을 chunkById(키셋 순회)로 통일했다.

브라우저 검수(28축)에서 계획에 없던 결함 12건이 드러나 같은 사슬에서
수정했다. 이 중 layout_strategy=keep 무효화와 수정 감지 실패 시 "수정 없음"
오보는 사용자 작업 소실에 직결된다.

재발 방지로 audit 룰을 추가했고, 수정 6곳을 원복해 red 6건 / 복원 후 green
0건으로 판별력을 검증했다.
This commit is contained in:
HeuJung
2026-07-31 21:50:45 +09:00
parent 1866f0d566
commit 1eedbbdda1
55 changed files with 2764 additions and 198 deletions
+4
View File
@@ -764,9 +764,13 @@ Controller → Request → Service → RepositoryInterface → Repository → Mo
필수: 마이그레이션 한국어 comment 필수, down() 구현 필수
필수: FK 컬럼의 ->comment() 는 ->constrained()/->references()/->on() 앞에 둔다 (뒤에 두면 comment 가 컬럼이 아닌 FK 정의에 부착되어 조용히 사라진다)
필수: 소스 교정만으로는 기설치본이 낫지 않는다 — 마이그레이션은 재실행되지 않으므로 업그레이드 스텝 백필을 함께 작성
필수: 필터가 걸린 쿼리를 순회하며 그 행을 update/delete 하면 chunkById() (키셋 순회)
절대 금지: 그 경우 chunk()/each()/lazy() 사용 — OFFSET 기반이라 처리된 행이 결과에서 이탈한 만큼 커서가 밀려 미처리 행을 조용히 건너뛴다 (250건/청크 100 → 100건 누락, 예외·로그 없음)
주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수
```
갱신값이 항상 필터 소속을 유지해 안전한 경우(예: `whereNotNull` + 갱신값이 항상 non-null)만 예외이며, 그 근거를 코드 주석에 남긴다. 정적 검사가 이 패턴을 검출한다.
> 상세 규칙 (API 리소스, ServiceProvider, validation, 인증, 활동 로그 등): [docs/backend/](docs/backend/) 각 문서 참조
### 컨트롤러 계층
+16
View File
@@ -55,6 +55,21 @@
### Fixed
#### 확장 업데이트
- 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다.
- 업데이트 전 표시되는 "수정된 레이아웃" 안내가 확인에 실패했을 때도 "수정된 레이아웃이 없습니다"라고 단언하던 문제를 수정했습니다. 네트워크가 끊긴 상태에서 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있었습니다. 이제 확인하지 못했다는 사실과 함께 '수정 유지' 권장 안내가 표시됩니다.
- 템플릿 업데이트 안내가 그 템플릿에 등록된 모듈·플러그인 화면의 수정까지 자기 것으로 세던 문제를 수정했습니다. 실제로 교체·보존되는 대상은 템플릿 자신의 화면뿐이라 표시 건수와 실제 동작이 어긋났습니다.
- '업데이트 확인' 버튼을 눌러 업데이트가 발견돼도 항상 "모두 최신 상태입니다"라고 안내되던 문제를 수정했습니다. 모듈·플러그인·템플릿 관리 화면 모두에 해당합니다.
- 업데이트 완료 안내에 확장 이름과 버전이 표시되지 않고 자리표시자 글자가 그대로 노출되던 문제를 수정했습니다.
- 모듈·플러그인·템플릿 관리 화면의 목록 요약이 항상 "총 0개 중 1-0개 표시"로 나오고, 목록이 한 화면을 넘어도 다음 쪽으로 넘어갈 수 없던 문제를 수정했습니다.
- 존재하지 않는 확장 이름으로 수정 화면 확인을 요청하면 "수정된 화면 없음"으로 응답하던 것을 "찾을 수 없음"으로 구분해 응답하도록 바꿨습니다.
#### 관리자 화면
- 템플릿 레이아웃 편집 화면의 파일 목록에서 이름·설명이 빈칸으로 보이던 문제를 수정했습니다. 번역 제외 표기를 쓴 글자가 화면 구성 요소의 본문 자리에서만 해석되지 않아 아무것도 출력되지 않았습니다.
- 번역 제외 표기를 쓴 글자가 검색엔진 봇 화면에서 해석되지 않던 문제를 함께 수정했습니다. 일반 화면에는 값이 나오는데 봇 화면에서만 비어 보일 수 있었습니다.
#### 검색엔진 봇 화면
- 검색엔진 봇에게만 게시글·답글의 원글·댓글·페이지·상품 설명의 본문이 통째로 빈 채 전달되던 문제를 수정했습니다. 위지윅 편집기 플러그인이 켜져 있으면 본문 영역이 플러그인 화면으로 교체되는데, 봇용 화면을 만드는 쪽이 그 교체 규칙을 몰라 내용 없는 껍데기만 만들었습니다. 미리보기용 요약(메타 태그)에는 본문이 들어가 있어 겉으로는 정상으로 보였습니다. 이제 일반 화면과 봇용 화면이 같은 본문을 출력합니다. 업그레이드 시 이미 만들어져 있던 봇용 화면은 한 번 비워져 새로 만들어집니다. (#86 @koojunho 님께서 제보해주셨습니다.)
@@ -80,6 +95,7 @@
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
- 데이터가 많은 사이트를 업그레이드할 때 기존 데이터를 채워 넣는 작업이 일부 행을 건너뛰던 문제를 수정했습니다. 100건씩 나눠 처리하면서 처리한 행이 대상 목록에서 빠지는 만큼 다음 묶음의 시작 위치가 밀려, 250건이면 100건이 처리되지 않은 채 남았습니다. 레이아웃 원본 대조값 채우기와 `json:convert-unicode` 명령이 대상이며, 이제 건수와 무관하게 전건이 처리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
#### 설정 저장·입력 검증
@@ -109,7 +109,7 @@ class ConvertJsonUnicodeEscapes extends Command
/**
* 테이블 존재 여부를 확인합니다.
*
* @param string $table 테이블명 (프리픽스 포함)
* @param string $table 테이블명 (프리픽스 포함)
* @return bool 존재 여부
*/
private function tableExists(string $table): bool
@@ -126,10 +126,10 @@ class ConvertJsonUnicodeEscapes extends Command
/**
* 특정 테이블의 특정 컬럼을 변환합니다.
*
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @return array{int, int} [변환 건수, 스킵 건수]
*/
private function processColumn(string $table, string $column, int $chunkSize, bool $dryRun): array
@@ -152,11 +152,13 @@ class ConvertJsonUnicodeEscapes extends Command
$this->components->twoColumnDetail("{$table}.{$column}", "대상 {$total}건 처리 중...");
// chunkById(키셋 순회) 필수 — 콜백이 필터 조건 컬럼을 UTF-8 로 다시 써서 처리된
// 행이 `LIKE '%\u%'` 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
// 아직 변환되지 않은 행을 건너뛴다. (대상 테이블 8종 모두 `id` auto-increment PK)
DB::table($table)
->whereNotNull($column)
->where($column, 'LIKE', '%\\\\u%')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
->chunkById($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
foreach ($rows as $row) {
$original = $row->{$column};
$decoded = json_decode($original, true);
+72 -7
View File
@@ -37,10 +37,14 @@ use Illuminate\Support\Facades\Config;
class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}';
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 테스트 유저/역할을 발급 전 정리. 0 이면 정리 안 함}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
/** 테스트 전용 역할 식별자 접두사 */
private const TEST_ROLE_PREFIX = 'playwright_test_';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
@@ -61,6 +65,15 @@ class PlaywrightIssueToken extends Command
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
Config::set('app.debug', true);
// 발급 전 오래된 테스트 아티팩트 정리 — 이 커맨드는 호출마다 유저 1명 + 역할 1개를
// 새로 만들지만 회수 주체가 없어 그대로 누적된다. 정리하지 않으면 회원/역할 관리 화면이
// 테스트 잔재로 뒤덮인다(실측: 발급 2974회 → 유저 2974명·역할 2974개 잔존).
// 시간 임계값을 두어 동시 실행 중인 다른 워커의 계정은 건드리지 않는다.
$gcHours = (int) $this->option('gc-hours');
if ($gcHours > 0) {
$this->pruneStaleTestArtifacts($gcHours);
}
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
@@ -71,6 +84,54 @@ class PlaywrightIssueToken extends Command
return self::SUCCESS;
}
/**
* 임계 시간보다 오래된 playwright 테스트 유저/역할을 정리한다.
*
* 대상: `playwright_test_*` 역할과, 그 역할이 유일한 확장 역할인 유저(= 이 커맨드가
* factory 로 만든 계정). 실제 회원과 구분되도록 반드시 역할 소유 관계로만 판별한다.
*
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반 chunk()/each()
* 는 다음 페이지가 줄어든 결과 집합을 지나쳐 일부를 건너뛴다.
*
* @param int $hours 이 시간보다 오래된 아티팩트만 정리
* @return void
*/
private function pruneStaleTestArtifacts(int $hours): void
{
$threshold = now()->subHours($hours);
$roles = 0;
$users = 0;
Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->where('created_at', '<', $threshold)
->chunkById(100, function ($chunk) use (&$roles, &$users) {
foreach ($chunk as $role) {
// 이 역할을 가진 유저 중, 다른 playwright 역할이 없는 계정만 제거 대상
foreach ($role->users()->get() as $user) {
$otherTestRoles = $user->roles()
->where('roles.id', '!=', $role->id)
->where('roles.identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->count();
if ($otherTestRoles === 0) {
$user->tokens()->delete();
$user->roles()->detach();
$user->forceDelete();
$users++;
}
}
$role->permissions()->detach();
$role->users()->detach();
$role->delete();
$roles++;
}
});
if ($roles > 0) {
$this->info("[gc] playwright 테스트 잔재 정리: 역할 {$roles}건, 유저 {$users}건");
}
}
/**
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
*
@@ -86,11 +147,15 @@ class PlaywrightIssueToken extends Command
$permissionIds = [];
foreach ($permissions as $identifier) {
// Role/Permission 의 name·description 은 모델에서 array 로 캐스팅된다.
// 여기서 json_encode 한 문자열을 넣으면 Eloquent 가 한 번 더 인코딩해
// 이중 인코딩된 값이 저장되고, 관리자 화면(역할 선택 등)에 JSON 원문이
// `{"ko":"\uXXXX…"}` 형태로 그대로 노출된다. 배열을 그대로 넘긴다.
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'name' => ['ko' => $identifier, 'en' => $identifier],
'description' => ['ko' => $identifier, 'en' => $identifier],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
@@ -101,16 +166,16 @@ class PlaywrightIssueToken extends Command
$testRole = Role::create([
'identifier' => 'playwright_test_'.uniqid(),
'name' => json_encode(['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin']),
'description' => json_encode(['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only']),
'name' => ['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin'],
'description' => ['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only'],
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
+5 -1
View File
@@ -4491,7 +4491,11 @@ class ModuleManager implements ModuleManagerInterface
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
if ($previousStatus === ExtensionStatus::Active->value && $module) {
$preserveModified = ($layoutStrategy === 'keep');
$this->registerModuleLayouts($identifier);
// registerModuleLayouts() 를 여기서 호출하지 않는다 — 그 메서드는 전략을
// 모른 채 모든 레이아웃의 content 와 original_content_hash 를 파일 기준으로
// 덮어써서, 뒤따르는 refreshModuleLayouts($preserveModified) 가 비교할
// "사용자 수정본" 을 이미 지워버린다(= keep 전략이 항상 무효화).
// 신규 레이아웃 생성은 refreshModuleLayouts 의 created 분기가 담당한다.
$this->registerLayoutExtensions($module);
$this->refreshModuleLayouts($identifier, $preserveModified);
}
+5 -1
View File
@@ -4671,7 +4671,11 @@ class PluginManager implements PluginManagerInterface
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
if ($previousStatus === ExtensionStatus::Active->value && $plugin) {
$preserveModified = ($layoutStrategy === 'keep');
$this->registerPluginLayouts($identifier);
// registerPluginLayouts() 를 여기서 호출하지 않는다 — 그 메서드는 전략을
// 모른 채 모든 레이아웃의 content 와 original_content_hash 를 파일 기준으로
// 덮어써서, 뒤따르는 refreshPluginLayouts($preserveModified) 가 비교할
// "사용자 수정본" 을 이미 지워버린다(= keep 전략이 항상 무효화).
// 신규 레이아웃 생성은 refreshPluginLayouts 의 created 분기가 담당한다.
$this->registerLayoutExtensions($plugin);
$this->refreshPluginLayouts($identifier, $preserveModified);
}
+9 -1
View File
@@ -2806,7 +2806,15 @@ class TemplateManager implements TemplateManagerInterface
];
}
$allLayouts = $this->layoutRepository->getByTemplateId($record->id);
// 템플릿 자신이 소유한 레이아웃만 센다 — getByTemplateId() 는 같은 template_id 에
// 등록된 모듈/플러그인 소유 레이아웃까지 반환해서, 남의 확장 수정본이 템플릿
// 업데이트 모달에 자기 것으로 집계된다. 실제 갱신 범위(refreshTemplateLayouts)는
// source_type='template' + source_identifier=null 뿐이므로 표시와 동작이 어긋난다.
$allLayouts = $this->layoutRepository->getByTemplateIdWithFilter(
$record->id,
'template',
null
);
$modifiedLayouts = $allLayouts->filter(function ($layout) {
if (! $layout->original_content_hash) {
return false; // hash 없으면 (레거시 데이터) 미수정 취급
@@ -3,9 +3,11 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Extension\Vendor\VendorMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Module\ActivateModuleRequest;
use App\Http\Requests\Module\DeactivateModuleRequest;
use App\Http\Requests\Module\IndexModuleRequest;
@@ -16,10 +18,10 @@ use App\Http\Requests\Module\PerformModuleUpdateRequest;
use App\Http\Requests\Module\PreviewModuleManifestRequest;
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
use App\Http\Requests\Module\UninstallModuleRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\ModuleCollection;
use App\Http\Resources\ModuleResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\ModuleService;
use App\Services\TemplateService;
@@ -169,7 +171,7 @@ class ModuleController extends AdminBaseController
public function installPreview(string $moduleName, ExtensionInstallPreviewBuilder $builder): JsonResponse
{
try {
$preview = $builder->build(\App\Enums\LanguagePackScope::Module, $moduleName);
$preview = $builder->build(LanguagePackScope::Module, $moduleName);
return $this->success('module.fetch_success', $preview);
} catch (\Exception $e) {
@@ -188,7 +190,7 @@ class ModuleController extends AdminBaseController
try {
$validated = $request->validated();
$moduleName = $validated['module_name'];
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
@@ -253,7 +255,7 @@ class ModuleController extends AdminBaseController
$moduleInfo = $result['module_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (요구사항 #8: 빈 배열이면 모달 표시 안 함)
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('module', $moduleName);
if ($moduleInfo) {
@@ -490,6 +492,13 @@ class ModuleController extends AdminBaseController
public function checkModifiedLayouts(string $moduleName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 모듈 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고되어, 오타·제거된 모듈이
// 조용히 "수정 없음" 으로 통과한다 (show/uninstall-info 와 동일 규약).
if (! $this->moduleService->getModuleInfo($moduleName)) {
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
}
$result = $this->moduleService->checkModifiedLayouts($moduleName);
return $this->success('modules.check_modified_layouts_success', $result);
@@ -515,7 +524,7 @@ class ModuleController extends AdminBaseController
{
try {
$validated = $request->validated();
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
@@ -524,14 +533,23 @@ class ModuleController extends AdminBaseController
$moduleInfo = $result['module_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":module"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'module' => $moduleName,
'version' => $result['to_version'] ?? ($moduleInfo['version'] ?? ''),
];
if ($moduleInfo) {
return $this->successWithResource(
'modules.update_success',
new ModuleResource($moduleInfo)
new ModuleResource($moduleInfo),
200,
$messageParams
);
}
return $this->success('modules.update_success', $result);
return $this->success('modules.update_success', $result, 200, $messageParams);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -601,7 +619,7 @@ class ModuleController extends AdminBaseController
/**
* 모듈의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 모듈 식별자
* @param string $identifier 모듈 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
@@ -3,24 +3,26 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Extension\Vendor\VendorMode;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Plugin\ActivatePluginRequest;
use App\Http\Requests\Plugin\DeactivatePluginRequest;
use App\Http\Requests\Plugin\IndexPluginRequest;
use App\Http\Requests\Plugin\InstallPluginFromFileRequest;
use App\Http\Requests\Plugin\PreviewPluginManifestRequest;
use App\Http\Requests\Plugin\InstallPluginFromGithubRequest;
use App\Http\Requests\Plugin\InstallPluginRequest;
use App\Http\Requests\Plugin\PerformPluginUpdateRequest;
use App\Http\Requests\Plugin\PreviewPluginManifestRequest;
use App\Http\Requests\Plugin\RefreshPluginLayoutsRequest;
use App\Http\Requests\Plugin\UninstallPluginRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\PluginCollection;
use App\Http\Resources\PluginResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\PluginService;
use App\Services\TemplateService;
@@ -177,7 +179,7 @@ class PluginController extends AdminBaseController
try {
$validated = $request->validated();
$pluginName = $validated['plugin_name'];
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
@@ -242,7 +244,7 @@ class PluginController extends AdminBaseController
$pluginInfo = $result['plugin_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('plugin', $pluginName);
if ($pluginInfo) {
@@ -498,6 +500,12 @@ class PluginController extends AdminBaseController
public function checkModifiedLayouts(string $pluginName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 플러그인 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고된다.
if (! $this->pluginService->getPluginInfo($pluginName)) {
return $this->error('plugins.not_found', 404, null, ['plugin' => $pluginName]);
}
$result = $this->pluginService->checkModifiedLayouts($pluginName);
return $this->success('plugins.check_modified_layouts_success', $result);
@@ -523,7 +531,7 @@ class PluginController extends AdminBaseController
{
try {
$validated = $request->validated();
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
@@ -532,14 +540,23 @@ class PluginController extends AdminBaseController
$pluginInfo = $result['plugin_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":plugin"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'plugin' => $pluginName,
'version' => $result['to_version'] ?? ($pluginInfo['version'] ?? ''),
];
if ($pluginInfo) {
return $this->successWithResource(
'plugins.update_success',
new PluginResource($pluginInfo)
new PluginResource($pluginInfo),
200,
$messageParams
);
}
return $this->success('plugins.update_success', $result);
return $this->success('plugins.update_success', $result, 200, $messageParams);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -623,7 +640,7 @@ class PluginController extends AdminBaseController
/**
* 플러그인의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 플러그인 식별자
* @param string $identifier 플러그인 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
@@ -7,20 +7,21 @@ use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Template\ActivateTemplateRequest;
use App\Http\Requests\Template\DeactivateTemplateRequest;
use App\Http\Requests\Template\IndexTemplateRequest;
use App\Http\Requests\Template\InstallTemplateFromFileRequest;
use App\Http\Requests\Template\PreviewTemplateManifestRequest;
use App\Http\Requests\Template\InstallTemplateFromGithubRequest;
use App\Http\Requests\Template\InstallTemplateRequest;
use App\Http\Requests\Template\PerformTemplateUpdateRequest;
use App\Http\Requests\Template\PreviewTemplateManifestRequest;
use App\Http\Requests\Template\RefreshTemplateLayoutsRequest;
use App\Http\Requests\Template\UninstallTemplateRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\TemplateCollection;
use App\Http\Resources\TemplateResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
@@ -215,7 +216,7 @@ class TemplateController extends AdminBaseController
$templateInfo = $result['template_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('template', $templateName);
if ($templateInfo) {
@@ -435,6 +436,12 @@ class TemplateController extends AdminBaseController
public function checkModifiedLayouts(string $templateName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 템플릿 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고된다.
if (! $this->templateService->getTemplateInfo($templateName)) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
$result = $this->templateService->checkModifiedLayouts($templateName);
return $this->success('templates.check_modified_layouts_success', $result);
@@ -466,14 +473,23 @@ class TemplateController extends AdminBaseController
$templateInfo = $result['template_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":template"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'template' => $templateName,
'version' => $result['to_version'] ?? ($templateInfo['version'] ?? ''),
];
if ($templateInfo) {
return $this->successWithResource(
'templates.update_success',
new TemplateResource($templateInfo)
new TemplateResource($templateInfo),
200,
$messageParams
);
}
return $this->success('templates.update_success', $result);
return $this->success('templates.update_success', $result, 200, $messageParams);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -516,7 +532,7 @@ class TemplateController extends AdminBaseController
/**
* 템플릿의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 템플릿 식별자
* @param string $identifier 템플릿 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
+26 -2
View File
@@ -24,6 +24,13 @@ class ExpressionEvaluator
*/
private array $seoOverrides = [];
/**
* 번역 면제 바인딩 접두사 (`{{raw:expr}}`)
*
* 프론트엔드 `rawMarkers.ts` 의 RAW_PREFIX 와 동일해야 합니다.
*/
private const RAW_PREFIX = 'raw:';
public function __construct()
{
$this->pipeRegistry = new PipeRegistry;
@@ -87,6 +94,11 @@ class ExpressionEvaluator
if ($this->isSingleBinding($trimmed)) {
$expr = trim(substr($trimmed, 2, -2));
// raw: 접두사 제거 — 남겨 두면 콜론이 식의 일부로 파싱돼 평가가 실패한다.
if (str_starts_with($expr, self::RAW_PREFIX)) {
$expr = trim(substr($expr, strlen(self::RAW_PREFIX)));
}
// 파이프 표현식: 원본 값을 파이프로 변환 후 반환
if (PipeRegistry::hasPipes($expr)) {
[$baseExpr, $pipes] = PipeRegistry::splitPipes($expr);
@@ -281,10 +293,21 @@ class ExpressionEvaluator
return $this->resolveTranslation($expression, $context);
}
// raw: 접두사(번역 면제 바인딩)를 벗기고 후처리 대상에서 제외한다.
// React 는 `{{raw:expr}}` 의 접두사를 떼고 평가한 뒤 결과의 `$t:` 토큰을
// 번역하지 않는다(rawMarkers). 여기서 접두사를 그대로 두면 콜론이 식의
// 일부로 파싱되어 평가가 실패하고, 봇 화면에서만 값이 사라진다.
$rawExempt = false;
// {{binding | pipe}} 패턴 치환 (파이프 표현식 지원)
$result = (string) preg_replace_callback('/\{\{(.+?)\}\}/', function ($matches) use ($context) {
$result = (string) preg_replace_callback('/\{\{(.+?)\}\}/', function ($matches) use ($context, &$rawExempt) {
$expr = trim($matches[1]);
if (str_starts_with($expr, self::RAW_PREFIX)) {
$rawExempt = true;
$expr = trim(substr($expr, strlen(self::RAW_PREFIX)));
}
// 파이프 분리: expr | pipe1 | pipe2(arg)
if (PipeRegistry::hasPipes($expr)) {
[$baseExpr, $pipes] = PipeRegistry::splitPipes($expr);
@@ -304,7 +327,8 @@ class ExpressionEvaluator
}, $expression);
// 인라인 $t:key 토큰 해석 ({{}} 외부 텍스트 내 $t:key|param=value 패턴)
if (str_contains($result, '$t:')) {
// raw: 바인딩 결과는 번역 면제 — React 와 동일하게 토큰을 그대로 둔다.
if (! $rawExempt && str_contains($result, '$t:')) {
$result = preg_replace_callback('/\$t:(?:defer:)?([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) use ($context) {
return $this->resolveTranslation('$t:'.$matches[1], $context);
}, $result);
@@ -13,21 +13,27 @@ return new class extends Migration
public function up(): void
{
Schema::table('template_layouts', function (Blueprint $table) {
$table->string('original_content_hash', 64)
->nullable()
->after('content')
->comment('원본 레이아웃 콘텐츠 SHA-256 해시 (수정 감지용)');
$table->unsignedInteger('original_content_size')
->nullable()
->after('original_content_hash')
->comment('원본 레이아웃 콘텐츠 바이트 크기 (변화량 표시용)');
if (! Schema::hasColumn('template_layouts', 'original_content_hash')) {
$table->string('original_content_hash', 64)
->nullable()
->after('content')
->comment('원본 레이아웃 콘텐츠 SHA-256 해시 (수정 감지용)');
}
if (! Schema::hasColumn('template_layouts', 'original_content_size')) {
$table->unsignedInteger('original_content_size')
->nullable()
->after('original_content_hash')
->comment('원본 레이아웃 콘텐츠 바이트 크기 (변화량 표시용)');
}
});
// 기존 데이터 hash 백필 (현재 content = 원본으로 간주)
// chunkById(키셋 순회) 필수 — 콜백이 필터 컬럼(original_content_hash)을 채우므로
// 처리된 행이 필터 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
// 미처리 행을 건너뛴다.
DB::table('template_layouts')
->whereNull('original_content_hash')
->orderBy('id')
->chunk(100, function ($layouts) {
->chunkById(100, function ($layouts) {
foreach ($layouts as $layout) {
$content = json_decode($layout->content, true);
$normalized = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -28,10 +28,12 @@ return new class extends Migration
});
// 기존 데이터 hash 백필 (현재 content = 원본으로 간주)
// chunkById(키셋 순회) 필수 — 콜백이 필터 컬럼(original_content_hash)을 채우므로
// 처리된 행이 필터 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
// 미처리 행을 건너뛴다.
DB::table('template_layout_extensions')
->whereNull('original_content_hash')
->orderBy('id')
->chunk(100, function ($extensions) {
->chunkById(100, function ($extensions) {
foreach ($extensions as $extension) {
$content = json_decode($extension->content, true);
$normalized = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+31 -4
View File
@@ -3312,11 +3312,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| has_modified_layouts | boolean | 수정된 레이아웃 존재 여부 |
| modified_count | integer | 수정된 레이아웃 수 |
| modified_layouts | array | 수정된 레이아웃 목록 |
| modified_layouts[].id | integer | 레이아웃 ID |
| modified_layouts[].name | string | 레이아웃 이름 |
| modified_layouts[].updated_at | string\|null | 최종 수정 일시 (`Y-m-d H:i:s`) |
| modified_layouts[].size_diff | integer | 원본 대비 바이트 증감 (음수는 축소) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "수정된 레이아웃 확인이 완료되었습니다.",
"data": {
"has_modified_layouts": true,
"modified_count": 1,
"modified_layouts": [
{
"id": 47,
"name": "sirsoft-board.admin_board_index",
"updated_at": "2026-07-31 09:20:05",
"size_diff": 28
}
]
}
}
```
집계 범위는 해당 모듈이 **소유한** 레이아웃으로 한정됩니다. 같은 템플릿에 등록된 다른 확장의 레이아웃은 포함되지 않습니다.
**에러 응답**
@@ -3324,7 +3351,7 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 404 | Not Found | 해당 식별자의 모듈이 설치되어 있지 않은 경우 (레이아웃 0건과 구분됨) |
<!-- @generated:end -->
@@ -3467,7 +3494,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 특정 모듈을 최신 버전으로 업데이트합니다. `core.modules.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다.
**설명** 특정 모듈을 최신 버전으로 업데이트합니다. `core.modules.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다.
### GET /api/modules/assets/{identifier}/{path}
+31 -4
View File
@@ -1064,11 +1064,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| has_modified_layouts | boolean | 수정된 레이아웃 존재 여부 |
| modified_count | integer | 수정된 레이아웃 수 |
| modified_layouts | array | 수정된 레이아웃 목록 |
| modified_layouts[].id | integer | 레이아웃 ID |
| modified_layouts[].name | string | 레이아웃 이름 |
| modified_layouts[].updated_at | string\|null | 최종 수정 일시 (`Y-m-d H:i:s`) |
| modified_layouts[].size_diff | integer | 원본 대비 바이트 증감 (음수는 축소) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "수정된 레이아웃 확인이 완료되었습니다.",
"data": {
"has_modified_layouts": true,
"modified_count": 1,
"modified_layouts": [
{
"id": 47,
"name": "sirsoft-board.admin_board_index",
"updated_at": "2026-07-31 09:20:05",
"size_diff": 28
}
]
}
}
```
집계 범위는 해당 플러그인이 **소유한** 레이아웃으로 한정됩니다. 같은 템플릿에 등록된 다른 확장의 레이아웃은 포함되지 않습니다.
**에러 응답**
@@ -1076,7 +1103,7 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 404 | Not Found | 해당 식별자의 플러그인이 설치되어 있지 않은 경우 (레이아웃 0건과 구분됨) |
<!-- @generated:end -->
@@ -1219,7 +1246,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 특정 플러그인을 최신 버전으로 업데이트합니다. `core.plugins.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다.
**설명** 특정 플러그인을 최신 버전으로 업데이트합니다. `core.plugins.install` 권한이 필요합니다. `layout_strategy` 로 레이아웃 처리 방식을(overwrite: 새 버전으로 교체, keep: 사용자 수정본 유지) 지정하며, `vendor_mode` 로 Composer 의존성 처리 방식을 선택합니다. 버전 제약·호환성 문제로 막힐 경우 `force: true` 로 강제 진행할 수 있습니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다.
### GET /api/plugins/assets/{identifier}/{path}
+31 -4
View File
@@ -1469,11 +1469,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| has_modified_layouts | boolean | 수정된 레이아웃 존재 여부 |
| modified_count | integer | 수정된 레이아웃 수 |
| modified_layouts | array | 수정된 레이아웃 목록 |
| modified_layouts[].id | integer | 레이아웃 ID |
| modified_layouts[].name | string | 레이아웃 이름 |
| modified_layouts[].updated_at | string\|null | 최종 수정 일시 (`Y-m-d H:i:s`) |
| modified_layouts[].size_diff | integer | 원본 대비 바이트 증감 (음수는 축소) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "수정된 레이아웃 확인이 완료되었습니다.",
"data": {
"has_modified_layouts": true,
"modified_count": 1,
"modified_layouts": [
{
"id": 47,
"name": "sirsoft-board.admin_board_index",
"updated_at": "2026-07-31 09:20:05",
"size_diff": 28
}
]
}
}
```
집계 범위는 해당 템플릿이 **소유한** 레이아웃으로 한정됩니다. 같은 템플릿에 등록된 다른 확장의 레이아웃은 포함되지 않습니다.
**에러 응답**
@@ -1481,7 +1508,7 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 404 | Not Found | 해당 식별자의 템플릿이 설치되어 있지 않은 경우 (레이아웃 0건과 구분됨) |
<!-- @generated:end -->
@@ -2523,7 +2550,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 설치된 템플릿을 최신 버전으로 업데이트합니다. `layout_strategy`(overwrite: 레이아웃 전면 교체 / keep: 사용자 수정 레이아웃 유지)로 레이아웃 처리 방식을 결정하며, `force`로 강제 진행할 수 있습니다(TemplateService::performVersionUpdate). `core.templates.install` 권한이 필요합니다.
**설명** 설치된 템플릿을 최신 버전으로 업데이트합니다. `layout_strategy`(overwrite: 레이아웃 전면 교체 / keep: 사용자 수정 레이아웃 유지)로 레이아웃 처리 방식을 결정하며, `force`로 강제 진행할 수 있습니다(TemplateService::performVersionUpdate). `core.templates.install` 권한이 필요합니다. `keep` 을 지정하면 사용자가 수정한 레이아웃(원본 해시와 현재 내용이 다른 레이아웃)은 갱신 대상에서 제외되어 현재 내용이 그대로 유지되고, 나머지 레이아웃만 파일 기준으로 갱신됩니다. 성공 응답 메시지에는 대상 식별자와 적용된 버전이 채워집니다.
### GET /api/templates/assets/{identifier}/{path}
@@ -8,6 +8,8 @@
### Added
- 확장 업데이트 모달의 "수정 여부 확인 실패" 안내 문구 일본어 번역 추가 (`{modules,plugins,templates}.modals.modified_layouts_check_failed`) — 네트워크 오류로 수정 여부를 확인하지 못했을 때의 경고와 '수정 유지' 권장 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 공용 문구 3종 일본어 번역 추가 (`common.refresh`, `common.activate`, `common.skip`) — 게시판·상품 카테고리 목록의 새로고침 버튼과 언어팩 재활성화 안내에서 번역되지 않은 내부 문구가 보이던 문제가 해소됩니다.
- 환경설정 > 보안의 2단계 인증 항목 문구 일본어 번역 추가 (`settings.security.two_factor_auth*`) — 항목 이름과 설명이 일본어 로케일에서 자연스럽게 표시됩니다.
- 회원 상세의 계정 잠금 상태 표시와 잠금 해제 버튼 문구 일본어 번역 추가 (`users.detail.unlock*`, `users.detail.lock_status`) — 무기한 잠금·기한부 잠금·정상 상태 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 언어팩 관리 목록의 "파일 없음" 배지와 다시 설치 버튼 문구 일본어 번역 추가 (`language_packs.badge.files_missing`, `language_packs.actions.reinstall`).
@@ -728,7 +728,8 @@
"modified_layouts_warning": "修正されたレイアウトが {{count}} 個あります。「すべて置き換え」を選択すると、修正内容が削除されます。",
"no_modified_layouts": "修正されたレイアウトがありません。",
"modified_layouts_list_title": "保持される修正レイアウト ({{count}}個)",
"modified_layouts_keep_notice": "上記のレイアウトはアップデート後も現在の内容が保持されます。"
"modified_layouts_keep_notice": "上記のレイアウトはアップデート後も現在の内容が保持されます。",
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。"
},
"tabs": {
"file_upload": "ファイル アップロード",
@@ -933,7 +934,8 @@
"modified_layouts_warning": "編集されたレイアウトが{{count}}個あります。「すべて置換」を選択すると、編集内容が削除されます。",
"no_modified_layouts": "編集されたレイアウトがありません。",
"modified_layouts_list_title": "維持される編集レイアウト({{count}}個)",
"modified_layouts_keep_notice": "上記のレイアウトはアップデート後も現在の内容が維持されます。"
"modified_layouts_keep_notice": "上記のレイアウトはアップデート後も現在の内容が維持されます。",
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。"
},
"tabs": {
"file_upload": "ファイルアップロード",
@@ -2172,7 +2174,8 @@
"restore_note": "復元後は、必ず保存ボタンを押して変更内容を適用してください。",
"lines": "行",
"chars": "文字"
}
},
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。"
},
"tabs_install": {
"file_upload": "ファイルアップロード",
@@ -76,5 +76,8 @@
"scope_type_role_desc": "同じロールのユーザーリソースのみ",
"multilingual": {
"add_language": "言語を追加"
}
},
"refresh": "更新",
"activate": "有効化",
"skip": "スキップ"
}
@@ -53,6 +53,7 @@
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
- 게시판 모듈을 제거할 때 게시판별로 자동 생성된 관리자·승인 단계 역할이 1,000개를 넘으면 일부가 삭제되지 않고 남던 문제를 수정했습니다. 남은 역할은 사용하지 않는 게시판의 것이면서도 회원 권한 목록에 계속 노출됐습니다. 이제 개수와 무관하게 전부 정리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
## [1.0.2] - 2026-07-14
+18 -11
View File
@@ -6,12 +6,13 @@ use App\Extension\AbstractModule;
use App\Models\Role;
use App\Seo\Concerns\LocalizesSeoValues;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Database\Seeders\BoardTypeSeeder;
use Modules\Sirsoft\Board\Listeners\ActivityLogDescriptionResolver;
use Modules\Sirsoft\Board\Listeners\BoardActivityLogListener;
use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationChannelListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationDataListener;
use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener;
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
@@ -22,6 +23,7 @@ use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
use Modules\Sirsoft\Board\Listeners\SeoBoardSettingsCacheListener;
use Modules\Sirsoft\Board\Listeners\UserNotificationSettingsListener;
use Modules\Sirsoft\Board\Models\Board;
class Module extends AbstractModule
{
@@ -39,12 +41,17 @@ class Module extends AbstractModule
public function uninstall(): bool
{
// 게시판별 동적 역할 정리 (sirsoft-board.*.manager, sirsoft-board.*.step)
// chunkById(키셋 순회) 필수 — each() 는 OFFSET 기반 청크(기본 1000건)라
// 콜백이 순회 대상을 삭제하면 다음 페이지의 OFFSET 이 줄어든 결과 집합을 지나쳐
// 1000건을 넘는 역할이 그대로 남는다.
Role::where('extension_type', 'module')
->where('extension_identifier', 'sirsoft-board')
->each(function (Role $role) {
$role->permissions()->detach();
$role->users()->detach();
$role->delete();
->chunkById(100, function ($roles) {
foreach ($roles as $role) {
$role->permissions()->detach();
$role->users()->detach();
$role->delete();
}
});
return parent::uninstall();
@@ -308,14 +315,14 @@ class Module extends AbstractModule
*/
public function getDynamicPermissionIdentifiers(): array
{
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
if (! Schema::hasTable('boards')) {
return [];
}
$actions = array_keys((array) config('sirsoft-board.board_permission_definitions', []));
$module = $this->getIdentifier();
$ids = [];
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
foreach (Board::query()->select('slug')->get() as $board) {
$category = $module.'.'.$board->slug;
$ids[] = $category;
foreach ($actions as $action) {
@@ -335,13 +342,13 @@ class Module extends AbstractModule
*/
public function getDynamicRoleIdentifiers(): array
{
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
if (! Schema::hasTable('boards')) {
return [];
}
$module = $this->getIdentifier();
$ids = [];
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
foreach (Board::query()->select('slug')->get() as $board) {
$ids[] = $module.'.'.$board->slug.'.manager';
$ids[] = $module.'.'.$board->slug.'.step';
}
@@ -356,12 +363,12 @@ class Module extends AbstractModule
*/
public function getDynamicMenuSlugs(): array
{
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
if (! Schema::hasTable('boards')) {
return [];
}
$slugs = [];
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
foreach (Board::query()->select('slug')->get() as $board) {
$slugs[] = 'board-'.$board->slug;
}
@@ -0,0 +1,195 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use App\Models\Permission;
use App\Models\Role;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Module;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 모듈 제거 시 동적 역할 정리 누락 회귀 테스트.
*
* 재현 대상 결함: `Role::where(...)->each(callback)` 은 Eloquent 의 OFFSET 기반
* chunk 순회(기본 1000건)다. 콜백이 순회 대상 행을 삭제하므로 두 번째 페이지의
* OFFSET 1000 이 이미 줄어든 결과 집합을 지나쳐 남은 행을 건너뛴다.
*
* 따라서 동적 역할이 1000건을 넘는 사이트에서 모듈을 제거하면 초과분이 그대로
* 남는다. `chunkById()`(키셋 순회) 로 바꾸면 삭제와 무관하게 전건이 처리된다.
*
* 시드 건수는 Eloquent `each()` 의 기본 청크 크기(1000)보다 커야 결함이 드러나므로
* 1100건을 사용한다.
*/
class UninstallDynamicRoleCleanupTest extends ModuleTestCase
{
/**
* 시드 건수 (Eloquent each() 기본 청크 1000 초과 필수)
*/
private const SEED_COUNT = 1100;
/**
* 권한/사용자 피벗을 연결할 역할 수 (뒤쪽 = OFFSET skip 구간)
*/
private const PIVOT_TAIL_COUNT = 50;
/**
* 시드 역할 식별자 프리픽스 (정리 대상 식별용)
*/
private const IDENTIFIER_PREFIX = 'chunkskip';
private Module $module;
protected function setUp(): void
{
parent::setUp();
$this->module = new Module;
$this->purgeSeededRoles();
}
/**
* ModuleTestCase 는 프로세스당 1회 `migrate:fresh`(DDL) 를 실행하므로 그 케이스의
* 트랜잭션이 암묵적으로 커밋된다 — 시드 행이 롤백되지 않고 다음 케이스로 새어
* identifier unique 충돌을 일으킨다. 케이스 전후로 명시 정리한다.
*/
protected function tearDown(): void
{
$this->purgeSeededRoles();
parent::tearDown();
}
/**
* 이 테스트가 시드한 역할과 그 피벗을 제거합니다.
*
* @return void
*/
private function purgeSeededRoles(): void
{
$roleIds = DB::table('roles')
->where('identifier', 'LIKE', '%.'.self::IDENTIFIER_PREFIX.'%')
->pluck('id')
->all();
if ($roleIds === []) {
return;
}
DB::table('role_permissions')->whereIn('role_id', $roleIds)->delete();
DB::table('user_roles')->whereIn('role_id', $roleIds)->delete();
DB::table('roles')->whereIn('id', $roleIds)->delete();
}
/**
* 게시판별 동적 역할을 시드합니다.
*
* @return void
*/
private function seedDynamicRoles(): void
{
$rows = [];
for ($i = 0; $i < self::SEED_COUNT; $i++) {
$rows[] = [
'identifier' => 'sirsoft-board.chunkskip'.$i.'.manager',
'name' => json_encode(['ko' => '게시판 관리자 '.$i, 'en' => 'Board Manager '.$i]),
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
];
}
foreach (array_chunk($rows, 200) as $batch) {
DB::table('roles')->insert($batch);
}
}
/**
* 동적 역할 잔여 건수를 반환합니다.
*
* @return int 잔여 건수
*/
private function remainingDynamicRoleCount(): int
{
return Role::where('extension_type', 'module')
->where('extension_identifier', 'sirsoft-board')
->count();
}
public function test_uninstall_removes_every_dynamic_role_beyond_the_first_chunk(): void
{
$this->seedDynamicRoles();
$this->assertSame(
self::SEED_COUNT,
$this->remainingDynamicRoleCount(),
'시드 직후에는 동적 역할이 시드 건수만큼 존재해야 합니다.'
);
$this->assertTrue($this->module->uninstall());
$this->assertSame(
0,
$this->remainingDynamicRoleCount(),
'OFFSET 순회로 인해 삭제되지 않고 남은 동적 역할이 있습니다 (each → chunkById 필요).'
);
}
public function test_uninstall_detaches_permissions_of_roles_in_the_skipped_range(): void
{
$this->seedDynamicRoles();
$permission = Permission::firstOrCreate(
['identifier' => 'sirsoft-board.chunkskip.test'],
['name' => ['ko' => '테스트 권한', 'en' => 'Test Permission'], 'type' => 'admin']
);
$tailRoleIds = Role::where('extension_identifier', 'sirsoft-board')
->orderByDesc('id')
->limit(self::PIVOT_TAIL_COUNT)
->pluck('id')
->all();
foreach ($tailRoleIds as $roleId) {
DB::table('role_permissions')->insert([
'role_id' => $roleId,
'permission_id' => $permission->id,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->module->uninstall();
$this->assertSame(
0,
DB::table('role_permissions')->whereIn('role_id', $tailRoleIds)->count(),
'삭제된 역할의 권한 피벗이 남아 있습니다.'
);
}
public function test_uninstall_keeps_roles_owned_by_other_extensions(): void
{
$this->seedDynamicRoles();
DB::table('roles')->insert([
'identifier' => 'sirsoft-other.chunkskip.manager',
'name' => json_encode(['ko' => '타 확장 역할', 'en' => 'Other Extension Role']),
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-other',
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$this->module->uninstall();
$this->assertTrue(
Role::where('extension_identifier', 'sirsoft-other')->exists(),
'다른 확장이 소유한 역할은 삭제되지 않아야 합니다.'
);
}
}
@@ -20,6 +20,9 @@ return new class extends Migration
->whereNotNull($column)
->where($column, '!=', '')
->orderBy('id')
// audit:allow offset-chunk-mutates-filter 갱신값 json_encode(['ko'=>…]) 은 항상
// `{"ko":…}` 라 non-null·non-empty — 갱신 후에도 whereNotNull + != '' 소속이
// 유지되어 결과 집합 크기가 변하지 않는다(OFFSET 밀림 없음).
->chunk(500, function ($rows) use ($column) {
foreach ($rows as $row) {
$value = $row->{$column};
@@ -60,6 +63,9 @@ return new class extends Migration
DB::table('ecommerce_order_options')
->whereNotNull($column)
->orderBy('id')
// audit:allow offset-chunk-mutates-filter 갱신값 $koValue 는 최악의 경우 빈
// 문자열이며 NULL 이 아니다 — 갱신 후에도 whereNotNull 소속이 유지되어
// 결과 집합 크기가 변하지 않는다(OFFSET 밀림 없음).
->chunk(500, function ($rows) use ($column) {
foreach ($rows as $row) {
$value = $row->{$column};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,6 +5,17 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.54.4] - 2026-07-31
### Fixed
#### 노드 `text` 키의 `{{raw:...}}` 바인딩이 평가되지 않고 텍스트가 통째로 사라지던 문제
- `DynamicRenderer.tsx` `renderChildren` — 노드 레벨 `text` 키 처리에서 `raw:` 접두사를 벗기지 않은 채 표현식 평가로 넘겨, `raw:` 의 콜론이 식의 일부로 파싱되며 `Unexpected token ':'` 로 예외가 났다. props 값 해석(`renderProps`)과 반복 렌더(`RenderHelpers.renderItemChildren`)에는 이미 있던 접두사 처리가 이 경로에만 빠져 있었다.
- 증상: 관리자 레이아웃 편집 화면의 파일 목록 등 `text: "{{raw:...}}"` 를 쓰는 자리에서 텍스트가 빈 값으로 렌더된다. 콘솔에는 경고만 남고 에러 화면이 뜨지 않아 조용한 실패로 관측된다(해당 화면 1회 진입에 174건).
- 접두사 제거 후 파이프/복합식/단순 경로 분기를 동일하게 태우고, 결과는 `wrapRawDeep` 로 감싸 번역 면제를 유지한다 — 다른 두 경로와 같은 규칙이다.
- 기존 동작 무영향: `raw:` 가 없는 `text` 는 종전 경로·결과 그대로다.
## [engine-v1.54.3] - 2026-07-25
### Fixed
@@ -2606,13 +2606,24 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
const singleBindingPath = extractSingleBinding(text);
if (singleBindingPath !== null) {
// raw: 접두사 감지 — props 처리(위 renderProps)와 동일 규칙.
// 접두사를 벗기지 않으면 `raw:` 의 콜론이 식의 일부로 파싱되어
// evaluateExpression 이 `Unexpected token ':'` 로 던지고 텍스트가 통째로 사라진다.
// @since engine-v1.54.4
let isRawBinding = false;
let effectivePath = singleBindingPath;
if (singleBindingPath.startsWith(RAW_PREFIX)) {
isRawBinding = true;
effectivePath = singleBindingPath.slice(RAW_PREFIX.length);
}
// 파이프가 있는 경우 resolveBindings 사용 (파이프 처리 포함)
if (hasPipes(singleBindingPath)) {
resolvedText = bindingEngine.resolveBindings(text, extendedDataContext, { skipCache: true }, textTrackingInfo);
} else if (isComplexExpression(singleBindingPath)) {
if (hasPipes(effectivePath)) {
resolvedText = bindingEngine.resolveBindings(`{{${effectivePath}}}`, extendedDataContext, { skipCache: true }, textTrackingInfo);
} else if (isComplexExpression(effectivePath)) {
// 복잡한 표현식인 경우 evaluateExpression 사용
try {
resolvedText = bindingEngine.evaluateExpression(singleBindingPath, extendedDataContext, textTrackingInfo);
resolvedText = bindingEngine.evaluateExpression(effectivePath, extendedDataContext, textTrackingInfo);
} catch (error) {
logger.warn(
`text 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
@@ -2621,7 +2632,11 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
resolvedText = '';
}
} else {
resolvedText = bindingEngine.resolve(singleBindingPath, extendedDataContext, { skipCache: true }, textTrackingInfo);
resolvedText = bindingEngine.resolve(effectivePath, extendedDataContext, { skipCache: true }, textTrackingInfo);
}
if (isRawBinding && resolvedText != null) {
resolvedText = wrapRawDeep(resolvedText);
}
} else if (text.includes('{{')) {
// 문자열 보간
@@ -2023,4 +2023,78 @@ describe('DynamicRenderer - TranslationEngine 통합', () => {
expect(screen.getByRole('button').textContent).toBe('일반 제목 텍스트');
});
});
describe('노드 text 키의 raw: 접두사 (engine-v1.54.4 회귀)', () => {
// 배경: props.text 는 raw: 를 벗겼지만 노드 레벨 `text` 키 경로는 접두사를
// 그대로 evaluateExpression 에 넘겨 `Unexpected token ':'` 로 던졌다.
// 결과적으로 레이아웃 편집 화면의 파일 목록 텍스트가 통째로 빈 값이 되었다.
it('노드 text 의 {{raw:복합식}} 이 평가되어 표시되어야 함', () => {
const componentDef: ComponentDefinition = {
id: 'raw-text-node-1',
type: 'basic',
name: 'Button',
text: '{{raw:file.description || file.name}}',
};
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={{ file: { description: '', name: 'admin_module_list.json' } }}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
/>
);
expect(screen.getByRole('button').textContent).toBe('admin_module_list.json');
});
it('노드 text 의 {{raw:단순경로}} 결과의 $t: 토큰은 번역 면제되어야 함', () => {
const componentDef: ComponentDefinition = {
id: 'raw-text-node-2',
type: 'basic',
name: 'Button',
text: '{{raw:post.title}}',
};
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={{ post: { title: '$t:admin.dashboard' } }}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
/>
);
expect(screen.getByRole('button').textContent).toBe('$t:admin.dashboard');
});
it('노드 text 의 raw: 없는 복합식은 종전대로 평가되어야 함 (비회귀)', () => {
const componentDef: ComponentDefinition = {
id: 'raw-text-node-3',
type: 'basic',
name: 'Button',
text: "{{file.description || file.name}}",
};
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={{ file: { description: '', name: '저장' } }}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
/>
);
expect(screen.getByRole('button').textContent).toBe('저장');
});
});
});
@@ -30,6 +30,10 @@
### Fixed
- 모듈·플러그인·템플릿 관리 화면에서 '업데이트 확인'을 눌러 업데이트가 발견돼도 항상 "모두 최신 상태입니다"라고 안내되던 문제를 수정했습니다.
- 게시판·상품 카테고리 목록의 새로고침 버튼과 언어팩 재활성화 안내에서 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다.
- 모듈·플러그인·템플릿 관리 화면의 목록 요약이 항상 "총 0개 중 1-0개 표시"로 나오고, 목록이 한 화면을 넘어도 다음 쪽으로 넘어갈 수 없던 문제를 수정했습니다.
- 업데이트 모달에서 수정된 화면 확인에 실패했는데도 "수정된 레이아웃이 없습니다"라고 표시되던 문제를 수정했습니다. 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있어, 이제 확인하지 못했다는 안내와 함께 '수정 유지'를 권장합니다.
- 본인확인 코드 입력 화면에서 "남은 시도" 안내를 제거했습니다. 외부 인증 화면을 거쳐 돌아온 경우 실제보다 많은 횟수가 표시될 수 있어, 잘못된 숫자를 보여주는 대신 표시하지 않도록 했습니다. 시도 횟수 제한 자체는 종전대로 적용됩니다.
- 회원 목록에서 검색할 때 페이지를 넘길 때와 달리 표 위에 로딩 표시가 뜨지 않던 문제를 수정했습니다.
- 회원·역할·스케줄 목록에서 검색·정렬·페이지 이동 시 표 위에 걸어 둔 로딩 표시가 실제로는 동작하지 않던 문제를 수정했습니다. 다른 관리자 목록 화면과 같은 방식으로 표 본문에만 표시됩니다.
@@ -0,0 +1,139 @@
/**
* @file layout-translation-keys.test.ts
* @description 레이아웃이 참조하는 `$t:` 키가 템플릿 언어 파일에 실제로 정의돼 있는지 전수 검사
*
* Chrome MCP 정밀 점검에서 `common.refresh` / `common.activate` / `common.skip` 세 키가
* 정의되지 않아, 게시판·이커머스 목록의 새로고침 버튼과 언어팩 재활성화 모달에 키 원문이
* 그대로 노출됐다(D13). 화면에는 아무 오류도 뜨지 않고 문자열만 깨져 보인다.
*
* 특정 키를 손으로 열거하면 다음에 추가되는 키를 다시 놓친다. 그래서 이 테스트는
* **레이아웃에서 참조된 키를 스캔해** 그 전부가 ko/en 양쪽에 있는지 확인한다.
* 검사 대상에는 이 템플릿의 레이아웃뿐 아니라, 같은 관리자 화면에 얹히는 번들
* 모듈/플러그인 레이아웃도 포함한다 — 이들이 참조하는 `common.*` 도 결국 이 템플릿의
* 언어 파일로 해석되기 때문이다.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const here = path.dirname(fileURLToPath(import.meta.url));
const templateDir = path.resolve(here, '../..');
/** artisan 파일을 기준으로 프로젝트 루트를 찾는다 (_bundled/활성 디렉토리 모두 호환) */
function findProjectRoot(startDir: string): string {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
dir = path.dirname(dir);
}
return path.resolve(startDir, '../..');
}
const rootDir = findProjectRoot(templateDir);
/** 디렉토리 하위의 모든 JSON 파일 경로를 수집한다 */
function collectJsonFiles(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectJsonFiles(full);
return entry.isFile() && entry.name.endsWith('.json') ? [full] : [];
});
}
/** 확장 종류별 번들 레이아웃 디렉토리를 수집한다 */
function collectBundledLayoutDirs(kind: 'modules' | 'plugins'): string[] {
const base = path.join(rootDir, kind, '_bundled');
if (!fs.existsSync(base)) return [];
return fs
.readdirSync(base, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => path.join(base, e.name, 'resources', 'layouts'))
.filter((p) => fs.existsSync(p));
}
const LAYOUT_DIRS = [
path.join(templateDir, 'layouts'),
...collectBundledLayoutDirs('modules'),
...collectBundledLayoutDirs('plugins'),
];
/** `$t:<namespace>.<key>` 참조를 수집한다 (namespace → key 집합) */
function collectTranslationRefs(): Map<string, Map<string, Set<string>>> {
const refs = new Map<string, Map<string, Set<string>>>();
const pattern = /\$t:([a-zA-Z0-9_]+)\.([a-zA-Z0-9_.]+)/g;
for (const dir of LAYOUT_DIRS) {
for (const file of collectJsonFiles(dir)) {
const raw = fs.readFileSync(file, 'utf8');
for (const match of raw.matchAll(pattern)) {
const [, namespace, key] = match;
// 표현식 끝의 마침표는 키의 일부가 아니다
const normalized = key.replace(/\.$/, '');
if (!refs.has(namespace)) refs.set(namespace, new Map());
const byKey = refs.get(namespace)!;
if (!byKey.has(normalized)) byKey.set(normalized, new Set());
byKey.get(normalized)!.add(path.relative(rootDir, file));
}
}
}
return refs;
}
/** 로케일별 언어 파일을 읽는다 (없으면 null) */
function loadLangFile(locale: string, namespace: string): Record<string, unknown> | null {
const file = path.join(templateDir, 'lang', 'partial', locale, `${namespace}.json`);
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/** 점 표기 경로가 객체에 존재하는지 확인한다 */
function hasPath(source: Record<string, unknown>, dotted: string): boolean {
return dotted.split('.').reduce<unknown>(
(acc, segment) =>
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[segment] : undefined,
source
) !== undefined;
}
const REFS = collectTranslationRefs();
const LOCALES = ['ko', 'en'];
describe('레이아웃 $t: 키 정의 검사', () => {
it('검사 대상 레이아웃과 참조 키가 실제로 수집되어야 함 (모집단 도달 확인)', () => {
expect(LAYOUT_DIRS.length).toBeGreaterThan(1);
expect(REFS.get('common')?.size ?? 0).toBeGreaterThan(10);
});
it.each(LOCALES)('common 네임스페이스의 모든 참조 키가 %s 에 정의되어야 함', (locale) => {
const defined = loadLangFile(locale, 'common');
expect(defined, `lang/partial/${locale}/common.json 이 없습니다`).not.toBeNull();
const missing: string[] = [];
for (const [key, files] of REFS.get('common') ?? []) {
if (!hasPath(defined!, key)) {
missing.push(`common.${key} (참조: ${[...files].slice(0, 3).join(', ')})`);
}
}
expect(
missing,
`정의되지 않은 키가 화면에 원문으로 노출됩니다:\n - ${missing.join('\n - ')}`
).toEqual([]);
});
it('D13 회귀: 새로고침·활성화·건너뛰기 키가 ko/en 양쪽에 있어야 함', () => {
for (const locale of LOCALES) {
const defined = loadLangFile(locale, 'common')!;
for (const key of ['refresh', 'activate', 'skip']) {
expect(hasPath(defined, key), `${locale}/common.json 에 ${key} 누락`).toBe(true);
}
}
});
});
@@ -0,0 +1,99 @@
/**
* @file extension-list-contract.test.tsx
* @description 확장(모듈/플러그인/템플릿) 목록·업데이트 모달의 API 계약 회귀 테스트
*
* Chrome MCP 정밀 점검에서 실측된 결함 3건을 레이아웃 JSON 수준에서 고정한다.
*
* 1) check-updates 응답 필드는 `updated_count` 인데 레이아웃이 `update_count` 를 읽어
* 업데이트가 감지돼도 항상 "모든 X가 최신 상태입니다" 토스트가 떴다.
* 2) 목록 페이지네이션이 `data.total` / `data.current_page` 를 읽는데 실제 응답은
* `data.pagination.*` 이라 "총 0개 중 1-0개 표시" 로 고정 표기됐다.
* 3) check-modified-layouts 호출에 onError 가 없어, 네트워크 실패 시 모달이
* "수정된 레이아웃이 없습니다" 라고 단언 → '모두 교체' 진행 시 수정 소실.
*/
import { describe, it, expect } from 'vitest';
import moduleList from '../../layouts/admin_module_list.json';
import pluginList from '../../layouts/admin_plugin_list.json';
import templateList from '../../layouts/admin_template_list.json';
import tabAdmin from '../../layouts/partials/admin_template_list/_tab_admin.json';
import tabUser from '../../layouts/partials/admin_template_list/_tab_user.json';
import moduleModal from '../../layouts/partials/admin_module_list/_modal_update.json';
import pluginModal from '../../layouts/partials/admin_plugin_list/_modal_update.json';
import templateModal from '../../layouts/partials/admin_template_list/_modal_update.json';
const LIST_LAYOUTS: Array<[string, unknown]> = [
['admin_module_list', moduleList],
['admin_plugin_list', pluginList],
['admin_template_list', templateList],
];
const PAGINATION_LAYOUTS: Array<[string, unknown, string]> = [
['admin_module_list', moduleList, 'modules'],
['admin_plugin_list', pluginList, 'plugins'],
['_tab_admin', tabAdmin, 'templates'],
['_tab_user', tabUser, 'templates'],
];
const MODALS: Array<[string, unknown, string]> = [
['module', moduleModal, 'Module'],
['plugin', pluginModal, 'Plugin'],
['template', templateModal, 'Template'],
];
const CHECK_SOURCES: Array<[string, unknown, string]> = [
['admin_module_list', moduleList, 'Module'],
['admin_plugin_list', pluginList, 'Plugin'],
['_tab_admin', tabAdmin, 'Template'],
['_tab_user', tabUser, 'Template'],
];
describe('확장 목록 레이아웃 — API 계약', () => {
it.each(LIST_LAYOUTS)('%s: check-updates 는 updated_count 를 읽어야 함', (_name, layout) => {
const json = JSON.stringify(layout);
expect(json).toContain('response.data?.updated_count');
// 잘못된 필드명이 남아있으면 토스트가 영구히 "최신 상태" 로 고정된다
expect(json).not.toContain('response.data?.update_count');
expect(json).not.toContain('response.data.update_count');
});
it.each(PAGINATION_LAYOUTS)('%s: 페이지네이션은 data.pagination.* 경로여야 함', (_name, layout, ds) => {
const json = JSON.stringify(layout);
for (const key of ['total', 'current_page', 'last_page', 'per_page']) {
expect(json).not.toContain(`${ds}?.data?.${key}`);
}
expect(json).toContain(`${ds}?.data?.pagination?.total`);
});
it.each(CHECK_SOURCES)('%s: check-modified-layouts 호출에 onError 가 있어야 함', (_name, layout, kind) => {
const found: any[] = [];
const walk = (node: any) => {
if (Array.isArray(node)) { node.forEach(walk); return; }
if (!node || typeof node !== 'object') return;
if (node.handler === 'apiCall' && typeof node.target === 'string'
&& node.target.includes('/check-modified-layouts')) {
found.push(node);
}
Object.values(node).forEach(walk);
};
walk(layout);
expect(found.length).toBeGreaterThan(0);
for (const call of found) {
expect(Array.isArray(call.onError)).toBe(true);
expect(JSON.stringify(call.onError)).toContain(`modified${kind}LayoutsCheckFailed`);
// 성공 시에는 플래그를 반드시 해제해야 재조회가 경고에 갇히지 않는다
expect(JSON.stringify(call.onSuccess)).toContain(`modified${kind}LayoutsCheckFailed`);
}
});
it.each(MODALS)('%s 업데이트 모달: 확인 실패 분기가 있고 "수정 없음" 단언을 가로막아야 함', (_name, modal, kind) => {
const json = JSON.stringify(modal);
expect(json).toContain('modified_layouts_check_failed');
expect(json).toContain(`_global.modified${kind}LayoutsCheckFailed`);
// "수정된 레이아웃이 없습니다" 는 확인이 성공했을 때만 표시되어야 한다
expect(json).toContain(
`{{!_global.hasModified${kind}Layouts && !_global.modified${kind}LayoutsCheckFailed}}`
);
});
});
@@ -723,6 +723,7 @@
"layout_strategy_keep_desc": "Preserve user-modified layouts.",
"modified_layouts_warning": "{{count}} modified layout(s) exist. Choosing 'Overwrite All' will discard the modifications.",
"no_modified_layouts": "No modified layouts.",
"modified_layouts_check_failed": "Could not check for modified layouts. Check your connection and retry, or choose 'Keep changes' to be safe.",
"modified_layouts_list_title": "Modified layouts to keep ({{count}})",
"modified_layouts_keep_notice": "These layouts will retain their current content after the update."
},
@@ -928,6 +929,7 @@
"layout_strategy_keep_desc": "Preserve user-modified layouts.",
"modified_layouts_warning": "{{count}} modified layout(s) exist. Choosing 'Overwrite All' will discard the modifications.",
"no_modified_layouts": "No modified layouts.",
"modified_layouts_check_failed": "Could not check for modified layouts. Check your connection and retry, or choose 'Keep changes' to be safe.",
"modified_layouts_list_title": "Modified layouts to keep ({{count}})",
"modified_layouts_keep_notice": "These layouts will retain their current content after the update."
},
@@ -2120,6 +2122,7 @@
"layout_strategy_keep_desc": "Keep user-modified layouts.",
"modified_layouts_warning": "{{count}} layout(s) have been modified. Selecting 'Overwrite All' will discard modifications.",
"no_modified_layouts": "No modified layouts found.",
"modified_layouts_check_failed": "Could not check for modified layouts. Check your connection and retry, or choose 'Keep changes' to be safe.",
"modified_layouts_list_title": "Modified layouts to be preserved ({{count}})",
"modified_layouts_keep_notice": "These layouts will retain their current content after the update.",
"activate_warning_title": "Template Activation Warning",
@@ -76,5 +76,8 @@
"scope_type_role_desc": "Only resources from users with the same role",
"multilingual": {
"add_language": "Add Language"
}
}
},
"refresh": "Refresh",
"activate": "Activate",
"skip": "Skip"
}
@@ -727,6 +727,7 @@
"layout_strategy_keep_desc": "사용자가 수정한 레이아웃을 유지합니다.",
"modified_layouts_warning": "수정된 레이아웃이 {{count}}개 있습니다. '모두 교체'를 선택하면 수정 내용이 삭제됩니다.",
"no_modified_layouts": "수정된 레이아웃이 없습니다.",
"modified_layouts_check_failed": "수정 여부를 확인하지 못했습니다. 네트워크 상태를 확인한 뒤 다시 시도하거나, 안전하게 '수정 유지'를 선택하세요.",
"modified_layouts_list_title": "유지될 수정 레이아웃 ({{count}}개)",
"modified_layouts_keep_notice": "위 레이아웃은 업데이트 후에도 현재 내용이 유지됩니다."
},
@@ -932,6 +933,7 @@
"layout_strategy_keep_desc": "사용자가 수정한 레이아웃을 유지합니다.",
"modified_layouts_warning": "수정된 레이아웃이 {{count}}개 있습니다. '모두 교체'를 선택하면 수정 내용이 삭제됩니다.",
"no_modified_layouts": "수정된 레이아웃이 없습니다.",
"modified_layouts_check_failed": "수정 여부를 확인하지 못했습니다. 네트워크 상태를 확인한 뒤 다시 시도하거나, 안전하게 '수정 유지'를 선택하세요.",
"modified_layouts_list_title": "유지될 수정 레이아웃 ({{count}}개)",
"modified_layouts_keep_notice": "위 레이아웃은 업데이트 후에도 현재 내용이 유지됩니다."
},
@@ -2124,6 +2126,7 @@
"layout_strategy_keep_desc": "사용자가 수정한 레이아웃을 유지합니다.",
"modified_layouts_warning": "수정된 레이아웃이 {{count}}개 있습니다. '모두 교체'를 선택하면 수정 내용이 삭제됩니다.",
"no_modified_layouts": "수정된 레이아웃이 없습니다.",
"modified_layouts_check_failed": "수정 여부를 확인하지 못했습니다. 네트워크 상태를 확인한 뒤 다시 시도하거나, 안전하게 '수정 유지'를 선택하세요.",
"modified_layouts_list_title": "유지될 수정 레이아웃 ({{count}}개)",
"modified_layouts_keep_notice": "위 레이아웃은 업데이트 후에도 현재 내용이 유지됩니다.",
"activate_warning_title": "템플릿 활성화 경고",
@@ -76,5 +76,8 @@
"scope_type_role_desc": "같은 역할의 사용자 리소스만",
"multilingual": {
"add_language": "언어 추가"
}
}
},
"refresh": "새로고침",
"activate": "활성화",
"skip": "건너뛰기"
}
@@ -49,10 +49,12 @@
"fallback": {
"data": {
"data": [],
"current_page": 1,
"last_page": 1,
"per_page": 12,
"total": 0
"pagination": {
"current_page": 1,
"last_page": 1,
"per_page": 12,
"total": 0
}
}
}
},
@@ -167,7 +169,7 @@
"handler": "toast",
"params": {
"type": "success",
"message": "{{response.data?.update_count > 0 ? '$t:admin.modules.check_updates_success|count=' + response.data.update_count : '$t:admin.modules.check_updates_no_updates'}}"
"message": "{{response.data?.updated_count > 0 ? '$t:admin.modules.check_updates_success|count=' + response.data.updated_count : '$t:admin.modules.check_updates_no_updates'}}"
}
}
],
@@ -640,8 +642,8 @@
"pagination": true,
"alwaysShowPagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{modules?.data?.current_page ?? 1}}",
"serverTotalPages": "{{modules?.data?.last_page ?? 1}}",
"serverCurrentPage": "{{modules?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{modules?.data?.pagination?.last_page ?? 1}}",
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
"cardColumns": [
{
@@ -1010,7 +1012,8 @@
"moduleLayoutStrategy": "overwrite",
"hasModifiedModuleLayouts": false,
"modifiedModuleLayoutsCount": 0,
"modifiedModuleLayouts": []
"modifiedModuleLayouts": [],
"modifiedModuleLayoutsCheckFailed": false
}
},
{
@@ -1027,7 +1030,17 @@
"target": "global",
"hasModifiedModuleLayouts": "{{response.data?.has_modified_layouts ?? false}}",
"modifiedModuleLayoutsCount": "{{response.data?.modified_count ?? 0}}",
"modifiedModuleLayouts": "{{response.data?.modified_layouts ?? []}}"
"modifiedModuleLayouts": "{{response.data?.modified_layouts ?? []}}",
"modifiedModuleLayoutsCheckFailed": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"modifiedModuleLayoutsCheckFailed": true
}
}
]
@@ -1397,7 +1410,7 @@
{
"type": "basic",
"name": "Span",
"text": "$t:admin.modules.pagination_info|total={{modules?.data?.total ?? 0}}|from={{((modules?.data?.current_page ?? 1) - 1) * (modules?.data?.per_page ?? 12) + 1}}|to={{Math.min((modules?.data?.current_page ?? 1) * (modules?.data?.per_page ?? 12), modules?.data?.total ?? 0)}}"
"text": "$t:admin.modules.pagination_info|total={{modules?.data?.pagination?.total ?? 0}}|from={{((modules?.data?.pagination?.current_page ?? 1) - 1) * (modules?.data?.pagination?.per_page ?? 12) + 1}}|to={{Math.min((modules?.data?.pagination?.current_page ?? 1) * (modules?.data?.pagination?.per_page ?? 12), modules?.data?.pagination?.total ?? 0)}}"
}
]
}
@@ -49,10 +49,12 @@
"fallback": {
"data": {
"data": [],
"current_page": 1,
"last_page": 1,
"per_page": 12,
"total": 0
"pagination": {
"current_page": 1,
"last_page": 1,
"per_page": 12,
"total": 0
}
}
}
},
@@ -167,7 +169,7 @@
"handler": "toast",
"params": {
"type": "success",
"message": "{{response.data?.update_count > 0 ? '$t:admin.plugins.check_updates_success|count=' + response.data.update_count : '$t:admin.plugins.check_updates_no_updates'}}"
"message": "{{response.data?.updated_count > 0 ? '$t:admin.plugins.check_updates_success|count=' + response.data.updated_count : '$t:admin.plugins.check_updates_no_updates'}}"
}
}
],
@@ -649,8 +651,8 @@
"pagination": true,
"alwaysShowPagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{plugins?.data?.current_page ?? 1}}",
"serverTotalPages": "{{plugins?.data?.last_page ?? 1}}",
"serverCurrentPage": "{{plugins?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{plugins?.data?.pagination?.last_page ?? 1}}",
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
"cardColumns": [
{
@@ -1022,7 +1024,8 @@
"pluginLayoutStrategy": "overwrite",
"hasModifiedPluginLayouts": false,
"modifiedPluginLayoutsCount": 0,
"modifiedPluginLayouts": []
"modifiedPluginLayouts": [],
"modifiedPluginLayoutsCheckFailed": false
}
},
{
@@ -1039,7 +1042,17 @@
"target": "global",
"hasModifiedPluginLayouts": "{{response.data?.has_modified_layouts ?? false}}",
"modifiedPluginLayoutsCount": "{{response.data?.modified_count ?? 0}}",
"modifiedPluginLayouts": "{{response.data?.modified_layouts ?? []}}"
"modifiedPluginLayouts": "{{response.data?.modified_layouts ?? []}}",
"modifiedPluginLayoutsCheckFailed": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"modifiedPluginLayoutsCheckFailed": true
}
}
]
@@ -1422,7 +1435,7 @@
{
"type": "basic",
"name": "Span",
"text": "$t:admin.plugins.pagination_info|total={{plugins?.data?.total ?? 0}}|from={{((plugins?.data?.current_page ?? 1) - 1) * (plugins?.data?.per_page ?? 12) + 1}}|to={{Math.min((plugins?.data?.current_page ?? 1) * (plugins?.data?.per_page ?? 12), plugins?.data?.total ?? 0)}}"
"text": "$t:admin.plugins.pagination_info|total={{plugins?.data?.pagination?.total ?? 0}}|from={{((plugins?.data?.pagination?.current_page ?? 1) - 1) * (plugins?.data?.pagination?.per_page ?? 12) + 1}}|to={{Math.min((plugins?.data?.pagination?.current_page ?? 1) * (plugins?.data?.pagination?.per_page ?? 12), plugins?.data?.pagination?.total ?? 0)}}"
}
]
}
@@ -52,10 +52,12 @@
"fallback": {
"data": {
"data": [],
"current_page": 1,
"last_page": 1,
"per_page": 12,
"total": 0
"pagination": {
"current_page": 1,
"last_page": 1,
"per_page": 12,
"total": 0
}
}
}
},
@@ -170,7 +172,7 @@
"handler": "toast",
"params": {
"type": "success",
"message": "{{response.data?.update_count > 0 ? '$t:admin.templates.check_updates_success|count=' + response.data.update_count : '$t:admin.templates.check_updates_no_updates'}}"
"message": "{{response.data?.updated_count > 0 ? '$t:admin.templates.check_updates_success|count=' + response.data.updated_count : '$t:admin.templates.check_updates_no_updates'}}"
}
}
],
@@ -440,7 +440,17 @@
{
"type": "basic",
"name": "Div",
"if": "{{!_global.hasModifiedModuleLayouts}}",
"if": "{{_global.modifiedModuleLayoutsCheckFailed}}",
"props": {
"className": "mb-2 p-2 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded text-xs text-red-700 dark:text-red-400",
"role": "alert"
},
"text": "$t:admin.modules.modals.modified_layouts_check_failed"
},
{
"type": "basic",
"name": "Div",
"if": "{{!_global.hasModifiedModuleLayouts && !_global.modifiedModuleLayoutsCheckFailed}}",
"props": {
"className": "mb-2 p-2 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded text-xs text-green-700 dark:text-green-400"
},
@@ -440,7 +440,17 @@
{
"type": "basic",
"name": "Div",
"if": "{{!_global.hasModifiedPluginLayouts}}",
"if": "{{_global.modifiedPluginLayoutsCheckFailed}}",
"props": {
"className": "mb-2 p-2 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded text-xs text-red-700 dark:text-red-400",
"role": "alert"
},
"text": "$t:admin.plugins.modals.modified_layouts_check_failed"
},
{
"type": "basic",
"name": "Div",
"if": "{{!_global.hasModifiedPluginLayouts && !_global.modifiedPluginLayoutsCheckFailed}}",
"props": {
"className": "mb-2 p-2 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded text-xs text-green-700 dark:text-green-400"
},
@@ -440,7 +440,17 @@
{
"type": "basic",
"name": "Div",
"if": "{{!_global.hasModifiedTemplateLayouts}}",
"if": "{{_global.modifiedTemplateLayoutsCheckFailed}}",
"props": {
"className": "mb-2 p-2 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded text-xs text-red-700 dark:text-red-400",
"role": "alert"
},
"text": "$t:admin.templates.modals.modified_layouts_check_failed"
},
{
"type": "basic",
"name": "Div",
"if": "{{!_global.hasModifiedTemplateLayouts && !_global.modifiedTemplateLayoutsCheckFailed}}",
"props": {
"className": "mb-2 p-2 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded text-xs text-green-700 dark:text-green-400"
},
@@ -104,8 +104,8 @@
"pagination": true,
"alwaysShowPagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{templates?.data?.current_page ?? 1}}",
"serverTotalPages": "{{templates?.data?.last_page ?? 1}}",
"serverCurrentPage": "{{templates?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{templates?.data?.pagination?.last_page ?? 1}}",
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
"cardColumns": [
{
@@ -122,7 +122,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex-shrink-0 w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"
"className": "flex-shrink-0 text-5xl bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"
},
"children": [
{
@@ -272,7 +272,7 @@
"name": "Icon",
"props": {
"name": "exclamation-triangle",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -346,7 +346,7 @@
"if": "{{dep.type === 'module'}}",
"props": {
"name": "cube",
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
"className": "text-sm text-gray-500 dark:text-gray-400"
}
},
{
@@ -355,7 +355,7 @@
"if": "{{dep.type === 'plugin'}}",
"props": {
"name": "puzzle-piece",
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
"className": "text-sm text-gray-500 dark:text-gray-400"
}
},
{
@@ -428,7 +428,7 @@
"name": "Icon",
"props": {
"name": "code",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -470,7 +470,7 @@
"name": "Icon",
"props": {
"name": "edit",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -520,7 +520,7 @@
"name": "Icon",
"props": {
"name": "download",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -554,7 +554,8 @@
"templateLayoutStrategy": "overwrite",
"hasModifiedTemplateLayouts": false,
"modifiedTemplateLayoutsCount": 0,
"modifiedTemplateLayouts": []
"modifiedTemplateLayouts": [],
"modifiedTemplateLayoutsCheckFailed": false
}
},
{
@@ -571,7 +572,17 @@
"target": "global",
"hasModifiedTemplateLayouts": "{{response.data?.has_modified_layouts ?? false}}",
"modifiedTemplateLayoutsCount": "{{response.data?.modified_count ?? 0}}",
"modifiedTemplateLayouts": "{{response.data?.modified_layouts ?? []}}"
"modifiedTemplateLayouts": "{{response.data?.modified_layouts ?? []}}",
"modifiedTemplateLayoutsCheckFailed": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"modifiedTemplateLayoutsCheckFailed": true
}
}
]
@@ -627,7 +638,7 @@
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -872,7 +883,7 @@
{
"type": "basic",
"name": "Span",
"text": "$t:admin.templates.pagination_info|total={{templates?.data?.total ?? 0}}|from={{((templates?.data?.current_page ?? 1) - 1) * (templates?.data?.per_page ?? 12) + 1}}|to={{Math.min((templates?.data?.current_page ?? 1) * (templates?.data?.per_page ?? 12), templates?.data?.total ?? 0)}}"
"text": "$t:admin.templates.pagination_info|total={{templates?.data?.pagination?.total ?? 0}}|from={{((templates?.data?.pagination?.current_page ?? 1) - 1) * (templates?.data?.pagination?.per_page ?? 12) + 1}}|to={{Math.min((templates?.data?.pagination?.current_page ?? 1) * (templates?.data?.pagination?.per_page ?? 12), templates?.data?.pagination?.total ?? 0)}}"
}
]
}
@@ -898,7 +909,7 @@
"name": "Icon",
"props": {
"name": "info-circle",
"className": "w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
"className": "text-xl text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -104,8 +104,8 @@
"pagination": true,
"alwaysShowPagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{templates?.data?.current_page ?? 1}}",
"serverTotalPages": "{{templates?.data?.last_page ?? 1}}",
"serverCurrentPage": "{{templates?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{templates?.data?.pagination?.last_page ?? 1}}",
"cardClassName": "border border-gray-200 dark:border-gray-700 rounded-lg p-4 relative",
"cardColumns": [
{
@@ -122,7 +122,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex-shrink-0 w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"
"className": "flex-shrink-0 text-5xl bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"
},
"children": [
{
@@ -272,7 +272,7 @@
"name": "Icon",
"props": {
"name": "exclamation-triangle",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -346,7 +346,7 @@
"if": "{{dep.type === 'module'}}",
"props": {
"name": "cube",
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
"className": "text-sm text-gray-500 dark:text-gray-400"
}
},
{
@@ -355,7 +355,7 @@
"if": "{{dep.type === 'plugin'}}",
"props": {
"name": "puzzle-piece",
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
"className": "text-sm text-gray-500 dark:text-gray-400"
}
},
{
@@ -428,7 +428,7 @@
"name": "Icon",
"props": {
"name": "code",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -470,7 +470,7 @@
"name": "Icon",
"props": {
"name": "edit",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -520,7 +520,7 @@
"name": "Icon",
"props": {
"name": "download",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -583,7 +583,8 @@
"templateLayoutStrategy": "overwrite",
"hasModifiedTemplateLayouts": false,
"modifiedTemplateLayoutsCount": 0,
"modifiedTemplateLayouts": []
"modifiedTemplateLayouts": [],
"modifiedTemplateLayoutsCheckFailed": false
}
},
{
@@ -600,7 +601,17 @@
"target": "global",
"hasModifiedTemplateLayouts": "{{response.data?.has_modified_layouts ?? false}}",
"modifiedTemplateLayoutsCount": "{{response.data?.modified_count ?? 0}}",
"modifiedTemplateLayouts": "{{response.data?.modified_layouts ?? []}}"
"modifiedTemplateLayouts": "{{response.data?.modified_layouts ?? []}}",
"modifiedTemplateLayoutsCheckFailed": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"modifiedTemplateLayoutsCheckFailed": true
}
}
]
@@ -656,7 +667,7 @@
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -864,7 +875,7 @@
{
"type": "basic",
"name": "Span",
"text": "$t:admin.templates.pagination_info|total={{templates?.data?.total ?? 0}}|from={{((templates?.data?.current_page ?? 1) - 1) * (templates?.data?.per_page ?? 12) + 1}}|to={{Math.min((templates?.data?.current_page ?? 1) * (templates?.data?.per_page ?? 12), templates?.data?.total ?? 0)}}"
"text": "$t:admin.templates.pagination_info|total={{templates?.data?.pagination?.total ?? 0}}|from={{((templates?.data?.pagination?.current_page ?? 1) - 1) * (templates?.data?.pagination?.per_page ?? 12) + 1}}|to={{Math.min((templates?.data?.pagination?.current_page ?? 1) * (templates?.data?.pagination?.per_page ?? 12), templates?.data?.pagination?.total ?? 0)}}"
}
]
}
@@ -890,7 +901,7 @@
"name": "Icon",
"props": {
"name": "info-circle",
"className": "w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
"className": "text-xl text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -0,0 +1,213 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\ModuleService;
use App\Services\PluginService;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* `check-modified-layouts` 및 확장 업데이트 응답의 계약 회귀 테스트.
*
* 배경(Chrome MCP 정밀 점검에서 실측):
* - 미존재 식별자에 대해 형제 엔드포인트(show/uninstall-info)는 404 를 주는데
* check-modified-layouts 만 200 + "수정된 레이아웃 없음" 을 반환했다. 오타·제거된
* 확장이 "수정 없음" 으로 조용히 통과해, 레이아웃 보존 판단을 그르친다.
* - 업데이트 성공 메시지가 치환 파라미터 없이 반환되어 `:module` / `:version`
* 플레이스홀더가 그대로 사용자에게 노출됐다.
*
* 컨트롤러 소스의 철자가 아니라 **실제 HTTP 응답**(상태코드·메시지 본문)으로 계약을
* 고정한다. 확장 서비스는 컨테이너에서 대역으로 바꿔 파일시스템/네트워크 의존을 제거한다.
*/
class CheckModifiedLayoutsContractTest extends TestCase
{
use RefreshDatabase;
private string $token;
/** 확장 종류별 검증 축: [서비스, 조회 메서드, URL 세그먼트, 업데이트 메서드, 치환 키] */
private const EXTENSIONS = [
['service' => ModuleService::class, 'lookup' => 'getModuleInfo', 'segment' => 'modules', 'update' => 'updateModule', 'name' => 'zz-audit-module'],
['service' => PluginService::class, 'lookup' => 'getPluginInfo', 'segment' => 'plugins', 'update' => 'updatePlugin', 'name' => 'zz-audit-plugin'],
['service' => TemplateService::class, 'lookup' => 'getTemplateInfo', 'segment' => 'templates', 'update' => 'performVersionUpdate', 'name' => 'zz-audit-template'],
];
protected function setUp(): void
{
parent::setUp();
$admin = $this->createAdminUser([
'core.modules.read', 'core.modules.install',
'core.plugins.read', 'core.plugins.install',
'core.templates.read', 'core.templates.install',
]);
$this->token = $admin->createToken('test-token')->plainTextToken;
}
/**
* admin 역할 + 지정 권한을 가진 사용자를 생성합니다.
*
* AdminMiddleware 가 hasRole('admin') 을 확인하므로 admin 역할 부여가 필수입니다.
*
* @param array<int, string> $permissions 부여할 권한 식별자 목록
* @return User 생성된 관리자
*/
private function createAdminUser(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$permissionIds[] = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => ['ko' => $identifier, 'en' => $identifier],
'description' => ['ko' => $identifier, 'en' => $identifier],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
]
)->id;
}
$testRole = Role::create([
'identifier' => 'admin_test_'.uniqid(),
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Administrator'],
'description' => ['ko' => '테스트 관리자', 'en' => 'Test Administrator'],
'is_active' => true,
]);
$testRole->permissions()->sync($permissionIds);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Administrator'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]
);
foreach ([$adminRole->id, $testRole->id] as $roleId) {
$user->roles()->attach($roleId, ['assigned_at' => now(), 'assigned_by' => null]);
}
return $user->fresh();
}
/**
* 인증 헤더가 붙은 요청 헬퍼.
*/
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
/**
* 미존재 식별자는 200 "수정 없음" 이 아니라 404 여야 한다 (D4).
*/
public function test_check_modified_layouts_returns_404_for_missing_identifier(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->mock($ext['service'], function ($mock) use ($ext) {
$mock->shouldReceive($ext['lookup'])->andReturn(null);
$mock->shouldNotReceive('checkModifiedLayouts');
});
$response = $this->authRequest()
->getJson("/api/admin/{$ext['segment']}/{$ext['name']}/check-modified-layouts");
$response->assertStatus(404);
$this->assertFalse(
$response->json('success'),
"{$ext['segment']}: 미존재 식별자가 성공 응답으로 통과했습니다."
);
$this->assertStringNotContainsString(
'modified',
(string) $response->json('message'),
"{$ext['segment']}: 미존재인데 수정 감지 결과 메시지가 반환되었습니다."
);
}
}
/**
* 존재하는 식별자는 종전대로 200 + 감지 결과를 반환한다 (비회귀).
*/
public function test_check_modified_layouts_returns_200_for_existing_identifier(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->mock($ext['service'], function ($mock) use ($ext) {
$mock->shouldReceive($ext['lookup'])->andReturn(['identifier' => $ext['name']]);
$mock->shouldReceive('checkModifiedLayouts')->andReturn([
'has_modified_layouts' => true,
'modified_count' => 2,
'modified_layouts' => [
['name' => 'a', 'size_diff' => 10],
['name' => 'b', 'size_diff' => -4],
],
]);
});
$response = $this->authRequest()
->getJson("/api/admin/{$ext['segment']}/{$ext['name']}/check-modified-layouts");
$response->assertStatus(200);
$this->assertSame(2, $response->json('data.modified_count'), $ext['segment']);
}
}
/**
* 업데이트 성공 메시지에 식별자·버전이 실제로 채워져야 한다 (D7).
*/
public function test_update_success_message_has_substituted_placeholders(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->mock($ext['service'], function ($mock) use ($ext) {
// module_info/plugin_info/template_info 를 비워 success() 경로를 통과시킨다.
$mock->shouldReceive($ext['update'])->andReturn([
'success' => true,
'from_version' => '1.0.0',
'to_version' => '1.2.3',
]);
});
$response = $this->authRequest()
->postJson("/api/admin/{$ext['segment']}/{$ext['name']}/update", [
'layout_strategy' => 'keep',
]);
$response->assertStatus(200);
$message = (string) $response->json('message');
$this->assertStringContainsString($ext['name'], $message, "{$ext['segment']}: 식별자가 메시지에 치환되지 않았습니다.");
$this->assertStringContainsString('1.2.3', $message, "{$ext['segment']}: 버전이 메시지에 치환되지 않았습니다.");
$this->assertDoesNotMatchRegularExpression(
'/:(module|plugin|template|version)\b/',
$message,
"{$ext['segment']}: 치환되지 않은 플레이스홀더가 사용자에게 노출됩니다 — {$message}"
);
}
}
/**
* 인증 없이 호출하면 401 (라우트 가드 비회귀).
*/
public function test_check_modified_layouts_requires_authentication(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->getJson("/api/admin/{$ext['segment']}/{$ext['name']}/check-modified-layouts")
->assertStatus(401);
}
}
}
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* json:convert-unicode 커맨드의 변환 누락 회귀 테스트.
*
* 재현 대상 결함: `WHERE name LIKE '%\u%'` 로 필터한 결과를 `chunk()`(OFFSET
* 페이지네이션)로 순회하면서 콜백이 그 컬럼을 UTF-8 로 다시 써 필터 조건에서
* 이탈시킨다. 처리된 행이 다음 페이지 조회 결과에서 빠지므로 OFFSET 이 아직
* 변환되지 않은 행을 건너뛴다.
*
* 250건 / 청크 100 기준으로 100건이 변환되지 않은 채 남는다.
*/
class ConvertJsonUnicodeEscapesTest extends TestCase
{
use RefreshDatabase;
/**
* 시드 건수 (청크 크기 100 보다 충분히 커야 skip 이 드러남)
*/
private const SEED_COUNT = 250;
/**
* `\uXXXX` 이스케이프가 포함된 boards 행을 시드합니다.
*
* @return void
*/
private function seedEscapedBoards(): void
{
$rows = [];
for ($i = 0; $i < self::SEED_COUNT; $i++) {
$rows[] = [
// 기본 json_encode 는 멀티바이트를 \uXXXX 로 인코딩한다 (변환 대상 상태)
'name' => json_encode(['ko' => '게시판 '.$i, 'en' => 'Board '.$i]),
'slug' => 'chunkskip-'.$i,
'created_at' => now(),
'updated_at' => now(),
];
}
foreach (array_chunk($rows, 100) as $batch) {
DB::table('boards')->insert($batch);
}
}
/**
* `\uXXXX` 이스케이프가 남아 있는 boards 건수를 반환합니다.
*
* @return int 잔여 건수
*/
private function remainingEscapedCount(): int
{
return DB::table('boards')->where('name', 'LIKE', '%\\\\u%')->count();
}
public function test_conversion_leaves_no_escaped_rows_when_filter_column_is_mutated_during_iteration(): void
{
$this->seedEscapedBoards();
$this->assertSame(
self::SEED_COUNT,
$this->remainingEscapedCount(),
'시드 직후에는 \\uXXXX 이스케이프 행이 시드 건수만큼 존재해야 합니다.'
);
$this->artisan('json:convert-unicode', ['--table' => 'boards', '--chunk' => 100])
->assertExitCode(0);
$this->assertSame(
0,
$this->remainingEscapedCount(),
'OFFSET 순회로 인해 변환되지 않고 남은 행이 있습니다 (chunk → chunkById 필요).'
);
}
public function test_converted_values_preserve_original_payload(): void
{
$this->seedEscapedBoards();
$this->artisan('json:convert-unicode', ['--table' => 'boards', '--chunk' => 100])
->assertExitCode(0);
$rows = DB::table('boards')->orderBy('id')->get(['slug', 'name']);
foreach ($rows as $row) {
$index = (int) str_replace('chunkskip-', '', $row->slug);
$this->assertSame(
['ko' => '게시판 '.$index, 'en' => 'Board '.$index],
json_decode($row->name, true),
'변환 후에도 원본 값이 그대로 보존되어야 합니다.'
);
}
}
public function test_dry_run_does_not_modify_rows(): void
{
$this->seedEscapedBoards();
$this->artisan('json:convert-unicode', ['--table' => 'boards', '--chunk' => 100, '--dry-run' => true])
->assertExitCode(0);
$this->assertSame(
self::SEED_COUNT,
$this->remainingEscapedCount(),
'dry-run 은 실제 데이터를 변경하지 않아야 합니다.'
);
}
}
@@ -0,0 +1,256 @@
<?php
namespace Tests\Feature\Console;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* `playwright:issue-token` 커맨드의 회귀 테스트.
*
* 배경(Chrome MCP 정밀 점검에서 실측):
* - D10: `Role`/`Permission` 의 `name`·`description` 은 모델에서 `array` 로 캐스팅되는데
* 커맨드가 `json_encode(...)` 문자열을 넣어 Eloquent 가 한 번 더 인코딩했다. 그 결과
* 관리자 회원 수정 화면의 역할 선택 목록에 `{"ko":"Playwright \uXXXX…"}` JSON 원문이
* 그대로 노출됐다.
* - D11: 커맨드가 호출마다 유저 1명 + 역할 1개를 만들고 회수하지 않아 역할·유저가
* 각각 2974건 누적됐다(전체의 99%). `--gc-hours` 정리가 추가됐고, 그 정리는
* 콜백이 순회 대상을 삭제하므로 `chunkById`(키셋) 여야 한다 — OFFSET 기반이면
* 청크 크기(100)를 넘는 순간부터 조용히 건너뛴다.
*
* 소스 철자가 아니라 **DB 에 실제로 저장/삭제된 결과**로 계약을 고정한다.
*/
class PlaywrightIssueTokenTest extends TestCase
{
use RefreshDatabase;
/** 정리 대상 시드 규모 — GC 의 청크 크기(100)를 넘겨야 OFFSET 누락이 재현된다 */
private const STALE_COUNT = 250;
/** 커맨드가 붙이는 테스트 역할 접두사 */
private const TEST_ROLE_PREFIX = 'playwright_test_';
protected function setUp(): void
{
parent::setUp();
// 커맨드의 옵트인 가드(② G7_PLAYWRIGHT_BYPASS) 통과. CLI 가드(①)는 PHPUnit 이 이미 cli.
putenv('G7_PLAYWRIGHT_BYPASS=1');
$_ENV['G7_PLAYWRIGHT_BYPASS'] = '1';
$_SERVER['G7_PLAYWRIGHT_BYPASS'] = '1';
}
protected function tearDown(): void
{
putenv('G7_PLAYWRIGHT_BYPASS');
unset($_ENV['G7_PLAYWRIGHT_BYPASS'], $_SERVER['G7_PLAYWRIGHT_BYPASS']);
parent::tearDown();
}
/**
* 테스트 역할 1건과 그 역할을 가진 유저를 생성합니다.
*
* @param string $identifier 역할 식별자
* @param Carbon|null $createdAt 생성 시각 (GC 임계값 판정용)
* @param int $userCount 이 역할에 붙일 유저 수
* @return Role 생성된 역할
*/
private function seedRoleWithUsers(string $identifier, $createdAt = null, int $userCount = 1): Role
{
$role = Role::create([
'identifier' => $identifier,
'name' => ['ko' => '시드 역할', 'en' => 'Seeded Role'],
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
'is_active' => true,
]);
if ($createdAt) {
Role::where('id', $role->id)->update(['created_at' => $createdAt]);
}
for ($i = 0; $i < $userCount; $i++) {
$user = User::factory()->create();
$user->roles()->attach($role->id, ['assigned_at' => now(), 'assigned_by' => null]);
}
return $role;
}
// ========================================================================
// D10 — array 캐스팅 컬럼 이중 인코딩
// ========================================================================
/**
* 커맨드가 만든 역할/권한의 name·description 이 이중 인코딩되지 않아야 한다.
*/
public function test_created_role_and_permission_are_not_double_encoded(): void
{
$this->artisan('playwright:issue-token', [
'--permissions' => ['core.audit.doubleencode'],
'--gc-hours' => 0,
])->assertSuccessful();
$permission = Permission::where('identifier', 'core.audit.doubleencode')->firstOrFail();
$role = Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')->firstOrFail();
foreach ([
['permissions', $permission->id, $permission],
['roles', $role->id, $role],
] as [$table, $id, $model]) {
foreach (['name', 'description'] as $column) {
// 모델 캐스팅을 거친 값은 배열이어야 한다
$this->assertIsArray(
$model->{$column},
"{$table}.{$column} 이 배열로 캐스팅되지 않았습니다 (이중 인코딩 의심)."
);
$this->assertArrayHasKey('ko', $model->{$column});
// 원시 저장값을 1회 디코딩하면 배열이어야 한다.
// 이중 인코딩이면 1회 디코딩 결과가 문자열(JSON) 이 된다.
$raw = DB::table($table)->where('id', $id)->value($column);
$decoded = json_decode((string) $raw, true);
$this->assertIsArray(
$decoded,
"{$table}.{$column} 원시값이 이중 인코딩되었습니다 — 화면에 JSON 원문이 노출됩니다: {$raw}"
);
$this->assertArrayHasKey('ko', $decoded);
}
}
}
/**
* 저장된 한글 값이 화면에서 그대로 읽히는지 (유니코드 이스케이프 원문 미노출).
*/
public function test_created_role_name_reads_back_as_plain_text(): void
{
$this->artisan('playwright:issue-token', ['--gc-hours' => 0])->assertSuccessful();
$role = Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')->firstOrFail();
$this->assertSame('Playwright 테스트 관리자', $role->name['ko']);
$this->assertStringNotContainsString('\\u', json_encode($role->name, JSON_UNESCAPED_UNICODE));
}
// ========================================================================
// D11 — 오래된 테스트 아티팩트 정리 (--gc-hours)
// ========================================================================
/**
* 청크 크기를 넘는 규모에서도 오래된 테스트 역할/유저가 하나도 남지 않아야 한다.
*
* 250건 시드 → OFFSET 기반 순회면 100건이 남는다(키셋 순회 필요).
*/
public function test_gc_removes_all_stale_artifacts_beyond_chunk_size(): void
{
$stale = now()->subHours(24);
for ($i = 0; $i < self::STALE_COUNT; $i++) {
$this->seedRoleWithUsers(self::TEST_ROLE_PREFIX.'stale'.$i, $stale);
}
$this->assertSame(
self::STALE_COUNT,
Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')->count(),
'시드 직후에는 정리 대상이 시드 건수만큼 존재해야 합니다.'
);
$seededUsers = User::count();
$this->artisan('playwright:issue-token', ['--gc-hours' => 6])->assertSuccessful();
// 커맨드가 방금 만든 역할 1건(발급용) 외에는 남으면 안 된다
$remaining = Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->where('created_at', '<', now()->subHours(6))
->count();
$this->assertSame(
0,
$remaining,
'OFFSET 순회로 인해 정리되지 않고 남은 테스트 역할이 있습니다 (chunk → chunkById 필요).'
);
// 유저도 함께 회수되어야 한다 (시드 유저 250명 제거 + 커맨드가 만든 1명 추가)
$this->assertSame(
$seededUsers - self::STALE_COUNT + 1,
User::count(),
'오래된 테스트 유저가 회수되지 않았습니다.'
);
}
/**
* 임계 시간 이내의 아티팩트는 건드리면 안 된다 (동시 실행 중인 다른 워커 보호).
*/
public function test_gc_preserves_recent_artifacts(): void
{
$recent = $this->seedRoleWithUsers(self::TEST_ROLE_PREFIX.'recent', now()->subHour());
$this->artisan('playwright:issue-token', ['--gc-hours' => 6])->assertSuccessful();
$this->assertDatabaseHas('roles', ['id' => $recent->id]);
$this->assertSame(1, $recent->users()->count(), '동시 실행 중인 워커의 계정이 삭제되었습니다.');
}
/**
* playwright 테스트 역할이 아닌 역할과 그 소속 회원은 절대 삭제되지 않아야 한다.
*/
public function test_gc_never_touches_real_roles_and_members(): void
{
$realRole = $this->seedRoleWithUsers('shop_manager', now()->subYear());
$realUserId = $realRole->users()->first()->id;
$this->artisan('playwright:issue-token', ['--gc-hours' => 6])->assertSuccessful();
$this->assertDatabaseHas('roles', ['id' => $realRole->id]);
$this->assertDatabaseHas('users', ['id' => $realUserId]);
}
/**
* 오래된 역할을 지우더라도, 아직 유효한 다른 테스트 역할을 가진 계정은 남겨야 한다.
*
* 병렬 워커가 같은 계정을 쓰는 상황에서 세션이 끊기는 것을 막는 분기다.
*/
public function test_gc_keeps_user_that_still_holds_a_recent_test_role(): void
{
$staleRole = Role::create([
'identifier' => self::TEST_ROLE_PREFIX.'stale_shared',
'name' => ['ko' => '오래된 역할', 'en' => 'Stale Role'],
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
'is_active' => true,
]);
Role::where('id', $staleRole->id)->update(['created_at' => now()->subHours(24)]);
$recentRole = Role::create([
'identifier' => self::TEST_ROLE_PREFIX.'recent_shared',
'name' => ['ko' => '최근 역할', 'en' => 'Recent Role'],
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
'is_active' => true,
]);
$user = User::factory()->create();
$user->roles()->attach($staleRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($recentRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$this->artisan('playwright:issue-token', ['--gc-hours' => 6])->assertSuccessful();
$this->assertDatabaseMissing('roles', ['id' => $staleRole->id]);
$this->assertDatabaseHas('roles', ['id' => $recentRole->id]);
$this->assertDatabaseHas('users', ['id' => $user->id]);
}
/**
* `--gc-hours=0` 이면 정리를 수행하지 않는다 (옵트아웃).
*/
public function test_gc_is_skipped_when_disabled(): void
{
$stale = $this->seedRoleWithUsers(self::TEST_ROLE_PREFIX.'stale_disabled', now()->subHours(24));
$this->artisan('playwright:issue-token', ['--gc-hours' => 0])->assertSuccessful();
$this->assertDatabaseHas('roles', ['id' => $stale->id]);
}
}
@@ -0,0 +1,259 @@
<?php
namespace Tests\Feature\Extension;
use App\Enums\ExtensionStatus;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Models\Module;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use ReflectionMethod;
use Tests\TestCase;
/**
* 확장 업데이트의 `layout_strategy=keep` 이 실제로 사용자 수정 레이아웃을 보존하는지
* 보장하는 회귀 테스트.
*
* 배경: `updateModule()` / `updatePlugin()` 의 레이아웃 갱신 단계가 전략을 모르는
* `registerModuleLayouts()` / `registerPluginLayouts()` 를 전략 인지 refresh **앞에서**
* 호출했다. 그 메서드는 모든 레이아웃의 content 와 `original_content_hash` 를 파일 기준으로
* 덮어쓰므로, 뒤이어 실행되는 `refresh*Layouts($preserveModified)` 가 비교할 "수정본" 이
* 이미 사라져 keep 전략이 언제나 no-op 이 되었다 — 사용자는 '수정 유지' 를 골랐는데도
* 커스터마이징이 소실됐다.
*
* 실제 파일 교체·다운로드를 동반하는 전체 업데이트 경로는 네트워크/디스크에 의존하므로,
* 여기서는 그 경로가 호출하는 두 단계를 **실제로 실행해** 다음을 고정한다:
* 1. keep 전략의 보존/덮어쓰기 동작 자체 (§keep/overwrite)
* 2. 선행 register 가 수정 마커를 파괴한다는 회귀의 인과 (§선행 register 금지)
* 3. register 를 빼도 신규 레이아웃이 누락되지 않는다는 것 (§created 분기)
* 4. 업데이트 경로에 그 선행 호출이 남아 있지 않다는 호출 순서 계약 (§호출 계약)
*/
class UpdateLayoutStrategyKeepTest extends TestCase
{
use RefreshDatabase;
/** 레이아웃 파일이 실제로 존재하는 설치본 모듈 */
private const MODULE = 'sirsoft-board';
private ModuleManager $moduleManager;
private Template $adminTemplate;
protected function setUp(): void
{
parent::setUp();
$this->moduleManager = app(ModuleManager::class);
$this->adminTemplate = Template::create([
'identifier' => 'test-keep-admin',
'vendor' => 'test',
'name' => ['ko' => '보존 테스트 관리자', 'en' => 'Keep Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
Module::create([
'identifier' => self::MODULE,
'vendor' => 'sirsoft',
'name' => ['ko' => '게시판', 'en' => 'Board'],
'version' => '1.0.3',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$this->moduleManager->loadModules();
if (! $this->moduleManager->getModule(self::MODULE)) {
$this->markTestSkipped(self::MODULE.' 설치본이 없어 레이아웃 보존 동작을 실행할 수 없습니다.');
}
}
/**
* 배포 상태(파일 기준)로 레이아웃을 DB 에 등록합니다.
*
* `registerModuleLayouts()` 는 protected 이므로 리플렉션으로 호출합니다.
* 이 메서드가 곧 D6 의 원인이 된 "전략 미인지 선덮어쓰기" 단계입니다.
*
* @return int 등록된 레이아웃 수
*/
private function registerFromFiles(): int
{
$method = new ReflectionMethod(ModuleManager::class, 'registerModuleLayouts');
$method->setAccessible(true);
return (int) $method->invoke($this->moduleManager, self::MODULE);
}
/**
* 사용자가 UI 에서 레이아웃 1건을 수정한 상태를 만듭니다.
*
* content 만 바꾸고 original_content_hash 는 배포 원본 그대로 두어야
* "수정됨" 으로 판정됩니다.
*
* @return TemplateLayout 수정된 레이아웃
*/
private function markOneLayoutAsUserModified(): TemplateLayout
{
$layout = TemplateLayout::where('source_type', 'module')
->where('source_identifier', self::MODULE)
->orderBy('id')
->firstOrFail();
$content = $layout->content;
$content['__user_marker'] = 'keep-me';
$layout->content = $content;
$layout->saveQuietly();
return $layout->fresh();
}
// ========================================================================
// keep / overwrite 동작
// ========================================================================
/**
* keep 전략은 사용자 수정 레이아웃을 덮어쓰지 않는다.
*/
public function test_keep_strategy_preserves_user_modified_layout(): void
{
$this->assertGreaterThan(0, $this->registerFromFiles(), '레이아웃이 등록되지 않았습니다.');
$modified = $this->markOneLayoutAsUserModified();
$this->moduleManager->refreshModuleLayouts(self::MODULE, true);
$this->assertArrayHasKey(
'__user_marker',
$modified->fresh()->content,
'keep 전략인데 사용자 수정본이 파일 내용으로 덮어써졌습니다.'
);
}
/**
* overwrite 전략은 사용자 수정 레이아웃을 파일 내용으로 되돌린다 (비회귀).
*/
public function test_overwrite_strategy_replaces_user_modified_layout(): void
{
$this->registerFromFiles();
$modified = $this->markOneLayoutAsUserModified();
$this->moduleManager->refreshModuleLayouts(self::MODULE, false);
$this->assertArrayNotHasKey(
'__user_marker',
$modified->fresh()->content,
'overwrite 전략인데 수정본이 그대로 남았습니다.'
);
}
// ========================================================================
// 선행 register 금지 (D6 의 인과)
// ========================================================================
/**
* 전략 인지 refresh **앞에서** register 를 호출하면 keep 이 무효가 된다.
*
* 이 테스트는 결함 재현을 고정한다 — 여기서 보존이 되어버리면 오히려 전제가 바뀐
* 것이므로, updateModule 의 호출 계약(아래)과 함께 읽어야 한다.
*/
public function test_pre_registering_before_refresh_destroys_the_modification_marker(): void
{
$this->registerFromFiles();
$modified = $this->markOneLayoutAsUserModified();
// 결함 당시 updateModule 이 하던 순서를 그대로 재현
$this->registerFromFiles();
$this->moduleManager->refreshModuleLayouts(self::MODULE, true);
$this->assertArrayNotHasKey(
'__user_marker',
$modified->fresh()->content,
'선행 register 가 수정 마커를 파괴하지 않는다면 이 회귀의 전제가 달라진 것입니다 — 계약을 재검토하세요.'
);
}
/**
* register 를 빼도 DB 에 없는 레이아웃은 refresh 가 생성한다.
*/
public function test_refresh_creates_layouts_missing_from_database(): void
{
$registered = $this->registerFromFiles();
TemplateLayout::where('source_type', 'module')
->where('source_identifier', self::MODULE)
->forceDelete();
$this->assertSame(0, $this->countModuleLayouts());
$this->moduleManager->refreshModuleLayouts(self::MODULE, true);
$this->assertSame(
$registered,
$this->countModuleLayouts(),
'registerModuleLayouts 제거 후 신규 레이아웃이 누락되었습니다 (refresh 의 created 분기 확인 필요).'
);
}
/**
* 현재 템플릿에 등록된 모듈 레이아웃 수.
*/
private function countModuleLayouts(): int
{
return TemplateLayout::where('template_id', $this->adminTemplate->id)
->where('source_type', 'module')
->where('source_identifier', self::MODULE)
->count();
}
// ========================================================================
// 호출 계약 — 전체 업데이트 경로는 실행 불가하므로 순서만 고정
// ========================================================================
/**
* updateModule / updatePlugin 의 레이아웃 단계에 전략 미인지 선덮어쓰기가 없어야 한다.
*/
public function test_update_path_does_not_pre_register_layouts(): void
{
foreach ([
[ModuleManager::class, 'updateModule', 'refreshModuleLayouts', 'registerModuleLayouts'],
[PluginManager::class, 'updatePlugin', 'refreshPluginLayouts', 'registerPluginLayouts'],
] as [$class, $method, $refresh, $register]) {
$source = $this->methodSource($class, $method);
$this->assertMatchesRegularExpression(
'/'.preg_quote($refresh, '/').'\(\s*\$identifier\s*,\s*\$preserveModified\s*\)/',
$source,
"{$class}::{$method} 는 전략 인지 {$refresh} 를 호출해야 한다"
);
$this->assertDoesNotMatchRegularExpression(
'/\$this->'.preg_quote($register, '/').'\(\s*\$identifier\s*\)/',
$source,
"{$class}::{$method} 가 {$register} 를 호출하면 keep 전략이 무효화된다 "
.'(content/original_content_hash 를 파일 기준으로 선덮어쓰기)'
);
}
}
/**
* 지정 메서드의 소스 본문을 반환합니다.
*
* @param string $class 대상 클래스 FQCN
* @param string $method 대상 메서드명
* @return string 메서드 본문 소스
*/
private function methodSource(string $class, string $method): string
{
$ref = new ReflectionMethod($class, $method);
$lines = file($ref->getFileName());
return implode('', array_slice(
$lines,
$ref->getStartLine() - 1,
$ref->getEndLine() - $ref->getStartLine() + 1
));
}
}
@@ -0,0 +1,153 @@
<?php
namespace Tests\Feature\Upgrades;
use App\Extension\UpgradeContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* 코어 7.0.0-alpha.15 업그레이드 스텝의 UUID 백필 누락 회귀 테스트.
*
* 재현 대상 결함: `whereNull('uuid')` 로 필터한 결과를 `chunk()`(OFFSET 페이지네이션)로
* 순회하면서 콜백이 그 필터 컬럼(uuid)을 채운다. 처리된 행이 다음 페이지 조회의 결과
* 집합에서 이탈하므로 OFFSET 이 아직 처리되지 않은 행을 건너뛴다.
*
* 250건(청크 100) 기준: 1페이지 100건 처리 → 남은 150건에 OFFSET 100 적용 → 50건만
* 조회 → 3페이지는 OFFSET 200 이라 0건 → 총 150건만 처리되고 100건이 누락된다.
* `chunkById()`(키셋 순회)로 바꾸면 누락이 0건이 된다.
*/
class Alpha15UuidBackfillTest extends TestCase
{
use RefreshDatabase;
/**
* 시드 건수 (스텝의 청크 크기 100 보다 충분히 커야 skip 이 드러남)
*/
private const SEED_COUNT = 250;
/**
* 시드 사용자 이메일 프리픽스 (tearDown 정리 대상 식별용)
*/
private const EMAIL_PREFIX = 'alpha15-chunkskip-';
private object $upgrade;
private UpgradeContext $context;
protected function setUp(): void
{
parent::setUp();
require_once base_path('upgrades/Upgrade_7_0_0_alpha_15.php');
$class = 'App\\Upgrades\\Upgrade_7_0_0_alpha_15';
$this->upgrade = new $class;
$this->context = new UpgradeContext(
fromVersion: '7.0.0-alpha.14',
toVersion: '7.0.0-alpha.15',
currentStep: '7.0.0-alpha.15',
);
}
/**
* 스텝의 ALTER TABLE 은 MySQL 에서 암묵적 커밋을 유발하므로 RefreshDatabase 의
* 트랜잭션 롤백으로 되돌아가지 않는다. 시드 행과 컬럼 제약을 명시적으로 원복한다.
*/
protected function tearDown(): void
{
try {
DB::table('users')->where('email', 'LIKE', self::EMAIL_PREFIX.'%')->delete();
if ($this->uuidColumnIsNotNull()) {
Schema::table('users', function ($table) {
$table->uuid('uuid')->nullable()->change();
});
}
} catch (\Throwable) {
// 정리 실패는 테스트 결과에 영향을 주지 않도록 무시 (RefreshDatabase 가 다음 실행에서 재생성)
}
parent::tearDown();
}
/**
* uuid 컬럼이 NOT NULL 인지 확인합니다.
*
* @return bool NOT NULL 이면 true
*/
private function uuidColumnIsNotNull(): bool
{
$column = DB::selectOne("SHOW COLUMNS FROM {$this->context->table('users')} WHERE Field = 'uuid'");
return $column !== null && $column->Null === 'NO';
}
/**
* uuid 가 NULL 인 사용자 레코드를 시드합니다.
*
* @return void
*/
private function seedUsersWithoutUuid(): void
{
$rows = [];
for ($i = 0; $i < self::SEED_COUNT; $i++) {
$rows[] = [
'uuid' => null,
'name' => 'chunk skip user '.$i,
'email' => self::EMAIL_PREFIX.$i.'@g7.test',
'password' => bcrypt('password'),
'created_at' => now(),
'updated_at' => now(),
];
}
foreach (array_chunk($rows, 100) as $batch) {
DB::table('users')->insert($batch);
}
}
public function test_uuid_backfill_leaves_no_null_rows_when_filter_column_is_mutated_during_iteration(): void
{
$this->seedUsersWithoutUuid();
$this->assertSame(
self::SEED_COUNT,
DB::table('users')->whereNull('uuid')->count(),
'시드 직후에는 uuid NULL 레코드가 시드 건수만큼 존재해야 합니다.'
);
$this->upgrade->run($this->context);
$this->assertSame(
0,
DB::table('users')->whereNull('uuid')->count(),
'OFFSET 순회로 인해 백필되지 않고 남은 레코드가 있습니다 (chunk → chunkById 필요).'
);
// 백필이 끝나야 컬럼을 NOT NULL 로 조일 수 있다 — 한 건이라도 남으면
// 스텝의 ALTER TABLE 이 실패하거나 제약이 적용되지 않은 채 넘어간다.
$this->assertTrue(
$this->uuidColumnIsNotNull(),
'백필 후 uuid 컬럼이 NOT NULL 로 전환되지 않았습니다.'
);
}
public function test_backfilled_uuids_are_unique(): void
{
$this->seedUsersWithoutUuid();
$this->upgrade->run($this->context);
$uuids = DB::table('users')
->where('email', 'LIKE', self::EMAIL_PREFIX.'%')
->pluck('uuid')
->all();
$this->assertCount(self::SEED_COUNT, $uuids);
$this->assertCount(self::SEED_COUNT, array_unique($uuids), '백필된 UUID 는 모두 고유해야 합니다.');
}
}
@@ -0,0 +1,185 @@
<?php
namespace Tests\Feature\Upgrades;
use App\Extension\UpgradeContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use ReflectionClass;
use Tests\TestCase;
/**
* 코어 7.0.0-beta.2 업그레이드 스텝의 레이아웃 hash 백필 누락 회귀 테스트.
*
* 재현 대상 결함: `whereNull('original_content_hash')` 로 필터한 결과를 `chunk()`
* (OFFSET 페이지네이션)로 순회하면서 콜백이 그 필터 컬럼을 채운다. 처리된 행이
* 다음 페이지 조회 결과에서 이탈하므로 OFFSET 이 미처리 행을 건너뛴다.
*
* 이 테스트는 동일 백필 코드를 공유하는 마이그레이션 2건
* (`add_original_content_hash_to_template_layouts_table`,
* `add_original_content_hash_to_template_layout_extensions_table`) 의 정본 증명이기도 하다.
*/
class Beta2LayoutHashBackfillTest extends TestCase
{
use RefreshDatabase;
/**
* 시드 건수 (스텝의 청크 크기 100 보다 충분히 커야 skip 이 드러남)
*/
private const SEED_COUNT = 250;
private object $upgrade;
private UpgradeContext $context;
protected function setUp(): void
{
parent::setUp();
require_once base_path('upgrades/Upgrade_7_0_0_beta_2.php');
$class = 'App\\Upgrades\\Upgrade_7_0_0_beta_2';
$this->upgrade = new $class;
$this->context = new UpgradeContext(
fromVersion: '7.0.0-beta.1',
toVersion: '7.0.0-beta.2',
currentStep: '7.0.0-beta.2',
);
}
/**
* 백필 private 메서드만 호출합니다.
*
* (전체 run() 은 알림 시스템 이관 등 무관한 단계를 함께 실행하므로 검증 대상인
* 백필 로직만 reflection 으로 직접 실행합니다 — 기존 업그레이드 테스트와 동일 방식)
*
* @return void
*/
private function runBackfill(): void
{
$reflection = new ReflectionClass($this->upgrade);
$method = $reflection->getMethod('backfillModuleAndPluginLayoutHashes');
$method->setAccessible(true);
$method->invoke($this->upgrade, $this->context);
}
/**
* 테스트용 템플릿 1건을 생성합니다.
*
* @return int 생성된 템플릿 ID
*/
private function seedTemplate(): int
{
return DB::table('templates')->insertGetId([
'identifier' => 'test-chunkskip_template',
'vendor' => 'test',
'name' => json_encode(['ko' => '청크 테스트 템플릿', 'en' => 'Chunk Test Template']),
'version' => '1.0.0',
'type' => 'admin',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* hash 가 NULL 인 모듈/플러그인 레이아웃을 시드합니다.
*
* @param int $templateId 템플릿 ID
* @return void
*/
private function seedLayoutsWithoutHash(int $templateId): void
{
$rows = [];
for ($i = 0; $i < self::SEED_COUNT; $i++) {
$sourceType = $i % 2 === 0 ? 'module' : 'plugin';
$rows[] = [
'template_id' => $templateId,
'name' => 'chunkskip/layout_'.$i,
'content' => json_encode(['type' => 'page', 'index' => $i]),
'source_type' => $sourceType,
'source_identifier' => $sourceType === 'module' ? 'test-module' : 'test-plugin',
'original_content_hash' => null,
'original_content_size' => null,
'created_at' => now(),
'updated_at' => now(),
];
}
foreach (array_chunk($rows, 100) as $batch) {
DB::table('template_layouts')->insert($batch);
}
}
public function test_layout_hash_backfill_leaves_no_null_rows_when_filter_column_is_mutated_during_iteration(): void
{
$templateId = $this->seedTemplate();
$this->seedLayoutsWithoutHash($templateId);
$this->assertSame(
self::SEED_COUNT,
DB::table('template_layouts')->whereNull('original_content_hash')->count(),
'시드 직후에는 hash NULL 레이아웃이 시드 건수만큼 존재해야 합니다.'
);
$this->runBackfill();
$this->assertSame(
0,
DB::table('template_layouts')
->whereIn('source_type', ['module', 'plugin'])
->whereNull('original_content_hash')
->count(),
'OFFSET 순회로 인해 백필되지 않고 남은 레이아웃이 있습니다 (chunk → chunkById 필요).'
);
}
public function test_backfilled_hash_and_size_match_normalized_content(): void
{
$templateId = $this->seedTemplate();
$this->seedLayoutsWithoutHash($templateId);
$this->runBackfill();
$rows = DB::table('template_layouts')
->where('template_id', $templateId)
->get(['content', 'original_content_hash', 'original_content_size']);
foreach ($rows as $row) {
$normalized = json_encode(
json_decode($row->content, true),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$this->assertSame(hash('sha256', $normalized), $row->original_content_hash);
$this->assertSame(strlen($normalized), (int) $row->original_content_size);
}
}
public function test_template_source_layouts_are_not_touched(): void
{
$templateId = $this->seedTemplate();
$this->seedLayoutsWithoutHash($templateId);
DB::table('template_layouts')->insert([
'template_id' => $templateId,
'name' => 'chunkskip/template_owned',
'content' => json_encode(['type' => 'page']),
'source_type' => 'template',
'original_content_hash' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$this->runBackfill();
$row = DB::table('template_layouts')
->where('template_id', $templateId)
->where('source_type', 'template')
->first(['original_content_hash']);
$this->assertNull($row->original_content_hash, '템플릿 소유 레이아웃은 백필 대상이 아닙니다.');
}
}
@@ -0,0 +1,227 @@
/**
* 확장(모듈/플러그인/템플릿) 관리 화면의 업데이트 계약 회귀 검증
*
* Chrome MCP 정밀 점검(2026-07-31)에서 실측된 결함을 브라우저 경로로 고정한다.
*
* - 목록 요약이 `data.total` 을 읽어 항상 "총 0개 중 1-0개 표시" 로 고정 표기됐다.
* 실제 응답은 `data.pagination.total` 이다.
* - `check-updates` 응답 필드는 `updated_count` 인데 레이아웃이 `update_count` 를
* 읽어, 업데이트가 감지돼도 항상 "모두 최신 상태입니다" 토스트가 떴다.
* - 업데이트 모달의 수정 감지 조회가 실패해도 "수정된 레이아웃이 없습니다" 라고
* 단언해, 그대로 '모두 교체' 를 진행하면 사용자가 고친 화면이 소실됐다.
*
* @scenario extension-update-modal
* @effects pagination-summary, check-updates-toast, modified-layouts-warning
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
import type { Locator, Page } from '@playwright/test';
// 이 파일의 테스트는 매번 관리자 SPA 를 냉시동해 목록 화면을 완전히 렌더한 뒤 조작한다.
// 전역 기본 30s 는 워커가 여럿 붙어 서로 PHP-FPM 을 나눠 쓰는 병렬 실행에서 부족하다
// (실측: 6워커 동시 실행 시 테스트당 22~35s). 회귀 감지는 개별 단언 타임아웃이 맡고,
// 이 값은 하네스 예산일 뿐이므로 파일 단위로 늘린다.
test.describe.configure({ timeout: 60_000 });
const LIST_ROUTES: Array<{ path: string; label: string; api: string; permission: string }> = [
{ path: '/admin/modules', label: '모듈', api: '**/api/admin/modules?*', permission: 'core.modules.read' },
{ path: '/admin/plugins', label: '플러그인', api: '**/api/admin/plugins?*', permission: 'core.plugins.read' },
];
/**
* 목록 화면이 함께 부르는 부수 데이터소스를 고정한다.
*
* `admin_module_list` / `admin_plugin_list` 는 목록 외에 `auto_deactivated_banner`
* (`/api/admin/extensions/auto-deactivated`) 도 페이지 데이터소스로 선언한다. 카드 그리드에는
* `blur_until_loaded: true` 가 걸려 있어 **모든** 데이터소스가 끝나기 전까지 래퍼에
* `pointer-events-none` 이 붙고(DynamicRenderer), 툴바 버튼은 목록 응답의 abilities 로
* `disabled` 가 결정된다. 이 요청 하나를 실서버에 남겨두면 병렬 워커 경합에서 응답이 늦어져
* 클릭이 로딩 가드에 막힌다 — 실측: 6워커에서 카드 버튼 클릭이 `#content_area` 로 흘러 20s 타임아웃.
* 이 spec 이 검증하는 것은 바인딩 계약이지 서버 지연이 아니므로 빈 응답으로 고정한다.
*/
async function stubSupportingSources(page: Page): Promise<void> {
await page.route('**/api/admin/extensions/auto-deactivated*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'ok',
data: {
items: { plugins: [], modules: [], templates: [] },
current_core_version: '7.0.6',
},
}),
});
});
}
/**
* 카드 그리드 안의 대상이 실제로 클릭 가능해질 때까지 기다린다.
*
* `blur_until_loaded` 래퍼는 로딩 중 `opacity-50 blur-sm … pointer-events-none` 을 달아
* 카드 안 버튼 클릭을 부모(`#content_area`)로 흘려보낸다. `domcontentloaded` 는 SPA 가
* 데이터를 받기 전에 끝나므로, 이 대기 없이 카드 버튼을 누르면 클릭이 가로채인다.
*
* 순서가 중요하다 — blur 부재(`toHaveCount(0)`)만 먼저 단언하면 그리드가 **아직 렌더되기
* 전이라** 즉시 통과하고, 그 뒤에 blur 가 붙어 대기가 무효가 된다. 대상이 보이는 것을
* 먼저 확인해 그리드 렌더를 확정한 뒤 blur 해제를 기다린다.
*/
async function waitForGridInteractive(page: Page, target: Locator): Promise<void> {
await expect(target).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#content_area .blur-sm')).toHaveCount(0, { timeout: 20_000 });
}
for (const { path, label, api, permission } of LIST_ROUTES) {
test(`${label} 목록의 페이지네이션 요약이 응답의 총건수를 표시한다`, async ({ page }) => {
const token = issueToken(permission);
await authenticatePage(page, token);
await stubSupportingSources(page);
// 응답을 고정해 "환경에 몇 건 있는지" 가 아니라 바인딩 경로를 검증한다.
// (회귀 시 응답이 무엇이든 화면은 "총 0개 중 1-0개 표시" 로 고정된다)
await page.route(api, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'ok',
data: {
data: [],
pagination: { total: 37, current_page: 1, last_page: 4, per_page: 12 },
meta: {},
abilities: {},
},
}),
});
});
await page.goto(path);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const summary = page.locator('#pagination_info');
await expect(summary).toBeVisible({ timeout: 20_000 });
// "총 0개 중 1-0개 표시" 는 data.pagination 경로 회귀의 지문이다.
await expect(summary).toContainText('총 37개', { timeout: 20_000 });
await expect(summary).not.toContainText('총 0개');
});
}
test('업데이트 확인은 감지 건수를 토스트에 반영한다', async ({ page }) => {
const token = issueToken('core.modules.read', 'core.modules.install');
await authenticatePage(page, token);
await stubSupportingSources(page);
// 툴바의 '업데이트 확인' 은 `disabled: "{{… modules?.data?.abilities?.can_install !== true}}"` 이라
// 목록 응답이 도착하기 전까지 비활성이다. 목록도 고정해 버튼 활성 시점을 서버 지연에서 분리한다.
// (route 는 나중에 등록한 것이 먼저 매칭되므로 check-updates 보다 앞서 등록한다 —
// `**/api/admin/modules?*` 의 `?` 는 임의 1문자라 check-updates 경로에도 매칭될 수 있다)
await page.route('**/api/admin/modules?*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'ok',
data: {
data: [],
pagination: { total: 0, current_page: 1, last_page: 1, per_page: 12 },
meta: {},
abilities: { can_install: true, can_activate: true, can_uninstall: true },
},
}),
});
});
// check-updates 응답을 감지 1건으로 고정 — 레이아웃이 updated_count 를 읽는지 검증
await page.route('**/api/admin/modules/check-updates', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: '업데이트 확인이 완료되었습니다.',
data: {
updated_count: 1,
details: [
{
identifier: 'sirsoft-board',
current_version: '1.0.1',
latest_version: '1.0.2',
update_source: 'bundled',
},
],
},
}),
});
});
await page.goto('/admin/modules');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const checkButton = page.getByRole('button', { name: /업데이트 확인/ });
await expect(checkButton).toBeEnabled({ timeout: 20_000 });
await checkButton.click();
const toast = page.getByRole('alert');
await expect(toast).toContainText(/업데이트 확인 완료/, { timeout: 20_000 });
await expect(toast).not.toContainText(/최신 상태입니다/);
});
test('수정 감지 조회가 실패하면 "수정 없음" 대신 확인 실패를 알린다', async ({ page }) => {
const token = issueToken('core.modules.read', 'core.modules.install');
await authenticatePage(page, token);
await stubSupportingSources(page);
// 업데이트 가용 상태를 고정하고, 수정 감지 조회만 실패시킨다
await page.route('**/api/admin/modules?*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'ok',
data: {
data: [
{
identifier: 'sirsoft-board',
vendor: 'sirsoft',
name: '게시판',
description: '검수용 고정 응답',
version: '1.0.1',
status: 'active',
dependencies: [],
update_available: true,
update_source: 'bundled',
latest_version: '1.0.2',
is_compatible: true,
is_pending: false,
is_bundled: true,
abilities: { can_install: true, can_activate: true, can_uninstall: true },
},
],
pagination: { total: 1, current_page: 1, last_page: 1, per_page: 12 },
meta: {},
abilities: { can_install: true, can_activate: true, can_uninstall: true },
},
}),
});
});
await page.route('**/check-modified-layouts', (route) => route.abort('failed'));
await page.route('**/changelog*', (route) => route.abort('failed'));
await page.goto('/admin/modules');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 접근성 이름은 아이콘 글리프를 포함한다 ("refresh 업데이트") — '업데이트 확인' 과 구분한다.
const updateButton = page
.getByRole('button', { name: 'refresh 업데이트', exact: true })
.first();
await waitForGridInteractive(page, updateButton);
await updateButton.click({ timeout: 20_000 });
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 20_000 });
await expect(dialog).toContainText(/확인하지 못했습니다/, { timeout: 20_000 });
await expect(dialog).not.toContainText(/수정된 레이아웃이 없습니다/);
});
@@ -0,0 +1,159 @@
<?php
namespace Tests\Unit\Extension;
use App\Enums\ExtensionStatus;
use App\Extension\TemplateManager;
use App\Extension\Traits\ComputesLayoutContentHash;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 템플릿 수정 레이아웃 감지의 소유 범위 회귀 테스트.
*
* 배경(Chrome MCP 정밀 점검에서 실측): `hasModifiedLayouts()` 가
* `getByTemplateId()` 로 해당 template_id 의 **모든** 레이아웃을 세어,
* 같은 템플릿에 등록된 모듈/플러그인 소유 레이아웃의 수정본까지 템플릿 자신의
* 수정으로 집계했다(실측: 모달 4건 중 3건이 board/gdpr/sample 소유).
* 실제 갱신 범위인 `refreshTemplateLayouts()` 는 source_type='template' +
* source_identifier=null 만 다루므로 표시와 동작이 어긋난다.
*
* 조회 소스의 철자가 아니라 **실제 집계 결과**로 범위를 고정한다.
*/
class TemplateModifiedLayoutScopeTest extends TestCase
{
use ComputesLayoutContentHash, RefreshDatabase;
private TemplateManager $templateManager;
private Template $template;
/** 배포 원본 콘텐츠 */
private const ORIGINAL = ['layout_name' => 'home', 'slots' => ['content' => []]];
/** 사용자가 UI 에서 수정한 콘텐츠 */
private const MODIFIED = ['layout_name' => 'home', 'slots' => ['content' => [['component' => 'Div']]]];
protected function setUp(): void
{
parent::setUp();
$this->templateManager = app(TemplateManager::class);
$this->template = Template::create([
'identifier' => 'test-scope-admin',
'vendor' => 'test',
'name' => ['ko' => '범위 테스트 관리자', 'en' => 'Scope Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
}
/**
* 레이아웃 1건을 시드합니다.
*
* original_content_hash 는 항상 배포 원본 기준으로 기록하고, content 만 수정본으로
* 바꿔 "사용자가 UI 에서 고친 상태" 를 재현합니다.
*
* @param int $templateId 소속 템플릿 ID
* @param string $name 레이아웃 이름
* @param string $sourceType 소유 구분 (template/module/plugin)
* @param string|null $sourceIdentifier 소유 확장 식별자 (템플릿 소유는 null)
* @param bool $modified true 면 수정된 상태로 시드
* @return TemplateLayout 생성된 레이아웃
*/
private function seedLayout(
int $templateId,
string $name,
string $sourceType,
?string $sourceIdentifier,
bool $modified
): TemplateLayout {
return TemplateLayout::create([
'template_id' => $templateId,
'name' => $name,
'content' => $modified ? self::MODIFIED : self::ORIGINAL,
'source_type' => $sourceType,
'source_identifier' => $sourceIdentifier,
'original_content_hash' => $this->computeContentHash(self::ORIGINAL),
'original_content_size' => $this->computeContentSize(self::ORIGINAL),
]);
}
/**
* 템플릿 소유 수정본만 집계하고 남의 확장 소유는 제외해야 한다.
*/
public function test_has_modified_layouts_counts_only_template_owned_layouts(): void
{
$this->seedLayout($this->template->id, 'admin_home', 'template', null, true);
// 같은 template_id 에 얹힌 남의 확장 소유 수정본 3건
$this->seedLayout($this->template->id, 'sirsoft-board.admin_index', 'module', 'sirsoft-board', true);
$this->seedLayout($this->template->id, 'sirsoft-page.admin_index', 'module', 'sirsoft-page', true);
$this->seedLayout($this->template->id, 'sirsoft-gdpr.admin_index', 'plugin', 'sirsoft-gdpr', true);
$result = $this->templateManager->hasModifiedLayouts('test-scope-admin');
$this->assertTrue($result['has_modified_layouts']);
$this->assertSame(
1,
$result['modified_count'],
'템플릿 업데이트가 실제로 갱신하지 않는 모듈/플러그인 소유 레이아웃이 수정 건수에 합산되었습니다.'
);
$this->assertSame(['admin_home'], array_column($result['modified_layouts'], 'name'));
}
/**
* 남의 확장만 수정된 상태에서는 경고가 뜨면 안 된다.
*/
public function test_has_modified_layouts_returns_zero_when_only_other_extensions_are_modified(): void
{
$this->seedLayout($this->template->id, 'admin_home', 'template', null, false);
$this->seedLayout($this->template->id, 'sirsoft-board.admin_index', 'module', 'sirsoft-board', true);
$result = $this->templateManager->hasModifiedLayouts('test-scope-admin');
$this->assertFalse($result['has_modified_layouts']);
$this->assertSame(0, $result['modified_count']);
$this->assertEmpty($result['modified_layouts']);
}
/**
* 다른 템플릿의 수정본은 집계 대상이 아니다.
*/
public function test_has_modified_layouts_ignores_layouts_of_other_templates(): void
{
$other = Template::create([
'identifier' => 'test-scope-user',
'vendor' => 'test',
'name' => ['ko' => '범위 테스트 사용자', 'en' => 'Scope Test User'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$this->seedLayout($other->id, 'user_home', 'template', null, true);
$this->seedLayout($this->template->id, 'admin_home', 'template', null, false);
$result = $this->templateManager->hasModifiedLayouts('test-scope-admin');
$this->assertSame(0, $result['modified_count'], '다른 템플릿의 수정본까지 집계되었습니다.');
}
/**
* 미존재 템플릿은 빈 결과를 반환한다.
*/
public function test_has_modified_layouts_returns_empty_for_unknown_template(): void
{
$result = $this->templateManager->hasModifiedLayouts('no-such-template');
$this->assertFalse($result['has_modified_layouts']);
$this->assertSame(0, $result['modified_count']);
$this->assertEmpty($result['modified_layouts']);
}
}
@@ -0,0 +1,69 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\ExpressionEvaluator;
use Tests\TestCase;
/**
* SEO(봇) 렌더러의 `{{raw:...}}` 번역 면제 바인딩 패리티 회귀 테스트.
*
* 배경: React 쪽 노드 `text` 처리에서 `raw:` 접두사를 벗기지 않아 표현식 평가가
* 예외로 끊기던 결함(engine-v1.54.4)을 고치면서, 봇 렌더러에는 접두사 처리 자체가
* 아예 없다는 사실이 드러났다. 같은 표현식이 화면에서는 값이 나오고 봇 화면에서만
* 사라지는 패리티 붕괴가 잠재해 있었다.
*/
class ExpressionEvaluatorRawPrefixTest extends TestCase
{
private function evaluator(): ExpressionEvaluator
{
$e = new ExpressionEvaluator;
$e->setTranslations(['common' => ['save' => '저장']]);
return $e;
}
public function test_raw_prefix_is_stripped_before_evaluation(): void
{
$context = ['file' => ['description' => '', 'name' => 'admin_module_list.json']];
$this->assertSame(
'admin_module_list.json',
$this->evaluator()->evaluate('{{raw:file.description || file.name}}', $context),
'raw: 접두사를 벗기지 않으면 콜론이 식으로 파싱돼 평가가 실패한다'
);
}
public function test_raw_prefix_result_is_translation_exempt(): void
{
$context = ['post' => ['title' => '$t:common.save']];
$this->assertSame(
'$t:common.save',
$this->evaluator()->evaluate('{{raw:post.title}}', $context),
'raw: 결과의 $t: 토큰은 번역하지 않아야 한다 (React rawMarkers 와 동일)'
);
}
public function test_non_raw_binding_still_resolves_translation_tokens(): void
{
$context = ['post' => ['title' => '$t:common.save']];
$this->assertSame(
'저장',
$this->evaluator()->evaluate('{{post.title}}', $context),
'raw: 가 없으면 종전대로 번역되어야 한다 (비회귀)'
);
}
public function test_evaluate_raw_strips_prefix(): void
{
$context = ['items' => [1, 2, 3]];
$this->assertSame(
[1, 2, 3],
$this->evaluator()->evaluateRaw('{{raw:items}}', $context),
'evaluateRaw 도 접두사를 벗기고 원본 타입을 유지해야 한다'
);
}
}
+8 -3
View File
@@ -20,7 +20,7 @@ class Upgrade_7_0_0_alpha_15 implements UpgradeStepInterface
/**
* 업그레이드 스텝을 실행합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return void
*/
public function run(UpgradeContext $context): void
@@ -37,7 +37,9 @@ class Upgrade_7_0_0_alpha_15 implements UpgradeStepInterface
if ($nullCount > 0) {
$context->logger->info("UUID 백필 시작: {$nullCount}건의 기존 레코드");
DB::table('users')->whereNull('uuid')->orderBy('id')->chunk(100, function ($users) {
// chunkById(키셋 순회) 필수 — 콜백이 필터 컬럼(uuid)을 채우므로 처리된 행이
// 필터 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려 미처리 행을 건너뛴다.
DB::table('users')->whereNull('uuid')->chunkById(100, function ($users) {
foreach ($users as $user) {
DB::table('users')
->where('id', $user->id)
@@ -54,8 +56,11 @@ class Upgrade_7_0_0_alpha_15 implements UpgradeStepInterface
$columnType = DB::selectOne("SHOW COLUMNS FROM {$context->table('users')} WHERE Field = 'uuid'");
if ($columnType && $columnType->Null === 'YES') {
// unique 인덱스는 add_uuid_to_users_table 마이그레이션이 이미 생성했다.
// change() 에 ->unique() 를 함께 붙이면 동일 이름의 인덱스를 다시 만들려 해
// "Duplicate key name" 으로 실패한다 (MODIFY COLUMN 은 기존 인덱스를 보존).
Schema::table('users', function ($table) {
$table->uuid('uuid')->nullable(false)->unique()->change();
$table->uuid('uuid')->nullable(false)->change();
});
$context->logger->info('uuid 컬럼에 NOT NULL 제약을 추가했습니다.');
+25 -21
View File
@@ -6,7 +6,9 @@ use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\UpgradeContext;
use App\Models\NotificationDefinition;
use App\Models\NotificationTemplate;
use App\Services\CoreUpdateService;
use Database\Seeders\NotificationDefinitionSeeder;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
@@ -53,7 +55,7 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
/**
* 업그레이드 스텝을 실행합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return void
*/
public function run(UpgradeContext $context): void
@@ -77,7 +79,7 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
// 2. 코어 알림 정의 시딩 (3종)
$context->logger->info('[beta.2] 코어 알림 정의 시딩...');
(new NotificationDefinitionSeeder())->run();
(new NotificationDefinitionSeeder)->run();
// 3. mail_templates → notification_templates 데이터 이관
if (Schema::hasTable('mail_templates')) {
@@ -151,7 +153,7 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
*
* 멱등: 이미 hash 가 있는 행은 건너뜀.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return void
*/
private function backfillModuleAndPluginLayoutHashes(UpgradeContext $context): void
@@ -164,11 +166,13 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
}
$total = 0;
// chunkById(키셋 순회) 필수 — 콜백이 필터 컬럼(original_content_hash)을 채우므로
// 처리된 행이 필터 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
// 미처리 행을 건너뛴다.
DB::table('template_layouts')
->whereIn('source_type', ['module', 'plugin'])
->whereNull('original_content_hash')
->orderBy('id')
->chunk(100, function ($layouts) use (&$total) {
->chunkById(100, function ($layouts) use (&$total) {
foreach ($layouts as $layout) {
$content = json_decode($layout->content, true);
$normalized = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@@ -200,7 +204,7 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
*
* 멱등: 파일/테이블이 없으면 no-op.
*
* @param UpgradeContext $context
* @param UpgradeContext $context
* @return void
*/
private function cleanupMailTemplateShim(UpgradeContext $context): void
@@ -274,7 +278,7 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
}
config(['core' => $fresh]);
$service = app(\App\Services\CoreUpdateService::class);
$service = app(CoreUpdateService::class);
try {
$service->syncCoreRolesAndPermissions();
$context->logger->info('[beta.2] fallback: 코어 권한 재동기화 완료');
@@ -305,7 +309,7 @@ class Upgrade_7_0_0_beta_2 implements UpgradeStepInterface
* (eval 실행 컨텍스트의 cwd 가 base_path 와 일치한다는 보장 없음).
*
* @param UpgradeContext $context
* @return bool spawn 성공 여부 (false 면 호출자가 in-process fallback 진행)
* @return bool spawn 성공 여부 (false 면 호출자가 in-process fallback 진행)
*/
private function spawnResyncInlineLocal(UpgradeContext $context): bool
{
@@ -488,7 +492,7 @@ PHP;
* - TTY 연결 + 감지 결과 있음 → 목록 출력 후 "yes/no [yes]" 프롬프트. 빈 응답=yes.
*
* @param UpgradeContext $context
* @return bool 사용자가 일괄 업데이트에 동의했으면 true
* @return bool 사용자가 일괄 업데이트에 동의했으면 true
*/
private function promptBulkBundledUpdateLocal(UpgradeContext $context): bool
{
@@ -538,7 +542,7 @@ PHP;
return false;
}
echo " 번들에 포함된 새 버전으로 일괄 업데이트하시겠습니까? (yes/no) [yes]: ";
echo ' 번들에 포함된 새 버전으로 일괄 업데이트하시겠습니까? (yes/no) [yes]: ';
$answer = trim((string) fgets(STDIN));
$confirmed = ($answer === '' || strtolower($answer) === 'yes' || strtolower($answer) === 'y');
@@ -627,7 +631,7 @@ PHP;
* user_overrides 는 JSON 배열(보통 수정된 필드명 목록). null / 빈 배열 / 빈 객체 / 빈 문자열
* 을 모두 "비어있음" 으로 판정.
*
* @param \Illuminate\Database\Query\Builder $query
* @param Builder $query
* @param bool $hasOverrides
* @return void
*/
@@ -904,7 +908,7 @@ PHP;
* beta.1 메모리의 구 클래스를 우회하기 위해 로컬 구현을 유지한다.
* beta.3 에서 본 upgrade step 전체가 제거되며 공용 Helper 만 남는다.
*
* @return array{0: int|false, 1: int|false, 2: string} [owner, group, source]
* @return array{0: int|false, 1: int|false, 2: string} [owner, group, source]
*/
private function inferWebServerOwnershipLocal(): array
{
@@ -945,9 +949,9 @@ PHP;
* 로직은 `FilePermissionHelper::chownRecursive()` 과 동일하나, beta.1 메모리 구
* 클래스 우회를 위해 로컬 구현을 유지한다.
*
* @param string $path 대상 경로
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID
* @param string $path 대상 경로
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID
* @return int 변경 건수
*/
private function chownRecursiveLocal(string $path, int $owner, int|false $group): int
@@ -986,7 +990,7 @@ PHP;
* 본 정리 스텝은 이미 buggy 시드가 실행된 dev/staging 환경에서만 영향이 있으며,
* 신규 설치에서는 no-op 입니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return void
*/
private function cleanupUserNotificationPermissions(UpgradeContext $context): void
@@ -1026,7 +1030,7 @@ PHP;
*
* 기존 사용자 수정 데이터(user_overrides)를 보존합니다.
*
* @param UpgradeContext $context
* @param UpgradeContext $context
* @return void
*/
private function migrateMailTemplates(UpgradeContext $context): void
@@ -1078,7 +1082,7 @@ PHP;
*
* 멱등성: 두 번째 실행 시 매칭 0건 (안전).
*
* @param UpgradeContext $context
* @param UpgradeContext $context
* @return void
*/
private function migrateActivityLogDescriptionKeys(UpgradeContext $context): void
@@ -1108,7 +1112,7 @@ PHP;
/**
* mail_send_logs → notification_logs 데이터를 이관합니다.
*
* @param UpgradeContext $context
* @param UpgradeContext $context
* @return void
*/
private function migrateMailSendLogs(UpgradeContext $context): void
@@ -1165,7 +1169,7 @@ PHP;
/**
* 권한 식별자를 mail-send-logs → notification-logs로 전환합니다.
*
* @param UpgradeContext $context
* @param UpgradeContext $context
* @return void
*/
private function migratePermissions(UpgradeContext $context): void
@@ -1249,7 +1253,7 @@ PHP;
// 1. pre-beta.2 메일 템플릿 캐시 (기존 로직 유지)
$mailTypes = ['welcome', 'reset_password', 'password_changed'];
foreach ($mailTypes as $type) {
Cache::forget('mail_template:core:' . $type);
Cache::forget('mail_template:core:'.$type);
}
// 2. 공통 캐시 이관 이전의 레거시 키 제거 (고아 방지)