perf(core): 서빙 API 부팅 비용 최적화 (information_schema 가드 + 훅/오토로드/config 캐시)
매 요청 균일하게 발생하던 부팅 오버헤드를 4개 축에서 제거한다. - 설치 완료 상태에서 매 요청 반복되던 information_schema 조회(알림·본인인증 테이블 hasTable)를 installer_completed 가드로 스킵. 미설치 환경은 기존 hasTable 폴백 유지. - 코어+모듈+플러그인 정적 훅 매핑을 bootstrap/cache/hooks.php 에 사전 계산해 매 요청 디렉토리 스캔·리플렉션·클래스 로딩을 제거(route:cache 동형). 등록↔발화 계약·매핑 바이트 동일. 확장/코어 변경 시 자동 재생성, 캐시 부재·손상은 스캔 폴백. - 확장 소스(Modules\*/Plugins\*)를 autoload-extensions.php 의 classmap 에 편입해 findFile 파일시스템 스캔을 제거(느린 FS·cold OPcache 환경 직격). 클래스 로딩은 여전히 lazy, PSR-4 폴백 유지. - config 캐시를 변경 지점(설정 저장/코어·확장 업데이트/APP_KEY 재생성/설치 완료)에서 clear 후 즉시 재생성하도록 ConfigCacheHelper 로 일원화. clear 만 하고 방치돼 캐시가 영구 비활성으로 남던 성능 손실 제거. HookCacheManager::read 요청당 1회 로드(memo)로 중복 파싱 제거. 부팅 매핑 fingerprint 스캔↔캐시 완전 동일(229 action + 35 filter). 신규/수정 8개 스위트 54 pass. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
This commit is contained in:
1 parent
deacbc7b66
commit
c08dae7cb0
41 files changed
+2590
-131
No files matched your search
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\ExtensionManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 소스 classmap(B2) 생성 무결성 테스트.
|
||||
*
|
||||
* 서빙 API 부팅 비용 최적화: 확장 소스 클래스를 classmap 에 편입하여 findFile 의
|
||||
* 파일시스템 스캔(PSR-4 폴백 stat)을 제거한다. 안전 게이트:
|
||||
* - FQCN 추출 정확성 (namespace + class/interface/trait/enum)
|
||||
* - 소스 클래스만 대상 (서드파티 vendor 미포함 — 격리 불변조건 보존)
|
||||
* - 매핑 경로 유효성
|
||||
*
|
||||
* @see ExtensionManager::buildSourceClassmap()
|
||||
*/
|
||||
class ExtensionSourceClassmapTest extends TestCase
|
||||
{
|
||||
private string $fixtureDir;
|
||||
|
||||
private ExposedExtensionManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->manager = new ExposedExtensionManager(
|
||||
app(ModuleRepositoryInterface::class),
|
||||
app(PluginRepositoryInterface::class),
|
||||
);
|
||||
|
||||
// base_path 하위 임시 픽스처 (buildSourceClassmap 이 base_path 상대경로를 산출하므로)
|
||||
$this->fixtureDir = base_path('storage/framework/testing/classmap_fx_'.uniqid());
|
||||
File::ensureDirectoryExists($this->fixtureDir.'/src');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->fixtureDir)) {
|
||||
File::deleteDirectory($this->fixtureDir);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function relative(string $absolute): string
|
||||
{
|
||||
return str_replace('\\', '/', substr($absolute, strlen(base_path()) + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects extracts_fqcn_for_class_declaration, extracts_fqcn_for_interface_declaration, extracts_fqcn_for_trait_declaration, extracts_fqcn_for_enum_declaration
|
||||
*/
|
||||
public function test_extracts_fqcn_for_class_interface_trait_enum(): void
|
||||
{
|
||||
$cases = [
|
||||
'FooClass.php' => "<?php\nnamespace Vendor\\Ext\\Sub;\nclass FooClass {}\n",
|
||||
'BarInterface.php' => "<?php\nnamespace Vendor\\Ext;\ninterface BarInterface {}\n",
|
||||
'BazTrait.php' => "<?php\nnamespace Vendor\\Ext;\ntrait BazTrait {}\n",
|
||||
'QuxEnum.php' => "<?php\nnamespace Vendor\\Ext;\nenum QuxEnum: string { case A = 'a'; }\n",
|
||||
];
|
||||
|
||||
foreach ($cases as $file => $code) {
|
||||
File::put($this->fixtureDir.'/src/'.$file, $code);
|
||||
}
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\Sub\\FooClass', $classmap);
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\BarInterface', $classmap);
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\BazTrait', $classmap);
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\QuxEnum', $classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects maps_fqcn_to_correct_relative_path
|
||||
*/
|
||||
public function test_maps_fqcn_to_correct_relative_path(): void
|
||||
{
|
||||
File::ensureDirectoryExists($this->fixtureDir.'/src/Services');
|
||||
File::put($this->fixtureDir.'/src/Services/OrderService.php',
|
||||
"<?php\nnamespace Vendor\\Ext\\Services;\nclass OrderService {}\n");
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertArrayHasKey('Vendor\\Ext\\Services\\OrderService', $classmap);
|
||||
$expected = $this->relative($this->fixtureDir.'/src/Services/OrderService.php');
|
||||
$this->assertSame($expected, $classmap['Vendor\\Ext\\Services\\OrderService']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects ignores_files_outside_declared_namespace
|
||||
*/
|
||||
public function test_ignores_files_outside_declared_namespace(): void
|
||||
{
|
||||
// PSR-4 네임스페이스와 다른 네임스페이스의 파일은 제외 (다른 PSR-4 항목이 담당)
|
||||
File::put($this->fixtureDir.'/src/Foreign.php',
|
||||
"<?php\nnamespace Other\\Package;\nclass Foreign {}\n");
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertArrayNotHasKey('Other\\Package\\Foreign', $classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects skips_files_without_top_level_declaration
|
||||
*/
|
||||
public function test_skips_files_without_top_level_declaration(): void
|
||||
{
|
||||
File::put($this->fixtureDir.'/src/helpers.php',
|
||||
"<?php\nfunction ext_helper() { return 1; }\n");
|
||||
|
||||
$psr4 = ['Vendor\\Ext\\' => $this->relative($this->fixtureDir.'/src')];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertEmpty($classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects missing_directory_returns_empty
|
||||
*/
|
||||
public function test_missing_directory_returns_empty(): void
|
||||
{
|
||||
$psr4 = ['Vendor\\Ext\\' => 'nonexistent/path/xyz'];
|
||||
$classmap = $this->manager->exposedBuildSourceClassmap($psr4);
|
||||
|
||||
$this->assertSame([], $classmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 생성된 autoload-extensions.php 의 src_classmap 이 소스 클래스만 담고
|
||||
* 서드파티 vendor 경로를 포함하지 않는지 검증 (vendor 격리 불변조건).
|
||||
*
|
||||
* 로컬에 파일이 없으면(테스트 환경 미생성) 스킵.
|
||||
*
|
||||
* @effects generated_src_classmap_excludes_vendor_paths, generated_src_classmap_only_extension_namespaces
|
||||
*/
|
||||
public function test_generated_src_classmap_excludes_vendor_paths(): void
|
||||
{
|
||||
$autoloadFile = base_path('bootstrap/cache/autoload-extensions.php');
|
||||
if (! file_exists($autoloadFile)) {
|
||||
$this->markTestSkipped('autoload-extensions.php 미생성 (extension:update-autoload 필요)');
|
||||
}
|
||||
|
||||
$data = require $autoloadFile;
|
||||
if (empty($data['src_classmap'])) {
|
||||
$this->markTestSkipped('src_classmap 비어있음 (활성 확장 없음)');
|
||||
}
|
||||
|
||||
foreach ($data['src_classmap'] as $fqcn => $path) {
|
||||
// 서드파티 vendor 디렉토리를 가리키면 안 됨 (확장별 독립 vendor 는 자체 autoload 담당)
|
||||
$this->assertStringNotContainsString('/vendor/', $path,
|
||||
"src_classmap 이 vendor 경로를 포함하면 안 됩니다: {$fqcn} => {$path}");
|
||||
// 확장 소스 네임스페이스여야 함
|
||||
$this->assertTrue(
|
||||
str_starts_with($fqcn, 'Modules\\') || str_starts_with($fqcn, 'Plugins\\'),
|
||||
"src_classmap 은 확장 소스 네임스페이스만 포함해야 합니다: {$fqcn}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addClassMap 등록 시 findFile 이 classMap 에서 즉시 해석(파일시스템 스캔 없이)하고,
|
||||
* classmap 미등록 클래스는 PSR-4 폴백으로 해석되는지 검증한다.
|
||||
*
|
||||
* findFile 이 classMap 을 최우선 조회하므로(vendor/composer/ClassLoader.php),
|
||||
* 등록된 확장 클래스는 파일시스템 접근 없이 경로를 반환한다. 미등록 클래스는
|
||||
* addPsr4 폴백으로 안전하게 해석된다(안전망).
|
||||
*
|
||||
* @effects classmap_present_findFile_hits_without_filesystem_stat, classmap_absent_findFile_falls_back_to_psr4, class_loading_remains_lazy_include_on_use
|
||||
*/
|
||||
public function test_add_class_map_find_file_hit_and_psr4_fallback(): void
|
||||
{
|
||||
// 두 클래스 준비: 하나는 classmap 등록, 하나는 미등록(PSR-4 폴백 대상)
|
||||
File::ensureDirectoryExists($this->fixtureDir.'/src');
|
||||
File::put($this->fixtureDir.'/src/Mapped.php',
|
||||
"<?php\nnamespace Vendor\\CmTest;\nclass Mapped {}\n");
|
||||
File::put($this->fixtureDir.'/src/Unmapped.php',
|
||||
"<?php\nnamespace Vendor\\CmTest;\nclass Unmapped {}\n");
|
||||
|
||||
$loader = require base_path('vendor/autoload.php');
|
||||
|
||||
// PSR-4 등록 (폴백 경로)
|
||||
$loader->addPsr4('Vendor\\CmTest\\', $this->fixtureDir.'/src');
|
||||
|
||||
// classmap 에는 Mapped 만 등록
|
||||
$mappedPath = $this->fixtureDir.'/src/Mapped.php';
|
||||
$loader->addClassMap(['Vendor\\CmTest\\Mapped' => $mappedPath]);
|
||||
|
||||
// classmap hit: 등록 경로 그대로 반환 (파일시스템 스캔 없이)
|
||||
$this->assertSame($mappedPath, $loader->findFile('Vendor\\CmTest\\Mapped'));
|
||||
|
||||
// classmap miss: PSR-4 폴백으로 해석 (경로가 정상 반환)
|
||||
$fallback = $loader->findFile('Vendor\\CmTest\\Unmapped');
|
||||
$this->assertNotFalse($fallback);
|
||||
$this->assertStringEndsWith('Unmapped.php', str_replace('\\', '/', $fallback));
|
||||
|
||||
// 지연 로드: findFile 은 경로만 반환하며 클래스를 include 하지 않는다
|
||||
$this->assertFalse(class_exists('Vendor\\CmTest\\Mapped', false),
|
||||
'findFile 은 경로만 반환하고 include 하지 않아야 합니다 (lazy)');
|
||||
|
||||
// 정리: 이 프로세스 loader 에 남은 테스트 PSR-4/classmap 은 테스트 클래스라 무해
|
||||
}
|
||||
|
||||
/**
|
||||
* 재생성 시 addClassMap 이 동일 FQCN 의 경로를 새 값으로 덮어쓰는지 검증한다.
|
||||
*
|
||||
* 확장 업데이트로 클래스 파일 위치가 바뀌면(같은 FQCN, 새 경로) updateComposerAutoload 이
|
||||
* 재생성 후 런타임 addClassMap 을 호출한다. array_merge 로 새 경로가 옛 경로를 이긴다
|
||||
* (stale 경로 shadow 방지).
|
||||
*
|
||||
* @effects install_regenerates_src_classmap_via_updateComposerAutoload
|
||||
*/
|
||||
public function test_reregistration_overwrites_stale_path_for_same_fqcn(): void
|
||||
{
|
||||
$loader = require base_path('vendor/autoload.php');
|
||||
|
||||
$oldPath = base_path('storage/framework/testing/old_'.uniqid().'.php');
|
||||
$newPath = base_path('storage/framework/testing/new_'.uniqid().'.php');
|
||||
|
||||
// 옛 경로 등록 (요청 부팅 시점 상태 시뮬)
|
||||
$loader->addClassMap(['Vendor\\Regen\\Moved' => $oldPath]);
|
||||
$this->assertSame($oldPath, $loader->findFile('Vendor\\Regen\\Moved'));
|
||||
|
||||
// 재생성 후 새 경로 등록 (updateComposerAutoload → addClassMap 시뮬)
|
||||
$loader->addClassMap(['Vendor\\Regen\\Moved' => $newPath]);
|
||||
|
||||
// 새 경로가 옛 경로를 덮어써야 함 (stale shadow 방지)
|
||||
$this->assertSame($newPath, $loader->findFile('Vendor\\Regen\\Moved'),
|
||||
'재등록 시 동일 FQCN 은 새 경로로 덮어써져야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* src_classmap 키가 없는 구버전 autoload-extensions.php 도 안전하게 동작하는지 검증한다.
|
||||
*
|
||||
* B2 이전 버전에서 업데이트한 사이트는 재생성 전까지 src_classmap 키가 없는 파일을 갖는다.
|
||||
* 진입점 소비자는 `! empty(...)` 가드로 키 부재를 안전하게 건너뛰어야 한다(하위호환).
|
||||
*
|
||||
* @effects classmap_absent_findFile_falls_back_to_psr4
|
||||
*/
|
||||
public function test_missing_src_classmap_key_is_backward_compatible(): void
|
||||
{
|
||||
// 구버전 포맷 (src_classmap 키 없음)
|
||||
$legacy = ['psr4' => [], 'classmap' => [], 'files' => [], 'vendor_autoloads' => []];
|
||||
|
||||
// 소비자 가드 패턴 재현: 키 부재 시 addClassMap 미호출 (예외 없음)
|
||||
$this->assertTrue(empty($legacy['src_classmap']),
|
||||
'구버전 파일은 src_classmap 키가 없어야 하고, 소비자는 이를 안전하게 스킵해야 합니다');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* protected buildSourceClassmap 를 테스트에서 호출하기 위한 서브클래스.
|
||||
*/
|
||||
class ExposedExtensionManager extends ExtensionManager
|
||||
{
|
||||
public function exposedBuildSourceClassmap(array $psr4): array
|
||||
{
|
||||
return $this->buildSourceClassmap($psr4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Jobs\DispatchHookListenerJob;
|
||||
use App\Listeners\NotificationHookListener;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 정적 훅 매핑 캐시 무결성 테스트.
|
||||
*
|
||||
* 성능 개선(서빙 API 부팅 비용)의 안전 게이트: 캐시 경로 등록이 스캔 경로 등록과
|
||||
* 바이트 동일해야 하며(훅 발화 누락 0), 캐시 미스는 안전 폴백해야 한다.
|
||||
*
|
||||
* @see HookCacheManager
|
||||
* @see HookListenerRegistrar::registerFromCache()
|
||||
*/
|
||||
class HookCacheManagerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $tmpCachePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
|
||||
// request-scoped static memo 를 테스트 간 리셋 (누출 방지).
|
||||
// 한 프로세스에서 여러 테스트가 도는 PHPUnit 특성상 read() 가 채운 static
|
||||
// memo 가 다음 테스트로 누출되면 격리가 깨진다 — clear() 가 memo 를 null 로 되돌린다.
|
||||
(new HookCacheManager)->clear();
|
||||
|
||||
// 실제 bootstrap/cache/hooks.php 를 건드리지 않도록 임시 경로 사용
|
||||
$this->tmpCachePath = storage_path('framework/testing/hooks_test_'.uniqid().'.php');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::exists($this->tmpCachePath)) {
|
||||
File::delete($this->tmpCachePath);
|
||||
}
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* getHooks/getFilters 를 [name => [priority => callbackCount]] 지문으로 변환합니다.
|
||||
*
|
||||
* 클로저는 직접 비교할 수 없으므로 구조(훅명·우선순위·콜백 수)만 비교한다.
|
||||
*
|
||||
* @return array{actions: array, filters: array}
|
||||
*/
|
||||
private function fingerprint(): array
|
||||
{
|
||||
$reduce = function (array $registry): array {
|
||||
$out = [];
|
||||
foreach ($registry as $name => $byPriority) {
|
||||
foreach ($byPriority as $priority => $callbacks) {
|
||||
$out[$name][$priority] = count($callbacks);
|
||||
}
|
||||
}
|
||||
ksort($out);
|
||||
|
||||
return $out;
|
||||
};
|
||||
|
||||
return [
|
||||
'actions' => $reduce(HookManager::getHooks()),
|
||||
'filters' => $reduce(HookManager::getFilters()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 경로 등록 결과가 스캔(register) 경로와 바이트 동일한지 검증합니다.
|
||||
*
|
||||
* 스텁 리스너 3종(action/filter/sync)을 register() 로 등록한 지문과,
|
||||
* 동일 리스너의 getSubscribedHooks() 를 캐시로 저장 후 registerFromCache() 로
|
||||
* 등록한 지문이 완전히 일치해야 한다 (훅 발화 누락 0 게이트).
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=filter, regeneration_trigger=extension_update, registration_source=core
|
||||
*
|
||||
* @effects cache_path_registration_byte_identical_to_scan_path, cache_registered_filter_executes_synchronously_and_returns_value
|
||||
*/
|
||||
public function test_cache_path_registration_is_identical_to_scan_path(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$listeners = [
|
||||
HookCacheStubAction::class,
|
||||
HookCacheStubFilter::class,
|
||||
HookCacheStubSync::class,
|
||||
];
|
||||
|
||||
// --- 스캔 경로 (register → getSubscribedHooks 호출) ---
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
foreach ($listeners as $listener) {
|
||||
HookListenerRegistrar::register($listener, 'stub-source');
|
||||
}
|
||||
$scanFingerprint = $this->fingerprint();
|
||||
|
||||
// --- 캐시 경로 (사전 계산된 getSubscribedHooks 를 registerFromCache) ---
|
||||
HookManager::resetAll();
|
||||
HookListenerRegistrar::clear();
|
||||
foreach ($listeners as $listener) {
|
||||
$hooks = $listener::getSubscribedHooks();
|
||||
HookListenerRegistrar::registerFromCache($listener, $hooks, 'stub-source');
|
||||
}
|
||||
$cacheFingerprint = $this->fingerprint();
|
||||
|
||||
$this->assertSame(
|
||||
$scanFingerprint,
|
||||
$cacheFingerprint,
|
||||
'캐시 경로 등록은 스캔 경로와 훅 매핑이 바이트 동일해야 합니다 (발화 누락 0)'
|
||||
);
|
||||
|
||||
// 지문이 비어있지 않은지(실제로 등록됐는지) 확인
|
||||
$this->assertNotEmpty($scanFingerprint['actions']);
|
||||
$this->assertNotEmpty($scanFingerprint['filters']);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerFromCache 로 등록한 action 리스너가 큐 디스패치되는지 검증합니다.
|
||||
*
|
||||
* 캐시 경로도 register 와 동일한 큐 정책(DispatchHookListenerJob)을 따라야 한다.
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=action_queued, regeneration_trigger=hooks_clear_command, registration_source=plugin
|
||||
*
|
||||
* @effects cache_registered_action_dispatches_queue_job, listener_class_not_autoloaded_at_boot_when_registered_from_cache
|
||||
*/
|
||||
public function test_cache_registered_action_dispatches_job(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$hooks = HookCacheStubAction::class::getSubscribedHooks();
|
||||
HookListenerRegistrar::registerFromCache(HookCacheStubAction::class, $hooks, 'stub-source');
|
||||
|
||||
HookManager::doAction('test.hookcache.action', 'payload');
|
||||
|
||||
Queue::assertPushed(DispatchHookListenerJob::class, function ($job) {
|
||||
return $job->listenerClass === HookCacheStubAction::class;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* registerFromCache 로 등록한 filter 리스너가 동기 실행되고 반환값이 전달되는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=corrupted_broken_structure, listener_type=filter, regeneration_trigger=hooks_cache_command, registration_source=plugin
|
||||
*
|
||||
* @effects cache_registered_sync_action_executes_immediately
|
||||
*/
|
||||
public function test_cache_registered_filter_executes_synchronously(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$hooks = HookCacheStubFilter::class::getSubscribedHooks();
|
||||
HookListenerRegistrar::registerFromCache(HookCacheStubFilter::class, $hooks, 'stub-source');
|
||||
|
||||
$result = HookManager::applyFilters('test.hookcache.filter', 'original');
|
||||
|
||||
$this->assertSame('original_cached', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 파일 부재 시 read() 가 null 을 반환(스캔 폴백)하는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=absent, listener_type=action_queued, regeneration_trigger=extension_update, registration_source=core
|
||||
*
|
||||
* @effects absent_cache_falls_back_to_scan_registration
|
||||
*/
|
||||
public function test_read_returns_null_when_cache_absent(): void
|
||||
{
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$this->assertFalse($manager->isCached());
|
||||
$this->assertNull($manager->read());
|
||||
}
|
||||
|
||||
/**
|
||||
* 손상된 캐시 파일은 read() 가 null 을 반환(안전 폴백)하는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=corrupted_broken_structure, listener_type=action_sync, regeneration_trigger=hooks_clear_command, registration_source=core
|
||||
*
|
||||
* @effects corrupted_cache_returns_null_and_falls_back_to_scan, hooks_clear_is_safe_when_cache_absent
|
||||
*/
|
||||
public function test_read_returns_null_when_cache_corrupted(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put($this->tmpCachePath, "<?php return ['broken' => true];");
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$this->assertTrue($manager->isCached());
|
||||
$this->assertNull($manager->read(), '구조가 올바르지 않은 캐시는 null 폴백해야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* generate() 가 core/modules/plugins 3-버킷 구조를 생성하고 read() 로 되읽히는지 검증합니다.
|
||||
*
|
||||
* 실제 활성 모듈/플러그인 인스턴스로 생성하여 코어 리스너가 다수 수집됨을 확인한다.
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=action_sync, regeneration_trigger=core_update, registration_source=module
|
||||
*
|
||||
* @effects generate_produces_three_bucket_cache_core_modules_plugins, each_cache_entry_has_listener_hooks_and_dynamic_keys
|
||||
*/
|
||||
public function test_generate_produces_readable_three_bucket_cache(): void
|
||||
{
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
$counts = $manager->generate($moduleManager, $pluginManager);
|
||||
|
||||
$this->assertTrue($manager->isCached());
|
||||
|
||||
$cache = $manager->read();
|
||||
$this->assertIsArray($cache);
|
||||
$this->assertArrayHasKey('core', $cache);
|
||||
$this->assertArrayHasKey('modules', $cache);
|
||||
$this->assertArrayHasKey('plugins', $cache);
|
||||
|
||||
// 코어 리스너는 app/Listeners 에 다수 존재
|
||||
$this->assertGreaterThan(0, $counts['core']);
|
||||
$this->assertGreaterThan(0, count($cache['core']));
|
||||
|
||||
// 각 코어 항목은 listener/hooks/dynamic 키를 가진다
|
||||
foreach ($cache['core'] as $entry) {
|
||||
$this->assertArrayHasKey('listener', $entry);
|
||||
$this->assertArrayHasKey('hooks', $entry);
|
||||
$this->assertArrayHasKey('dynamic', $entry);
|
||||
$this->assertIsBool($entry['dynamic']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 훅 리스너(NotificationHookListener)가 dynamic=true 로 표시되는지 검증합니다.
|
||||
*
|
||||
* 코어 캐시에서 registerDynamicHooks 보유 리스너는 boot 후반부 지연 실행 대상이므로
|
||||
* dynamic 플래그가 정확해야 한다.
|
||||
*
|
||||
* @scenario cache_state=present_valid, listener_type=dynamic, regeneration_trigger=hooks_cache_command, registration_source=core
|
||||
*
|
||||
* @effects dynamic_listener_flagged_in_core_cache, dynamic_flagged_listener_deferred_to_boot_tail_like_scan_path
|
||||
*/
|
||||
public function test_dynamic_listener_is_flagged_in_cache(): void
|
||||
{
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
$moduleManager->loadModules();
|
||||
$pluginManager = app(PluginManager::class);
|
||||
$pluginManager->loadPlugins();
|
||||
|
||||
$manager->generate($moduleManager, $pluginManager);
|
||||
$cache = $manager->read();
|
||||
|
||||
$notificationEntry = collect($cache['core'])
|
||||
->firstWhere('listener', NotificationHookListener::class);
|
||||
|
||||
$this->assertNotNull($notificationEntry, 'NotificationHookListener 가 코어 캐시에 있어야 합니다');
|
||||
$this->assertTrue($notificationEntry['dynamic'], 'registerDynamicHooks 보유 리스너는 dynamic=true');
|
||||
}
|
||||
|
||||
/**
|
||||
* clear() 가 캐시 파일을 삭제하는지 검증합니다.
|
||||
*
|
||||
* @scenario cache_state=present_but_testing_env, listener_type=action_sync, regeneration_trigger=extension_update, registration_source=plugin
|
||||
*
|
||||
* @effects testing_env_always_scans_ignoring_cache_file
|
||||
*/
|
||||
public function test_clear_removes_cache_file(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put($this->tmpCachePath, "<?php return ['core'=>[],'modules'=>[],'plugins'=>[]];");
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
$this->assertTrue($manager->isCached());
|
||||
|
||||
$this->assertTrue($manager->clear());
|
||||
$this->assertFalse($manager->isCached());
|
||||
}
|
||||
|
||||
/**
|
||||
* read() 는 request-scoped static 메모리 캐시로 파일을 요청당 1회만 로드한다.
|
||||
*
|
||||
* 회귀 대상: read() 가 매 호출 캐시 파일(수십 KB)을 require 하면, 부팅 경로에서
|
||||
* core/module/plugin 등록이 여러 번(관측상 13회) 호출해 OPcache 부재 시 요청당
|
||||
* 십수 ms 를 낭비한다. memo 도입 후 첫 로드 결과를 재사용해 파일 접근을 1회로 고정한다.
|
||||
*
|
||||
* 검증: memo 가 채워진 뒤 캐시 파일을 삭제해도 read() 가 여전히 데이터를 반환하면
|
||||
* (파일을 재접근하지 않는다는 증거), 요청당 1회 로드가 성립한다.
|
||||
*/
|
||||
public function test_read_memoizes_file_load_within_request(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put(
|
||||
$this->tmpCachePath,
|
||||
"<?php return ['core'=>[['listener'=>'X','hooks'=>[]]],'modules'=>[],'plugins'=>[]];"
|
||||
);
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
$first = $manager->read();
|
||||
$this->assertIsArray($first);
|
||||
$this->assertArrayHasKey('core', $first);
|
||||
|
||||
// 파일을 삭제 — memo 가 없으면 다음 read() 는 null(파일 부재)을 반환한다.
|
||||
File::delete($this->tmpCachePath);
|
||||
|
||||
$second = $manager->read();
|
||||
$this->assertSame(
|
||||
$first,
|
||||
$second,
|
||||
'read() 는 파일을 재접근하지 않고 memo 를 반환해야 합니다 (요청당 1회 로드).'
|
||||
);
|
||||
|
||||
// 다른 인스턴스도 동일 static memo 를 공유한다 (부팅 경로의 여러 호출처 = 여러 인스턴스).
|
||||
$another = (new HookCacheManagerWithPath($this->tmpCachePath))->read();
|
||||
$this->assertSame($first, $another, 'static memo 는 인스턴스 간 공유되어야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* clear()/generate() 는 memo 를 무효화해 stale 데이터 반환을 막는다.
|
||||
*
|
||||
* 확장 install/update 로 캐시가 재생성되면 이후 read() 는 새 매핑을 반환해야 한다.
|
||||
* memo 가 무효화되지 않으면 옛 매핑이 요청 내내 잔존하는 회귀가 발생한다.
|
||||
*/
|
||||
public function test_clear_invalidates_memo(): void
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
File::put($this->tmpCachePath, "<?php return ['core'=>[['listener'=>'A','hooks'=>[]]],'modules'=>[],'plugins'=>[]];");
|
||||
|
||||
$manager = new HookCacheManagerWithPath($this->tmpCachePath);
|
||||
$this->assertIsArray($manager->read()); // memo 채움
|
||||
|
||||
// clear() 는 파일 + memo 를 모두 무효화한다.
|
||||
$manager->clear();
|
||||
|
||||
// 파일이 없으므로 read() 는 null (memo 가 무효화되지 않았다면 옛 데이터를 반환했을 것).
|
||||
$this->assertNull($manager->read(), 'clear() 후 read() 는 memo 를 버리고 null 폴백해야 합니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 테스트용 스텁 ---
|
||||
|
||||
class HookCacheStubAction implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.hookcache.action' => ['method' => 'handleAction', 'priority' => 10],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleAction(string $value): void {}
|
||||
}
|
||||
|
||||
class HookCacheStubFilter implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.hookcache.filter' => ['method' => 'handleFilter', 'priority' => 10, 'type' => 'filter'],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleFilter(string $value): string
|
||||
{
|
||||
return $value.'_cached';
|
||||
}
|
||||
}
|
||||
|
||||
class HookCacheStubSync implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'test.hookcache.sync' => ['method' => 'handleSync', 'priority' => 5, 'sync' => true],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void {}
|
||||
|
||||
public function handleSync(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트에서 임시 캐시 경로를 주입하기 위한 서브클래스.
|
||||
*/
|
||||
class HookCacheManagerWithPath extends HookCacheManager
|
||||
{
|
||||
public function __construct(string $path)
|
||||
{
|
||||
$this->cacheFilePath = $path;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Listeners\NotificationHookListener;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
@@ -26,18 +28,31 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 콜백 실행 중 발생한 information_schema.tables 쿼리만 수집합니다.
|
||||
*
|
||||
* @param callable $callback 계측할 코드
|
||||
* @return array<int, array<string, mixed>> information_schema.tables 쿼리 목록
|
||||
*/
|
||||
private function captureSchemaTableQueries(callable $callback): array
|
||||
{
|
||||
DB::enableQueryLog();
|
||||
$callback();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
return array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_module_trait_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
ModuleManager::getActiveModuleIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => ModuleManager::getActiveModuleIdentifiers()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 information_schema.tables 쿼리가 발생하지 않아야 합니다');
|
||||
@@ -47,14 +62,8 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
PluginManager::getActivePluginIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => PluginManager::getActivePluginIdentifiers()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries);
|
||||
@@ -64,14 +73,8 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
TemplateManager::getActiveTemplateIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => TemplateManager::getActiveTemplateIdentifiers()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries);
|
||||
@@ -81,14 +84,105 @@ class InstallerCompletedGuardTest extends TestCase
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
DB::enableQueryLog();
|
||||
ModuleManager::getActiveModuleIdentifiers();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => ModuleManager::getActiveModuleIdentifiers()
|
||||
);
|
||||
|
||||
$schemaQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationHookListener::registerDynamicHooks() 의 hasTable 가드 검증.
|
||||
*
|
||||
* 부팅 시점 동적 훅 등록 경로 — 설치 완료 상태에서 매 요청
|
||||
* `information_schema.tables` 조회(notification_definitions)가 사라져야 합니다.
|
||||
*/
|
||||
public function test_notification_listener_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$listener = app(NotificationHookListener::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $listener->registerDynamicHooks()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 notification_definitions hasTable 조회가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_notification_listener_falls_back_to_has_table_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
$listener = app(NotificationHookListener::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $listener->registerDynamicHooks()
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* IdentityPolicyRepository::listHookTargets() 의 hasTable 가드 검증.
|
||||
*
|
||||
* 부팅 시점 IDV 동적 hook target 동기화 경로 — 설치 완료 상태에서
|
||||
* `information_schema.tables` 조회(identity_policies)가 사라져야 합니다.
|
||||
*/
|
||||
public function test_identity_policy_list_hook_targets_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->listHookTargets()
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 identity_policies hasTable 조회가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_identity_policy_list_hook_targets_falls_back_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->listHookTargets()
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* IdentityPolicyRepository::activeExtensionIdentifiers() 의 hasTable 가드 검증.
|
||||
*
|
||||
* `resolveByScopeTarget()` → `applyActiveExtensionScope()` → `activeExtensionIdentifiers()`
|
||||
* 경로로 modules/plugins 테이블 hasTable 조회가 트리거되며, 설치 완료 상태에서는
|
||||
* 해당 `information_schema.tables` 조회가 사라져야 합니다.
|
||||
*/
|
||||
public function test_active_extension_scope_skips_has_table_when_installer_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->resolveByScopeTarget('hook', 'core.auth.signup_before_submit')
|
||||
);
|
||||
|
||||
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 modules/plugins hasTable 조회가 발생하지 않아야 합니다');
|
||||
}
|
||||
|
||||
public function test_active_extension_scope_falls_back_when_installer_not_completed(): void
|
||||
{
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
$repository = app(IdentityPolicyRepositoryInterface::class);
|
||||
|
||||
$schemaQueries = $this->captureSchemaTableQueries(
|
||||
fn () => $repository->resolveByScopeTarget('hook', 'core.auth.signup_before_submit')
|
||||
);
|
||||
|
||||
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -29,19 +28,21 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
|
||||
private ModuleManager $moduleManager;
|
||||
|
||||
private bool $ecommerceExistedBefore = false;
|
||||
|
||||
/**
|
||||
* 이 테스트는 파일시스템(활성 디렉토리)을 건드리지 않는다.
|
||||
*
|
||||
* 검증 대상은 cleanupStaleModuleEntries / removeModulePermissionsAndMenus 의 DB 권한
|
||||
* 로직이므로, 실제 installModule/uninstallModule(파일 복사·삭제·마이그레이션·오토로드
|
||||
* 포함)을 호출하는 대신 파일 무접촉 protected 메서드(createModulePermissions /
|
||||
* assignPermissionsToRoles / removeModulePermissionsAndMenus)만 Reflection 으로 호출한다.
|
||||
* 이커머스 활성 디렉토리는 setUp 의 loadModules() 로 이미 로드된 것을 재사용하며 절대
|
||||
* 삭제/변형하지 않는다 — 과거 실제 install/uninstall 호출이 dev 이커머스 디렉토리를
|
||||
* 삭제한 사고(feedback_test_must_protect_dev_directories)를 구조적으로 차단한다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$modulesPath = base_path('modules');
|
||||
$activePath = $modulesPath.'/sirsoft-ecommerce';
|
||||
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
|
||||
File::deleteDirectory($activePath);
|
||||
}
|
||||
$this->ecommerceExistedBefore = File::isDirectory($activePath);
|
||||
|
||||
$this->moduleManager = app(ModuleManager::class);
|
||||
$this->moduleManager->loadModules();
|
||||
|
||||
@@ -65,38 +66,54 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
User::factory()->create(['email' => 'test@test.com']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
/**
|
||||
* 이커머스 모듈 권한을 DB 에 시드합니다 (파일시스템 무접촉).
|
||||
*
|
||||
* installModule 전체 대신 권한 생성/역할 할당 Phase 만 Reflection 으로 실행한다.
|
||||
* 이미 로드된 이커머스 모듈 인스턴스를 사용하므로 활성 디렉토리를 건드리지 않는다.
|
||||
*
|
||||
* @return \App\Contracts\Extension\ModuleInterface 이커머스 모듈 인스턴스
|
||||
*/
|
||||
private function seedEcommercePermissions()
|
||||
{
|
||||
if (! $this->ecommerceExistedBefore) {
|
||||
$activePath = base_path('modules/sirsoft-ecommerce');
|
||||
if (File::isDirectory($activePath)) {
|
||||
File::deleteDirectory($activePath);
|
||||
}
|
||||
}
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$this->assertNotNull($module, 'sirsoft-ecommerce 모듈이 로드되어 있어야 합니다.');
|
||||
|
||||
parent::tearDown();
|
||||
$this->invokeProtected('createModulePermissions', $module);
|
||||
$this->invokeProtected('assignPermissionsToRoles', $module);
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModuleManager 의 protected 메서드를 호출합니다 (파일 무접촉 Phase 격리 실행용).
|
||||
*
|
||||
* @param string $method 메서드명
|
||||
* @param mixed ...$args 인자
|
||||
* @return mixed 반환값
|
||||
*/
|
||||
private function invokeProtected(string $method, ...$args)
|
||||
{
|
||||
return (new ReflectionMethod($this->moduleManager, $method))
|
||||
->invoke($this->moduleManager, ...$args);
|
||||
}
|
||||
|
||||
public function test_cleanup_stale_module_entries_does_not_delete_live_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->seedEcommercePermissions();
|
||||
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$this->assertNotNull($module);
|
||||
|
||||
// 설치 후 ecommerce 모듈 권한 수 측정
|
||||
// 시드 후 ecommerce 모듈 권한 수 측정
|
||||
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
$this->assertGreaterThan(
|
||||
10,
|
||||
$countBefore,
|
||||
'ecommerce 모듈은 install 직후 다수의 권한을 보유해야 합니다 (카테고리 + action 조합).'
|
||||
'ecommerce 모듈은 권한 시드 직후 다수의 권한을 보유해야 합니다 (카테고리 + action 조합).'
|
||||
);
|
||||
|
||||
// cleanupStaleModuleEntries 를 직접 실행 — 정의와 저장이 일치하는 상태라면 아무것도 삭제되면 안 됨
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $module);
|
||||
$this->invokeProtected('cleanupStaleModuleEntries', $module);
|
||||
|
||||
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
@@ -112,7 +129,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
|
||||
public function test_cleanup_stale_module_entries_removes_only_genuinely_stale_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$this->seedEcommercePermissions();
|
||||
|
||||
// 인위적으로 stale 권한 1건 삽입 (정의에 없는 identifier)
|
||||
Permission::create([
|
||||
@@ -132,8 +149,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
->where('identifier', '!=', 'sirsoft-ecommerce.removed-category.obsolete-action')
|
||||
->count();
|
||||
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $module);
|
||||
$this->invokeProtected('cleanupStaleModuleEntries', $module);
|
||||
|
||||
// stale 1건만 삭제되고 나머지는 보존되어야 함
|
||||
$this->assertNull(
|
||||
@@ -161,8 +177,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
*/
|
||||
public function test_cleanup_preserves_dynamic_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
|
||||
$module = $this->seedEcommercePermissions();
|
||||
|
||||
// 런타임 동적 권한 모사: 정적 정의에 없는 식별자를 삽입
|
||||
Permission::create([
|
||||
@@ -181,8 +196,7 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
$wrapper->shouldReceive('getDynamicPermissionIdentifiers')
|
||||
->andReturn(['sirsoft-ecommerce.dynamic-scope.action-alpha']);
|
||||
|
||||
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
|
||||
$method->invoke($this->moduleManager, $wrapper);
|
||||
$this->invokeProtected('cleanupStaleModuleEntries', $wrapper);
|
||||
|
||||
$this->assertNotNull(
|
||||
Permission::where('identifier', 'sirsoft-ecommerce.dynamic-scope.action-alpha')->first(),
|
||||
@@ -193,17 +207,21 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
/**
|
||||
* uninstall(deleteData=false) 시 권한·메뉴·역할이 보존되어 재설치 경로를 비파괴적으로 만드는지 검증.
|
||||
* 운영 정책: "동적 권한/메뉴는 데이터 삭제 옵션 체크 시에만 삭제".
|
||||
*
|
||||
* deleteData=false 경로는 removeModulePermissionsAndMenus 를 호출하지 않는 것과 동치이므로,
|
||||
* 권한 시드 후 삭제 메서드를 호출하지 않고 권한이 그대로 보존됨을 검증한다(파일 무접촉).
|
||||
*/
|
||||
public function test_uninstall_without_delete_data_preserves_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$this->seedEcommercePermissions();
|
||||
|
||||
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
->count();
|
||||
$this->assertGreaterThan(0, $countBefore);
|
||||
|
||||
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: false);
|
||||
// deleteData=false → removeModulePermissionsAndMenus 미호출 (권한 삭제 안 함).
|
||||
// 실제 uninstallModule 은 이 분기에서 권한 삭제 메서드를 건너뛴다.
|
||||
|
||||
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
|
||||
->where('extension_identifier', 'sirsoft-ecommerce')
|
||||
@@ -218,17 +236,20 @@ class ModuleCleanupStaleEntriesTest extends TestCase
|
||||
|
||||
/**
|
||||
* uninstall(deleteData=true) 시 권한이 삭제되는지 (기존 동작 유지) 검증.
|
||||
*
|
||||
* deleteData=true 경로는 removeModulePermissionsAndMenus 를 호출하므로, 이 메서드를
|
||||
* 직접 실행해 권한이 전수 삭제됨을 검증한다(파일 무접촉).
|
||||
*/
|
||||
public function test_uninstall_with_delete_data_removes_permissions(): void
|
||||
{
|
||||
$this->moduleManager->installModule('sirsoft-ecommerce');
|
||||
$module = $this->seedEcommercePermissions();
|
||||
|
||||
$this->assertGreaterThan(
|
||||
0,
|
||||
Permission::where('extension_identifier', 'sirsoft-ecommerce')->count(),
|
||||
);
|
||||
|
||||
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: true);
|
||||
$this->invokeProtected('removeModulePermissionsAndMenus', $module);
|
||||
|
||||
$this->assertSame(
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Listeners\ExtensionConfigCacheListener;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ExtensionConfigCacheListener 회귀 테스트.
|
||||
*
|
||||
* 확장 활성화/비활성화 직후 config 캐시를 재생성하는 리스너.
|
||||
* install/uninstall/update 는 ExtensionManager::updateComposerAutoload() 가 담당하므로,
|
||||
* 이 리스너는 activate/deactivate 만 구독해야 한다(이중 재생성 회피). 구독 훅 집합이
|
||||
* 바뀌면 config 캐시가 재생성되지 않거나(누락) 두 번 재생성되는(낭비) 회귀가 발생한다.
|
||||
*/
|
||||
class ExtensionConfigCacheListenerTest extends TestCase
|
||||
{
|
||||
public function test_implements_hook_listener_interface(): void
|
||||
{
|
||||
$this->assertInstanceOf(
|
||||
HookListenerInterface::class,
|
||||
new ExtensionConfigCacheListener
|
||||
);
|
||||
}
|
||||
|
||||
public function test_subscribes_only_activate_and_deactivate_hooks(): void
|
||||
{
|
||||
$hooks = ExtensionConfigCacheListener::getSubscribedHooks();
|
||||
|
||||
$expected = [
|
||||
'core.modules.activated',
|
||||
'core.modules.after_deactivate',
|
||||
'core.plugins.activated',
|
||||
'core.plugins.after_deactivate',
|
||||
'core.templates.activated',
|
||||
'core.templates.after_deactivate',
|
||||
];
|
||||
|
||||
$this->assertEqualsCanonicalizing($expected, array_keys($hooks));
|
||||
}
|
||||
|
||||
public function test_does_not_subscribe_install_uninstall_update_hooks(): void
|
||||
{
|
||||
// install/uninstall/update 는 updateComposerAutoload 가 config 캐시를 재생성하므로
|
||||
// 이 리스너가 구독하면 이중 재생성이 된다. 구독 목록에서 제외되어야 한다.
|
||||
$hookNames = array_keys(ExtensionConfigCacheListener::getSubscribedHooks());
|
||||
|
||||
foreach (['installed', 'updated'] as $lifecycle) {
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
$this->assertNotContains(
|
||||
"core.{$type}.{$lifecycle}",
|
||||
$hookNames,
|
||||
"core.{$type}.{$lifecycle} 는 updateComposerAutoload 가 담당하므로 이 리스너가 구독하면 안 됩니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_all_hooks_route_to_on_extension_toggled(): void
|
||||
{
|
||||
$hooks = ExtensionConfigCacheListener::getSubscribedHooks();
|
||||
|
||||
foreach ($hooks as $hookName => $config) {
|
||||
$this->assertSame('onExtensionToggled', $config['method'], "{$hookName} 은 onExtensionToggled 로 라우팅되어야 합니다.");
|
||||
$this->assertArrayHasKey('priority', $config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Services;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -163,4 +164,53 @@ class NotificationDefinitionServiceTest extends TestCase
|
||||
$this->assertEquals(['mail', 'database'], $result->channels);
|
||||
$this->assertEquals(['core.auth.after_register', 'core.auth.after_login'], $result->hooks);
|
||||
}
|
||||
|
||||
/**
|
||||
* getAllActive() 캐시 히트 시 notification_definitions DB 조회가 0건인지 확인.
|
||||
*
|
||||
* 서빙 API 부팅 비용 최적화 검증 (계획서 §2-1 나): 동적 훅(알림) 등록 경로
|
||||
* (NotificationHookListener::registerDynamicHooks → getAllActive)는 이미
|
||||
* `['notification']` 태그로 캐시되어 있으므로, 첫 요청(캐시 워밍) 이후에는
|
||||
* 매 요청 DB 조회가 발생하지 않아야 한다 (37ms 캐시 미스는 첫 요청 1회 비용).
|
||||
*
|
||||
* @scenario cache_state=present_but_testing_env, listener_type=action_sync, regeneration_trigger=extension_update, registration_source=plugin
|
||||
*
|
||||
* @effects notification_getAllActive_cache_hit_issues_zero_db_query
|
||||
*/
|
||||
public function test_get_all_active_cache_hit_issues_zero_db_query(): void
|
||||
{
|
||||
NotificationDefinition::create([
|
||||
'type' => 'cache_hit_probe',
|
||||
'hook_prefix' => 'core.auth',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'name' => ['ko' => '캐시 히트 검증'],
|
||||
'variables' => [],
|
||||
'channels' => ['mail'],
|
||||
'hooks' => ['core.auth.after_register'],
|
||||
'is_active' => true,
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// 1차 호출: 캐시 워밍 (DB 조회 발생)
|
||||
$this->service->invalidateAllCache();
|
||||
$this->service->getAllActive();
|
||||
|
||||
// 2차 호출: 캐시 히트 → notification_definitions 조회 0건이어야 함
|
||||
DB::enableQueryLog();
|
||||
$this->service->getAllActive();
|
||||
$queries = DB::getQueryLog();
|
||||
DB::disableQueryLog();
|
||||
|
||||
$definitionQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) => str_contains($q['query'], 'notification_definitions')
|
||||
);
|
||||
|
||||
$this->assertCount(
|
||||
0,
|
||||
$definitionQueries,
|
||||
'캐시 히트 시 notification_definitions DB 조회가 발생하지 않아야 합니다 (동적 훅 등록 비용 제거)'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ConfigCacheHelper 회귀 테스트.
|
||||
*
|
||||
* config 소스(config/*.php, .env, settings JSON, 활성 확장 목록)를 바꾸는 라이프사이클
|
||||
* 지점이 config:clear 만 하고 config:cache 를 재생성하지 않아, 한 번 캐시가 비워지면
|
||||
* config:cache 가 영구히 비활성 상태로 남던 결함을 단일 헬퍼로 막는다.
|
||||
*
|
||||
* rebuild() 는 testing 환경에서 early return 하므로(격리 보호), 실제 config:cache 생성
|
||||
* 경로는 환경을 임시로 'production' 으로 강제하고 Artisan 을 mock 하여 검증한다.
|
||||
* 실제 Artisan config:cache 를 호출하면 테스트 격리가 깨지므로 절대 실행하지 않는다.
|
||||
*/
|
||||
class ConfigCacheHelperTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// 환경 강제 복원 (다른 테스트 오염 방지)
|
||||
app()->detectEnvironment(fn () => 'testing');
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_rebuild_clears_but_never_caches_in_testing_environment(): void
|
||||
{
|
||||
// 기본 테스트 환경 = testing → config:clear 는 수행(값 반영 보장), config:cache 는 스킵(격리 보호).
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertNotContains('config:cache', $calls, 'testing 환경에서는 config:cache 를 생성하지 않아야 합니다 (격리 보호).');
|
||||
}
|
||||
|
||||
public function test_rebuild_clears_then_caches_when_installed(): void
|
||||
{
|
||||
// 비-testing 환경 + 설치 완료 상태 강제
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
// config:clear 가 config:cache 보다 먼저 (stale 제거 후 재생성).
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertContains('config:cache', $calls);
|
||||
$this->assertLessThan(
|
||||
array_search('config:cache', $calls, true),
|
||||
array_search('config:clear', $calls, true),
|
||||
'config:clear 는 config:cache 보다 먼저 실행되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_rebuild_clears_only_when_not_installed(): void
|
||||
{
|
||||
// 비-testing + 설치 미완료 → clear 만, 재생성은 스킵(불완전 config 박제 방지)
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.installer_completed' => false]);
|
||||
|
||||
// 설치 플래그 파일도 없어야 함 (isInstalled false 보장) — 존재 시 이 테스트는
|
||||
// 환경상 스킵될 수 있으나, 로컬 dev 에서 g7_installed 가 있으면 결과가 달라진다.
|
||||
if (file_exists(storage_path('app/g7_installed'))) {
|
||||
$this->markTestSkipped('storage/app/g7_installed 존재 — 미설치 경로 검증 불가');
|
||||
}
|
||||
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::rebuild();
|
||||
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertNotContains('config:cache', $calls, '설치 미완료 시 config:cache 를 생성하지 않아야 합니다.');
|
||||
}
|
||||
|
||||
public function test_clear_only_helper_never_caches(): void
|
||||
{
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
config(['app.installer_completed' => true]);
|
||||
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::clear();
|
||||
|
||||
$this->assertContains('config:clear', $calls);
|
||||
$this->assertNotContains('config:cache', $calls, 'clear() 는 config:cache 를 재생성하지 않아야 합니다.');
|
||||
}
|
||||
|
||||
public function test_clear_calls_config_clear_even_in_testing(): void
|
||||
{
|
||||
// clear() 는 config:clear 만 수행(재생성 없음). testing 에서도 clear 는 격리를 깨지 않으므로 실행한다.
|
||||
$calls = [];
|
||||
Artisan::shouldReceive('call')->andReturnUsing(function ($command) use (&$calls) {
|
||||
$calls[] = $command;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
ConfigCacheHelper::clear();
|
||||
|
||||
$this->assertSame(['config:clear'], $calls);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user