Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php
T
HeuJung abb40ad210 fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의
하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로
통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가
서로 다른 강도로 검증하던 부분도 맞췄다.

계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고,
검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다.
설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해
게시판 설정이 실제로 적용되게 했다.

쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로
새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동).

재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다.

공개 이슈: gnuboard/g7
2026-07-24 16:17:44 +09:00

161 lines
5.3 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3;
/**
* 1.0.3 댓글 계층 오염 데이터 정리 업그레이드 스텝 테스트
*
* 검증 목적:
* - 교차 게시글 부모를 가진 고아 댓글이 최상위로 승격된다 (본문 보존)
* - depth 가 루트부터 부모 depth + 1 로 재계산된다
* - 재실행해도 결과가 동일하다 (멱등)
*
* @group board
* @group upgrade
*/
class CommentHierarchyCleanupTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'comment-hierarchy-cleanup';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 계층 정리 게시판', 'en' => 'Comment Hierarchy Cleanup Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
/**
* 교차 게시글 부모 댓글이 최상위로 승격되고 본문은 보존된다
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects existing_cross_post_parent_detached_content_preserved
*/
public function test_cross_post_parent_is_detached_and_content_preserved(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInA = $this->createTestComment($postA, ['depth' => 0]);
$orphan = $this->createTestComment($postB, [
'parent_id' => $parentInA,
'depth' => 1,
'content' => '보존되어야 하는 본문',
]);
$this->runCleanup();
$row = DB::table('board_comments')->where('id', $orphan)->first();
$this->assertNull($row->parent_id, '교차 게시글 부모는 해제되어야 합니다.');
$this->assertSame(0, (int) $row->depth, '승격된 댓글의 depth 는 0 이어야 합니다.');
$this->assertSame('보존되어야 하는 본문', $row->content, '본문은 보존되어야 합니다.');
$this->assertSame($postB, (int) $row->post_id, 'post_id 는 바뀌지 않아야 합니다.');
}
/**
* 클램프로 뭉개진 depth 가 부모 depth + 1 로 재계산된다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects existing_clamped_depth_recalculated
*/
public function test_clamped_depth_is_recalculated_from_root(): void
{
$postId = $this->createTestPost();
// 8단계 체인을 만들되, 6단계 이후 depth 를 5 로 뭉개 둔다 (구 클램프 재현)
$parentId = null;
$ids = [];
for ($i = 0; $i < 8; $i++) {
$storedDepth = min($i, 5);
$parentId = $this->createTestComment($postId, [
'parent_id' => $parentId,
'depth' => $storedDepth,
]);
$ids[$i] = $parentId;
}
$this->runCleanup();
foreach ($ids as $expectedDepth => $id) {
$this->assertSame(
$expectedDepth,
(int) DB::table('board_comments')->where('id', $id)->value('depth'),
"{$expectedDepth} 단계 댓글의 depth 가 재계산되어야 합니다."
);
}
}
/**
* 재실행해도 결과가 동일하다 (멱등)
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects cleanup_is_idempotent
*/
public function test_cleanup_is_idempotent(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInA = $this->createTestComment($postA, ['depth' => 0]);
$this->createTestComment($postB, ['parent_id' => $parentInA, 'depth' => 1]);
$chainRoot = $this->createTestComment($postA, ['parent_id' => $parentInA, 'depth' => 5]);
$this->createTestComment($postA, ['parent_id' => $chainRoot, 'depth' => 5]);
$this->runCleanup();
$first = $this->snapshot();
$this->runCleanup();
$second = $this->snapshot();
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
}
/**
* 1.0.3 정리 마이그레이션을 실행합니다.
*/
private function runCleanup(): void
{
$context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade');
$step = new Upgrade_1_0_3;
$step->run($context);
}
/**
* 이 게시판 댓글의 (id, parent_id, depth) 스냅샷을 반환합니다.
*
* @return array<int, array{id: int, parent_id: int|null, depth: int}> 스냅샷 배열
*/
private function snapshot(): array
{
return DB::table('board_comments')
->where('board_id', $this->board->id)
->orderBy('id')
->get(['id', 'parent_id', 'depth'])
->map(fn ($row) => [
'id' => (int) $row->id,
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
])
->all();
}
}