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종
This commit is contained in:
HeuJung committed 2026-08-17 02:44:54 +09:00
1 parent 1d895a6428
commit cfc2caba1b
170 files changed
+11142 -209

No files matched your search

@@ -10,6 +10,7 @@ use App\Models\Attachment;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\AttachmentService;
use App\Services\SettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
@@ -1497,6 +1498,7 @@ class SettingsControllerTest extends TestCase
$app->make(ConfigRepositoryInterface::class),
$app->make(AttachmentRepositoryInterface::class),
$app->make(CacheInterface::class),
$app->make(AttachmentService::class),
);
}
@@ -1596,6 +1598,7 @@ class SettingsControllerTest extends TestCase
$app->make(ConfigRepositoryInterface::class),
$app->make(AttachmentRepositoryInterface::class),
$app->make(CacheInterface::class),
$app->make(AttachmentService::class),
);
$this->stub = $stub;
}
@@ -1650,6 +1653,7 @@ class SettingsControllerTest extends TestCase
$app->make(ConfigRepositoryInterface::class),
$app->make(AttachmentRepositoryInterface::class),
$app->make(CacheInterface::class),
$app->make(AttachmentService::class),
);
}
@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* DB 백업 엔드포인트의 미제공 응답 검증 (공개 #115 부록 B3)
*
* `POST /api/admin/settings/backup-database` 는 존재하지 않는
* `SettingsService::backupDatabase()` 를 호출해 왔다. PHP 가 던지는 것은 `Error` 라
* 컨트롤러의 `catch (\Exception)` 에 걸리지 않고 그대로 500 이 됐다.
* 화면 버튼이 없어 운영 중에는 드러나지 않지만, 공개 API 레퍼런스에는 정상 기능으로
* 실려 있어 문서를 보고 호출한 쪽에는 무조건 서버 오류였다.
*
* 기능은 아직 없으므로 "서버가 고장났다"(500)가 아니라 "이 기능은 제공하지 않는다"(501)로
* 정직하게 답하는 것이 계약이다.
*/
class SettingsDatabaseBackupTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->admin = $this->createAdminUser();
$this->token = $this->admin->createToken('test-token')->plainTextToken;
}
/**
* 설정 갱신 권한을 가진 관리자 생성
*/
private function createAdminUser(): User
{
$user = User::factory()->create([
'password' => Hash::make('password123'),
]);
$permissionIds = [];
foreach (['core.settings.read', 'core.settings.update'] as $permIdentifier) {
$permission = Permission::firstOrCreate(
['identifier' => $permIdentifier],
[
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
'description' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$roleIdentifier = 'admin_test_'.uniqid();
$adminRole = Role::create([
'identifier' => $roleIdentifier,
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']),
'description' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'is_active' => true,
]);
$adminBaseRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]
);
$adminRole->permissions()->sync($permissionIds);
$user->roles()->attach($adminBaseRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
$user->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
return $user->fresh();
}
/**
* 인증된 요청 헬퍼
*/
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
/**
* 미제공 기능은 500 이 아니라 501 로 응답해야 한다.
*/
#[Test]
public function database_backup_responds_with_not_implemented(): void
{
$response = $this->authRequest()->postJson('/api/admin/settings/backup-database');
$response->assertStatus(501);
$response->assertJson(['success' => false]);
}
/**
* 응답 메시지는 다국어 키가 해석된 사람 말이어야 한다.
*
* 키 원문(`settings.database_backup_unavailable`)이 그대로 나가면 호출자는
* 무슨 상태인지 알 수 없다.
*/
#[Test]
public function not_implemented_response_carries_a_translated_message(): void
{
$response = $this->authRequest()->postJson('/api/admin/settings/backup-database');
$message = (string) $response->json('message');
$this->assertNotSame('', $message);
$this->assertStringNotContainsString('settings.', $message);
$this->assertSame(__('settings.database_backup_unavailable'), $message);
}
/**
* 미구현 응답으로 바꾸더라도 권한 경계는 그대로여야 한다.
*
* 501 을 먼저 돌려주느라 인증·권한 검사를 건너뛰면, 권한 없는 요청에까지
* 내부 구현 상태를 알려주게 된다.
*/
#[Test]
public function guest_still_receives_unauthenticated(): void
{
$response = $this->withHeaders(['Accept' => 'application/json'])
->postJson('/api/admin/settings/backup-database');
$response->assertStatus(401);
}
}
@@ -0,0 +1,282 @@
<?php
namespace Tests\Feature\Console;
use App\Enums\AttachmentSourceType;
use App\Extension\Storage\CoreStorageDriver;
use App\Models\Attachment;
use App\Services\AttachmentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 고아 첨부 정리 커맨드 테스트
*
* 판정식이 하나라도 느슨해지면 현역 첨부가 지워진다. 네 조건(소유자 없음 / 보존기간 경과 /
* 확장 소유 아님 / 현역 아님)을 각각 회귀로 고정합니다.
*/
class PruneOrphanAttachmentsCommandTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// 개발 환경 설정 파일에 실제 사이트 로고가 남아 있으면 그 ID 가 보호 목록에 실려
// 같은 ID 로 생성된 픽스처까지 보호된다. 보호 목록은 각 테스트가 명시적으로 정한다.
Config::set('g7_settings.core.general.site_logo', []);
}
/**
* 첨부 파일과 기록을 함께 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @param int $ageDays 업로드 경과 일수
* @return Attachment 생성된 첨부
*/
private function makeAttachment(array $attributes = [], int $ageDays = 40): Attachment
{
$filename = uniqid('orphan-').'.png';
$path = "2026/08/14/{$filename}";
$disk = config('attachment.disk');
(new CoreStorageDriver($disk))->put('', $path, 'bytes');
$attachment = Attachment::create(array_merge([
'attachmentable_type' => null,
'attachmentable_id' => null,
'source_type' => AttachmentSourceType::Core,
'source_identifier' => null,
'original_filename' => $filename,
'stored_filename' => $filename,
'disk' => $disk,
'path' => $path,
'mime_type' => 'image/png',
'size' => 5,
'collection' => 'default',
'order' => 0,
], $attributes));
$attachment->forceFill(['created_at' => now()->subDays($ageDays)])->saveQuietly();
return $attachment->refresh();
}
/**
* @effects core_orphan_prune_gate_off_by_default
*/
#[Test]
public function scheduled_run_deletes_nothing_when_the_toggle_is_off(): void
{
$attachment = $this->makeAttachment();
$this->artisan('attachments:prune-orphans --scheduled')->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* @effects core_orphan_prune_gate_on
*/
#[Test]
public function scheduled_run_prunes_when_the_toggle_is_on(): void
{
Config::set('g7_settings.core.upload.orphan_cleanup_enabled', true);
Config::set('g7_settings.core.upload.orphan_retention_days', 30);
$attachment = $this->makeAttachment();
$this->artisan('attachments:prune-orphans --scheduled')->assertSuccessful();
$this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
}
/**
* 소유자가 있는 첨부는 어떤 경우에도 고아가 아니다.
*
* @scenario age=past_retention, owner_state=has_owner, protected=not_protected, source_owner=core
*
* @effects core_orphan_prune_skips_owned
*/
#[Test]
public function attachment_with_an_owner_is_never_pruned(): void
{
$attachment = $this->makeAttachment([
'attachmentable_type' => 'App\\Models\\User',
'attachmentable_id' => 1,
]);
$this->artisan('attachments:prune-orphans --days=1')->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* 확장이 소유한 첨부는 그 확장의 라이프사이클 소관이라 코어가 지우지 않는다.
*
* @scenario age=past_retention, owner_state=no_owner, protected=not_protected, source_owner=extension
*
* @effects core_orphan_prune_skips_extension_owned
*/
#[Test]
public function attachment_owned_by_an_extension_is_never_pruned(): void
{
$attachment = $this->makeAttachment([
'source_type' => AttachmentSourceType::Module,
'source_identifier' => 'sirsoft-board',
]);
$this->artisan('attachments:prune-orphans --days=1')->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* 현역 사이트 로고는 소유자가 없어도 설정이 직접 참조하므로 보호해야 한다.
*
* @scenario age=past_retention, owner_state=no_owner, protected=current_site_logo, source_owner=core
*
* @effects core_orphan_prune_protects_site_logo
*/
#[Test]
public function the_current_site_logo_is_protected(): void
{
$logo = $this->makeAttachment(['collection' => 'site_logo']);
$orphan = $this->makeAttachment();
Config::set('g7_settings.core.general.site_logo', [$logo->id]);
$this->artisan('attachments:prune-orphans --days=1')->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $logo->id]);
$this->assertDatabaseMissing('attachments', ['id' => $orphan->id]);
}
/**
* 로고 보호는 커맨드를 거치지 않아도 성립해야 한다 (조회의 불변식).
*
* 보호 대상을 호출자가 넘기는 값으로 두면 그 인자를 빠뜨린 호출 하나가 운영 중인 로고를
* 파기한다. 나머지 세 조건은 조회 자체의 불변식이므로 보호 조건만 규약으로 남을 수 없다.
*
* @scenario age=past_retention, owner_state=no_owner, protected=current_site_logo, source_owner=core
*
* @effects core_orphan_prune_protects_site_logo
*/
#[Test]
public function the_site_logo_is_protected_even_when_the_service_is_called_directly(): void
{
$logo = $this->makeAttachment(['collection' => 'site_logo']);
$orphan = $this->makeAttachment();
Config::set('g7_settings.core.general.site_logo', [$logo->id]);
app(AttachmentService::class)->pruneOrphans(1, 500);
$this->assertDatabaseHas('attachments', ['id' => $logo->id]);
$this->assertDatabaseMissing('attachments', ['id' => $orphan->id]);
}
/**
* 보호는 **현역** 로고에만 걸린다 — 설정에서 빠진 구 로고는 회수 대상이다.
*
* 로고 교체는 저장이 성공한 뒤에야 구 파일을 파기한다. 저장이 실패하면 파기하지 않으므로
* (설정에는 없는데 파일만 남는 상태를 막기 위한 순서다) 그렇게 남은 첨부를 거둘 마지막
* 통로가 이 GC 다. 보호 조건이 컬렉션 전체로 넓어지면 그 통로가 막혀 구 로고가 영구
* 잔존하므로, 보호 방향(현역 보존)과 반대 방향(구 로고 회수)을 함께 고정한다.
*
* @scenario age=past_retention, owner_state=no_owner, protected=not_protected, source_owner=core
*
* @effects core_orphan_prune_collects_replaced_site_logo
*/
#[Test]
public function a_replaced_site_logo_is_collected_once_it_leaves_the_setting(): void
{
$previous = $this->makeAttachment(['collection' => 'site_logo']);
$current = $this->makeAttachment(['collection' => 'site_logo']);
// 교체 후 설정에는 새 로고만 남아 있다.
Config::set('g7_settings.core.general.site_logo', [$current->id]);
$this->artisan('attachments:prune-orphans --days=1')->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $current->id]);
$this->assertDatabaseMissing('attachments', ['id' => $previous->id]);
}
/**
* @scenario age=within_retention, owner_state=no_owner, protected=not_protected, source_owner=core
*
* @effects core_orphan_prune_keeps_within_retention
*/
#[Test]
public function attachment_within_retention_is_kept(): void
{
$attachment = $this->makeAttachment(ageDays: 3);
$this->artisan('attachments:prune-orphans --days=30')->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* @effects core_orphan_prune_dry_run
*/
#[Test]
public function dry_run_reports_targets_without_deleting(): void
{
$attachment = $this->makeAttachment();
$this->artisan('attachments:prune-orphans --days=1 --dry-run')
->expectsOutputToContain('[DRY RUN]')
->assertSuccessful();
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=past_retention, owner_state=no_owner, protected=not_protected, source_owner=core
*
* @effects core_orphan_prune_deletes_file
*/
#[Test]
public function pruning_removes_the_physical_file_as_well(): void
{
$attachment = $this->makeAttachment();
$storage = new CoreStorageDriver($attachment->disk);
$this->assertTrue($storage->exists('', $attachment->path));
$this->artisan('attachments:prune-orphans --days=1')->assertSuccessful();
$this->assertFalse($storage->exists('', $attachment->path));
}
/**
* 소프트삭제된 고아도 회수된다.
*
* 후보 조회는 소프트삭제 행까지 포함하므로, 삭제 경로가 기본 조회를 쓰면 대상을 찾지 못해
* 매 회차 같은 행을 실패로만 계상하고 파일·기록이 영원히 남는다.
*
* @scenario age=past_retention, owner_state=no_owner, protected=not_protected, source_owner=core
*
* @effects core_orphan_prune_deletes_soft_deleted
*/
#[Test]
public function soft_deleted_orphan_is_purged_too(): void
{
$attachment = $this->makeAttachment();
$storage = new CoreStorageDriver($attachment->disk);
$attachment->delete();
$this->assertNotNull(Attachment::withTrashed()->find($attachment->id));
$this->artisan('attachments:prune-orphans --days=1')->assertSuccessful();
$this->assertNull(Attachment::withTrashed()->find($attachment->id));
$this->assertFalse($storage->exists('', $attachment->path));
}
}
@@ -0,0 +1,189 @@
<?php
namespace Tests\Feature\Services;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Enums\AttachmentSourceType;
use App\Extension\Storage\CoreStorageDriver;
use App\Models\Attachment;
use App\Services\SettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 사이트 로고 교체 시 구 로고가 재편입되지 않는지 검증 (회귀)
*
* 저장할 목록은 **이번 요청이 제출한 것**이 정한다. 컬렉션 전체를 다시 수집하면 저장에
* 실패했거나 작성 중 이탈해 남은 미참조 첨부까지 설정에 되살아나, 운영자가 올린 적 없는
* 로고가 화면에 다시 나타난다(브라우저 실측 — 신규 1건만 제출했는데 저장 결과가 2건).
*/
class SettingsServiceSiteLogoTest extends TestCase
{
use RefreshDatabase;
/**
* site_logo 컬렉션 첨부를 파일과 함께 만듭니다.
*
* @return Attachment 생성된 첨부
*/
private function makeLogo(): Attachment
{
$filename = uniqid('logo-').'.png';
$path = "2026/08/14/{$filename}";
$disk = config('attachment.disk');
(new CoreStorageDriver($disk))->put('', $path, 'bytes');
return Attachment::create([
'attachmentable_type' => null,
'attachmentable_id' => null,
'source_type' => AttachmentSourceType::Core,
'source_identifier' => null,
'original_filename' => $filename,
'stored_filename' => $filename,
'disk' => $disk,
'path' => $path,
'mime_type' => 'image/png',
'size' => 5,
'collection' => 'site_logo',
'order' => 0,
]);
}
/**
* @effects site_logo_replacement_drops_previous
*/
#[Test]
public function replacing_the_logo_removes_the_previous_one_instead_of_re_collecting_it(): void
{
$service = app(SettingsService::class);
$config = app(ConfigRepositoryInterface::class);
// 1차 저장 — 첫 로고가 설정에 실린다.
$first = $this->makeLogo();
$service->saveSettings(['_tab' => 'general', 'general' => ['site_logo' => [$first->id]]]);
$this->assertSame([$first->id], $config->getCategory('general')['site_logo']);
// 2차 저장 — 운영자가 첫 로고를 빼고 새 로고를 올린 상태.
// 화면은 업로드를 끝낸 뒤 저장을 보내므로 새 로고 id 가 제출값에 실린다.
$second = $this->makeLogo();
$service->saveSettings(['_tab' => 'general', 'general' => ['site_logo' => [$second->id]]]);
$saved = $config->getCategory('general')['site_logo'];
$this->assertSame([$second->id], $saved, '설정에는 최신 로고만 남아야 한다.');
$this->assertDatabaseMissing('attachments', ['id' => $first->id]);
}
/**
* 저장에 실패했거나 이탈로 남은 미참조 첨부가 설정에 되살아나면 안 된다.
*
* @effects site_logo_unreferenced_collection_member_not_re_absorbed
*/
#[Test]
public function an_unreferenced_collection_member_is_not_re_absorbed(): void
{
$service = app(SettingsService::class);
$config = app(ConfigRepositoryInterface::class);
// 설정에 실린 적 없는 첨부 (직전 저장 실패로 남은 상태)
$orphan = $this->makeLogo();
// 운영자는 새 로고 1건만 올려 저장한다.
$fresh = $this->makeLogo();
$service->saveSettings(['_tab' => 'general', 'general' => ['site_logo' => [$fresh->id]]]);
$this->assertSame(
[$fresh->id],
$config->getCategory('general')['site_logo'],
'제출하지 않은 미참조 첨부가 설정에 편입되면 안 된다.'
);
$this->assertDatabaseHas('attachments', ['id' => $orphan->id]);
}
/**
* 제출값에 이미 삭제된 첨부 id 가 섞여 있어도 그 id 는 저장되지 않아야 한다.
*
* @effects site_logo_stale_submitted_id_filtered
*/
#[Test]
public function a_submitted_id_that_no_longer_exists_is_filtered_out(): void
{
$service = app(SettingsService::class);
$config = app(ConfigRepositoryInterface::class);
$logo = $this->makeLogo();
$service->saveSettings(['_tab' => 'general', 'general' => ['site_logo' => [$logo->id, 999999]]]);
$this->assertSame([$logo->id], $config->getCategory('general')['site_logo']);
}
/**
* site_logo 를 제출하지 않은 저장(다른 필드만 변경)은 로고를 건드리지 않아야 한다.
*
* @effects site_logo_untouched_when_not_submitted
*/
#[Test]
public function saving_other_fields_does_not_remove_the_logo(): void
{
$service = app(SettingsService::class);
$config = app(ConfigRepositoryInterface::class);
$logo = $this->makeLogo();
$service->saveSettings(['_tab' => 'general', 'general' => ['site_logo' => [$logo->id]]]);
$service->saveSettings(['_tab' => 'general', 'general' => ['site_name' => '테스트 사이트']]);
$this->assertSame([$logo->id], $config->getCategory('general')['site_logo']);
$this->assertDatabaseHas('attachments', ['id' => $logo->id]);
}
/**
* 저장이 실패하면 뺀 로고의 파일도 남아 있어야 한다 (회귀).
*
* 파기를 저장보다 먼저 하면, 저장이 실패했을 때 설정에는 구 로고 id 가 그대로 남는데
* 파일은 이미 사라져 로고가 깨진다. 파기는 저장이 확정된 뒤에만 수행해야 한다.
*
* @effects site_logo_failed_save_keeps_removed_attachment
*/
#[Test]
public function a_failed_save_does_not_destroy_the_removed_logo(): void
{
$config = app(ConfigRepositoryInterface::class);
// 로고가 저장된 상태를 만든다.
$logo = $this->makeLogo();
app(SettingsService::class)->saveSettings([
'_tab' => 'general',
'general' => ['site_logo' => [$logo->id]],
]);
$this->assertSame([$logo->id], $config->getCategory('general')['site_logo']);
// 저장 단계만 실패하도록 만든다 (조회는 실제 동작 유지).
$failing = \Mockery::mock($config);
$failing->shouldReceive('saveCategory')->andReturnFalse();
$this->app->instance(ConfigRepositoryInterface::class, $failing);
// 운영자가 로고를 빼고 저장 → 저장 실패
app(SettingsService::class)->saveSettings([
'_tab' => 'general',
'general' => ['site_logo' => []],
]);
$this->app->instance(ConfigRepositoryInterface::class, $config);
$this->assertDatabaseHas('attachments', ['id' => $logo->id]);
$this->assertTrue(
(new CoreStorageDriver($logo->disk))->exists('', $logo->path),
'저장이 실패했으면 파일도 남아 있어야 한다.'
);
$this->assertSame(
[$logo->id],
$config->getCategory('general')['site_logo'],
'저장이 실패했으므로 설정도 그대로여야 한다.'
);
}
}
@@ -0,0 +1,99 @@
/**
* E2E: 환경설정 > 업로드 — "고아 첨부 정리" 카드 (공개 #115)
*
* @scenario admin_settings_upload_orphan_cleanup
* @effects core_settings_orphan_toggle_default_off, core_settings_orphan_keys_declared
*
* 배경: 폼을 저장하지 않고 나가 소유자 없이 남은 첨부를 매일 파기하는 기능의 운영자 스위치다.
* 사용자 파일을 실제로 지우는 설정이라 "기본 꺼짐" 이 인수 기준이고, 그 상태는 서버 기본값과
* 화면 렌더가 함께 맞아야 성립한다. 단위 테스트(admin-settings-orphan-cleanup)는 레이아웃 JSON
* 의 바인딩 키만 검증하므로, 실제 값이 실려 렌더되는지는 브라우저로만 확인된다.
*
* 검증:
* 1. 업로드 탭에 카드가 마운트되고 항목명·설명이 raw 키가 아닌 번역문으로 표시된다
* 2. 자동 정리 토글이 기본 꺼짐으로 보인다 (설정을 만지지 않은 사이트의 인수 기준)
* 3. 보존기간에 0 을 넣고 저장하면 422 로 거절되고, 안내 문구에 항목명이 사람 말로 나온다
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const CARD = '#card_orphan_cleanup';
const TOGGLE_ROW = '#field_orphan_cleanup_enabled';
const RETENTION_ROW = '#field_orphan_retention_days';
/** 관리자 환경설정 업로드 탭 진입 */
async function gotoUploadTab(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=upload');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(CARD)).toBeAttached({ timeout: 20_000 });
}
// @scenario tab=upload, permitted=yes
// @effects core_settings_orphan_keys_declared
test('#115 - 업로드 탭에 "고아 첨부 정리" 카드가 번역문과 함께 마운트된다', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoUploadTab(page);
expect(page.url()).not.toMatch(/\/admin\/login/);
const card = page.locator(CARD);
const text = (await card.innerText()).trim();
// 다국어 키가 해석되지 않으면 "$t:admin.settings.upload.orphan_..." 원문이 그대로 노출된다.
expect(text).not.toContain('$t:');
expect(text).not.toContain('orphan_cleanup');
expect(text.length).toBeGreaterThan(0);
await expect(page.locator(TOGGLE_ROW)).toBeAttached();
await expect(page.locator(RETENTION_ROW)).toBeAttached();
expect(consoleErrors).toEqual([]);
});
// @scenario tab=upload, permitted=yes
// @effects core_settings_orphan_toggle_default_off
test('#115 - 자동 정리 토글은 기본 꺼짐으로 렌더된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoUploadTab(page);
const toggle = page.locator(TOGGLE_ROW).locator('input[type="checkbox"]').first();
await expect(toggle).toBeAttached();
// 설정을 만지지 않은 사이트에서 자동 삭제 0 이 인수 기준이다 — 화면도 그 상태를 보여야 한다.
expect(await toggle.isChecked()).toBe(false);
});
// @scenario tab=upload, permitted=yes, value=invalid
// @effects core_settings_orphan_keys_declared
test('#115 - 보존기간 0 은 항목명이 드러난 안내와 함께 거절된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoUploadTab(page);
const input = page.locator(RETENTION_ROW).locator('input').first();
await input.fill('0');
const response = page.waitForResponse(
(res) => res.url().includes('/api/admin/settings') && res.request().method() === 'POST',
{ timeout: 20_000 },
);
await page.getByRole('button', { name: /저장|Save/ }).first().click();
const res = await response;
expect(res.status()).toBe(422);
const body = await res.json();
const message = JSON.stringify(body);
// 원시 키(`upload.orphan retention days`)가 그대로 나오면 attributes 정의가 빠진 것이다.
expect(message).not.toContain('orphan retention days');
});
+193
View File
@@ -0,0 +1,193 @@
<?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());
}
}
@@ -0,0 +1,79 @@
feature: 코어 고아 첨부 회수 (Orphan Attachment Cleanup)
description: |
폼 저장 전에 즉시 업로드되는 첨부(FileUploader 등)는 소유자 없이 먼저 만들어지므로,
폼을 저장하지 않고 이탈하면 파일과 기록이 그대로 남는다. 공개 이슈 #115 동류 결함.
판정식은 네 조건의 AND 다 — 하나라도 느슨해지면 현역 첨부가 지워진다.
1. 소유자(attachmentable_type/id)가 없다
2. 업로드 후 보존기간이 지났다 (폼 작성 중 유예)
3. 확장 소유가 아니다 (source_identifier 없음 — 확장 라이프사이클 소관)
4. 현역으로 쓰이는 첨부가 아니다 (현재 사이트 로고 등 보호 목록)
사이트 로고는 소유자 없이 설정값이 직접 참조하는 첨부라 판정식만으로는 고아와 구분되지
않는다. 그래서 설정에 실린 ID 를 보호 목록으로 삼으며, 그 해석은 정리 서비스 자신이
수행한다 — 호출자가 넘기는 값으로 두면 인자를 빠뜨린 호출 하나가 운영 중인 로고를 파기한다.
함께 고친 것: 로고 교체 시 구 로고가 설정에 재편입되던 결함. 종전 저장 흐름은 컬렉션
전체를 다시 수집했으므로(방금 올린 로고가 제출 payload 에 아직 없어서), 운영자가 화면에서
뺀 로고를 실제로 파기하지 않으면 교체할 때마다 파일·기록·설정 세 곳에 누적됐다.
지금은 이번 요청이 제출한 목록이 저장 대상을 정한다 — sub_flow `site_logo_replacement` 참조.
axes:
owner_state: [no_owner, has_owner]
source_owner: [core, extension]
age: [within_retention, past_retention]
protected: [not_protected, current_site_logo]
exclusions:
- { owner_state: has_owner, source_owner: extension, reason: "소유자가 있으면 source 와 무관하게 대상이 아니다 — 코어 소유 1건으로 충분" }
- { owner_state: has_owner, age: within_retention, reason: "소유자가 있으면 나이 판정에 도달하지 않는다" }
- { owner_state: has_owner, protected: current_site_logo, reason: "사이트 로고는 소유자 없이 설정이 직접 참조하는 첨부라 소유자 보유와 양립하지 않는다" }
- { source_owner: extension, age: within_retention, reason: "확장 소유는 나이와 무관하게 코어 GC 대상이 아니다 — 경과분 1건으로 충분" }
- { source_owner: extension, protected: current_site_logo, reason: "사이트 로고는 코어 소유 컬렉션이라 확장 소유와 양립하지 않는다" }
- { protected: current_site_logo, age: within_retention, reason: "보호 목록은 보존기간 경과분에서만 판정에 관여한다 — 미경과분은 나이에서 이미 제외" }
sub_flows:
- id: dry_run
description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로
effects:
- core_orphan_prune_dry_run
- id: schedule_gate
description: |
자동 정리는 사용자 파일을 지우므로 기본 꺼짐이다. 커맨드가 `--scheduled` 에서
설정을 false 폴백으로 확인한 뒤에만 수행한다.
effects:
- core_orphan_prune_gate_off_by_default
- core_orphan_prune_gate_on
- id: site_logo_replacement
description: |
저장할 로고 목록은 이번 요청이 제출한 것이 정한다. 컬렉션 전체를 다시 훑으면 저장에
실패했거나 이탈로 남은 미참조 첨부가 설정에 되살아난다(브라우저 실측 — 신규 1건만
제출했는데 저장 결과가 2건).
effects:
- site_logo_replacement_drops_previous
- site_logo_unreferenced_collection_member_not_re_absorbed
- site_logo_stale_submitted_id_filtered
- site_logo_untouched_when_not_submitted
- site_logo_failed_save_keeps_removed_attachment
- id: settings_surface
description: 화면 바인딩 키가 코어 기본값·서버 검증과 일치하는지
effects:
- core_settings_orphan_toggle_default_off
- core_settings_orphan_keys_declared
effects:
- core_orphan_prune_skips_owned
- core_orphan_prune_skips_extension_owned
- core_orphan_prune_protects_site_logo
- core_orphan_prune_collects_replaced_site_logo
- core_orphan_prune_keeps_within_retention
- core_orphan_prune_deletes_file
- core_orphan_prune_deletes_soft_deleted
test_files:
- tests/Feature/Console/PruneOrphanAttachmentsCommandTest.php
- tests/Feature/Services/SettingsServiceSiteLogoTest.php
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-orphan-cleanup.test.ts
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-logo-remove-expression.test.ts
- tests/Playwright/specs/admin/settings-upload-orphan-cleanup.spec.ts
@@ -0,0 +1,167 @@
feature: 에디터 업로드 이미지 미참조 정리 (CKEditor5 Unused Image Cleanup)
description: |
에디터로 올렸지만 어떤 콘텐츠에서도 쓰이지 않는 이미지를 파일·기록 함께 회수한다.
공개 이슈 #115 — 종전에는 회수 경로가 플러그인 언인스톨(테이블 드롭)뿐이었다.
핵심 동작:
- 참조 판정 토큰 2종: hash(API 폴백 URL형) + 저장 파일명(디스크 직접 URL형).
한쪽만 검사하면 다른 형태로 저장된 이미지를 미참조로 오판해 삭제한다.
- 참조 소스는 코어 6개 + 확장이 filter 훅으로 등록(게시글/페이지/상품 설명).
로그 사본 테이블은 제외 — 소스로 삼으면 "로그 삭제 → 이미지 고아화" 역전이 생긴다.
- 삭제 순서는 파일 먼저, 기록 나중. 파일이 이미 없으면 고아 기록이므로 기록만 지운다
(그러지 않으면 매 회차 같은 행을 재시도하는 루프가 된다).
- 자동 정리는 기본 꺼짐(운영자 옵트인). 스케줄 게이트가 설정 조회 실패 시 true 로
폴백하므로 커맨드가 `--scheduled` 에서 false 폴백으로 재확인한다.
- 관리 화면은 참조 상태를 저장하지 않고 조회 시 판정한다(참조는 수시 변동).
기본 목록은 현재 페이지만, 참조 필터는 최신 500건 윈도우 + scan_limited 고지.
# axes 는 "한 업로드 행의 정리 여부를 가르는 판정 입력" 만 둔다 — 이 셋은 한 테스트가
# 동시에 지정하는 값이라 조합마다 실제 케이스가 대응된다.
# 실행 경로(스케줄 게이트 / 수동 CLI / 관리 화면)와 dry-run 은 판정과 직교한 흐름이라
# sub_flows 로 분리했다. 한 축으로 묶으면 "화면 삭제 + dry-run" 같은 존재하지 않는 조합이
# cross product 에 생기고, 그 조합에 @scenario 를 붙이는 순간 마킹이 사실이 아니게 된다.
axes:
url_variant: [hash_api_fallback, direct_url_uuid, absent]
age: [within_retention, past_retention]
disk_state: [default_local, secondary_disk, file_missing]
exclusions:
- { url_variant: hash_api_fallback, disk_state: file_missing, reason: "참조된 이미지는 삭제 대상이 아니라 파일 부재 분기(고아 기록 수거)에 도달하지 않는다" }
- { url_variant: direct_url_uuid, disk_state: file_missing, reason: "동일" }
- { url_variant: hash_api_fallback, age: within_retention, reason: "보존기간 안이면 참조 판정 전에 후보에서 빠진다 — 참조 변종은 경과분에서만 의미가 있다" }
- { url_variant: direct_url_uuid, age: within_retention, reason: "동일" }
- { url_variant: hash_api_fallback, disk_state: secondary_disk, reason: "참조 보존은 파일을 건드리지 않으므로 disk 축과 무관 — 기본 디스크 1건으로 충분" }
- { url_variant: direct_url_uuid, disk_state: secondary_disk, reason: "동일" }
- { age: within_retention, disk_state: secondary_disk, reason: "보존기간 안이면 후보 조회 단계에서 빠져 파일 접근에 도달하지 않는다 — disk 상태가 결과를 바꾸지 않는다" }
- { age: within_retention, disk_state: file_missing, reason: "동일" }
effects:
# 참조 판정
- reference_sources_default_core_only
- reference_sources_hook_append
- reference_match_hash_api_url
- reference_match_direct_url
- reference_unreferenced
- reference_like_escape
- reference_invalid_source_skipped
- reference_invalid_source_warns
- reference_map_batch
- reference_source_listener_subscribes_as_filter
- reference_source_listener_appends_without_dropping
- reference_source_listener_schema_contract
# 정리 동작
- prune_deletes_file_and_record
- prune_keeps_referenced_hash_form
- prune_keeps_referenced_direct_url
- prune_keeps_within_retention
- prune_dry_run_no_delete
- prune_limit_oldest_first
- prune_missing_file_removes_record
- prune_delete_failure_keeps_record
- prune_logs_run_summary
- prune_respects_row_disk
- delete_upload_shared_by_ui_and_command
# 스케줄 게이트 (인수 기준: 설정을 만지지 않은 사이트에서 자동 삭제 0)
- schedule_gate_off_by_default
- schedule_gate_off_explicit
- schedule_gate_on
- retention_disabled_guard
- days_option_overrides_setting
- manual_run_dry_run_no_delete
- manual_run_ignores_toggle
# 관리 API
- admin_index_lists_uploads
- admin_index_column_pruning
- admin_index_referenced_badge
- admin_index_unreferenced_filter
- admin_index_scan_window_limited
- admin_index_rejects_unknown_sort
- admin_index_requires_read_permission
- admin_index_requires_authentication
- admin_destroy_removes_file_and_record
- admin_destroy_not_found
- admin_destroy_requires_delete_permission
- admin_bulk_delete_removes_selected
- admin_bulk_delete_rejects_empty
- admin_bulk_delete_rejects_unknown_id
# 화면 계약
- admin_ui_selection_scope_page
- admin_ui_merge_query
- admin_ui_sort_subset
- admin_ui_reference_badge
- settings_cleanup_toggle_default_off
- settings_cleanup_keys_declared
- settings_cleanup_retention_round_trip
sub_flows:
- id: schedule_gate
description: |
자동 정리는 사용자 파일을 지우므로 기본 꺼짐이다. 스케줄 등록부의 enabled_config 게이트는
설정 조회가 실패하면 true 로 폴백하므로, 커맨드가 `--scheduled` 에서 같은 설정을 false
폴백으로 재확인한다. 인수 기준은 "설정을 만지지 않은 사이트에서 자동 삭제 0".
effects:
- schedule_gate_off_by_default
- schedule_gate_off_explicit
- schedule_gate_on
- manual_run_ignores_toggle
- id: retention_resolution
description: 보존기간 해석 순서(옵션 > 플러그인 설정 > 기본 30일)와 1일 미만 자기 차단
effects:
- retention_disabled_guard
- days_option_overrides_setting
- id: dry_run
description: 판정만 수행하고 파일·기록을 건드리지 않는 미리보기 경로
effects:
- prune_dry_run_no_delete
- manual_run_dry_run_no_delete
- id: admin_api
description: 관리 화면이 쓰는 조회·단건 삭제·일괄 삭제 API 와 권한 경계
effects:
- admin_index_lists_uploads
- admin_index_referenced_badge
- admin_index_unreferenced_filter
- admin_index_scan_window_limited
- admin_index_rejects_unknown_sort
- admin_index_requires_read_permission
- admin_index_requires_authentication
- admin_destroy_removes_file_and_record
- admin_destroy_not_found
- admin_destroy_requires_delete_permission
- admin_bulk_delete_removes_selected
- admin_bulk_delete_rejects_empty
- admin_bulk_delete_rejects_unknown_id
- delete_upload_shared_by_ui_and_command
- id: reference_source_registry
description: 참조 소스 확장점(filter 훅)과 불량 선언 방어
effects:
- reference_sources_default_core_only
- reference_sources_hook_append
- reference_invalid_source_skipped
- reference_invalid_source_warns
- reference_map_batch
- reference_source_listener_subscribes_as_filter
- reference_source_listener_appends_without_dropping
- reference_source_listener_schema_contract
- id: admin_screen_contract
description: 화면이 지키기로 한 계약 (선택 범위 / 목록 상태 승계 / 정렬 부분집합 / 설정 기본값)
effects:
- admin_ui_selection_scope_page
- admin_ui_merge_query
- admin_ui_sort_subset
- admin_ui_reference_badge
- settings_cleanup_toggle_default_off
- settings_cleanup_keys_declared
- settings_cleanup_retention_round_trip
test_files:
- plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageReferenceScanServiceTest.php
- plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Services/ImageCleanupServiceTest.php
- plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Console/PruneUnusedImagesCommandTest.php
- plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/Admin/ImageUploadAdminControllerTest.php
- plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/ckeditor5Uploads.test.tsx
- plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsCleanup.test.tsx
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/uploads-admin.spec.ts
- modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
@@ -0,0 +1,45 @@
feature: 플러그인 관리자 메뉴 동기화 (Plugin Admin Menu Sync)
description: |
플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 로 관리자 메뉴를 선언한다.
그 선언을 실제 메뉴 행으로 만드는 것은 코어(PluginManager)의 책임이다.
이 동기화가 특정 진입점에만 걸려 있으면 나머지 진입점에서는 메뉴가 조용히 없다 —
예외도 경고도 로그도 남지 않고, 선언한 화면이 주소를 직접 입력해야만 닿는 상태가 된다.
실제로 두 번 드러났다.
1. 활성화(activate)에만 있었을 때: 이미 활성인 사이트가 업데이트로 새 화면을 받아도
메뉴가 생기지 않았다 (공개 #115 검수 F1, 브라우저 실측 — 반영 후 메뉴 0건)
2. 선언형 산출물 동기화에만 있었을 때: 그 경로를 지나는 것은 업데이트뿐이라, 신규 설치는
플러그인이 activate() 를 직접 구현한 경우에만 메뉴가 생겼다. AbstractPlugin 의 기본
activate() 는 `return true` 뿐이므로 getAdminMenus() 만 선언한 플러그인은 설치·활성화를
마쳐도 메뉴가 없었다. 모듈은 installModule() 이 createModuleMenus() 를 직접 호출해
이 구멍이 없었다 (형제 경로 비대칭)
그래서 판정 대상은 "메뉴가 만들어지는가" 하나가 아니라 **진입점마다 만들어지는가** 다.
진입점은 선언 여부·재실행과 직교하므로 축이 아니라 sub_flow 로 둔다 — 축으로 묶으면
"선언하지 않은 플러그인의 설치 경로" 같은 판정이 필요 없는 조합이 cross product 에 생긴다.
axes:
declaration: [declared, not_declared]
run: [first_run, repeated_run]
exclusions:
- { declaration: not_declared, run: repeated_run, reason: "선언이 없으면 몇 번을 실행해도 만들 것이 없다 — 1회 실행으로 판정이 끝난다" }
sub_flows:
- id: lifecycle_entry_points
description: |
설치·업데이트 어느 쪽으로 들어와도 같은 선언형 산출물 단계를 지나야 한다.
단계 목록을 검사 쪽에 손으로 적어두면 새 산출물 종류가 늘었을 때 그 사실이 반영되지
않으므로, 두 경로 모두 소스에서 도출해 대조한다.
effects:
- plugin_install_path_syncs_declared_menus
effects:
- plugin_admin_menu_synced_on_declarative_sync
- plugin_admin_menu_sync_is_idempotent
- plugin_without_menu_declaration_creates_nothing
test_files:
- tests/Unit/Extension/PluginMenuSyncTest.php