Files
Gnuboard7/tests/Feature/Console/PruneOrphanAttachmentsCommandTest.php
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

283 lines
10 KiB
PHP

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