Files
Gnuboard7/tests/Unit/Extension/ExtensionSourceClassmapTest.php
T
HeuJung c08dae7cb0 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. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
2026-07-03 17:47:23 +09:00

269 lines
12 KiB
PHP

<?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);
}
}