https://github.com/gnuboard/g7/issues/125 — 상품 상세설명을 HTML 로 저장할 때 HTMLPurifier 가 모듈 vendor 폴더 안에 정의 캐시를 만들려다 실패해 저장이 매번 500 으로 끝나던 문제를 고친다. vendor 를 읽기 전용으로 두는 표준 배포에서 그 쓰기는 예외가 아니라 PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시킨다. 캐시는 설정 해시당 1회만 기록되므로 캐시가 영영 생기지 않아 재시도해도 같은 결과였다. 캐시 경로를 storage 아래로 옮기고, 그 경로마저 확보하지 못하면 캐시만 끄고 정화는 그대로 수행한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 전자의 실패가 후자를 건너뛰게 만들면 안 된다. 저장은 성공하므로 운영자에게 도달하는 흔적이 로그 하나뿐이라 error 수준으로 남긴다 (출하 기본 로그 수준이 error 라 warning 은 기본 설치 상태에서 파일에 남지 않는다). 그 과정에서 갈라져 있던 두 축을 코어 한 곳으로 모은다. - 쓰기 디렉토리 확보: 억제 생성·chmod·setgid·소유권 상속·쓰기 판정 절차가 정적 게시와 정의 캐시 두 곳에 서로 다른 하드닝으로 복제돼 있었다(억제 mkdir·setgid·clearstatcache 가 사본마다 한쪽씩 빠져 있었다). FilePermissionHelper 의 ensureWritableDirectory 와 hardenDirectory 로 통합하고, 실패 사유는 out 파라미터로 올려 정책(조용한 성능 저하 대 시끄러운 실패)은 호출부가 정하게 둔다. - 확장 저장 경로: storage_path('app/modules/…') 손조립이 30곳에 흩어져 있어 테스트 격리 분기를 넣으려면 사본마다 복제해야 했고, 한 곳만 빠뜨려도 그 확장의 테스트가 운영 설정 파일을 덮어쓴다. 디스크 root 를 단일 출처로 읽는 ExtensionStoragePath 로 전환하고 테스트 분기는 config/filesystems.php 한 줄에서 끝낸다. 함께 고친 것 - 테스트가 운영 라우트 캐시로 부팅해 확장 allowlist 가 라우트 축에서 통째로 무력화되던 문제. 삭제가 아니라 경로를 돌린다 — 라우트 캐시는 확장 작업 전까지 재생성되지 않아, 삭제하면 운영 사이트가 그때까지 라우트 파일 스캔 경로로 떨어진다. - PHPUnit 프로세스가 확장 vendor 의 제3자 composer 패키지를 오토로드하지 않아 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였던 문제. 확장 자신의 오토로더를 그대로 쓰면 활성 디렉토리가 _bundled 를 이기고 base path 유추까지 깨지므로, 생성된 맵에서 제3자 항목만 골라 별도 로더로 등록한다. - 게시 폴더가 setgid 를 갖지 않아, 명령줄과 웹이 번갈아 만든 하위 폴더를 다른 쪽이 쓰지 못하던 문제. - 관리자 템플릿이 HTML 정화 라이브러리를 직접 지정하지 않아 전이 의존으로 딸려온 구버전이 쓰이던 문제. 동반 산출물 - 규정 표(·AGENTS.md) 6행 + storage-driver/service-repository/testing-guide 문서 - audit 룰 2종 + coverage 6항목. 저장소가 이미 전량 전환돼 전수 실행이 공허 통과하므로 판정식은 픽스처 36건이 잠근다 - INSTALL.md 에 설치 후 파일 권한 절 추가 (vendor 쓰기 권한 불요를 명시)
458 lines
17 KiB
PHP
458 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Extension;
|
|
|
|
use App\Extension\Helpers\SettingsMigrator;
|
|
use App\Support\ExtensionStoragePath;
|
|
use Illuminate\Support\Facades\File;
|
|
use Tests\TestCase;
|
|
|
|
class SettingsMigratorTest extends TestCase
|
|
{
|
|
private string $moduleSettingsDir;
|
|
|
|
private string $pluginSettingsDir;
|
|
|
|
private string $moduleDir;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->moduleSettingsDir = ExtensionStoragePath::module('test-migrator-mod', 'settings');
|
|
$this->pluginSettingsDir = ExtensionStoragePath::plugin('test-migrator-plug', 'settings');
|
|
$this->moduleDir = base_path('modules/test-migrator-mod');
|
|
|
|
// 모듈 설정 디렉토리 및 파일 생성
|
|
File::ensureDirectoryExists($this->moduleSettingsDir);
|
|
File::put($this->moduleSettingsDir.'/basic.json', json_encode([
|
|
'shop_name' => 'Test Shop',
|
|
'email' => 'test@example.com',
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
|
|
File::put($this->moduleSettingsDir.'/shipping.json', json_encode([
|
|
'default_country' => 'KR',
|
|
'remote_area_fee' => 3000,
|
|
'nested' => ['deep' => ['value' => 'original']],
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
|
|
// 플러그인 설정 디렉토리 및 파일 생성
|
|
File::ensureDirectoryExists($this->pluginSettingsDir);
|
|
File::put($this->pluginSettingsDir.'/setting.json', json_encode([
|
|
'api_key' => '',
|
|
'sandbox_mode' => true,
|
|
'webhook_url' => 'https://example.com/hook',
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
|
|
// defaults.json 생성 (addCategory 테스트용)
|
|
File::ensureDirectoryExists($this->moduleDir.'/config/settings');
|
|
File::put($this->moduleDir.'/config/settings/defaults.json', json_encode([
|
|
'_meta' => [
|
|
'version' => '1.0.0',
|
|
'categories' => ['basic', 'shipping'],
|
|
],
|
|
'defaults' => [
|
|
'basic' => ['shop_name' => '', 'email' => ''],
|
|
'shipping' => ['default_country' => 'KR'],
|
|
],
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
if (File::isDirectory(ExtensionStoragePath::module('test-migrator-mod'))) {
|
|
File::deleteDirectory(ExtensionStoragePath::module('test-migrator-mod'));
|
|
}
|
|
|
|
if (File::isDirectory(ExtensionStoragePath::plugin('test-migrator-plug'))) {
|
|
File::deleteDirectory(ExtensionStoragePath::plugin('test-migrator-plug'));
|
|
}
|
|
|
|
if (File::isDirectory($this->moduleDir)) {
|
|
File::deleteDirectory($this->moduleDir);
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
// ========================================================================
|
|
// addField 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 모듈에서 새 필드가 추가되는지 확인합니다.
|
|
*/
|
|
public function test_add_field_creates_new_field_for_module(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addField('basic.phone', '010-0000-0000')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/basic.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertEquals(0, $result['skipped']);
|
|
$this->assertEquals('010-0000-0000', $data['phone']);
|
|
}
|
|
|
|
/**
|
|
* 이미 존재하는 필드는 스킵되는지 확인합니다 (사용자 값 보존).
|
|
*/
|
|
public function test_add_field_skips_existing_field(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addField('basic.shop_name', 'New Default')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/basic.json'), true);
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(1, $result['skipped']);
|
|
$this->assertEquals('Test Shop', $data['shop_name']); // 기존 값 유지
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 새 필드가 추가되는지 확인합니다.
|
|
*/
|
|
public function test_add_field_creates_new_field_for_plugin(): void
|
|
{
|
|
$result = SettingsMigrator::forPlugin('test-migrator-plug')
|
|
->addField('timeout', 30)
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->pluginSettingsDir.'/setting.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertEquals('', $data['api_key']); // 기존 필드 유지
|
|
$this->assertEquals(30, $data['timeout']); // 새 필드 추가
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 이미 존재하는 필드는 스킵됩니다.
|
|
*/
|
|
public function test_add_field_skips_existing_field_for_plugin(): void
|
|
{
|
|
$result = SettingsMigrator::forPlugin('test-migrator-plug')
|
|
->addField('sandbox_mode', false)
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->pluginSettingsDir.'/setting.json'), true);
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(1, $result['skipped']);
|
|
$this->assertTrue($data['sandbox_mode']); // 기존 값 유지
|
|
}
|
|
|
|
// ========================================================================
|
|
// renameField 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 필드 이름이 변경되는지 확인합니다.
|
|
*/
|
|
public function test_rename_field_moves_value(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->renameField('shipping.remote_area_fee', 'shipping.remote_extra_fee')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/shipping.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertArrayNotHasKey('remote_area_fee', $data);
|
|
$this->assertEquals(3000, $data['remote_extra_fee']);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 필드의 이름 변경은 스킵됩니다.
|
|
*/
|
|
public function test_rename_field_skips_nonexistent(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->renameField('shipping.nonexistent', 'shipping.new_field')
|
|
->apply();
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(1, $result['skipped']);
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 필드 이름이 변경됩니다.
|
|
*/
|
|
public function test_rename_field_for_plugin(): void
|
|
{
|
|
$result = SettingsMigrator::forPlugin('test-migrator-plug')
|
|
->renameField('webhook_url', 'callback_url')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->pluginSettingsDir.'/setting.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertArrayNotHasKey('webhook_url', $data);
|
|
$this->assertEquals('https://example.com/hook', $data['callback_url']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// removeField 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 필드가 제거되는지 확인합니다.
|
|
*/
|
|
public function test_remove_field_deletes_key(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->removeField('basic.email')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/basic.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertArrayNotHasKey('email', $data);
|
|
$this->assertEquals('Test Shop', $data['shop_name']); // 다른 필드 유지
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 필드 제거는 스킵됩니다.
|
|
*/
|
|
public function test_remove_field_skips_nonexistent(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->removeField('basic.nonexistent')
|
|
->apply();
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(1, $result['skipped']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// transformField 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 필드 값이 콜백으로 변환되는지 확인합니다.
|
|
*/
|
|
public function test_transform_field_applies_callback(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->transformField('shipping.remote_area_fee', fn ($v) => $v * 2)
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/shipping.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertEquals(6000, $data['remote_area_fee']);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 필드의 변환은 스킵됩니다.
|
|
*/
|
|
public function test_transform_field_skips_nonexistent(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->transformField('shipping.nonexistent', fn ($v) => $v)
|
|
->apply();
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(1, $result['skipped']);
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 필드 변환이 동작합니다.
|
|
*/
|
|
public function test_transform_field_for_plugin(): void
|
|
{
|
|
$result = SettingsMigrator::forPlugin('test-migrator-plug')
|
|
->transformField('sandbox_mode', fn ($v) => ! $v)
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->pluginSettingsDir.'/setting.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertFalse($data['sandbox_mode']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// addCategory 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 모듈에서 새 카테고리가 생성되는지 확인합니다.
|
|
*/
|
|
public function test_add_category_creates_file_for_module(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addCategory('notifications', ['email_enabled' => true, 'sms_enabled' => false])
|
|
->apply();
|
|
|
|
$categoryFile = $this->moduleSettingsDir.'/notifications.json';
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertFileExists($categoryFile);
|
|
|
|
$data = json_decode(File::get($categoryFile), true);
|
|
$this->assertTrue($data['email_enabled']);
|
|
$this->assertFalse($data['sms_enabled']);
|
|
|
|
// defaults.json의 categories 업데이트 확인
|
|
$defaults = json_decode(File::get($this->moduleDir.'/config/settings/defaults.json'), true);
|
|
$this->assertContains('notifications', $defaults['_meta']['categories']);
|
|
}
|
|
|
|
/**
|
|
* 이미 존재하는 카테고리는 스킵됩니다.
|
|
*/
|
|
public function test_add_category_skips_existing(): void
|
|
{
|
|
// basic.json이 이미 존재
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addCategory('basic', ['new_field' => true])
|
|
->apply();
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(1, $result['skipped']);
|
|
|
|
// 기존 파일 내용 유지 확인
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/basic.json'), true);
|
|
$this->assertEquals('Test Shop', $data['shop_name']);
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 addCategory 호출 시 예외가 발생합니다.
|
|
*/
|
|
public function test_add_category_throws_for_plugin(): void
|
|
{
|
|
$result = SettingsMigrator::forPlugin('test-migrator-plug')
|
|
->addCategory('notifications', ['enabled' => true])
|
|
->apply();
|
|
|
|
// 예외가 errors 배열에 기록됨
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertNotEmpty($result['errors']);
|
|
$this->assertStringContainsString('모듈에서만', $result['errors'][0]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 중첩 dot path 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 중첩된 dot path로 필드가 추가됩니다.
|
|
*/
|
|
public function test_nested_dot_path_add_field(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addField('shipping.nested.deep.new_key', 'new_value')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/shipping.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertEquals('new_value', $data['nested']['deep']['new_key']);
|
|
$this->assertEquals('original', $data['nested']['deep']['value']); // 기존 값 유지
|
|
}
|
|
|
|
/**
|
|
* 중첩된 dot path로 필드가 제거됩니다.
|
|
*/
|
|
public function test_nested_dot_path_remove_field(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->removeField('shipping.nested.deep.value')
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->moduleSettingsDir.'/shipping.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertArrayNotHasKey('value', $data['nested']['deep']);
|
|
}
|
|
|
|
/**
|
|
* 플러그인에서 중첩된 dot path가 동작합니다.
|
|
*/
|
|
public function test_nested_dot_path_for_plugin(): void
|
|
{
|
|
// 먼저 중첩 구조 추가
|
|
File::put($this->pluginSettingsDir.'/setting.json', json_encode([
|
|
'api_key' => '',
|
|
'options' => ['retry' => ['count' => 3, 'delay' => 100]],
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
|
|
$result = SettingsMigrator::forPlugin('test-migrator-plug')
|
|
->transformField('options.retry.count', fn ($v) => $v + 2)
|
|
->apply();
|
|
|
|
$data = json_decode(File::get($this->pluginSettingsDir.'/setting.json'), true);
|
|
|
|
$this->assertEquals(1, $result['applied']);
|
|
$this->assertEquals(5, $data['options']['retry']['count']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 복합 오퍼레이션 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 여러 오퍼레이션이 순서대로 실행됩니다.
|
|
*/
|
|
public function test_multiple_operations_apply_in_order(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addField('basic.phone', '010-1234-5678')
|
|
->renameField('basic.email', 'basic.contact_email')
|
|
->removeField('shipping.remote_area_fee')
|
|
->transformField('shipping.default_country', fn ($v) => strtolower($v))
|
|
->apply();
|
|
|
|
$this->assertEquals(4, $result['applied']);
|
|
$this->assertEquals(0, $result['skipped']);
|
|
$this->assertEmpty($result['errors']);
|
|
|
|
$basic = json_decode(File::get($this->moduleSettingsDir.'/basic.json'), true);
|
|
$this->assertEquals('010-1234-5678', $basic['phone']);
|
|
$this->assertEquals('test@example.com', $basic['contact_email']);
|
|
$this->assertArrayNotHasKey('email', $basic);
|
|
|
|
$shipping = json_decode(File::get($this->moduleSettingsDir.'/shipping.json'), true);
|
|
$this->assertArrayNotHasKey('remote_area_fee', $shipping);
|
|
$this->assertEquals('kr', $shipping['default_country']);
|
|
}
|
|
|
|
/**
|
|
* apply() 결과에 정확한 카운트가 반환됩니다.
|
|
*/
|
|
public function test_apply_returns_accurate_counts(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('test-migrator-mod')
|
|
->addField('basic.new_field', 'value') // applied
|
|
->addField('basic.shop_name', 'overwrite') // skipped (exists)
|
|
->removeField('basic.nonexistent') // skipped (not found)
|
|
->removeField('basic.email') // applied
|
|
->apply();
|
|
|
|
$this->assertEquals(2, $result['applied']);
|
|
$this->assertEquals(2, $result['skipped']);
|
|
$this->assertEmpty($result['errors']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 설정 파일 미존재 시 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 설정 파일이 없으면 오퍼레이션이 스킵됩니다.
|
|
*/
|
|
public function test_operations_skip_when_file_not_found(): void
|
|
{
|
|
$result = SettingsMigrator::forModule('nonexistent-module')
|
|
->addField('basic.field', 'value')
|
|
->renameField('basic.old', 'basic.new')
|
|
->removeField('basic.field')
|
|
->transformField('basic.field', fn ($v) => $v)
|
|
->apply();
|
|
|
|
$this->assertEquals(0, $result['applied']);
|
|
$this->assertEquals(4, $result['skipped']);
|
|
}
|
|
}
|