Files
Gnuboard7/tests/Feature/Services/TemplateServiceLanguageMergeTest.php
T
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00

540 lines
19 KiB
PHP

<?php
namespace Tests\Feature\Services;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\HookManager;
use App\Models\Template;
use App\Repositories\TemplateRepository;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Mockery;
use Tests\TestCase;
class TemplateServiceLanguageMergeTest extends TestCase
{
use RefreshDatabase;
private TemplateService $templateService;
private TemplateRepository $templateRepository;
private TemplateManagerInterface $templateManager;
private ModuleManagerInterface $moduleManager;
private PluginManagerInterface $pluginManager;
/** @var bool 활성 디렉토리가 테스트 전에 이미 존재했는지 (tearDown에서 정리 판단용) */
private bool $boardExistedBefore = false;
protected function setUp(): void
{
parent::setUp();
// 모듈 다국어 파일 테스트를 위해 _bundled에서 활성 디렉토리로 복사
$activePath = base_path('modules/sirsoft-board');
$bundledPath = base_path('modules/_bundled/sirsoft-board');
$this->boardExistedBefore = File::isDirectory($activePath);
if (! $this->boardExistedBefore && File::isDirectory($bundledPath)) {
File::copyDirectory($bundledPath, $activePath);
}
// TemplateManager Mock 생성
$this->templateManager = Mockery::mock(TemplateManagerInterface::class);
$this->templateManager->shouldReceive('loadTemplates')
->zeroOrMoreTimes()
->andReturnNull();
$this->templateManager->shouldReceive('ensureLoaded')
->zeroOrMoreTimes()
->andReturnNull();
// ModuleManager Mock 생성
$this->moduleManager = Mockery::mock(ModuleManagerInterface::class);
// PluginManager Mock 생성
$this->pluginManager = Mockery::mock(PluginManagerInterface::class);
// TemplateRepository 인스턴스 생성
$this->templateRepository = new TemplateRepository;
// TemplateService 인스턴스 생성
$this->templateService = new TemplateService(
$this->templateRepository,
$this->templateManager,
$this->moduleManager,
$this->pluginManager,
app(LayoutVersionRepositoryInterface::class)
);
}
protected function tearDown(): void
{
// 테스트에서 생성한 활성 디렉토리만 정리 (기존에 있었으면 건드리지 않음)
if (! $this->boardExistedBefore) {
$activePath = base_path('modules/sirsoft-board');
if (File::isDirectory($activePath)) {
File::deleteDirectory($activePath);
}
}
Mockery::close();
parent::tearDown();
}
/**
* 템플릿 다국어 데이터가 올바르게 반환되는지 테스트
*/
public function test_get_language_data_returns_template_data(): void
{
// Arrange
$identifier = 'sirsoft-admin_basic';
$locale = 'ko';
// 템플릿 DB 레코드 생성
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
// TemplateManager Mock 설정
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
// 모듈/플러그인이 없는 경우
$this->moduleManager
->shouldReceive('getActiveModules')
->andReturn([]);
$this->pluginManager
->shouldReceive('getActivePlugins')
->andReturn([]);
// Act
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// Assert
$this->assertTrue($result['success']);
$this->assertIsArray($result['data']);
$this->assertArrayHasKey('auth', $result['data']);
$this->assertArrayHasKey('admin', $result['data']);
}
/**
* 모듈 다국어 데이터가 템플릿 데이터와 병합되는지 테스트
*/
public function test_module_language_data_is_merged_with_template_data(): void
{
// Arrange
$identifier = 'sirsoft-admin_basic';
$locale = 'ko';
// 템플릿 DB 레코드 생성
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
// TemplateManager Mock 설정
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
// 활성화된 모듈 Mock 생성
$mockModule = Mockery::mock(ModuleInterface::class);
$mockModule->shouldReceive('getIdentifier')
->andReturn('sirsoft-board');
$this->moduleManager
->shouldReceive('getActiveModules')
->andReturn(['sirsoft-board' => $mockModule]);
$this->pluginManager
->shouldReceive('getActivePlugins')
->andReturn([]);
// Act
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// Assert
$this->assertTrue($result['success']);
$this->assertIsArray($result['data']);
// 템플릿 데이터
$this->assertArrayHasKey('auth', $result['data']);
// 모듈 데이터 (sirsoft-board 키로 병합)
$this->assertArrayHasKey('sirsoft-board', $result['data']);
$this->assertArrayHasKey('messages', $result['data']['sirsoft-board']);
$this->assertArrayHasKey('boards', $result['data']['sirsoft-board']['messages']);
}
/**
* 플러그인 다국어 데이터도 병합되는지 테스트
*/
public function test_plugin_language_data_is_merged(): void
{
// Arrange
$identifier = 'sirsoft-admin_basic';
$locale = 'ko';
// 템플릿 DB 레코드 생성
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
// TemplateManager Mock 설정
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
$this->moduleManager
->shouldReceive('getActiveModules')
->andReturn([]);
// 활성화된 플러그인 Mock (다국어 파일이 없는 경우)
$mockPlugin = Mockery::mock(PluginInterface::class);
$mockPlugin->shouldReceive('getIdentifier')
->andReturn('sirsoft-analytics');
$this->pluginManager
->shouldReceive('getActivePlugins')
->andReturn(['sirsoft-analytics' => $mockPlugin]);
// Act
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// Assert
$this->assertTrue($result['success']);
$this->assertIsArray($result['data']);
// 플러그인 다국어 파일이 없으면 키가 추가되지 않음
$this->assertArrayNotHasKey('sirsoft-analytics', $result['data']);
}
/**
* 존재하지 않는 템플릿에 대한 에러 처리 테스트
*/
public function test_returns_error_for_nonexistent_template(): void
{
// Arrange
$identifier = 'nonexistent-template';
$locale = 'ko';
// Act
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// Assert
$this->assertFalse($result['success']);
$this->assertEquals('template_not_found', $result['error']);
}
/**
* 지원하지 않는 로케일에 대한 에러 처리 테스트
*/
public function test_returns_error_for_unsupported_locale(): void
{
// Arrange
$identifier = 'sirsoft-admin_basic';
$locale = 'jp';
// 템플릿 DB 레코드 생성
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
// TemplateManager Mock 설정 (jp를 지원하지 않음)
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
// Act
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// Assert
$this->assertFalse($result['success']);
$this->assertEquals('locale_not_supported', $result['error']);
}
/**
* 병합된 데이터에서 모듈 식별자가 키로 사용되는지 테스트
*/
public function test_module_identifier_is_used_as_key(): void
{
// Arrange
$identifier = 'sirsoft-admin_basic';
$locale = 'ko';
// 템플릿 DB 레코드 생성
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
// 활성화된 모듈 Mock 생성
$mockModule = Mockery::mock(ModuleInterface::class);
$mockModule->shouldReceive('getIdentifier')
->andReturn('sirsoft-board');
$this->moduleManager
->shouldReceive('getActiveModules')
->andReturn(['sirsoft-board' => $mockModule]);
$this->pluginManager
->shouldReceive('getActivePlugins')
->andReturn([]);
// Act
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// Assert
$this->assertTrue($result['success']);
// 모듈 식별자가 키로 사용됨
$this->assertArrayHasKey('sirsoft-board', $result['data']);
// 모듈 내부 데이터 접근 (sirsoft-board의 messages.boards.menu_added_success)
$this->assertEquals(
'관리자 메뉴에 추가되었습니다.',
$result['data']['sirsoft-board']['messages']['boards']['menu_added_success']
);
}
/**
* 코어 프론트엔드 다국어 자원(`lang/{locale}.json`)이 베이스 레이어로 병합되어
* 어떤 템플릿이 부팅되든 `core.*` 키가 응답에 포함되는지 검증.
*/
public function test_core_frontend_lang_data_is_loaded_as_base_layer(): void
{
$identifier = 'sirsoft-admin_basic';
$locale = 'ko';
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([]);
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
$this->assertTrue($result['success']);
$this->assertIsArray($result['data']);
// 코어 프론트엔드 키가 베이스로 병합됨
$this->assertArrayHasKey('core', $result['data']);
$this->assertArrayHasKey('errors', $result['data']['core']);
// 한국어 번역 확인
$this->assertEquals(
'활성화된 템플릿이 없습니다',
$result['data']['core']['errors']['template_not_found']
);
// 템플릿 키도 동시에 존재
$this->assertArrayHasKey('auth', $result['data']);
}
/**
* en 로케일에서도 코어 프론트엔드 키가 영어로 해석되는지 검증.
*/
public function test_core_frontend_lang_data_resolved_in_english_locale(): void
{
$identifier = 'sirsoft-admin_basic';
$locale = 'en';
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([]);
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
$this->assertTrue($result['success']);
$this->assertEquals(
'No active template found',
$result['data']['core']['errors']['template_not_found']
);
}
/**
* 결함 A 회귀 테스트.
*
* 코어의 `layout_editor.chrome.*` 가 살아남으면서 sirsoft-basic 템플릿이 자체
* 정의한 `layout_editor.palette.*` 도 동시에 노출되어야 한다. 과거 array_merge
* shallow 는 템플릿 partial 의 `layout_editor` 가 코어 `layout_editor` 를 통째
* 덮어써서 chrome/device/zoom/save 키가 누락되었다.
*/
public function test_deep_merge_preserves_core_layout_editor_chrome_when_template_defines_palette(): void
{
$identifier = 'sirsoft-basic';
$locale = 'ko';
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Basic',
'type' => 'user',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([]);
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
$this->assertTrue($result['success']);
$this->assertIsArray($result['data']);
$this->assertArrayHasKey('layout_editor', $result['data']);
// 코어의 chrome / device / zoom / preview / save 키가 모두 살아남아야 함
$layoutEditor = $result['data']['layout_editor'];
$this->assertArrayHasKey('chrome', $layoutEditor, 'core layout_editor.chrome 누락');
$this->assertArrayHasKey('device', $layoutEditor, 'core layout_editor.device 누락');
$this->assertArrayHasKey('zoom', $layoutEditor, 'core layout_editor.zoom 누락');
$this->assertArrayHasKey('preview', $layoutEditor, 'core layout_editor.preview 누락');
$this->assertArrayHasKey('save', $layoutEditor, 'core layout_editor.save 누락');
// 동시에 템플릿이 정의한 palette 트리도 그대로 노출
$this->assertArrayHasKey('palette', $layoutEditor, 'template layout_editor.palette 누락');
$this->assertIsArray($layoutEditor['palette']);
}
/**
* deep merge — 양쪽이 assoc 일 때 leaf 병합 + 충돌 leaf 는 뒤가 우선.
*/
public function test_deep_merge_recursive_leaf_resolution(): void
{
$identifier = 'sirsoft-admin_basic';
$locale = 'ko';
Template::create([
'identifier' => $identifier,
'vendor' => 'sirsoft',
'name' => 'Admin Basic',
'type' => 'admin',
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
]);
$this->templateManager
->shouldReceive('getTemplateInfo')
->with($identifier)
->andReturn([
'identifier' => $identifier,
'locales' => ['ko', 'en'],
]);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([]);
// template.language.merge 훅으로 인공적인 deep merge 검증 입력 합류
$overrideCallback = function ($data) {
return array_replace_recursive($data, [
'core' => [
'errors' => [
'template_not_found' => '<<HOOK_OVERRIDE>>',
],
],
]);
};
HookManager::addFilter('template.language.merge', $overrideCallback);
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
HookManager::removeFilter('template.language.merge', $overrideCallback);
$this->assertTrue($result['success']);
$this->assertEquals(
'<<HOOK_OVERRIDE>>',
$result['data']['core']['errors']['template_not_found']
);
// 동시에 같은 core.errors 트리의 다른 leaf 는 코어 값이 살아남아야 함
// (deep merge 검증 — 충돌 leaf 만 override, 동기 트리의 다른 형제는 보존)
$this->assertArrayHasKey('layout_load_failed', $result['data']['core']['errors']);
}
}