|null 원본 argv 백업 */ private ?array $originalArgv = null; protected function tearDown(): void { config(['app.installer_completed' => false]); if ($this->originalArgv !== null) { $_SERVER['argv'] = $this->originalArgv; $this->originalArgv = null; } parent::tearDown(); } /** * $_SERVER['argv'] 를 마이그레이션 명령으로 임시 설정하고 콜백을 실행합니다. * * PHPUnit 은 이미 콘솔 컨텍스트이므로, argv[1] 만 'migrate' 로 바꾸면 * InstallerContext::isSchemaMutatingCommand() 가 true 가 되어 fast-path 가 무력화된다. * argv 는 finally 에서 반드시 원복해 다른 테스트 오염을 막는다. * * @param callable $callback 마이그레이션 컨텍스트에서 실행할 코드 */ private function withMigrationArgv(callable $callback): void { $this->originalArgv = $_SERVER['argv'] ?? []; $_SERVER['argv'] = ['artisan', 'migrate']; try { $callback(); } finally { $_SERVER['argv'] = $this->originalArgv; $this->originalArgv = null; } } /** * `.env` 와 `modules`/`plugins` 디렉토리가 존재하는 임시 앱 루트에서 콜백을 실행합니다. * * 라우트 프로바이더는 인스톨러 실행 전 부팅을 위해 `.env` 부재 시 조기 반환한다. * 그런데 `.env` 는 Git 추적 대상이 아니므로 신규 클론·CI 에는 존재하지 않고, 그러면 * 이 테스트가 검증하려는 `hasTable` 가드까지 도달하지도 못한 채 통과한 것처럼 보이거나 * (쿼리 0건) 실패한다. 실제로 그렇게 실패했다 — 개발자 로컬에 `.env` 가 있느냐에 * 결과가 좌우되는 상태였다. * * 프로젝트 루트에 파일을 만들지 않고 **임시 디렉토리를 앱 루트로 대체**해 전제를 * 결정적으로 만든다. DB 커넥션은 base path 와 무관하므로 `hasTable` 계측에는 영향이 없다. * * @param callable $callback 실행할 코드 */ private function withFakeAppRoot(callable $callback): void { $root = sys_get_temp_dir().'/g7-installer-guard-'.bin2hex(random_bytes(4)); $originalBasePath = $this->app->basePath(); File::ensureDirectoryExists($root.'/modules'); File::ensureDirectoryExists($root.'/plugins'); File::put($root.'/.env', "APP_ENV=testing\n"); $this->app->setBasePath($root); try { $callback(); } finally { $this->app->setBasePath($originalBasePath); File::deleteDirectory($root); } } /** * 주어진 콜백 실행 중 발생한 information_schema.tables 쿼리만 수집합니다. * * @param callable $callback 계측할 코드 * @return array> 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]); $schemaQueries = $this->captureSchemaTableQueries( fn () => ModuleManager::getActiveModuleIdentifiers() ); $this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 information_schema.tables 쿼리가 발생하지 않아야 합니다'); } public function test_plugin_trait_skips_has_table_when_installer_completed(): void { config(['app.installer_completed' => true]); $schemaQueries = $this->captureSchemaTableQueries( fn () => PluginManager::getActivePluginIdentifiers() ); $this->assertCount(0, $schemaQueries); } public function test_template_trait_skips_has_table_when_installer_completed(): void { config(['app.installer_completed' => true]); $schemaQueries = $this->captureSchemaTableQueries( fn () => TemplateManager::getActiveTemplateIdentifiers() ); $this->assertCount(0, $schemaQueries); } public function test_module_trait_falls_back_to_has_table_when_installer_not_completed(): void { config(['app.installer_completed' => false]); $schemaQueries = $this->captureSchemaTableQueries( fn () => ModuleManager::getActiveModuleIdentifiers() ); $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 경로가 실행되어야 합니다'); } /* |-------------------------------------------------------------------------- | 마이그레이션 컨텍스트 가드 (빈 DB 배포 안전성) |-------------------------------------------------------------------------- | | INSTALLER_COMPLETED=true .env 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` | 부팅 시, fast-path 가 테이블 존재를 잘못 전제해 뒤따르는 쿼리가 table not found 로 | 부팅을 깨뜨리던 결함을 막는다. 마이그레이션 계열 명령 컨텍스트에서는 | installer_completed=true 라도 fast-path 를 우회해 hasTable 실검증 경로로 진입해야 한다. | (RefreshDatabase 는 테이블을 만들므로 실제 not-found 는 재현 불가 → 가드 발동으로 | hasTable 쿼리가 다시 발생하는지를 쿼리카운트로 검증) */ public function test_module_trait_uses_has_table_during_migration_despite_installer_completed(): void { config(['app.installer_completed' => true]); $this->withMigrationArgv(function () { $schemaQueries = $this->captureSchemaTableQueries( fn () => ModuleManager::getActiveModuleIdentifiers() ); $this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 hasTable 폴백 경로로 진입해야 합니다'); }); } public function test_plugin_trait_uses_has_table_during_migration_despite_installer_completed(): void { config(['app.installer_completed' => true]); $this->withMigrationArgv(function () { $schemaQueries = $this->captureSchemaTableQueries( fn () => PluginManager::getActivePluginIdentifiers() ); $this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 hasTable 폴백 경로로 진입해야 합니다'); }); } public function test_template_trait_uses_has_table_during_migration_despite_installer_completed(): void { config(['app.installer_completed' => true]); $this->withMigrationArgv(function () { $schemaQueries = $this->captureSchemaTableQueries( fn () => TemplateManager::getActiveTemplateIdentifiers() ); $this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 hasTable 폴백 경로로 진입해야 합니다'); }); } public function test_module_route_provider_uses_has_table_during_migration_despite_installer_completed(): void { config(['app.installer_completed' => true]); $provider = new ModuleRouteServiceProvider($this->app); $method = (new ReflectionClass($provider))->getMethod('loadModuleRoutes'); $method->setAccessible(true); $this->withFakeAppRoot(function () use ($provider, $method) { $this->withMigrationArgv(function () use ($provider, $method) { $schemaQueries = $this->captureSchemaTableQueries( fn () => $method->invoke($provider) ); $this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 modules hasTable 검증 경로로 진입해 무방비 pluck 을 막아야 합니다'); }); }); } public function test_plugin_route_provider_uses_has_table_during_migration_despite_installer_completed(): void { config(['app.installer_completed' => true]); $provider = new PluginRouteServiceProvider($this->app); $method = (new ReflectionClass($provider))->getMethod('loadPluginRoutes'); $method->setAccessible(true); $this->withFakeAppRoot(function () use ($provider, $method) { $this->withMigrationArgv(function () use ($provider, $method) { $schemaQueries = $this->captureSchemaTableQueries( fn () => $method->invoke($provider) ); $this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 plugins hasTable 검증 경로로 진입해야 합니다'); }); }); } }