Files
HeuJung 3ebbda5d86 feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.

- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
 경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
 (전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
 거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
 change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
 (ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
2026-08-17 17:04:44 +09:00

280 lines
11 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Contracts\Extension\StorageInterface;
use App\Extension\HookManager;
use App\Extension\Storage\ModuleStorageDriver;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 게시판 영구 삭제 시 하위 데이터 정리 회귀 테스트 (#413-77)
*
* - 게시글·댓글·첨부 DB 레코드: soft delete → force delete (완전 제거)
* - 첨부 물리 파일: 저장 경로(slug 기준) 일치 삭제
* - 신고(Report): 설계 의도대로 보존 (board_id 만 NULL)
*/
class BoardDeletionTest extends ModuleTestCase
{
private User $adminUser;
private BoardService $boardService;
private StorageInterface $storage;
/**
* 테스트 환경 설정
*/
protected function setUp(): void
{
parent::setUp();
// 이전 실행 잔여 데이터 정리 (DatabaseTransactions 비활성 환경 호환)
DB::statement('SET FOREIGN_KEY_CHECKS=0');
DB::table('permissions')->where('identifier', 'like', 'sirsoft-board.del-test-%')->delete();
DB::table('boards')->where('slug', 'like', 'del-test-%')->delete();
DB::statement('SET FOREIGN_KEY_CHECKS=1');
$this->adminUser = User::factory()->create(['name' => 'Admin User']);
$this->actingAs($this->adminUser);
$this->boardService = app(BoardService::class);
// BoardService 에 주입되는 것과 동일한 모듈 스토리지 드라이버
// (AbstractModule::getStorage() 가 생성하는 것과 같은 identifier/disk)
$this->storage = new ModuleStorageDriver('sirsoft-board', 'modules');
}
/**
* 게시판 삭제 시 하위 게시글·댓글·첨부 DB 레코드가 완전히 제거되는지 검증합니다.
*
* 현행(soft delete) 코드에서는 withTrashed count 가 0 이 아니어서 fail (red).
*
* @scenario child_type=post
*
* @effects post_records_force_deleted,comment_records_force_deleted,attachment_records_force_deleted,board_itself_force_deleted
*/
public function test_deleting_board_force_deletes_child_records(): void
{
$board = $this->createTestBoard('del-test-1');
$post = Post::create([
'board_id' => $board->id,
'title' => '삭제 대상 게시글',
'content' => '본문',
'status' => PostStatus::Published,
'ip_address' => '127.0.0.1',
]);
Comment::create([
'board_id' => $board->id,
'post_id' => $post->id,
'content' => '삭제 대상 댓글',
'status' => PostStatus::Published,
'ip_address' => '127.0.0.1',
]);
Attachment::create([
'board_id' => $board->id,
'post_id' => $post->id,
'original_filename' => 'file.txt',
'stored_filename' => 'stored.txt',
'disk' => 'local',
'path' => "{$board->slug}/2026/06/25/stored.txt",
'mime_type' => 'text/plain',
'size' => 10,
'collection' => 'attachments',
'order' => 1,
]);
$boardId = $board->id;
$this->boardService->deleteBoard($board->id);
// withTrashed 로도 잔존하지 않아야 함 (force delete)
$this->assertSame(0, Post::withTrashed()->where('board_id', $boardId)->count());
$this->assertSame(0, Comment::withTrashed()->where('board_id', $boardId)->count());
$this->assertSame(0, Attachment::withTrashed()->where('board_id', $boardId)->count());
// 게시판 자체도 영구 삭제 (Board 모델은 SoftDeletes 미사용 → 단순 find)
$this->assertNull(Board::find($boardId));
}
/**
* 게시판 삭제 시 첨부 물리 파일이 실제로 삭제되는지 검증합니다.
*
* 저장 경로는 slug 기준({slug}/...)인데 기존 삭제는 board_id 기준이라
* 파일이 남아 있던 버그(#413-77)의 회귀 테스트. 현행 코드에서는 파일이
* 남아 fail (red), slug 기반 삭제로 수정하면 green.
*
* @scenario child_type=attachment
*
* @effects attachment_physical_files_removed
*/
public function test_deleting_board_removes_attachment_physical_files(): void
{
$board = $this->createTestBoard('del-test-2');
// 실제 저장 경로 패턴으로 파일 생성 (최종 + 임시)
$finalPath = "{$board->slug}/2026/06/25/final.txt";
$tempPath = "{$board->slug}/temp/tempkey/temp.txt";
$this->storage->put('attachments', $finalPath, 'final-content');
$this->storage->put('attachments', $tempPath, 'temp-content');
$this->assertTrue($this->storage->exists('attachments', $finalPath));
$this->assertTrue($this->storage->exists('attachments', $tempPath));
$this->boardService->deleteBoard($board->id);
// slug 디렉토리 통째 삭제로 최종·임시 모두 제거되어야 함
$this->assertFalse($this->storage->exists('attachments', $finalPath));
$this->assertFalse($this->storage->exists('attachments', $tempPath));
}
/**
* 게시판 삭제 시 신고(Report)는 설계 의도대로 보존되는지 검증합니다.
*
* boards_reports.board_id 는 nullable + nullOnDelete 로 설계되어 있어
* 게시판이 사라져도 신고 레코드는 보존되고 board_id 만 NULL 이 된다.
*
* @scenario child_type=report
*
* @effects reports_preserved_with_null_board_id
*/
public function test_deleting_board_preserves_reports(): void
{
$board = $this->createTestBoard('del-test-3');
$report = Report::factory()->create([
'board_id' => $board->id,
'author_id' => $this->adminUser->id,
]);
$reportId = $report->id;
$this->boardService->deleteBoard($board->id);
$preserved = Report::find($reportId);
$this->assertNotNull($preserved, '게시판 삭제 후에도 신고는 보존되어야 합니다.');
$this->assertNull($preserved->board_id, '게시판 삭제 후 신고의 board_id 는 NULL 이어야 합니다.');
}
/**
* 게시판 삭제가 다른 게시판의 첨부 파일을 건드리지 않는지 검증합니다.
*
* (cross product child_type=comment 케이스도 본 테스트가 포함 검증 — 다른 게시판의
* 댓글·첨부 등 하위 데이터가 영향받지 않음을 board 격리 관점에서 함께 보장)
*
* @scenario child_type=comment
*
* @effects other_board_files_not_affected
*/
public function test_deleting_board_does_not_affect_other_board_files(): void
{
$boardA = $this->createTestBoard('del-test-4a');
$boardB = $this->createTestBoard('del-test-4b');
$pathA = "{$boardA->slug}/2026/06/25/a.txt";
$pathB = "{$boardB->slug}/2026/06/25/b.txt";
$this->storage->put('attachments', $pathA, 'a');
$this->storage->put('attachments', $pathB, 'b');
$this->boardService->deleteBoard($boardA->id);
$this->assertFalse($this->storage->exists('attachments', $pathA));
$this->assertTrue(
$this->storage->exists('attachments', $pathB),
'다른 게시판(B)의 첨부 파일은 영향받지 않아야 합니다.'
);
// 정리
$this->boardService->deleteBoard($boardB->id);
}
/**
* 게시판 삭제 시 posts.before_force_delete 훅이 게시판 인스턴스와 함께
* 전체 글 ID(soft-deleted 포함)를 페이로드로 발화하는지 검증합니다.
*
* BoardService::deleteBoard 는 posts forceDelete 직전에 withTrashed 전체를
* 1,000건 chunk 당 1회 발화합니다 — 수집된 ID 합집합이 전체 글 ID 와 일치해야
* 연동 확장(이커머스 문의 피벗 정리 등)이 누락 없이 통지받습니다.
*/
public function test_fires_posts_before_force_delete_hook_with_all_ids(): void
{
$board = $this->createTestBoard('del-test-5');
// 일반 글 + soft-deleted 글 (둘 다 물리 삭제 대상이므로 훅 페이로드에 포함되어야 함)
$alivePost = Post::create([
'board_id' => $board->id,
'title' => '살아있는 게시글',
'content' => '본문',
'status' => PostStatus::Published,
'ip_address' => '127.0.0.1',
]);
$trashedPost = Post::create([
'board_id' => $board->id,
'title' => '소프트 삭제된 게시글',
'content' => '본문',
'status' => PostStatus::Deleted,
'ip_address' => '127.0.0.1',
]);
$trashedPost->delete();
$receivedIds = [];
$receivedBoardIds = [];
// 훅 스파이 — ModuleTestCase 가 setUp/tearDown 에서 HookManager 상태를
// 스냅샷/복원하므로 별도 정리는 불필요
HookManager::addAction(
'sirsoft-board.board.posts.before_force_delete',
function ($hookBoard, $postIds) use (&$receivedIds, &$receivedBoardIds) {
$receivedIds = array_merge($receivedIds, $postIds);
$receivedBoardIds[] = $hookBoard->id;
}
);
$this->boardService->deleteBoard($board->id);
// 수집된 ID 합집합 = 전체 글 ID (중복 발화 가능성 대비 unique 후 비교)
$union = array_values(array_unique($receivedIds));
sort($union);
$expected = [$alivePost->id, $trashedPost->id];
sort($expected);
$this->assertSame($expected, $union, '훅 페이로드 ID 합집합이 soft-deleted 포함 전체 글 ID 와 일치해야 합니다.');
// board 인자 전달 검증
$this->assertNotEmpty($receivedBoardIds, '훅이 최소 1회 발화되어야 합니다.');
foreach (array_unique($receivedBoardIds) as $receivedBoardId) {
$this->assertSame($board->id, $receivedBoardId, '훅의 첫 인자는 삭제 대상 게시판이어야 합니다.');
}
}
/**
* 테스트용 게시판을 생성합니다.
*
* @param string $slug 게시판 슬러그
* @return Board 생성된 게시판
*/
private function createTestBoard(string $slug): Board
{
return $this->boardService->createBoard([
'slug' => $slug,
'name' => ['ko' => "테스트 {$slug}", 'en' => "Test {$slug}"],
'type' => 'default',
]);
}
}