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:
HeuJung committed 2026-07-03 17:47:23 +09:00
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);
}
}