Merge pull request from gnuboard:HeuJung/issue496
feat(core,admin_basic): 번들 비-ko 언어팩 자동 프로비저닝 + 미설치·드리프트 발견성
This commit is contained in:
@@ -26,6 +26,11 @@
|
||||
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 설치 2단계 서버 요구사항 화면에서 감지 결과를 미리 확인할 수 있고, 3단계에서 직접 고를 수도 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
|
||||
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
|
||||
|
||||
#### 언어팩
|
||||
|
||||
- 사이트에서 쓰기로 한 언어의 언어팩을 한 번에 채워 넣는 기능을 추가했습니다. 한국어·영어 외 언어(일본어 등)는 설치되어 있어야 화면에 표시되는데, 설치가 빠져 있으면 오류 없이 한국어로 대체되어 운영자가 눈치채기 어려웠습니다. 이제 언어팩 관리 목록과 명령으로 "설치가 필요한 언어"를 한 번에 채울 수 있고, 여러 번 실행해도 안전합니다.
|
||||
- 언어팩 관리 목록이 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 상태를 눈에 띄게 보여주고, 그 자리에서 다시 설치할 수 있는 버튼을 제공합니다. 예전에는 목록에 정상으로 보이는데도 실제로는 번역이 적용되지 않는 경우를 발견하기 어려웠습니다. 파일이 사라진 언어팩은 화면의 다시 설치 버튼으로도, 언어팩을 채우는 명령으로도 복구할 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 설치된 언어팩 목록을 출력합니다.
|
||||
* 언어팩 목록을 출력합니다.
|
||||
*
|
||||
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구.
|
||||
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구. Service::list() 를 경유하므로
|
||||
* 설치된 DB 행뿐 아니라 "번들에 있으나 미설치"(uninstalled) 및 "active 로 기록됐으나
|
||||
* 설치본 파일 부재(드리프트)" 상태를 함께 표면화한다(이슈 #496).
|
||||
*/
|
||||
class ListLanguagePackCommand extends Command
|
||||
{
|
||||
@@ -20,13 +23,13 @@ class ListLanguagePackCommand extends Command
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '설치된 언어팩 목록을 출력합니다.';
|
||||
protected $description = '언어팩 목록을 출력합니다 (미설치 번들 및 드리프트 상태 포함).';
|
||||
|
||||
/**
|
||||
* @param LanguagePackRepositoryInterface $repository Repository
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackRepositoryInterface $repository,
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -43,11 +46,11 @@ class ListLanguagePackCommand extends Command
|
||||
$filters['scope'] = $scope;
|
||||
}
|
||||
|
||||
$paginator = $this->repository->paginate($filters, 100);
|
||||
$paginator = $this->service->list($filters, 100);
|
||||
$packs = $paginator->items();
|
||||
|
||||
if (empty($packs)) {
|
||||
$this->info('설치된 언어팩이 없습니다.');
|
||||
$this->info('언어팩이 없습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
@@ -61,7 +64,7 @@ class ListLanguagePackCommand extends Command
|
||||
$pack->locale,
|
||||
$pack->vendor,
|
||||
$pack->version,
|
||||
$pack->status,
|
||||
$this->formatStatus($pack),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -72,4 +75,19 @@ class ListLanguagePackCommand extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시용 상태 문자열을 만듭니다. 드리프트(active + 설치본 부재)는 명시적으로 표기합니다.
|
||||
*
|
||||
* @param LanguagePack $pack 대상 팩
|
||||
* @return string 표시 상태
|
||||
*/
|
||||
private function formatStatus(LanguagePack $pack): string
|
||||
{
|
||||
if ((bool) $pack->getAttribute('files_missing')) {
|
||||
return $pack->status.' (파일 없음)';
|
||||
}
|
||||
|
||||
return (string) $pack->status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\LanguagePack;
|
||||
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackBaseLocales;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 정책(supported_locales) 기준으로 미설치 번들 언어팩을 멱등 설치(프로비저닝)합니다.
|
||||
*
|
||||
* fresh install / 시더 / 수동 복구가 공유하는 단일 프로비저닝 SSoT 입니다.
|
||||
* base locale(ko/en)은 가상 보호 행으로 항상 서빙되므로 대상에서 제외되고,
|
||||
* 나머지 로케일(예: ja)은 `lang-packs/_bundled/` 에 소스가 있어도 설치본 디렉토리로
|
||||
* 복사·등록되어야 서빙되므로 이 커맨드가 그 프로비저닝을 담당합니다.
|
||||
*
|
||||
* 대상은 두 갈래입니다 — 미설치 번들 팩(신규 프로비저닝)과, 설치 행은 있으나 설치본 파일이
|
||||
* 사라진 드리프트 팩(수동 복구). 후자는 슬롯이 점유돼 있어 `getUninstalledBundledPacks()` 가
|
||||
* 잡지 못하므로 `getDriftedInstalledPacks()` 로 따로 모읍니다. 이 커맨드가 복구 경로를 겸하지 않으면
|
||||
* 드리프트는 화면에서 보이기만 하고 CLI 로는 고칠 수 없습니다.
|
||||
*
|
||||
* 멱등성: 정상 설치된 팩은 두 집합 어디에도 속하지 않으므로 재실행 시 신규 설치 0 건으로 수렴합니다.
|
||||
*/
|
||||
class ProvisionLanguagePackCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'language-pack:provision
|
||||
{--locale=* : 대상 로케일(미지정 시 supported_locales 에서 base locale 제외)}
|
||||
{--scope=* : core/module/plugin/template 로 대상 스코프 한정}
|
||||
{--no-activate : 설치 후 자동 활성화하지 않음}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '정책(supported_locales) 기준 미설치 번들 언어팩을 멱등 설치합니다.';
|
||||
|
||||
/**
|
||||
* @param LanguagePackService $service 언어팩 Service
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LanguagePackService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$targetLocales = $this->resolveTargetLocales();
|
||||
if (empty($targetLocales)) {
|
||||
$this->info('프로비저닝 대상 로케일이 없습니다 (base locale 외 supported_locales 미선언).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$scopeFilter = array_values(array_filter(array_map('strval', (array) $this->option('scope'))));
|
||||
$autoActivate = ! (bool) $this->option('no-activate');
|
||||
|
||||
$inScope = fn (LanguagePack $pack) => in_array($pack->locale, $targetLocales, true)
|
||||
&& (empty($scopeFilter) || in_array($pack->scope, $scopeFilter, true));
|
||||
|
||||
// 미설치 번들(신규 프로비저닝) + 드리프트 설치 행(수동 복구) 이 모두 대상이다.
|
||||
// 드리프트 행은 슬롯이 점유돼 있어 getUninstalledBundledPacks() 가 잡지 못하므로 따로 합친다.
|
||||
$candidates = $this->service->getUninstalledBundledPacks()->filter($inScope)
|
||||
->concat($this->service->getDriftedInstalledPacks()->filter($inScope))
|
||||
->values();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info(sprintf(
|
||||
'설치하거나 복구할 번들 언어팩이 없습니다 (로케일: %s). 이미 프로비저닝된 상태입니다.',
|
||||
implode(', ', $targetLocales),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$installed = 0;
|
||||
$repaired = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $pack) {
|
||||
$identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier);
|
||||
$blockedReason = $pack->getAttribute('install_blocked_reason');
|
||||
|
||||
// 대상 확장 미설치/미활성 등으로 설치가 불가한 팩은 best-effort 로 건너뛴다.
|
||||
if ($blockedReason !== null && $blockedReason !== '') {
|
||||
$this->warn(sprintf('건너뜀 (설치 불가: %s): %s', $blockedReason, $identifier));
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$isRepair = (bool) $pack->getAttribute('files_missing');
|
||||
$result = $this->service->installFromBundled($identifier, $autoActivate);
|
||||
$this->info(sprintf(
|
||||
'%s: %s v%s (status=%s)',
|
||||
$isRepair ? '복구' : '설치',
|
||||
$result->identifier,
|
||||
$result->version,
|
||||
$result->status,
|
||||
));
|
||||
$isRepair ? $repaired++ : $installed++;
|
||||
} catch (Throwable $e) {
|
||||
$this->warn(sprintf('실패 (건너뜀): %s — %s', $identifier, $e->getMessage()));
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'프로비저닝 완료 — 설치 %d, 복구 %d, 건너뜀 %d, 실패 %d',
|
||||
$installed,
|
||||
$repaired,
|
||||
$skipped,
|
||||
$failed,
|
||||
));
|
||||
|
||||
// 성과가 하나도 없고 실패만 있었다면 실패로 보고 (멱등 재실행/전량 스킵은 성공).
|
||||
return $installed === 0 && $repaired === 0 && $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로비저닝 대상 로케일을 해석합니다.
|
||||
*
|
||||
* `--locale` 지정 시 그 값, 미지정 시 `config('app.supported_locales')` 에서 base locale(ko/en)을 제외한 집합.
|
||||
*
|
||||
* @return array<int, string> 대상 로케일 목록 (base locale 제외)
|
||||
*/
|
||||
private function resolveTargetLocales(): array
|
||||
{
|
||||
$explicit = array_values(array_filter(array_map('strval', (array) $this->option('locale'))));
|
||||
$source = ! empty($explicit)
|
||||
? $explicit
|
||||
: array_map('strval', (array) config('app.supported_locales', []));
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
$source,
|
||||
fn (string $locale) => $locale !== '' && ! LanguagePackBaseLocales::isBaseLocale($locale),
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Enums\LanguagePackOrigin;
|
||||
use App\Extension\Helpers\ChangelogParser;
|
||||
use App\Helpers\TimezoneHelper;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 언어팩 단건 API 리소스.
|
||||
@@ -46,6 +49,8 @@ class LanguagePackResource extends BaseApiResource
|
||||
'github_changelog_url' => $this->resolveManifestField('github_changelog_url'),
|
||||
'bundled_identifier' => $this->getValue('bundled_identifier'),
|
||||
'install_blocked_reason' => $this->getValue('install_blocked_reason'),
|
||||
'files_missing' => (bool) $this->getValue('files_missing', false),
|
||||
'bundled_source_available' => (bool) $this->getValue('bundled_source_available', false),
|
||||
'target_name' => $this->resolveTargetName(),
|
||||
'installed_at' => $this->formatTimestamp('installed_at'),
|
||||
'activated_at' => $this->formatTimestamp('activated_at'),
|
||||
@@ -82,7 +87,7 @@ class LanguagePackResource extends BaseApiResource
|
||||
return null;
|
||||
}
|
||||
|
||||
$locale = \Illuminate\Support\Facades\App::getLocale();
|
||||
$locale = App::getLocale();
|
||||
$fallback = (string) config('app.fallback_locale', 'ko');
|
||||
|
||||
foreach ([$locale, $fallback, 'ko', 'en'] as $candidate) {
|
||||
@@ -153,7 +158,7 @@ class LanguagePackResource extends BaseApiResource
|
||||
$directoryPath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $directory);
|
||||
$changelogPath = $directory.DIRECTORY_SEPARATOR.'CHANGELOG.md';
|
||||
if (is_file($changelogPath)) {
|
||||
$changelogEntries = \App\Extension\Helpers\ChangelogParser::parse($changelogPath);
|
||||
$changelogEntries = ChangelogParser::parse($changelogPath);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$directoryPath = null;
|
||||
@@ -185,6 +190,10 @@ class LanguagePackResource extends BaseApiResource
|
||||
* 미설치 번들 가상 행(`status === uninstalled`)은 활성/비활성/제거 액션이
|
||||
* 무의미하므로 `can_install` 만 노출합니다. 모듈/플러그인 행 액션과 동일 패턴.
|
||||
*
|
||||
* 드리프트 행(설치본 파일 부재 + 번들 소스 실재)은 설치 행이지만 "재설치로 복구" 가
|
||||
* 유효한 액션이므로 `can_install` 을 함께 노출합니다. 이 플래그가 없으면 화면이
|
||||
* 복구 버튼을 켤 근거가 없어, 드리프트가 보이기만 하고 고칠 수 없는 상태로 남습니다.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function abilityMap(): array
|
||||
@@ -195,11 +204,17 @@ class LanguagePackResource extends BaseApiResource
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
$map = [
|
||||
'can_activate' => 'core.language_packs.manage',
|
||||
'can_deactivate' => 'core.language_packs.manage',
|
||||
'can_uninstall' => 'core.language_packs.manage',
|
||||
];
|
||||
|
||||
if ($this->getValue('files_missing') && $this->getValue('bundled_source_available')) {
|
||||
$map['can_install'] = 'core.language_packs.install';
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,7 +263,7 @@ class LanguagePackResource extends BaseApiResource
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = \Illuminate\Support\Facades\DB::table($table)
|
||||
$row = DB::table($table)
|
||||
->where('identifier', $target)
|
||||
->first(['name']);
|
||||
if (! $row || ! $row->name) {
|
||||
@@ -260,7 +275,7 @@ class LanguagePackResource extends BaseApiResource
|
||||
return is_string($row->name) ? $row->name : null;
|
||||
}
|
||||
|
||||
$locale = \Illuminate\Support\Facades\App::getLocale();
|
||||
$locale = App::getLocale();
|
||||
|
||||
return $decoded[$locale] ?? $decoded[config('app.fallback_locale', 'ko')] ?? (reset($decoded) ?: null);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class LanguagePackService
|
||||
->values();
|
||||
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$items = $merged->forPage($page, $perPage)->values();
|
||||
$items = $this->annotateFilesMissing($merged->forPage($page, $perPage)->values());
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
@@ -133,11 +133,13 @@ class LanguagePackService
|
||||
$uninstalledVirtual = $this->getUninstalledBundledPacks($filters)
|
||||
->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)]));
|
||||
|
||||
return $dbCollection
|
||||
->concat($builtInVirtual)
|
||||
->concat($uninstalledVirtual)
|
||||
->sortBy('locale')
|
||||
->values();
|
||||
return $this->annotateFilesMissing(
|
||||
$dbCollection
|
||||
->concat($builtInVirtual)
|
||||
->concat($uninstalledVirtual)
|
||||
->sortBy('locale')
|
||||
->values()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +166,92 @@ class LanguagePackService
|
||||
return $pack->scope.'|'.($pack->target_identifier ?? '').'|'.$pack->locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 각 팩에 드리프트 파생 플래그 `files_missing` 와 복구 가능 여부 `bundled_source_available` 를 부여합니다.
|
||||
*
|
||||
* `files_missing` 은 DB 에 active 로 기록됐으나 설치본 디렉토리가 실재하지 않는 행을 표면화하기 위한 것으로,
|
||||
* 이 상태의 팩은 런타임에 오류 없이 base locale 로 조용히 폴백한다(이슈 #496 의 드리프트 증상).
|
||||
*
|
||||
* `bundled_source_available` 은 그 팩을 `lang-packs/_bundled/` 소스로 재설치해 복구할 수 있는지를 나타낸다.
|
||||
* 설치 경로(source_type)가 아니라 **번들 소스의 실재 여부**로 판정하므로, github/url/zip 으로 설치된 팩이라도
|
||||
* 동일 식별자의 번들 소스가 있으면 복구 대상이 된다 — 판정을 source_type 에 걸면 그 팩들은 드리프트가
|
||||
* 보이기만 하고 복구 수단이 없는 상태로 남는다.
|
||||
*
|
||||
* @param Collection<int, LanguagePack> $packs 대상 컬렉션
|
||||
* @return Collection<int, LanguagePack> 동일 컬렉션(플래그 부여됨)
|
||||
*/
|
||||
private function annotateFilesMissing(Collection $packs): Collection
|
||||
{
|
||||
foreach ($packs as $pack) {
|
||||
$pack->setAttribute('files_missing', $this->isInstalledButFilesMissing($pack));
|
||||
$pack->setAttribute('bundled_source_available', $this->hasBundledSource($pack));
|
||||
}
|
||||
|
||||
return $packs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 팩과 동일한 식별자의 번들 소스(`lang-packs/_bundled/{identifier}`)가 실재하는지 확인합니다.
|
||||
*
|
||||
* @param LanguagePack $pack 판정 대상 팩
|
||||
* @return bool 번들 소스가 있으면 true
|
||||
*/
|
||||
private function hasBundledSource(LanguagePack $pack): bool
|
||||
{
|
||||
$identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier);
|
||||
if ($identifier === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return File::isDirectory(base_path('lang-packs/_bundled/'.$identifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치본 파일이 부재(드리프트)해 번들 소스로 복구 가능한 설치 행을 반환합니다.
|
||||
*
|
||||
* `getUninstalledBundledPacks()` 는 슬롯이 점유된 팩을 후보에서 제외하므로 드리프트 행을 잡지 못합니다.
|
||||
* 프로비저닝이 "수동 복구" 경로까지 겸하려면 이 집합이 함께 대상이 되어야 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $filters 필터 (scope/locale/target_identifier/search/vendor)
|
||||
* @return Collection<int, LanguagePack> 드리프트 설치 행 컬렉션
|
||||
*/
|
||||
public function getDriftedInstalledPacks(array $filters = []): Collection
|
||||
{
|
||||
return $this->annotateFilesMissing(
|
||||
$this->repository
|
||||
->getFilteredCollection($filters)
|
||||
->filter(fn (LanguagePack $pack) => $this->isInstalledButFilesMissing($pack))
|
||||
->filter(fn (LanguagePack $pack) => $this->hasBundledSource($pack))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 팩이 "설치(active) 로 기록됐으나 설치본 파일이 부재" 한 드리프트 상태인지 판정합니다.
|
||||
*
|
||||
* 가상 행(built_in / uninstalled)은 실제 설치본이 아니므로 대상에서 제외합니다.
|
||||
*
|
||||
* @param LanguagePack $pack 판정 대상 팩
|
||||
* @return bool 드리프트면 true
|
||||
*/
|
||||
private function isInstalledButFilesMissing(LanguagePack $pack): bool
|
||||
{
|
||||
// 가상 행(exists=false: built_in/uninstalled)은 설치본 개념이 없으므로 제외
|
||||
if (! $pack->exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($pack->status !== LanguagePackStatus::Active->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return ! File::isDirectory($pack->resolveDirectory());
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어/번들 확장의 `lang/{ko,en}/` 디렉토리를 자동 스캔하여 가상 보호 LanguagePack
|
||||
* 인스턴스 컬렉션을 반환합니다 (요구사항 #1, #2).
|
||||
|
||||
@@ -77,6 +77,8 @@ _목록 응답: `data.data[]` 배열 항목의 필드._
|
||||
| github_changelog_url | null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) |
|
||||
| bundled_identifier | string | `g7-module-gnuboard7-hello_module-ja` | 대응하는 번들 확장 식별자 (번들 원본 매칭용) |
|
||||
| install_blocked_reason | string | `target_not_installed` | 설치가 차단된 사유 (차단 없으면 null) |
|
||||
| files_missing | boolean | `false` | 드리프트 여부 — active 로 기록됐으나 설치본 파일이 부재해 런타임에 조용히 ko 로 폴백하는 상태이면 true |
|
||||
| bundled_source_available | boolean | `false` | 번들 소스(`lang-packs/_bundled/{identifier}`)가 실재해 재설치로 복구 가능하면 true. 설치 경로(source_type)와 무관하게 소스 실재 여부로 판정 |
|
||||
| target_name | string | `게시판` | 대상 확장의 표시 이름 (scope+target_identifier 로 해석) |
|
||||
| installed_at | string | `2026-07-03 19:20:23` | installed 일시 |
|
||||
| activated_at | string | `2026-07-03 19:20:23` | activated 일시 |
|
||||
@@ -123,6 +125,8 @@ HTTP/1.1 200
|
||||
"github_changelog_url": null,
|
||||
"bundled_identifier": null,
|
||||
"install_blocked_reason": null,
|
||||
"files_missing": false,
|
||||
"bundled_source_available": false,
|
||||
"target_name": null,
|
||||
"installed_at": null,
|
||||
"activated_at": null,
|
||||
@@ -161,6 +165,8 @@ HTTP/1.1 200
|
||||
"github_changelog_url": null,
|
||||
"bundled_identifier": null,
|
||||
"install_blocked_reason": null,
|
||||
"files_missing": false,
|
||||
"bundled_source_available": false,
|
||||
"target_name": null,
|
||||
"installed_at": "2026-07-05 10:41:24",
|
||||
"activated_at": "2026-07-05 10:41:24",
|
||||
|
||||
@@ -25,11 +25,15 @@
|
||||
```php
|
||||
class LanguagePackService
|
||||
{
|
||||
// 조회
|
||||
// 조회 — list() 는 DB 행 + 가상 보호 행 + 미설치 번들 가상 행을 병합하며,
|
||||
// 각 행에 드리프트 파생 플래그 files_missing(active 인데 설치본 부재) 을 부여
|
||||
public function list(array $filters = [], int $perPage = 20): LengthAwarePaginator;
|
||||
public function find(int $id): ?LanguagePack;
|
||||
public function getUninstalledBundledPacks(array $filters = []): Collection; // lang-packs/_bundled/ 미설치 번들 가상 행
|
||||
public function getDriftedInstalledPacks(array $filters = []): Collection; // 설치 행 존재 + 설치본 파일 부재 (복구 대상)
|
||||
|
||||
// 설치 (3가지 소스)
|
||||
// 설치 (4가지 소스)
|
||||
public function installFromBundled(string $identifier, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack;
|
||||
public function installFromFile(UploadedFile $file, bool $autoActivate = true, ?int $installedBy = null): LanguagePack;
|
||||
public function installFromGithub(string $githubUrl, bool $autoActivate = true, ?int $installedBy = null): LanguagePack;
|
||||
public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = true, ?int $installedBy = null): LanguagePack;
|
||||
@@ -82,6 +86,35 @@ LanguagePackRegistry::invalidate()
|
||||
HookManager::doAction('core.language_packs.after_activate', $pack) (active 진입한 경우만)
|
||||
```
|
||||
|
||||
## 프로비저닝 커맨드 (`language-pack:provision`)
|
||||
|
||||
fresh install · 복구 · 시더가 공유하는 멱등 프로비저닝 SSoT 입니다.
|
||||
|
||||
```bash
|
||||
php artisan language-pack:provision # supported_locales 의 비-base 로케일 대상
|
||||
php artisan language-pack:provision --locale=ja # 특정 로케일 한정
|
||||
php artisan language-pack:provision --scope=core # 스코프 한정
|
||||
php artisan language-pack:provision --no-activate # 설치만, 자동 활성화 생략
|
||||
```
|
||||
|
||||
- 대상 로케일 = `--locale` 지정값, 미지정 시 `config('app.supported_locales')` ∖ base locale(ko/en).
|
||||
- 대상 후보는 두 갈래입니다 — `getUninstalledBundledPacks()` (신규 프로비저닝) + `getDriftedInstalledPacks()` (수동 복구). 각각 `installFromBundled($id, autoActivate: true)` 를 호출하며, 정상 설치된 팩은 두 집합 어디에도 속하지 않으므로 **재실행 시 신규 설치 0 건으로 수렴(멱등)**.
|
||||
- 드리프트 팩은 슬롯이 점유돼 있어 미설치 목록에 잡히지 않습니다. 이 커맨드가 복구까지 겸하지 않으면 드리프트는 화면에서 보이기만 하고 CLI 로는 고칠 수 없습니다. 재설치는 `finalizeInstall` 의 update 경로를 타므로 동일 버전 재설치도 다운그레이드로 차단되지 않습니다.
|
||||
- 설치 차단 사유(`install_blocked_reason`: 대상 확장 미설치/미활성 등)가 있는 팩은 skip + 경고(best-effort). 신규 도메인 로직 없이 기존 Service 메서드만 조합합니다.
|
||||
|
||||
## 드리프트 표면화 (files_missing / bundled_source_available)
|
||||
|
||||
`list()` 와 `getPacksForExtension()` 은 각 팩에 파생 플래그 두 개를 부여합니다.
|
||||
|
||||
| 플래그 | 판정 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| `files_missing` | 실제 DB 설치 행(`exists=true`) + `status=active` 인데 설치본 디렉토리(`resolveDirectory()`) 부재 | 드리프트 발견 — 가상 보호 행/미설치 가상 행은 대상 아님 |
|
||||
| `bundled_source_available` | `lang-packs/_bundled/{identifier}` 실재 | 복구 가능 여부 — 재설치 버튼 노출 판정 |
|
||||
|
||||
`bundled_source_available` 은 설치 경로(`source_type`)가 아니라 **번들 소스의 실재 여부**로 판정합니다. 판정을 `source_type` 에 걸면 zip/url 로 설치된 팩이 드리프트됐을 때 배지만 뜨고 복구 수단이 없는 상태로 남습니다. 반대로 번들 소스가 없는 서드파티 팩에는 재설치 버튼이 뜨지 않습니다 — 복구할 소스 자체가 없기 때문입니다.
|
||||
|
||||
이 플래그로 `language-pack:list` 는 `active (파일 없음)` 을 표기하고, 관리자 화면은 배지 + 원클릭 재설치를 노출합니다.
|
||||
|
||||
## 슬롯 스위칭 (activate / deactivate)
|
||||
|
||||
**activate**: 동일 슬롯의 기존 active 팩을 inactive 로 강등 + 대상 팩을 active 로 승격. 단일 트랜잭션 내에서 원자적으로 수행. application-level 보장이므로 DB functional unique index 불필요.
|
||||
|
||||
@@ -224,6 +224,32 @@ UI 에서 라디오로 즉시 전환 가능. 활성 팩이 제거되면 slot 의
|
||||
- 설치 실행 시 5단계의 모든 확장 install/activate 가 완료된 뒤 `php artisan language-pack:install {identifier} --source=bundled` 가 선택된 각 언어팩에 대해 호출됩니다(자동 활성화 default).
|
||||
- 언어팩 1건 설치 실패는 best-effort 처리 — 전체 설치를 중단하지 않고 경고 로그만 남긴 뒤 다음 언어팩으로 진행합니다. 코어/모듈/플러그인 install 실패와 달리 rollback 을 발생시키지 않습니다.
|
||||
|
||||
## 프로비저닝과 드리프트 발견 (supported_locales)
|
||||
|
||||
코어의 `ko`/`en` 은 가상 보호 행으로 DB 설치 없이 항상 서빙되지만, 그 외 로케일(예: `ja`)의 번들 언어팩은 `lang-packs/_bundled/` 에 소스가 있어도 설치본 디렉토리로 복사·등록되어야 서빙됩니다. 이 비대칭 때문에 "설치본이 없거나 어긋난" 상태가 오류 없이 조용히 `ko` 로 폴백할 수 있어, 다음 도구로 프로비저닝·발견을 지원합니다.
|
||||
|
||||
### `language-pack:provision` (멱등 프로비저닝)
|
||||
|
||||
```bash
|
||||
php artisan language-pack:provision # supported_locales 의 비-base 로케일을 대상으로 미설치 번들 팩 설치
|
||||
php artisan language-pack:provision --locale=ja # 특정 로케일만
|
||||
php artisan language-pack:provision --scope=core # 스코프 한정
|
||||
```
|
||||
|
||||
- 대상 로케일 기본값은 `config('app.supported_locales')` 에서 base locale(ko/en)을 뺀 집합입니다. 즉 "사이트가 쓰겠다고 선언한 로케일" 만 채웁니다(불필요한 로케일 대량 설치 없음).
|
||||
- 미설치 번들 팩(신규 설치)과 설치본 파일이 사라진 드리프트 팩(복구)을 함께 대상으로 삼습니다. 정상 설치된 팩은 어느 쪽에도 해당하지 않아 재실행해도 신규 설치가 0 건으로 수렴합니다(완전 멱등). fresh install · 복구 · 시더가 공유하는 단일 프로비저닝 경로입니다.
|
||||
- 대상 확장 미설치/미활성 등 설치 차단 사유가 있는 팩은 건너뛰고 경고만 남깁니다(best-effort).
|
||||
|
||||
### 미설치·드리프트 발견
|
||||
|
||||
- `language-pack:list` 는 설치된 DB 행뿐 아니라 "번들에 있으나 미설치"(`uninstalled`) 및 "active 로 기록됐으나 설치본 파일 부재(드리프트)" 를 함께 표시합니다. 드리프트 행의 Status 는 `active (파일 없음)` 처럼 표기됩니다.
|
||||
- 관리자 언어팩 목록 화면도 동일하게 드리프트 행에 "파일 없음" 배지와 원클릭 재설치 버튼을 노출합니다(번들 소스 재설치).
|
||||
- 재설치 버튼은 **번들 소스가 실재하는 팩**에만 뜹니다. 설치 경로(zip/GitHub/URL)와 무관하게 동일 식별자의 `lang-packs/_bundled/` 소스가 있으면 복구할 수 있고, 소스가 없는 서드파티 팩은 배지로 발견만 되고 복구 버튼은 뜨지 않습니다(복구할 원본이 없기 때문).
|
||||
|
||||
### supported_locales ↔ 번들 소스 정합
|
||||
|
||||
`config/app.php` 의 `supported_locales` 에 비-base 로케일을 선언했다면, 대응하는 코어 번들 소스(`lang-packs/_bundled/g7-core-{locale}/`)가 존재해야 프로비저닝으로 채울 수 있습니다. 대응 소스가 없는 로케일을 선언하면 설치할 팩 자체가 없어 그 로케일이 조용히 `ko` 로 폴백합니다(복구 경로 없음). 이 정합은 정적 검사로 확인됩니다 — 소스를 추가하거나 미지원 로케일을 선언에서 제거하세요.
|
||||
|
||||
## 시더 통합 (HookManager 필터)
|
||||
|
||||
기존 시더에 `applyFilters` 1줄만 추가하면 자동으로 다국어 키가 병합됩니다.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 언어팩 관리 목록의 "파일 없음" 배지와 다시 설치 버튼 문구 일본어 번역 추가 (`language_packs.badge.files_missing`, `language_packs.actions.reinstall`).
|
||||
- 일반 설정의 에셋 파일 서빙 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지, 자동 감지 결과(확장자를 가로채는 서버 규칙 안내 포함)가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 대시보드의 에셋 파일 서빙 방식 불일치 안내 문구 일본어 번역 추가 (`dashboard.asset_url_mode_drift_*`) — 안내 제목과 설명, 환경설정 이동 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
|
||||
@@ -398,11 +398,13 @@
|
||||
"detail": "詳細を表示",
|
||||
"cancel": "キャンセル",
|
||||
"close": "閉じる",
|
||||
"reinstall": "再インストール",
|
||||
"preview_manifest": "マニフェストプレビュー"
|
||||
},
|
||||
"search_placeholder": "識別子/ベンダー/言語で検索",
|
||||
"badge": {
|
||||
"update_available": "アップデート可能"
|
||||
"update_available": "アップデート可能",
|
||||
"files_missing": "ファイルなし"
|
||||
},
|
||||
"banner": {
|
||||
"scoped": "{{scope}} \"{{target}}\" の言語パックのみ表示しています。",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Enums\PermissionType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Models\Module as ModuleRegistration;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
@@ -137,6 +139,18 @@ abstract class ModuleTestCase extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 모듈 등록 행(modules 테이블)을 매 테스트마다 보장.
|
||||
//
|
||||
// 이 행은 TestingSeeder 가 만들지만, 시딩은 migrate:fresh 와 함께 프로세스당 1회만 돈다.
|
||||
// 같은 프로세스에서 코어 테스트가 먼저 실행되면 코어의 migrate:fresh 가 스키마를 만들면서
|
||||
// 이 행을 남기지 않고, ecommerce 테이블은 이미 존재하므로 setUpTraits() 의 재마이그레이션
|
||||
// 가드도 발동하지 않는다. 결과적으로 "모듈이 설치돼 있다" 는 전제가 조용히 깨진 채로
|
||||
// 모듈 테스트가 돌아, 모듈 활성 여부를 보는 경로(언어팩 활성화 → entity 시더 재실행 등)가
|
||||
// 통째로 skip 된다 — 단독 통과 / 전체 실행 실패의 원인.
|
||||
//
|
||||
// 트랜잭션 안에서 수행되므로 테스트 종료 시 롤백된다.
|
||||
$this->ensureModuleRegistered();
|
||||
|
||||
// 모듈 오토로드 등록 (테스트 환경)
|
||||
$this->registerModuleAutoload();
|
||||
|
||||
@@ -173,6 +187,25 @@ abstract class ModuleTestCase extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `modules` 테이블에 이 모듈의 등록 행이 있음을 보장합니다 (없으면 생성).
|
||||
*
|
||||
* 값은 TestingSeeder 의 모듈 등록과 동일하게 맞춥니다 — 시딩이 돈 프로세스와
|
||||
* 돌지 않은 프로세스에서 테스트 전제가 달라지지 않도록 하기 위함입니다.
|
||||
*/
|
||||
private function ensureModuleRegistered(): void
|
||||
{
|
||||
ModuleRegistration::updateOrCreate(
|
||||
['identifier' => 'sirsoft-ecommerce'],
|
||||
[
|
||||
'vendor' => 'sirsoft',
|
||||
'name' => ['ko' => '이커머스', 'en' => 'Ecommerce'],
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'version' => '1.0.0',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* tearDown 에 HookManager 상태 복원.
|
||||
*/
|
||||
|
||||
@@ -683,6 +683,10 @@ if (isset($_GET['ajax_action'])) {
|
||||
<span>목록 조회</span>
|
||||
<span class="text-[10px] opacity-60">(language-pack:list)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('language-pack:provision')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>프로비저닝</span>
|
||||
<span class="text-[10px] opacity-60">(provision)</span>
|
||||
</button>
|
||||
<button onclick="runLanguagePackCommand('install')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>설치</span>
|
||||
<span class="text-[10px] opacity-60">(install)</span>
|
||||
|
||||
@@ -14,10 +14,8 @@
|
||||
- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다.
|
||||
- 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다.
|
||||
- 환경설정 > 정보의 시스템 사양에 OPcache 상태를 추가했습니다. 꺼져 있으면 사이트 응답 속도가 느려진다는 안내와 설정 방법을 함께 표시합니다.
|
||||
|
||||
### Added
|
||||
|
||||
- SEO 설정에서 sitemap 생성 진행 상황을 화면에서 확인할 수 있습니다. 대기·생성 중·파일 작성 중·완료·실패 단계와 현재 처리 중인 항목, 누적 URL 수가 표시됩니다. 실시간 연결(WebSocket)이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
|
||||
- 언어팩 관리 목록에서 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 언어팩에 "파일 없음" 표시와 다시 설치 버튼을 추가했습니다. 목록에는 정상으로 보여도 번역이 적용되지 않던 상태를 바로 발견해 복구할 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @file admin-language-pack-drift.test.tsx
|
||||
* @description 언어팩 목록 드리프트(files_missing) 배지 + 원클릭 재설치 배선 회귀 가드 (이슈 #496 Part B3)
|
||||
*
|
||||
* 실제 partial(_content.json)의 선언형 노드를 읽어, DB active 인데 설치본 파일이 없는
|
||||
* 드리프트 행에 대해 (1) "파일 없음" 배지, (2) install-from-bundled 모달을 재사용하는
|
||||
* 재설치 버튼이 files_missing 조건으로 배선돼 있는지 검증한다.
|
||||
*
|
||||
* @effects admin_list_shows_drift_badge_and_reinstall,
|
||||
* bundled_source_availability_flagged_independently_of_source_type,
|
||||
* third_party_pack_without_bundle_source_not_offered_for_reinstall
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
|
||||
|
||||
const CONTENT_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../layouts/partials/admin_language_pack_list/_content.json',
|
||||
);
|
||||
|
||||
/**
|
||||
* 노드 트리를 재귀 순회하며 조건에 맞는 첫 노드를 찾는다.
|
||||
*/
|
||||
function findNode(node: any, predicate: (n: any) => boolean): any | null {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findNode(child, predicate);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (predicate(node)) return node;
|
||||
for (const key of Object.keys(node)) {
|
||||
const found = findNode(node[key], predicate);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('admin language pack list — 드리프트(files_missing) 배선', () => {
|
||||
const layout = JSON.parse(fs.readFileSync(CONTENT_PATH, 'utf8'));
|
||||
|
||||
it('files_missing 배지 노드가 존재한다', () => {
|
||||
const badge = findNode(
|
||||
layout,
|
||||
(n) =>
|
||||
typeof n.if === 'string' &&
|
||||
n.if.includes('row.files_missing === true') &&
|
||||
n.text === '$t:admin.language_packs.badge.files_missing',
|
||||
);
|
||||
expect(badge).not.toBeNull();
|
||||
});
|
||||
|
||||
it('재설치 버튼이 번들 드리프트 행 조건으로 배선되어 install-bundled 모달을 재사용한다', () => {
|
||||
const button = findNode(
|
||||
layout,
|
||||
(n) =>
|
||||
n.name === 'Button' &&
|
||||
typeof n.if === 'string' &&
|
||||
n.if.includes('row.files_missing === true') &&
|
||||
n.if.includes('row.bundled_source_available === true') &&
|
||||
n.if.includes('row.abilities?.can_install === true'),
|
||||
);
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
// 재설치 액션이 selectedBundledLanguagePack 설정 + install-bundled 모달 오픈으로 배선됨
|
||||
const opensBundledModal = findNode(
|
||||
button,
|
||||
(n) => n.handler === 'openModal' && n.target === 'language_pack_install_bundled_modal',
|
||||
);
|
||||
expect(opensBundledModal).not.toBeNull();
|
||||
|
||||
const setsSelected = findNode(
|
||||
button,
|
||||
(n) =>
|
||||
n.handler === 'setState' &&
|
||||
n.params &&
|
||||
n.params.target === 'global' &&
|
||||
n.params.selectedBundledLanguagePack === '{{row}}',
|
||||
);
|
||||
expect(setsSelected).not.toBeNull();
|
||||
|
||||
// 재설치는 "복구" 이므로 활성 상태를 유지해야 한다 → 재설치 모드 플래그 ON.
|
||||
// 이 플래그가 빠지면 모달이 auto_activate=false 로 보내 팩이 강등되고,
|
||||
// 파일은 복구됐는데 해당 로케일은 계속 서빙되지 않는 상태가 된다.
|
||||
expect(setsSelected.params.languagePackReinstallMode).toBe(true);
|
||||
});
|
||||
|
||||
it('신규 설치 버튼은 재설치 모드를 끈다 (활성화 여부는 운영자 결정)', () => {
|
||||
const installButton = findNode(
|
||||
layout,
|
||||
(n) => n.name === 'Button' && n.if === "{{row.status === 'uninstalled'}}",
|
||||
);
|
||||
expect(installButton).not.toBeNull();
|
||||
|
||||
const setsSelected = findNode(
|
||||
installButton,
|
||||
(n) => n.handler === 'setState' && n.params?.selectedBundledLanguagePack === '{{row}}',
|
||||
);
|
||||
expect(setsSelected.params.languagePackReinstallMode).toBe(false);
|
||||
});
|
||||
|
||||
it('설치 모달이 재설치 모드에 따라 auto_activate 를 보낸다', () => {
|
||||
const modal = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '../../layouts/partials/admin_language_pack_list/_modal_install_bundled.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
const apiCall = findNode(
|
||||
modal,
|
||||
(n) => n.handler === 'apiCall' && String(n.target).includes('install-from-bundled'),
|
||||
);
|
||||
expect(apiCall).not.toBeNull();
|
||||
expect(apiCall.params.body.auto_activate).toBe('{{_global.languagePackReinstallMode === true}}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 렌더 평가 (createLayoutTest) ──────────────────────────────────────
|
||||
// 구조 가드(위)는 배선 존재만 보장한다. 아래는 실제 _content.json 의 조건식을
|
||||
// 그대로 옮겨 DynamicRenderer + ConditionEvaluator 로 평가해, 드리프트 행에서만
|
||||
// 배지/재설치가 뜨고 정상 행/비-번들 행/권한 부재에서는 뜨지 않음을 검증한다.
|
||||
|
||||
const TestDiv: React.FC<any> = ({ className, children, 'data-testid': testId }) => (
|
||||
<div className={className} data-testid={testId}>{children}</div>
|
||||
);
|
||||
const TestSpan: React.FC<any> = ({ className, children, text, 'data-testid': testId }) => (
|
||||
<span className={className} data-testid={testId}>{children || text}</span>
|
||||
);
|
||||
const TestButton: React.FC<any> = ({ className, disabled, children, text, onClick, 'data-testid': testId }) => (
|
||||
<button className={className} disabled={disabled} onClick={onClick} data-testid={testId}>{children || text}</button>
|
||||
);
|
||||
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupRegistry(): void {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
(registry as any).registry = {
|
||||
// DynamicRenderer 는 루트를 Fragment 로 감싸므로 Fragment 미등록 시 자식이 렌더되지 않는다.
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
};
|
||||
}
|
||||
|
||||
// _content.json 의 실제 조건식을 그대로 사용 (badge/reinstall)
|
||||
const BADGE_IF = '{{row.files_missing === true}}';
|
||||
const REINSTALL_IF =
|
||||
'{{row.files_missing === true && row.bundled_source_available === true && row.abilities?.can_install === true}}';
|
||||
|
||||
function driftLayout(canInstall: boolean) {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
layout_name: `lp_drift_render_${canInstall ? 'can' : 'cannot'}`,
|
||||
data_sources: [
|
||||
{
|
||||
id: 'language_packs',
|
||||
type: 'api',
|
||||
endpoint: '/api/admin/language-packs',
|
||||
method: 'GET',
|
||||
auto_fetch: true,
|
||||
fallback: { data: { data: [], meta: { total: 0 }, abilities: {} } },
|
||||
},
|
||||
],
|
||||
state: {},
|
||||
components: [
|
||||
{
|
||||
id: 'rows',
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
iteration: { source: 'language_packs?.data?.data', item_var: 'row' },
|
||||
children: [
|
||||
{
|
||||
id: 'badge',
|
||||
type: 'basic',
|
||||
name: 'Span',
|
||||
if: BADGE_IF,
|
||||
props: { 'data-testid': 'badge-{{row.identifier}}' },
|
||||
text: 'files-missing',
|
||||
},
|
||||
{
|
||||
id: 'reinstall',
|
||||
type: 'basic',
|
||||
name: 'Button',
|
||||
if: REINSTALL_IF,
|
||||
props: { 'data-testid': 'reinstall-{{row.identifier}}' },
|
||||
text: 'reinstall',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const ROWS = [
|
||||
// 드리프트 + 번들 소스 있음 → 배지 O, 재설치 O(권한 있을 때)
|
||||
{
|
||||
id: 1,
|
||||
identifier: 'drift-bundled',
|
||||
files_missing: true,
|
||||
source_type: 'bundled',
|
||||
bundled_source_available: true,
|
||||
status: 'active',
|
||||
abilities: { can_install: true },
|
||||
},
|
||||
// 정상(파일 있음) → 배지 X, 재설치 X
|
||||
{
|
||||
id: 2,
|
||||
identifier: 'healthy-bundled',
|
||||
files_missing: false,
|
||||
source_type: 'bundled',
|
||||
bundled_source_available: true,
|
||||
status: 'active',
|
||||
abilities: { can_install: true },
|
||||
},
|
||||
// zip 으로 설치됐지만 동일 식별자의 번들 소스가 있음 → 복구 가능하므로 재설치 O
|
||||
// (게이트를 source_type 에 걸면 이 행이 "보이기만 하고 못 고치는" 상태로 남는다)
|
||||
{
|
||||
id: 3,
|
||||
identifier: 'drift-zip-with-bundle',
|
||||
files_missing: true,
|
||||
source_type: 'zip',
|
||||
bundled_source_available: true,
|
||||
status: 'active',
|
||||
abilities: { can_install: true },
|
||||
},
|
||||
// 번들 소스가 없는 서드파티 팩(github) → 배지 O, 재설치 X (복구 소스 자체가 없음)
|
||||
{
|
||||
id: 4,
|
||||
identifier: 'drift-thirdparty',
|
||||
files_missing: true,
|
||||
source_type: 'github',
|
||||
bundled_source_available: false,
|
||||
status: 'active',
|
||||
abilities: { can_install: true },
|
||||
},
|
||||
];
|
||||
|
||||
describe('admin language pack list — 드리프트 렌더 평가 (createLayoutTest)', () => {
|
||||
beforeEach(() => {
|
||||
setupRegistry();
|
||||
});
|
||||
|
||||
it('files_missing=true 행에만 "파일 없음" 배지가 렌더된다', async () => {
|
||||
const utils = createLayoutTest(driftLayout(true));
|
||||
utils.mockApi('language_packs', {
|
||||
response: { data: { data: ROWS, meta: { total: ROWS.length }, abilities: { can_install: true } } },
|
||||
});
|
||||
|
||||
await utils.render();
|
||||
|
||||
expect(screen.getByTestId('badge-drift-bundled')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('badge-drift-zip-with-bundle')).toBeInTheDocument();
|
||||
// 복구 소스가 없는 서드파티 드리프트도 "발견" 은 되어야 한다
|
||||
expect(screen.getByTestId('badge-drift-thirdparty')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('badge-healthy-bundled')).not.toBeInTheDocument();
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('재설치 버튼은 드리프트 + 번들 소스 실재 + can_install 인 행에만 렌더된다', async () => {
|
||||
const utils = createLayoutTest(driftLayout(true));
|
||||
utils.mockApi('language_packs', {
|
||||
response: { data: { data: ROWS, meta: { total: ROWS.length }, abilities: { can_install: true } } },
|
||||
});
|
||||
|
||||
await utils.render();
|
||||
|
||||
// 드리프트 + 번들 소스 → O
|
||||
expect(screen.getByTestId('reinstall-drift-bundled')).toBeInTheDocument();
|
||||
// zip 설치분이라도 번들 소스가 있으면 복구 가능 → O
|
||||
expect(screen.getByTestId('reinstall-drift-zip-with-bundle')).toBeInTheDocument();
|
||||
// 정상 → files_missing=false 로 X
|
||||
expect(screen.queryByTestId('reinstall-healthy-bundled')).not.toBeInTheDocument();
|
||||
// 번들 소스 부재(서드파티) → 복구 소스가 없으므로 X
|
||||
expect(screen.queryByTestId('reinstall-drift-thirdparty')).not.toBeInTheDocument();
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('행 권한 can_install=false 면 드리프트 번들 행이어도 재설치 버튼이 렌더되지 않는다', async () => {
|
||||
// 게이트는 행 단위 권한(row.abilities)이다 — 컬렉션 권한은 행 컨텍스트에서 해석되지 않으므로
|
||||
// 그쪽에 걸면 조건이 상시 false 가 되어 버튼이 영영 뜨지 않는다(브라우저 실측에서 확인).
|
||||
const rowsWithoutPermission = ROWS.map((r) => ({ ...r, abilities: { can_install: false } }));
|
||||
const utils = createLayoutTest(driftLayout(false));
|
||||
utils.mockApi('language_packs', {
|
||||
response: {
|
||||
data: { data: rowsWithoutPermission, meta: { total: rowsWithoutPermission.length }, abilities: { can_install: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await utils.render();
|
||||
|
||||
// 배지는 권한과 무관하게 뜬다
|
||||
expect(screen.getByTestId('badge-drift-bundled')).toBeInTheDocument();
|
||||
// 권한 부재 → 재설치 버튼 전부 X
|
||||
expect(screen.queryByTestId('reinstall-drift-bundled')).not.toBeInTheDocument();
|
||||
|
||||
utils.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -394,11 +394,13 @@
|
||||
"detail": "Details",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"reinstall": "Reinstall",
|
||||
"preview_manifest": "Preview manifest"
|
||||
},
|
||||
"search_placeholder": "Search by identifier, vendor, or language",
|
||||
"badge": {
|
||||
"update_available": "Update available"
|
||||
"update_available": "Update available",
|
||||
"files_missing": "Files missing"
|
||||
},
|
||||
"banner": {
|
||||
"scoped": "Showing language packs for {{scope}} \"{{target}}\" only.",
|
||||
|
||||
@@ -398,11 +398,13 @@
|
||||
"detail": "상세 보기",
|
||||
"cancel": "취소",
|
||||
"close": "닫기",
|
||||
"reinstall": "재설치",
|
||||
"preview_manifest": "manifest 미리보기"
|
||||
},
|
||||
"search_placeholder": "식별자/벤더/언어로 검색",
|
||||
"badge": {
|
||||
"update_available": "업데이트 가능"
|
||||
"update_available": "업데이트 가능",
|
||||
"files_missing": "파일 없음"
|
||||
},
|
||||
"banner": {
|
||||
"scoped": "{{scope}} \"{{target}}\"의 언어팩만 보고 있습니다.",
|
||||
|
||||
+58
-1
@@ -798,6 +798,16 @@
|
||||
},
|
||||
"text": "$t:admin.language_packs.status.error"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{row.files_missing === true}}",
|
||||
"props": {
|
||||
"className": "px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400 rounded",
|
||||
"data-testid": "lp-drift-badge"
|
||||
},
|
||||
"text": "$t:admin.language_packs.badge.files_missing"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
@@ -987,7 +997,8 @@
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedBundledLanguagePack": "{{row}}"
|
||||
"selectedBundledLanguagePack": "{{row}}",
|
||||
"languagePackReinstallMode": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1013,6 +1024,52 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{row.files_missing === true && row.bundled_source_available === true && row.abilities?.can_install === true}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"title": "$t:admin.language_packs.actions.reinstall",
|
||||
"data-testid": "lp-reinstall-button",
|
||||
"className": "flex-center gap-2 px-4 py-2 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedBundledLanguagePack": "{{row}}",
|
||||
"languagePackReinstallMode": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "language_pack_install_bundled_modal"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fas fa-rotate",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:admin.language_packs.actions.reinstall"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"identifier": "{{_global.selectedBundledLanguagePack?.identifier}}",
|
||||
"auto_activate": false
|
||||
"auto_activate": "{{_global.languagePackReinstallMode === true}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\LanguagePack;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Enums\LanguagePackStatus;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* LanguagePackService::list() 의 드리프트 파생 상태(files_missing) 검증 (이슈 #496 결손 2).
|
||||
*
|
||||
* DB 에 active 로 기록됐으나 설치본 디렉토리(lang-packs/{id})가 부재한 행은
|
||||
* 런타임에 조용히 base locale 로 폴백한다. list() 가 이를 files_missing=true 로 표면화해야 한다.
|
||||
*/
|
||||
class ListDriftDetectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private LanguagePackService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = app(LanguagePackService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects active_row_with_missing_install_dir_flagged_files_missing,
|
||||
* third_party_pack_without_bundle_source_not_offered_for_reinstall
|
||||
*/
|
||||
public function test_active_row_with_missing_install_dir_is_flagged(): void
|
||||
{
|
||||
// 설치본 디렉토리(lang-packs/{id})가 실재하지 않는 식별자로 active 행 생성 → 드리프트
|
||||
$id = 'test-drift-missing-ja';
|
||||
LanguagePack::query()->create([
|
||||
'identifier' => $id,
|
||||
'vendor' => 'test',
|
||||
'scope' => LanguagePackScope::Core->value,
|
||||
'target_identifier' => null,
|
||||
'locale' => 'ja',
|
||||
'locale_name' => 'Japanese',
|
||||
'locale_native_name' => '日本語',
|
||||
'text_direction' => 'ltr',
|
||||
'version' => '1.0.0',
|
||||
'status' => LanguagePackStatus::Active->value,
|
||||
'is_protected' => false,
|
||||
'manifest' => [],
|
||||
'source_type' => 'zip',
|
||||
]);
|
||||
|
||||
$paginator = $this->service->list([], 100);
|
||||
$found = collect($paginator->items())->firstWhere('identifier', $id);
|
||||
|
||||
$this->assertNotNull($found, 'active 행이 목록에 나타나야 함');
|
||||
$this->assertTrue(
|
||||
(bool) $found->getAttribute('files_missing'),
|
||||
'설치본 디렉토리가 없는 active 행은 files_missing=true 여야 함'
|
||||
);
|
||||
|
||||
// 이 식별자는 lang-packs/_bundled/ 에 대응 소스가 없다 → 번들 재설치로 복구할 수 없다.
|
||||
// 화면은 이 플래그로 재설치 버튼 노출을 가르므로, 복구 불가 팩에 버튼이 뜨면 안 된다.
|
||||
$this->assertFalse(
|
||||
(bool) $found->getAttribute('bundled_source_available'),
|
||||
'번들 소스가 없는 팩은 bundled_source_available=false 여야 함'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects virtual_builtin_rows_not_flagged
|
||||
*/
|
||||
public function test_virtual_builtin_rows_are_not_flagged(): void
|
||||
{
|
||||
// 코어 ko/en 가상 보호 행은 lang/{locale}/ 로 항상 존재 → 드리프트 아님
|
||||
$paginator = $this->service->list([], 100);
|
||||
$koPack = collect($paginator->items())
|
||||
->first(fn ($p) => $p->scope === LanguagePackScope::Core->value && $p->locale === 'ko');
|
||||
|
||||
$this->assertNotNull($koPack, '코어 ko 가상 행이 존재해야 함');
|
||||
$this->assertFalse(
|
||||
(bool) $koPack->getAttribute('files_missing'),
|
||||
'가상 보호 행은 files_missing 대상이 아님'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\LanguagePack;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Enums\LanguagePackStatus;
|
||||
use App\Models\LanguagePack;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* language-pack:list 커맨드 회귀 테스트 (이슈 #496 결손 2 — B1).
|
||||
*
|
||||
* 커맨드가 repository->paginate() 직접 호출에서 Service::list() 경유로 전환되어,
|
||||
* 설치된 DB 행뿐 아니라 (1) 번들에 있으나 미설치(uninstalled) 및 (2) DB active 인데
|
||||
* 설치본 파일 부재(드리프트)를 함께 표면화하는지 출력으로 검증한다.
|
||||
*
|
||||
* repository->paginate() 로 되돌리면 미설치/드리프트가 출력에서 사라져 이 테스트가 회귀를 잡는다.
|
||||
*
|
||||
* Symfony Table 출력은 PendingCommand::expectsOutputToContain 으로 신뢰성 있게 캡처되지 않으므로
|
||||
* Artisan::call() + Artisan::output() 로 렌더된 원문을 직접 검사한다.
|
||||
*/
|
||||
class ListLanguagePackCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @effects cli_list_surfaces_uninstalled_and_drift
|
||||
*/
|
||||
public function test_lists_drift_row_with_files_missing_marker(): void
|
||||
{
|
||||
// 설치본 디렉토리(lang-packs/{id})가 실재하지 않는 active 행 → 드리프트
|
||||
$id = 'test-drift-missing-ja';
|
||||
LanguagePack::query()->create([
|
||||
'identifier' => $id,
|
||||
'vendor' => 'test',
|
||||
'scope' => LanguagePackScope::Core->value,
|
||||
'target_identifier' => null,
|
||||
'locale' => 'ja',
|
||||
'locale_name' => 'Japanese',
|
||||
'locale_native_name' => '日本語',
|
||||
'text_direction' => 'ltr',
|
||||
'version' => '1.0.0',
|
||||
'status' => LanguagePackStatus::Active->value,
|
||||
'is_protected' => false,
|
||||
'manifest' => [],
|
||||
'source_type' => 'zip',
|
||||
]);
|
||||
|
||||
$exit = Artisan::call('language-pack:list');
|
||||
$out = Artisan::output();
|
||||
|
||||
$this->assertSame(0, $exit);
|
||||
$this->assertStringContainsString($id, $out, '드리프트 행이 목록에 나타나야 함');
|
||||
$this->assertStringContainsString('파일 없음', $out, 'active + 설치본 부재는 "(파일 없음)" 으로 표기되어야 함');
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects cli_list_surfaces_uninstalled_and_drift
|
||||
*/
|
||||
public function test_lists_uninstalled_bundle_pack(): void
|
||||
{
|
||||
// RefreshDatabase 상태 → 설치된 팩 없음. 번들 소스(g7-core-ja)는 uninstalled 가상행으로 노출.
|
||||
$exit = Artisan::call('language-pack:list');
|
||||
$out = Artisan::output();
|
||||
|
||||
$this->assertSame(0, $exit);
|
||||
$this->assertStringContainsString('g7-core-ja', $out, '미설치 번들 팩이 목록에 나타나야 함');
|
||||
$this->assertStringContainsString('uninstalled', $out, '미설치 번들은 uninstalled 상태로 표기되어야 함');
|
||||
}
|
||||
|
||||
public function test_scope_filter_limits_output(): void
|
||||
{
|
||||
$exit = Artisan::call('language-pack:list', ['--scope' => 'core']);
|
||||
$out = Artisan::output();
|
||||
|
||||
$this->assertSame(0, $exit);
|
||||
// core 스코프만 → 모듈/플러그인/템플릿 팩은 제외
|
||||
$this->assertStringContainsString('g7-core-ja', $out);
|
||||
$this->assertStringNotContainsString('sirsoft-module-sirsoft-board', $out, 'core 스코프에 모듈 팩이 섞이면 안 됨');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\LanguagePack;
|
||||
|
||||
use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `language-pack:provision` 커맨드 Feature 테스트.
|
||||
*
|
||||
* 파일시스템 설치를 피하기 위해 LanguagePackService 를 목킹하여 커맨드의
|
||||
* 로케일/스코프 필터, 차단 사유 skip, 드리프트 복구, 멱등성(후보 없음 → 설치 0) 을 검증한다.
|
||||
*
|
||||
* 시나리오 매트릭스: tests/scenarios/language-pack-provision-and-drift.yaml
|
||||
*/
|
||||
class ProvisionLanguagePackCommandTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 미설치 번들 가상 팩을 만든다.
|
||||
*
|
||||
* @param string $identifier 번들 식별자
|
||||
* @param string $locale 로케일
|
||||
* @param string $scope 스코프 (core/module/plugin/template)
|
||||
* @param string|null $blockedReason 설치 차단 사유
|
||||
* @return LanguagePack 미설치 가상 팩
|
||||
*/
|
||||
private function bundledPack(string $identifier, string $locale, string $scope = 'core', ?string $blockedReason = null): LanguagePack
|
||||
{
|
||||
$pack = new LanguagePack;
|
||||
$pack->identifier = $identifier;
|
||||
$pack->locale = $locale;
|
||||
$pack->scope = $scope;
|
||||
$pack->version = '1.0.0';
|
||||
$pack->status = 'uninstalled';
|
||||
$pack->setAttribute('bundled_identifier', $identifier);
|
||||
$pack->setAttribute('install_blocked_reason', $blockedReason);
|
||||
$pack->exists = false;
|
||||
|
||||
return $pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드리프트(설치 행 존재 + 설치본 파일 부재) 팩을 만든다.
|
||||
*
|
||||
* @param string $identifier 식별자
|
||||
* @param string $locale 로케일
|
||||
* @param string $scope 스코프
|
||||
* @return LanguagePack 드리프트 설치 행
|
||||
*/
|
||||
private function driftedPack(string $identifier, string $locale, string $scope = 'core'): LanguagePack
|
||||
{
|
||||
$pack = new LanguagePack;
|
||||
$pack->identifier = $identifier;
|
||||
$pack->locale = $locale;
|
||||
$pack->scope = $scope;
|
||||
$pack->version = '1.0.0';
|
||||
$pack->status = 'active';
|
||||
$pack->setAttribute('files_missing', true);
|
||||
$pack->setAttribute('bundled_source_available', true);
|
||||
$pack->exists = true;
|
||||
|
||||
return $pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* installFromBundled 가 반환할 설치 완료 팩 스텁.
|
||||
*
|
||||
* @param string $identifier 식별자
|
||||
* @param string $locale 로케일
|
||||
* @return LanguagePack 설치 완료 팩
|
||||
*/
|
||||
private function installedResult(string $identifier, string $locale): LanguagePack
|
||||
{
|
||||
$pack = new LanguagePack;
|
||||
$pack->identifier = $identifier;
|
||||
$pack->locale = $locale;
|
||||
$pack->version = '1.0.0';
|
||||
$pack->status = 'active';
|
||||
|
||||
return $pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service 목을 만들어 컨테이너에 바인딩한다.
|
||||
*
|
||||
* @param array<int, LanguagePack> $uninstalled 미설치 번들 후보
|
||||
* @param array<int, LanguagePack> $drifted 드리프트 복구 후보
|
||||
* @return MockInterface Service 목
|
||||
*/
|
||||
private function mockService(array $uninstalled = [], array $drifted = [])
|
||||
{
|
||||
$service = Mockery::mock(LanguagePackService::class);
|
||||
$service->shouldReceive('getUninstalledBundledPacks')->andReturn(new Collection($uninstalled));
|
||||
$service->shouldReceive('getDriftedInstalledPacks')->andReturn(new Collection($drifted));
|
||||
$this->app->instance(LanguagePackService::class, $service);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
// ─── 미설치 → 신규 프로비저닝 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=uninstalled, scope=core
|
||||
*
|
||||
* @effects provisions_only_supported_non_base_locales
|
||||
*/
|
||||
public function test_provisions_only_supported_non_base_locales(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
$service = $this->mockService([
|
||||
$this->bundledPack('g7-core-ja', 'ja'),
|
||||
$this->bundledPack('g7-core-fr', 'fr'), // supported_locales 에 없음 → 설치 대상 아님
|
||||
]);
|
||||
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-core-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-core-ja', 'ja'));
|
||||
$service->shouldNotReceive('installFromBundled')->with('g7-core-fr', Mockery::any());
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=uninstalled, scope=extension
|
||||
*
|
||||
* @effects provisions_only_supported_non_base_locales
|
||||
*/
|
||||
public function test_provisions_extension_scoped_bundled_pack(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
$service = $this->mockService([
|
||||
$this->bundledPack('g7-module-sirsoft-board-ja', 'ja', 'module'),
|
||||
]);
|
||||
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-module-sirsoft-board-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-module-sirsoft-board-ja', 'ja'));
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=uninstalled, scope=core
|
||||
*
|
||||
* @effects explicit_locale_option_overrides_supported_locales
|
||||
*/
|
||||
public function test_explicit_locale_option_overrides_supported_locales(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko']]); // supported 에 ja 없음
|
||||
|
||||
$service = $this->mockService([
|
||||
$this->bundledPack('g7-core-ja', 'ja'),
|
||||
]);
|
||||
|
||||
// --locale=ja 명시 시 supported 에 없어도 설치
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-core-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-core-ja', 'ja'));
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja']])->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=uninstalled, scope=extension
|
||||
*
|
||||
* @effects explicit_locale_option_overrides_supported_locales, scope_filter_limits_provision_targets
|
||||
*/
|
||||
public function test_scope_option_limits_explicit_locale_targets(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko']]);
|
||||
|
||||
$service = $this->mockService([
|
||||
$this->bundledPack('g7-core-ja', 'ja'),
|
||||
$this->bundledPack('g7-module-sirsoft-board-ja', 'ja', 'module'),
|
||||
]);
|
||||
|
||||
// --scope=module 이면 코어 팩은 대상에서 빠진다
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-module-sirsoft-board-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-module-sirsoft-board-ja', 'ja'));
|
||||
$service->shouldNotReceive('installFromBundled')->with('g7-core-ja', Mockery::any());
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja'], '--scope' => ['module']])
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
// ─── 이미 설치됨 → 멱등 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=installed_active, scope=core
|
||||
*
|
||||
* @effects is_idempotent_when_no_uninstalled_candidates
|
||||
*/
|
||||
public function test_is_idempotent_when_no_uninstalled_candidates(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
// 이미 전부 설치됨 → 미설치 후보 없음, 드리프트도 없음
|
||||
$service = $this->mockService();
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=installed_active, scope=extension
|
||||
*
|
||||
* @effects is_idempotent_when_no_uninstalled_candidates
|
||||
*/
|
||||
public function test_is_idempotent_for_installed_extension_packs(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
$service = $this->mockService();
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
|
||||
$this->artisan('language-pack:provision', ['--scope' => ['module']])->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=installed_active, scope=core
|
||||
*
|
||||
* @effects is_idempotent_when_no_uninstalled_candidates
|
||||
*/
|
||||
public function test_explicit_locale_is_idempotent_for_installed_core_pack(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en']]);
|
||||
|
||||
$service = $this->mockService();
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja']])->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=installed_active, scope=extension
|
||||
*
|
||||
* @effects is_idempotent_when_no_uninstalled_candidates
|
||||
*/
|
||||
public function test_explicit_locale_is_idempotent_for_installed_extension_pack(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en']]);
|
||||
|
||||
$service = $this->mockService();
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja'], '--scope' => ['module']])
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
// ─── 설치 차단 → best-effort skip ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=install_blocked, scope=extension
|
||||
*
|
||||
* @effects skips_install_blocked_packs
|
||||
*/
|
||||
public function test_skips_blocked_packs(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
$service = $this->mockService([
|
||||
$this->bundledPack('g7-module-board-ja', 'ja', 'module', 'target_not_installed'),
|
||||
]);
|
||||
// 차단 사유가 있으면 설치 시도 자체를 하지 않는다
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=install_blocked, scope=extension
|
||||
*
|
||||
* @effects skips_install_blocked_packs
|
||||
*/
|
||||
public function test_skips_blocked_packs_with_explicit_locale(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko']]);
|
||||
|
||||
$service = $this->mockService([
|
||||
$this->bundledPack('g7-plugin-gdpr-ja', 'ja', 'plugin', 'target_inactive'),
|
||||
]);
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja']])->assertExitCode(0);
|
||||
}
|
||||
|
||||
// ─── 드리프트 → 복구 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=drifted_missing_files, scope=core
|
||||
*
|
||||
* @effects drifted_pack_repaired_by_provision
|
||||
*/
|
||||
public function test_repairs_drifted_core_pack(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
// 미설치 후보는 없지만 드리프트 행이 있다 — 슬롯이 점유돼 있어 미설치 목록엔 안 잡힌다
|
||||
$service = $this->mockService([], [
|
||||
$this->driftedPack('g7-core-ja', 'ja'),
|
||||
]);
|
||||
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-core-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-core-ja', 'ja'));
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared, pack_state=drifted_missing_files, scope=extension
|
||||
*
|
||||
* @effects drifted_pack_repaired_by_provision
|
||||
*/
|
||||
public function test_repairs_drifted_extension_pack(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en', 'ja']]);
|
||||
|
||||
$service = $this->mockService([], [
|
||||
$this->driftedPack('g7-module-sirsoft-board-ja', 'ja', 'module'),
|
||||
]);
|
||||
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-module-sirsoft-board-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-module-sirsoft-board-ja', 'ja'));
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=drifted_missing_files, scope=core
|
||||
*
|
||||
* @effects drifted_pack_repaired_by_provision, explicit_locale_option_overrides_supported_locales
|
||||
*/
|
||||
public function test_repairs_drifted_core_pack_with_explicit_locale(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko']]); // 선언에 없어도 명시하면 복구
|
||||
|
||||
$service = $this->mockService([], [
|
||||
$this->driftedPack('g7-core-ja', 'ja'),
|
||||
]);
|
||||
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-core-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-core-ja', 'ja'));
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja']])->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared, pack_state=drifted_missing_files, scope=extension
|
||||
*
|
||||
* @effects drifted_pack_repaired_by_provision, scope_filter_limits_provision_targets
|
||||
*/
|
||||
public function test_repairs_drifted_extension_pack_within_scope_filter(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko']]);
|
||||
|
||||
$service = $this->mockService([], [
|
||||
$this->driftedPack('g7-core-ja', 'ja'),
|
||||
$this->driftedPack('g7-module-sirsoft-board-ja', 'ja', 'module'),
|
||||
]);
|
||||
|
||||
$service->shouldReceive('installFromBundled')
|
||||
->with('g7-module-sirsoft-board-ja', true)
|
||||
->once()
|
||||
->andReturn($this->installedResult('g7-module-sirsoft-board-ja', 'ja'));
|
||||
$service->shouldNotReceive('installFromBundled')->with('g7-core-ja', Mockery::any());
|
||||
|
||||
$this->artisan('language-pack:provision', ['--locale' => ['ja'], '--scope' => ['module']])
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
// ─── base locale 만 선언 → 대상 없음 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=base_only, pack_state=uninstalled, scope=core
|
||||
*
|
||||
* @effects does_nothing_when_no_non_base_locale_declared
|
||||
*/
|
||||
public function test_does_nothing_when_no_non_base_locale_declared(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en']]);
|
||||
|
||||
$service = Mockery::mock(LanguagePackService::class);
|
||||
// 대상 로케일이 없으므로 후보 조회조차 하지 않아야 한다
|
||||
$service->shouldNotReceive('getUninstalledBundledPacks');
|
||||
$service->shouldNotReceive('getDriftedInstalledPacks');
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
$this->app->instance(LanguagePackService::class, $service);
|
||||
|
||||
$this->artisan('language-pack:provision')->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=base_only, pack_state=uninstalled, scope=extension
|
||||
*
|
||||
* @effects does_nothing_when_no_non_base_locale_declared
|
||||
*/
|
||||
public function test_does_nothing_for_extension_scope_when_base_only(): void
|
||||
{
|
||||
config(['app.supported_locales' => ['ko', 'en']]);
|
||||
|
||||
$service = Mockery::mock(LanguagePackService::class);
|
||||
$service->shouldNotReceive('getUninstalledBundledPacks');
|
||||
$service->shouldNotReceive('getDriftedInstalledPacks');
|
||||
$service->shouldNotReceive('installFromBundled');
|
||||
$this->app->instance(LanguagePackService::class, $service);
|
||||
|
||||
$this->artisan('language-pack:provision', ['--scope' => ['module']])->assertExitCode(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* E2E: 관리자 언어팩 목록의 드리프트(설치본 파일 부재) 발견 + 원클릭 복구 — 이슈 #496 Part B3
|
||||
*
|
||||
* 배경: DB 에 active 로 기록됐지만 설치본 디렉토리(`lang-packs/{id}/`)가 사라진 팩은 런타임에
|
||||
* 오류 없이 base locale(ko)로 조용히 폴백한다. 목록에는 정상(active)으로 보였기 때문에 운영자가
|
||||
* "번역이 적용되지 않는다"는 증상만 보고 원인을 찾지 못했다(#483 실측). 화면에 `파일 없음` 배지와
|
||||
* 재설치 버튼을 노출해 발견·복구가 한 화면에서 끝나게 한다.
|
||||
*
|
||||
* 계층 분담:
|
||||
* - files_missing / bundled_source_available 파생 판정 → PHPUnit (ListDriftDetectionTest)
|
||||
* - 배지/버튼 조건식 평가 → Vitest (admin-language-pack-drift.test.tsx, createLayoutTest)
|
||||
* - 본 spec → 실제 브라우저에서 목록 화면이 뜨고 조건이 라이브 데이터에 걸리는지 최종 확증
|
||||
*
|
||||
* @scenario target_locale_source=supported_locales_default, locale_membership=non_base_declared,
|
||||
* pack_state=drifted_missing_files, scope=core
|
||||
* @effects admin_list_shows_drift_badge_and_reinstall,
|
||||
* active_row_with_missing_install_dir_flagged_files_missing
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
// 표시 문구는 로케일에 따라 바뀐다(테스트 브라우저는 en 으로 렌더될 수 있다).
|
||||
// 로케일 무관하게 잠그기 위해 레이아웃의 data-testid 로 집는다.
|
||||
/** 드리프트 배지 test id */
|
||||
const DRIFT_BADGE = 'lp-drift-badge';
|
||||
|
||||
/** 재설치 버튼 test id */
|
||||
const REINSTALL_BUTTON = 'lp-reinstall-button';
|
||||
|
||||
test.describe('관리자 언어팩 목록 — 드리프트 발견', () => {
|
||||
test('배지·재설치 버튼 노출이 API 가 내려준 드리프트 상태와 일치한다', async ({ page }) => {
|
||||
const token = issueToken('core.language_packs.read', 'core.language_packs.install');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
// 뷰포트가 좁으면 행 액션 버튼이 카드 우측에서 화면 밖으로 밀린다.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
const listResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/admin/language-packs') && r.status() === 200,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.goto('/admin/settings?tab=language_packs');
|
||||
const rows = (await (await listResponse).json())?.data?.data ?? [];
|
||||
|
||||
// 목록이 비어 있으면 어떤 단언도 의미가 없다 — 화면/권한 회귀를 여기서 잡는다.
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
|
||||
// 화면에 떠야 할 배지/버튼 수를 응답에서 직접 계산한다.
|
||||
// "0건이어야 한다" 만 단언하면 아직 안 그려진 상태에서도 통과해(vacuous) 실제 결손을 놓친다.
|
||||
const expectedBadges = rows.filter((r: { files_missing?: boolean }) => r.files_missing === true).length;
|
||||
const expectedButtons = rows.filter(
|
||||
(r: { files_missing?: boolean; bundled_source_available?: boolean; abilities?: { can_install?: boolean } }) =>
|
||||
r.files_missing === true && r.bundled_source_available === true && r.abilities?.can_install === true,
|
||||
).length;
|
||||
|
||||
// 카드가 실제로 그려진 뒤에 세어야 한다. 앵커는 UI 문구가 아니라 응답 데이터(원어 언어명)라
|
||||
// 표시 로케일이 ko 든 en 이든 동일하게 걸린다. 배지는 카드와 같은 렌더 패스에서 결정되므로,
|
||||
// 카드가 보이는 시점에는 배지 노출 여부도 이미 확정돼 있다.
|
||||
const nativeName = rows[0]?.locale_native_name ?? '';
|
||||
if (nativeName) {
|
||||
await expect(page.getByText(nativeName, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => page.getByTestId(DRIFT_BADGE).count(), { timeout: 20_000 })
|
||||
.toBe(expectedBadges);
|
||||
await expect
|
||||
.poll(() => page.getByTestId(REINSTALL_BUTTON).count(), { timeout: 20_000 })
|
||||
.toBe(expectedButtons);
|
||||
});
|
||||
|
||||
/**
|
||||
* 드리프트 상태 주입에는 서버 파일시스템 조작(설치본 디렉토리 삭제)이 필요하다.
|
||||
* 브라우저에서는 만들 수 없는 상태이므로, 아래는 드리프트를 주입한 환경에서만 활성화한다.
|
||||
*
|
||||
* 활성화 절차:
|
||||
* 1. `php artisan language-pack:provision --locale=ja` 로 g7-core-ja 설치
|
||||
* 2. `lang-packs/g7-core-ja/` 디렉토리 삭제 (DB active 행은 남김)
|
||||
* 3. `test.describe.skip` → `test.describe` 로 전환
|
||||
*
|
||||
* 복구 확인 후에는 `php artisan language-pack:provision --locale=ja` 로 원상 복구된다.
|
||||
*
|
||||
* @scenario target_locale_source=explicit_locale_option, locale_membership=non_base_declared,
|
||||
* pack_state=drifted_missing_files, scope=core
|
||||
* @effects admin_list_shows_drift_badge_and_reinstall, drifted_pack_repaired_by_provision
|
||||
*/
|
||||
test.describe.skip('드리프트 주입 환경 (수동 활성화)', () => {
|
||||
test('드리프트 행에 배지와 재설치 버튼이 뜨고, 재설치로 복구된다', async ({ page }) => {
|
||||
const token = issueToken('core.language_packs.read', 'core.language_packs.install');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
// 행 액션 버튼은 카드 우측에 놓여 좁은 뷰포트에서 화면 밖으로 밀린다 — 넓게 잡는다.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
const listResponse = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/admin/language-packs') && r.status() === 200,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.goto('/admin/settings?tab=language_packs');
|
||||
await listResponse;
|
||||
|
||||
await expect(page.getByTestId(DRIFT_BADGE).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const reinstall = page.getByTestId(REINSTALL_BUTTON).first();
|
||||
await expect(reinstall).toBeVisible();
|
||||
await reinstall.click();
|
||||
|
||||
// 재설치는 번들 설치 모달(language_pack_install_bundled_modal)을 재사용한다
|
||||
const confirm = page.locator('button:visible', { hasText: /^설치$/ }).last();
|
||||
await expect(confirm).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
const install = page.waitForResponse(
|
||||
(r) => r.url().includes('install-from-bundled') && r.request().method() === 'POST',
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await confirm.click();
|
||||
expect((await install).status()).toBe(201);
|
||||
|
||||
// 복구 후에는 배지와 재설치 버튼이 사라진다
|
||||
await expect(page.getByTestId(DRIFT_BADGE)).toHaveCount(0, { timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
feature: 번들 언어팩 멱등 프로비저닝 + 드리프트 발견 (이슈 #496)
|
||||
|
||||
description: |
|
||||
비-base 로케일(ja 등) 번들 언어팩이 신규 설치/복구 시 자동으로 채워지지 않아 런타임에
|
||||
오류 없이 ko 로 조용히 폴백하던 문제를, 멱등 프로비저닝 커맨드(SSoT) + 설치 후 발견성
|
||||
(미설치/드리프트 표면화)으로 해소하는 시나리오.
|
||||
|
||||
배경:
|
||||
- ko/en 은 가상 보호 행으로 항상 서빙되나, ja 등은 설치본 디렉토리(gitignore)로 등록돼야 서빙.
|
||||
- language-pack:provision 은 supported_locales ∖ {ko,en} 의 미설치 번들 팩을 멱등 설치하고,
|
||||
설치 행은 있으나 파일이 사라진 드리프트 팩도 함께 복구한다(수동 복구 경로 겸용).
|
||||
- list()/Resource 는 파생 플래그 files_missing(active 인데 설치본 부재)과
|
||||
bundled_source_available(번들 소스로 복구 가능)을 부여해 드리프트 표면화 + 복구 가능 여부를 구분한다.
|
||||
- A2(인스톨러 기본 선택)는 admin_language 가 ko/en 한정이라 no-op → PO 결정으로 제외.
|
||||
|
||||
axes:
|
||||
target_locale_source: [supported_locales_default, explicit_locale_option]
|
||||
locale_membership: [non_base_declared, base_only]
|
||||
pack_state: [uninstalled, installed_active, drifted_missing_files, install_blocked]
|
||||
scope: [core, extension]
|
||||
|
||||
exclusions:
|
||||
- { target_locale_source: explicit_locale_option, locale_membership: base_only, reason: "--locale 은 base 를 필터로 제외 — 명시 지정으로 base 가 대상이 되는 경로 자체가 없음" }
|
||||
- { locale_membership: base_only, pack_state: installed_active, reason: "base 만 선언하면 비-base 팩이 후보에 오르지 않아 팩 상태가 결과를 바꾸지 않음 — uninstalled 대표 케이스로 수렴" }
|
||||
- { locale_membership: base_only, pack_state: drifted_missing_files, reason: "동일 — base 선언 하에서는 드리프트 복구 대상도 산출되지 않음" }
|
||||
- { locale_membership: base_only, pack_state: install_blocked, reason: "동일 — 차단 판정 이전에 대상 로케일이 비어 후보 조회조차 하지 않음" }
|
||||
- { pack_state: install_blocked, scope: core, reason: "코어 팩은 대상 확장이 없어 install_blocked 사유(target_not_installed/target_inactive)가 발생하지 않음" }
|
||||
|
||||
effects:
|
||||
- provisions_only_supported_non_base_locales
|
||||
- is_idempotent_when_no_uninstalled_candidates
|
||||
- skips_install_blocked_packs
|
||||
- does_nothing_when_no_non_base_locale_declared
|
||||
- explicit_locale_option_overrides_supported_locales
|
||||
- scope_filter_limits_provision_targets
|
||||
- drifted_pack_repaired_by_provision
|
||||
- active_row_with_missing_install_dir_flagged_files_missing
|
||||
- virtual_builtin_rows_not_flagged
|
||||
- bundled_source_availability_flagged_independently_of_source_type
|
||||
- cli_list_surfaces_uninstalled_and_drift
|
||||
- admin_list_shows_drift_badge_and_reinstall
|
||||
- third_party_pack_without_bundle_source_not_offered_for_reinstall
|
||||
- supported_locale_without_bundle_source_is_statically_flagged
|
||||
- declared_locale_without_installed_pack_is_statically_flagged
|
||||
|
||||
test_files:
|
||||
- tests/Feature/LanguagePack/ProvisionLanguagePackCommandTest.php
|
||||
- tests/Feature/LanguagePack/ListDriftDetectionTest.php
|
||||
- tests/Feature/LanguagePack/ListLanguagePackCommandTest.php
|
||||
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-language-pack-drift.test.tsx
|
||||
- .claude/scripts/audit/__tests__/rules.test.cjs
|
||||
|
||||
e2e_files:
|
||||
- tests/Playwright/specs/admin/language-pack-drift-discovery.spec.ts
|
||||
|
||||
rules_layer_coverage:
|
||||
- rule: service-direct-data-access
|
||||
coverage: ProvisionLanguagePackCommand 은 LanguagePackService 만 주입 — Model/DB facade 직접 접근 없음
|
||||
- rule: lang-pack-supported-locale-has-bundle
|
||||
coverage: config/app.php supported_locales 비-base 로케일 ↔ g7-core-{locale} 번들 소스 정합 정적 검사
|
||||
- rule: lang-pack-installed-version-drift
|
||||
coverage: 선언 로케일의 설치본 부재(미설치 사각)를 프로비저닝된 환경에 한해 정적 검사
|
||||
- rule: frontend-change-requires-e2e
|
||||
coverage: 관리자 언어팩 목록 partial 변경에 동반하는 language-pack-drift-discovery.spec.ts (템플릿 도메인)
|
||||
|
||||
notes: |
|
||||
드리프트 주입에는 서버 파일시스템 조작(설치본 디렉토리 삭제)이 필요해 브라우저에서 만들 수 없다.
|
||||
따라서 E2E spec 은 (1) 목록 렌더 + 정상 환경에서 배지 0건(오탐 가드)을 라이브로 검증하고,
|
||||
(2) 드리프트 주입 상태의 배지·재설치 흐름은 활성화 절차를 명시한 skip 블록으로 둔다.
|
||||
파생 판정 자체는 PHPUnit, 조건식 평가는 Vitest(createLayoutTest)가 잠근다.
|
||||
Reference in New Issue
Block a user