Files
Gnuboard7/tests/Unit/Extension/PluginMenuSyncTest.php
T
HeuJung cfc2caba1b feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
공개 (@Tuwasduliebst 제보) 대응.

- ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰,
 filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인,
 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본
 설정 백필 upgrade step
- board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI,
 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정
- ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정
- page: 임시 첨부 GC
- 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시
 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자
 메뉴 설치·업데이트 자동 동기화(F1·F7)
- 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정
 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서,
 hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
2026-08-17 02:44:54 +09:00

194 lines
7.2 KiB
PHP

<?php
namespace Tests\Unit\Extension;
use App\Enums\ExtensionOwnerType;
use App\Extension\AbstractPlugin;
use App\Extension\PluginManager;
use App\Models\Menu;
use Illuminate\Foundation\Testing\RefreshDatabase;
use ReflectionMethod;
use Tests\TestCase;
/**
* 메뉴 동기화 검증용 플러그인 스텁.
*
* 실제 플러그인 디렉토리를 만들지 않고 식별자만 고정하기 위해 `getPluginPath()` 를 덮어쓴다
* (AbstractPlugin 은 경로 basename 으로 식별자를 정한다).
*/
class MenuSyncProbePlugin extends AbstractPlugin
{
/**
* @param array<int, array<string, mixed>> $menus 선언할 관리자 메뉴
*/
public function __construct(private array $menus = []) {}
/**
* @return string 식별자를 결정하는 가상 경로
*/
protected function getPluginPath(): string
{
return base_path('plugins/sirsoft-menu_sync_probe');
}
/**
* @return array<int, array<string, mixed>> 관리자 메뉴 선언
*/
public function getAdminMenus(): array
{
return $this->menus;
}
}
/**
* 플러그인 관리자 메뉴가 선언형 산출물 동기화로 만들어지는지 검증 (회귀)
*
* 메뉴 동기화가 활성화(activate) 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을
* 업데이트해 새 화면을 받아도 메뉴가 생기지 않는다. 그 화면은 주소를 직접 입력해야만 닿을 수
* 있어 운영자에게는 없는 기능이 된다(브라우저 실측 — 업데이트 반영 후 메뉴 0건).
*
* 설치·업데이트가 공통으로 지나는 syncDeclarativeArtifacts 에서 메뉴를 동기화하는지 본다.
* 파일시스템(활성 디렉토리)은 건드리지 않고 DB 만 확인한다.
*/
class PluginMenuSyncTest extends TestCase
{
use RefreshDatabase;
/**
* 파일 무접촉으로 메뉴 동기화 메서드만 호출합니다.
*
* @param object $plugin 대상 플러그인 스텁
*/
private function syncMenus(object $plugin): void
{
$method = new ReflectionMethod(PluginManager::class, 'createPluginMenus');
$method->setAccessible(true);
$method->invoke(app(PluginManager::class), $plugin);
}
/**
* getAdminMenus 를 선언한 플러그인 스텁을 만듭니다.
*
* @param array<int, array<string, mixed>> $menus 선언할 메뉴 목록
*/
private function makePlugin(array $menus): object
{
return new MenuSyncProbePlugin($menus);
}
/**
* PluginManager 메서드 본문이 호출하는 `$this->...()` 이름을 원문에서 수집합니다.
*
* 단계 목록을 테스트에 손으로 적어두면 새 산출물 종류가 늘었을 때 그 사실이 검사에
* 반영되지 않으므로, 두 경로 모두 소스에서 도출해 비교한다.
*
* @param string $method 대상 메서드명
* @return array<int, string> 호출된 메서드명 목록 (중복 제거)
*/
private function selfCallsIn(string $method): array
{
$reflection = new ReflectionMethod(PluginManager::class, $method);
$source = implode('', array_slice(
file($reflection->getFileName()),
$reflection->getStartLine() - 1,
$reflection->getEndLine() - $reflection->getStartLine() + 1,
));
preg_match_all('/\$this->([A-Za-z_][A-Za-z0-9_]*)\s*\(/', $source, $matches);
return array_values(array_unique($matches[1]));
}
/**
* 설치 경로가 선언형 산출물 동기화 단계를 빠짐없이 지나는지 (회귀)
*
* 메뉴 동기화를 syncDeclarativeArtifacts 에만 넣으면 그 경로를 지나는 것은 업데이트뿐이라,
* 신규 설치는 플러그인이 activate() 를 직접 구현한 경우에만 메뉴가 생긴다. AbstractPlugin
* 의 기본 activate() 는 `return true` 뿐이므로, getAdminMenus() 만 선언한 플러그인은
* 설치·활성화를 마쳐도 메뉴가 없고 예외도 경고도 남지 않는다. 모듈은 installModule() 이
* createModuleMenus() 를 직접 호출해 이 구멍이 없다.
*
* 단계 목록은 syncDeclarativeArtifacts 원문에서 도출하므로, 앞으로 선언형 산출물이
* 추가될 때 설치 경로에 반영하지 않으면 그 사실이 여기서 드러난다.
*
* @effects plugin_install_path_syncs_declared_menus
*/
public function test_install_path_covers_every_declarative_sync_step(): void
{
// stale 정리는 기존 레코드를 대상으로 하므로 신규 설치 경로에는 대상이 없다.
$required = array_diff($this->selfCallsIn('syncDeclarativeArtifacts'), ['cleanupStalePluginEntries']);
$missing = array_values(array_diff($required, $this->selfCallsIn('installPlugin')));
$this->assertSame(
[],
$missing,
'installPlugin() 이 선언형 산출물 동기화 단계를 건너뛰었다: '.implode(', ', $missing),
);
}
/**
* @scenario declaration=declared, run=first_run
*
* @effects plugin_admin_menu_synced_on_declarative_sync
*/
public function test_declared_admin_menu_is_created(): void
{
$this->syncMenus($this->makePlugin([[
'name' => ['ko' => '검수 메뉴', 'en' => 'Probe Menu'],
'slug' => 'menu-sync-probe',
'url' => '/admin/plugins/sirsoft-menu_sync_probe/probe',
'icon' => 'fas fa-vial',
'order' => 50,
]]));
$menu = Menu::where('slug', 'menu-sync-probe')->first();
$this->assertNotNull($menu, '플러그인이 선언한 관리자 메뉴가 생성되어야 한다.');
$this->assertSame(ExtensionOwnerType::Plugin->value, $menu->extension_type instanceof ExtensionOwnerType
? $menu->extension_type->value
: $menu->extension_type);
$this->assertSame('sirsoft-menu_sync_probe', $menu->extension_identifier);
$this->assertSame('/admin/plugins/sirsoft-menu_sync_probe/probe', $menu->url);
}
/**
* 재호출은 행을 중복 생성하지 않는다 (업데이트마다 실행되므로 멱등이어야 한다).
*
* @scenario declaration=declared, run=repeated_run
*
* @effects plugin_admin_menu_sync_is_idempotent
*/
public function test_repeated_sync_does_not_duplicate_menu(): void
{
$plugin = $this->makePlugin([[
'name' => ['ko' => '검수 메뉴', 'en' => 'Probe Menu'],
'slug' => 'menu-sync-probe',
'url' => '/admin/plugins/sirsoft-menu_sync_probe/probe',
'order' => 50,
]]);
$this->syncMenus($plugin);
$this->syncMenus($plugin);
$this->assertSame(1, Menu::where('slug', 'menu-sync-probe')->count());
}
/**
* 메뉴를 선언하지 않은 플러그인은 아무것도 만들지 않는다.
*
* @scenario declaration=not_declared, run=first_run
*
* @effects plugin_without_menu_declaration_creates_nothing
*/
public function test_plugin_without_menus_creates_nothing(): void
{
$before = Menu::count();
$this->syncMenus($this->makePlugin([]));
$this->assertSame($before, Menu::count());
}
}