관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다. 설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자 리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다. 경계값 SSoT 도입: - config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. - 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다. 적용되지 않던 정책을 실제로 적용: - 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함) - 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로 - 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책, 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세 파생 결함: - 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고, 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다. - 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로 두고, 빈 목록 자체를 캐시하지 않는다. - 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다. - 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여). 재발 방지: - audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프) - 테스트 실행 범위 강제 훅(check-test-scope) - 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착) 공개 이슈: gnuboard/g7
281 lines
10 KiB
PHP
281 lines
10 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 Illuminate\Support\Facades\Log;
|
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
|
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* 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단계는 부모와 INNER JOIN 하므로 부모 행 자체가 없는 댓글은 매칭되지 않고,
|
|
* 2단계 BFS 는 `parent_id IS NULL` 루트에서 출발하므로 이 댓글에 도달하지 못한다.
|
|
* 값을 임의로 고치거나 지우면 데이터 손실이므로 보존하되, 정정되지 않았다는 사실은
|
|
* 경고로 남겨야 한다.
|
|
*
|
|
* @scenario entity=board_comment, parent_target=missing
|
|
*
|
|
* @effects dangling_parent_skipped_with_warning
|
|
*/
|
|
public function test_dangling_parent_is_skipped_with_warning(): void
|
|
{
|
|
$post = $this->createTestPost(['title' => '게시글 A']);
|
|
|
|
$parent = $this->createTestComment($post, ['depth' => 0]);
|
|
$dangling = $this->createTestComment($post, ['parent_id' => $parent, 'depth' => 3]);
|
|
|
|
// 부모 행만 물리 삭제하여 dangling parent_id 상태를 만든다.
|
|
DB::table('board_comments')->where('id', $parent)->delete();
|
|
|
|
$logger = \Mockery::spy(LoggerInterface::class);
|
|
Log::shouldReceive('channel')
|
|
->with('extension-upgrade')
|
|
->andReturn($logger);
|
|
|
|
$this->runCleanup();
|
|
|
|
$this->assertDatabaseHas('board_comments', [
|
|
'id' => $dangling,
|
|
'parent_id' => $parent,
|
|
'depth' => 3,
|
|
]);
|
|
|
|
$logger->shouldHaveReceived('warning')
|
|
->withArgs(fn (string $message) => str_contains($message, '부모 행이 없는 댓글')
|
|
&& str_contains($message, (string) $dangling))
|
|
->once();
|
|
}
|
|
|
|
/**
|
|
* 루트에서 도달할 수 없는 순환 참조 댓글은 값이 보존되고 경고 로그가 남는다
|
|
*
|
|
* 부모 행이 실재하므로 고아 판정(`부모 행이 없는 댓글`)에 걸리지 않고,
|
|
* BFS 는 루트에서만 출발하므로 순환 컴포넌트에 진입조차 못 해 세대 상한에도
|
|
* 걸리지 않는다. 두 그물을 모두 빠져나가므로 전용 경고가 없으면
|
|
* "정정되지 않았다" 는 사실이 조용히 묻힌다.
|
|
*
|
|
* @scenario entity=board_comment, parent_target=cycle
|
|
*
|
|
* @effects unreachable_cycle_skipped_with_warning
|
|
*/
|
|
public function test_unreachable_cycle_is_skipped_with_warning(): void
|
|
{
|
|
$post = $this->createTestPost(['title' => '게시글 A']);
|
|
|
|
$first = $this->createTestComment($post, ['depth' => 0]);
|
|
$second = $this->createTestComment($post, ['parent_id' => $first, 'depth' => 1]);
|
|
|
|
// 첫 댓글의 부모를 두 번째로 돌려 루트 없는 순환(A→B→A)을 만든다.
|
|
DB::table('board_comments')->where('id', $first)->update([
|
|
'parent_id' => $second,
|
|
'depth' => 4,
|
|
]);
|
|
|
|
$logger = \Mockery::spy(LoggerInterface::class);
|
|
Log::shouldReceive('channel')
|
|
->with('extension-upgrade')
|
|
->andReturn($logger);
|
|
|
|
$this->runCleanup();
|
|
|
|
// 값 보존 — 순환의 어느 지점을 끊을지는 운영자 판단이므로 건드리지 않는다.
|
|
$this->assertDatabaseHas('board_comments', [
|
|
'id' => $first,
|
|
'parent_id' => $second,
|
|
'depth' => 4,
|
|
]);
|
|
$this->assertDatabaseHas('board_comments', [
|
|
'id' => $second,
|
|
'parent_id' => $first,
|
|
'depth' => 1,
|
|
]);
|
|
|
|
$logger->shouldHaveReceived('warning')
|
|
->withArgs(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글')
|
|
&& str_contains($message, (string) $first)
|
|
&& str_contains($message, (string) $second))
|
|
->once();
|
|
}
|
|
|
|
/**
|
|
* 정합한 계층만 있으면 순환 경고가 발생하지 않는다 (과잉 경고 방지)
|
|
*
|
|
* @scenario entity=board_comment, parent_target=self
|
|
*
|
|
* @effects healthy_hierarchy_emits_no_cycle_warning
|
|
*/
|
|
public function test_healthy_hierarchy_emits_no_cycle_warning(): void
|
|
{
|
|
$post = $this->createTestPost(['title' => '게시글 A']);
|
|
|
|
$root = $this->createTestComment($post, ['depth' => 0]);
|
|
$this->createTestComment($post, ['parent_id' => $root, 'depth' => 1]);
|
|
|
|
$logger = \Mockery::spy(LoggerInterface::class);
|
|
Log::shouldReceive('channel')
|
|
->with('extension-upgrade')
|
|
->andReturn($logger);
|
|
|
|
$this->runCleanup();
|
|
|
|
$logger->shouldNotHaveReceived('warning', [
|
|
\Mockery::on(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글')),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|