Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.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

340 lines
12 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Console;
use App\Extension\Storage\ModuleStorageDriver;
use App\Services\ModuleSettingsService;
use Mockery;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 게시판 첨부 정리 커맨드 테스트
*
* 임시 첨부 정리는 상시, 소프트 삭제 첨부 영구 정리는 운영자 옵트인입니다.
* 두 파트의 게이트가 서로 섞이지 않는지를 회귀로 고정합니다.
*/
class PruneAttachmentsCommandTest extends ModuleTestCase
{
/**
* 모듈 설정 조회를 지정 값으로 고정합니다.
*
* @param array<string, mixed> $values 설정 키 => 값
*/
private function fakeModuleSettings(array $values): void
{
$service = Mockery::mock(ModuleSettingsService::class);
$service->shouldReceive('get')
->andReturnUsing(function (string $identifier, ?string $key = null, mixed $default = null) use ($values) {
return array_key_exists($key, $values) ? $values[$key] : $default;
});
$this->app->instance(ModuleSettingsService::class, $service);
}
/**
* 첨부 파일과 기록을 함께 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @return Attachment 생성된 첨부
*/
private function makeAttachment(array $attributes = []): Attachment
{
$filename = uniqid('board-att-').'.png';
$path = $attributes['path'] ?? "free/temp/tempkey/{$filename}";
(new ModuleStorageDriver('sirsoft-board', 'modules'))->put('attachments', $path, 'bytes');
return Attachment::create(array_merge([
'board_id' => 0,
'post_id' => null,
'temp_key' => 'tempkey',
'original_filename' => $filename,
'stored_filename' => $filename,
'disk' => 'modules',
'path' => $path,
'mime_type' => 'image/png',
'size' => 5,
'collection' => 'attachments',
'order' => 1,
], $attributes));
}
/**
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_deletes_file_and_record
*/
#[Test]
public function stale_temp_attachments_lose_both_file_and_record(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path)
);
$this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=within_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_keeps_within_retention
*/
#[Test]
public function temp_attachment_within_retention_is_kept(): void
{
$attachment = $this->makeAttachment();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* 게시글에 연결된(temp_key 가 비워진) 첨부는 임시 정리 대상이 아니다.
*
* @scenario age=past_retention, attachment_state=linked, purge_toggle=off
*
* @effects board_temp_prune_skips_linked
*/
#[Test]
public function linked_attachment_is_never_treated_as_temporary(): void
{
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('linked-').'.png',
]);
$attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=off
*
* @effects board_purge_gate_off_by_default
*/
#[Test]
public function scheduled_run_does_not_purge_soft_deleted_when_the_toggle_is_off(): void
{
$this->fakeModuleSettings(['attachment_settings.purge_enabled' => false]);
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('soft-').'.png',
]);
$attachment->delete();
$attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
$this->assertNotNull(Attachment::withTrashed()->find($attachment->id));
}
/**
* @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=on
*
* @effects board_purge_gate_on
*/
#[Test]
public function scheduled_run_purges_soft_deleted_when_the_toggle_is_on(): void
{
$this->fakeModuleSettings([
'attachment_settings.purge_enabled' => true,
'attachment_settings.purge_retention_days' => 30,
]);
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('purge-').'.png',
]);
$path = $attachment->path;
$attachment->delete();
$attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
$this->assertNull(Attachment::withTrashed()->find($attachment->id));
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $path)
);
}
/**
* 보존기간 안의 소프트 삭제 첨부는 복원 가능해야 하므로 파기하지 않는다.
*
* @scenario age=within_retention, attachment_state=soft_deleted, purge_toggle=on
*
* @effects board_purge_keeps_restorable_window
*/
#[Test]
public function soft_deleted_attachment_within_retention_stays_restorable(): void
{
$this->fakeModuleSettings([
'attachment_settings.purge_enabled' => true,
'attachment_settings.purge_retention_days' => 30,
]);
$attachment = $this->makeAttachment([
'board_id' => 1,
'post_id' => 1,
'temp_key' => null,
'path' => 'free/2026/08/14/'.uniqid('recent-').'.png',
]);
$attachment->delete();
$this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
$trashed = Attachment::withTrashed()->find($attachment->id);
$this->assertNotNull($trashed);
$this->assertTrue(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path),
'복원 가능 기간 안에는 파일도 남아 있어야 한다.'
);
}
/**
* @effects board_prune_dry_run_no_delete
*/
#[Test]
public function dry_run_reports_targets_without_deleting(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --dry-run')
->expectsOutputToContain('[DRY RUN]')
->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* @effects board_temp_prune_days_guard
*/
#[Test]
public function temp_retention_below_one_day_performs_no_cleanup(): void
{
$attachment = $this->makeAttachment();
$attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments --temp-days=0 --purge-days=0')
->assertSuccessful();
$this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
}
/**
* 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀)
*
* 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
* 저장소 흔적은 계속 늘어난다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_removes_empty_directory
*/
#[Test]
public function emptied_temp_directory_is_removed(): void
{
$attachment = $this->makeAttachment(['path' => 'free/temp/tempkey-empty-dir/'.uniqid('t-').'.png']);
$attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$storage = new ModuleStorageDriver('sirsoft-board', 'modules');
$this->assertSame(
[],
$storage->files('attachments', 'free/temp/tempkey-empty-dir'),
'디렉토리 안의 파일은 모두 삭제되어야 한다.'
);
$this->assertDirectoryDoesNotExist(
rtrim($storage->getBasePath('attachments'), '/\\').'/free/temp/tempkey-empty-dir',
'비워진 temp 디렉토리는 남기지 않는다.'
);
}
/**
* 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_keeps_directory_with_remaining_files
*/
#[Test]
public function temp_directory_with_remaining_files_is_kept(): void
{
$directory = 'free/temp/tempkey-partial';
$processed = $this->makeAttachment(['path' => $directory.'/'.uniqid('done-').'.png']);
$processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
// 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태
$storage = new ModuleStorageDriver('sirsoft-board', 'modules');
$storage->put('attachments', $directory.'/keep.png', 'bytes');
$this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
$this->assertTrue($storage->exists('attachments', $directory.'/keep.png'));
}
/**
* 대상이 한 회차 상한을 넘어도 누락 없이 전수 처리된다 (경계).
*
* 순회는 `--limit` 으로 잘린 단일 윈도우를 훑는다. 처리하며 행을 지우므로, 남은 대상이
* 다음 회차에서 반드시 잡혀야 적체가 수렴한다 — 커서가 밀려 영영 건너뛰는 행이 있으면
* 정리를 매일 돌려도 잔존물이 남는다.
*
* @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
*
* @effects board_temp_prune_processes_all_across_runs
*/
#[Test]
public function targets_beyond_the_limit_are_processed_on_the_next_run(): void
{
$attachments = [];
for ($i = 0; $i < 3; $i++) {
$attachment = $this->makeAttachment([
'path' => "free/temp/tempkey-batch-{$i}/".uniqid('b-').'.png',
]);
$attachment->forceFill(['created_at' => now()->subDays(5 + $i)])->saveQuietly();
$attachments[] = $attachment;
}
// 1회차: 상한 2건까지만 처리된다
$this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful();
$remaining = Attachment::withTrashed()
->whereIn('id', array_column($attachments, 'id'))
->count();
$this->assertSame(1, $remaining, '한 회차는 --limit 건까지만 처리한다.');
// 2회차: 남은 1건이 반드시 잡힌다 (커서 밀림으로 건너뛰지 않는다)
$this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful();
foreach ($attachments as $attachment) {
$this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]);
$this->assertFalse(
(new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path)
);
}
}
}