v7.0.0-beta.2 release

This commit is contained in:
chym1217 committed 2026-04-22 10:43:31 +09:00
1 parent 5d4890d6fb
commit d1e274165a
64 files changed
+5988 -316

No files matched your search

@@ -4,6 +4,19 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.3] - 2026-04-21
### Fixed
- 파티션 스키마 폐지 후에도 잔여 파티션 DDL 호출로 인해 신규 게시판을 만들지 못하던 문제 수정
- 일부 권한 설정에서 비밀글·접근 제한 게시글의 이전/다음 조회 시 500 오류가 발생하던 문제 수정 — 옆 글을 찾지 못하면 빈 값을 반환하도록 변경
- 큐 워커가 실행 중이지 않은 환경에서 댓글/게시글/첨부 수가 즉시 반영되지 않던 문제 수정
- CKEditor 등 임시 업로드를 거친 첨부가 게시글에 연결되어도 첨부 수가 늘어나지 않던 문제 수정
### Removed
- 내부 파티션 관리 코드 일괄 제거 (파티션 스키마 폐지로 더 이상 사용되지 않음)
## [1.0.0-beta.2] - 2026-04-20
### ⚠️ 업데이트 주의사항
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"license": "MIT",
"autoload": {
"psr-4": {
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.2",
"version": "1.0.0-beta.3",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -273,6 +273,15 @@ class BoardController extends AdminBaseController
$data['blocked_keywords'] = collect($spamSecurity['blocked_keywords'] ?? [])->join(',');
$data['allowed_extensions'] = collect($basicDefaults['allowed_extensions'] ?? [])->join(',');
// depth 필드는 reject(is_array)로 걸러지지 않지만 basic_defaults가 비어있을 수 있으므로 명시적 기본값 보장
$limits = config('sirsoft-board.limits', []);
if (! isset($data['max_reply_depth'])) {
$data['max_reply_depth'] = $limits['max_reply_depth_min'] ?? 1;
}
if (! isset($data['max_comment_depth'])) {
$data['max_comment_depth'] = $limits['max_comment_depth_min'] ?? 0;
}
$data['permissions'] = BoardResource::formatPermissionsForFrontend(
$basicDefaults['default_board_permissions'] ?? []
);
@@ -245,6 +245,8 @@ class CommentController extends PublicBaseController
'expires_at' => now()->addHours(1)->toIso8601String(), // 1시간 유효
]
);
} catch (ModelNotFoundException) {
return $this->error('sirsoft-board::messages.comment.not_found', 404);
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.comment.verify_password_failed', 500);
}
@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use App\Enums\PermissionType;
use Modules\Sirsoft\Board\Enums\SecretMode;
@@ -197,6 +198,9 @@ class PostController extends PublicBaseController
* 상세 API에서 분리하여 비동기 로딩 지원.
* 게시판 정렬 설정에 따라 이전/다음글을 반환합니다.
*
* 공지글·존재하지 않는 글·내부 조회 실패 등 어떤 상황에서도 500 대신
* `{prev: null, next: null}` 을 반환합니다. Board 조회 실패만 404로 처리합니다.
*
* @param Request $request HTTP 요청
* @param string $slug 게시판 슬러그
* @param string|int $id 게시글 ID
@@ -206,33 +210,39 @@ class PostController extends PublicBaseController
{
$id = (int) $id;
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board || ! $board->is_active) {
throw new BoardNotFoundException($slug);
}
$empty = ['prev' => null, 'next' => null];
// 공지글·존재하지 않는 글은 옆 글 없음으로 응답
$isNotice = $this->postService->isPostNotice($slug, $id, $board->id);
if ($isNotice === null || $isNotice === true) {
return $this->success('sirsoft-board::messages.posts.fetch_success', $empty);
}
// manager 권한 + del=1 시 삭제된 게시글 포함
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
$withTrashed = $canViewDeleted && $request->boolean('del');
try {
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board || ! $board->is_active) {
throw new BoardNotFoundException($slug);
}
// 공지글은 이전/다음 네비게이션 미제공
$post = $this->postService->getPost($slug, $id, context: 'user');
if ($post->is_notice) {
return $this->success('sirsoft-board::messages.posts.fetch_success', ['prev' => null, 'next' => null]);
}
// manager 권한 + del=1 시 삭제된 게시글 포함
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
$withTrashed = $canViewDeleted && $request->boolean('del');
$navigation = $this->postService->getAdjacentPosts($slug, $id, filters: [
'order_by' => $board->order_by instanceof \BackedEnum ? $board->order_by->value : $board->order_by,
'order_direction' => $board->order_direction instanceof \BackedEnum ? $board->order_direction->value : $board->order_direction,
], withTrashed: $withTrashed, board: $board);
return $this->success('sirsoft-board::messages.posts.fetch_success', $navigation);
} catch (BoardNotFoundException $e) {
throw $e;
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
} catch (\Throwable $e) {
// 내부 예외는 로그만 남기고 옆 글 없음으로 degrade
Log::warning('Post navigation fetch failed, returning empty result', [
'slug' => $slug,
'post_id' => $id,
'error' => $e->getMessage(),
]);
$navigation = $empty;
}
return $this->success('sirsoft-board::messages.posts.fetch_success', $navigation);
}
/**
@@ -19,9 +19,9 @@ class BoardCommentsCountSyncListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.comment.after_create' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_delete' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_restore' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_create' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.comment.after_delete' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.comment.after_restore' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true],
];
}
@@ -19,9 +19,9 @@ class BoardPostsCountSyncListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.post.after_create' => ['method' => 'syncPostsCount', 'priority' => 10],
'sirsoft-board.post.after_delete' => ['method' => 'syncPostsCount', 'priority' => 10],
'sirsoft-board.post.after_restore' => ['method' => 'syncPostsCount', 'priority' => 10],
'sirsoft-board.post.after_create' => ['method' => 'syncPostsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.post.after_delete' => ['method' => 'syncPostsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.post.after_restore' => ['method' => 'syncPostsCount', 'priority' => 10, 'sync' => true],
];
}
@@ -19,9 +19,9 @@ class CommentReplySyncListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.comment.after_create' => ['method' => 'syncRepliesCount', 'priority' => 10],
'sirsoft-board.comment.after_delete' => ['method' => 'syncRepliesCount', 'priority' => 10],
'sirsoft-board.comment.after_restore' => ['method' => 'syncRepliesCount', 'priority' => 10],
'sirsoft-board.comment.after_create' => ['method' => 'syncRepliesCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.comment.after_delete' => ['method' => 'syncRepliesCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.comment.after_restore' => ['method' => 'syncRepliesCount', 'priority' => 10, 'sync' => true],
];
}
@@ -19,8 +19,9 @@ class PostAttachmentCountSyncListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.attachment.after_upload' => ['method' => 'syncAttachmentsCount', 'priority' => 10],
'sirsoft-board.attachment.after_delete' => ['method' => 'syncAttachmentsCount', 'priority' => 10],
'sirsoft-board.attachment.after_upload' => ['method' => 'syncAttachmentsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.attachment.after_link' => ['method' => 'syncAttachmentsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.attachment.after_delete' => ['method' => 'syncAttachmentsCount', 'priority' => 10, 'sync' => true],
];
}
@@ -19,9 +19,9 @@ class PostCountSyncListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.comment.after_create' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_delete' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_restore' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_create' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.comment.after_delete' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.comment.after_restore' => ['method' => 'syncCommentsCount', 'priority' => 10, 'sync' => true],
];
}
@@ -19,9 +19,9 @@ class PostReplySyncListener implements HookListenerInterface
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.post.after_create' => ['method' => 'syncRepliesCount', 'priority' => 10],
'sirsoft-board.post.after_delete' => ['method' => 'syncRepliesCount', 'priority' => 10],
'sirsoft-board.post.after_restore' => ['method' => 'syncRepliesCount', 'priority' => 10],
'sirsoft-board.post.after_create' => ['method' => 'syncRepliesCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.post.after_delete' => ['method' => 'syncRepliesCount', 'priority' => 10, 'sync' => true],
'sirsoft-board.post.after_restore' => ['method' => 'syncRepliesCount', 'priority' => 10, 'sync' => true],
];
}
@@ -50,8 +50,7 @@ class Attachment extends Model
/**
* 기본키
*
* DB 복합 PK (id, board_id) — MySQL 파티션 제약으로 파티션 키 포함 필수.
* id는 AUTO_INCREMENT로 전역 유일하므로 Eloquent는 단일 PK로 동작.
* 단일 PK(id). id는 AUTO_INCREMENT로 전역 유일.
*/
protected $primaryKey = 'id';
@@ -53,8 +53,7 @@ class Comment extends Model
/**
* 기본키
*
* DB 복합 PK (id, board_id) — MySQL 파티션 제약으로 파티션 키 포함 필수.
* id는 AUTO_INCREMENT로 전역 유일하므로 Eloquent는 단일 PK로 동작.
* 단일 PK(id). id는 AUTO_INCREMENT로 전역 유일.
*/
protected $primaryKey = 'id';
@@ -41,8 +41,7 @@ class Post extends Model implements FulltextSearchable
/**
* 기본키
*
* DB 복합 PK (id, board_id) — MySQL 파티션 제약으로 파티션 키 포함 필수.
* id는 AUTO_INCREMENT로 전역 유일하므로 Eloquent는 단일 PK로 동작.
* 단일 PK(id). id는 AUTO_INCREMENT로 전역 유일.
*/
protected $primaryKey = 'id';
@@ -197,8 +196,8 @@ class Post extends Model implements FulltextSearchable
/**
* 외부 검색엔진(Meilisearch 등) 사용 시에만 검색 인덱스에 포함합니다.
*
* board_posts는 파티션 테이블로 MySQL FULLTEXT 불가.
* 외부 검색엔진 플러그인 설치 시 자동 활용됩니다.
* MySQL FULLTEXT는 board_posts에 이미 적용되어 있으며, 외부 검색엔진 플러그인
* 설치 시 자동 활용됩니다.
*
* @return bool
*/
@@ -199,7 +199,7 @@ class AttachmentRepository implements AttachmentRepositoryInterface
/**
* 임시 업로드의 최대 order 조회
*
* board_id=0인 임시 파티션에서 조회합니다.
* 임시 업로드 레코드는 board_id=0으로 저장됩니다.
*
* @param string $slug 게시판 슬러그
* @param string|null $tempKey 임시 업로드 키
@@ -212,7 +212,7 @@ class AttachmentRepository implements AttachmentRepositoryInterface
return 0;
}
// 임시 업로드는 board_id=0(더미 파티션)에 저장
// 임시 업로드 레코드: board_id=0
$maxOrder = Attachment::query()
->where('board_id', 0)
->whereNull('post_id')
@@ -226,7 +226,7 @@ class AttachmentRepository implements AttachmentRepositoryInterface
/**
* 임시 업로드 키로 첨부파일 조회
*
* board_id=0인 임시 파티션에서 조회합니다.
* 임시 업로드 레코드는 board_id=0으로 저장됩니다.
*
* @param string $slug 게시판 슬러그
* @param string $tempKey 임시 업로드 키
@@ -235,7 +235,7 @@ class AttachmentRepository implements AttachmentRepositoryInterface
*/
public function getByTempKey(string $slug, string $tempKey, ?string $collection = null): Collection
{
// 임시 업로드는 board_id=0(더미 파티션)에 저장
// 임시 업로드 레코드: board_id=0
$query = Attachment::query()
->where('board_id', 0)
->whereNull('post_id')
@@ -252,7 +252,7 @@ class AttachmentRepository implements AttachmentRepositoryInterface
/**
* 임시 첨부파일을 게시글에 연결
*
* board_id=0(더미 파티션)의 임시 파일을 실제 board_id로 이동합니다.
* 임시 레코드(board_id=0)를 실제 board_id로 이동합니다.
*
* @param string $slug 게시판 슬러그
* @param string $tempKey 임시 업로드 키
@@ -638,37 +638,4 @@ class BoardRepository implements BoardRepositoryInterface
{
return Board::where('type', $type)->count();
}
/**
* 게시판 파티션을 추가합니다. (MySQL/MariaDB 전용 DDL)
*
* board_posts, board_comments, board_attachments 테이블에
* p{boardId} 파티션을 추가합니다.
*
* @param int $boardId 게시판 ID
*/
public function addBoardPartitions(int $boardId): void
{
$prefix = DB::getTablePrefix();
DB::statement("ALTER TABLE {$prefix}board_posts ADD PARTITION (PARTITION p{$boardId} VALUES IN ({$boardId}))");
DB::statement("ALTER TABLE {$prefix}board_comments ADD PARTITION (PARTITION p{$boardId} VALUES IN ({$boardId}))");
DB::statement("ALTER TABLE {$prefix}board_attachments ADD PARTITION (PARTITION p{$boardId} VALUES IN ({$boardId}))");
}
/**
* 게시판 파티션을 삭제합니다. (MySQL/MariaDB 전용 DDL)
*
* board_posts, board_comments, board_attachments 테이블에서
* p{boardId} 파티션을 DROP합니다.
* DDL은 트랜잭션 롤백 불가 — 반드시 트랜잭션 외부에서 호출해야 합니다.
*
* @param int $boardId 게시판 ID
*/
public function dropBoardPartitions(int $boardId): void
{
$prefix = DB::getTablePrefix();
DB::statement("ALTER TABLE {$prefix}board_posts DROP PARTITION p{$boardId}");
DB::statement("ALTER TABLE {$prefix}board_comments DROP PARTITION p{$boardId}");
DB::statement("ALTER TABLE {$prefix}board_attachments DROP PARTITION p{$boardId}");
}
}
@@ -226,25 +226,4 @@ interface BoardRepositoryInterface
* @return int 해당 유형의 게시판 개수
*/
public function countByType(string $type): int;
/**
* 게시판 파티션을 추가합니다. (MySQL/MariaDB 전용 DDL)
*
* board_posts, board_comments, board_attachments 테이블에
* p{boardId} 파티션을 추가합니다.
*
* @param int $boardId 게시판 ID
*/
public function addBoardPartitions(int $boardId): void;
/**
* 게시판 파티션을 삭제합니다. (MySQL/MariaDB 전용 DDL)
*
* board_posts, board_comments, board_attachments 테이블에서
* p{boardId} 파티션을 DROP합니다.
* DDL은 트랜잭션 롤백 불가 — 반드시 트랜잭션 외부에서 호출해야 합니다.
*
* @param int $boardId 게시판 ID
*/
public function dropBoardPartitions(int $boardId): void;
}
@@ -110,6 +110,18 @@ interface PostRepositoryInterface
*/
public function incrementViewCount(string $slug, int $id): int;
/**
* 해당 게시판의 게시글이 공지글인지 여부만 경량 조회합니다.
*
* 존재하지 않으면 null을 반환합니다. 스코프 체크를 수행하지 않으므로
* 목록과 동일한 범위의 메타 판별(예: navigation)에서 사용합니다.
*
* @param int $id 게시글 ID
* @param int $boardId 게시판 ID
* @return bool|null 공지 여부 또는 미존재 시 null
*/
public function isNotice(int $id, int $boardId): ?bool;
/**
* 신고 처리를 위한 게시글 상태를 일괄 업데이트합니다.
*
@@ -302,6 +302,26 @@ class PostRepository implements PostRepositoryInterface
return $post?->view_count ?? 0;
}
/**
* 해당 게시판의 게시글이 공지글인지 경량 조회합니다.
*
* 존재하지 않으면 null을 반환합니다. trashed(`deleted_at`) 여부는 고려하지 않으며
* 스코프/권한 체크를 수행하지 않습니다.
*
* @param int $id 게시글 ID
* @param int $boardId 게시판 ID
* @return bool|null 공지 여부 또는 미존재 시 null
*/
public function isNotice(int $id, int $boardId): ?bool
{
$value = Post::withTrashed()
->where('id', $id)
->where('board_id', $boardId)
->value('is_notice');
return $value === null ? null : (bool) $value;
}
/**
* 신고 처리를 위한 게시글 상태를 일괄 업데이트합니다.
*
@@ -93,7 +93,7 @@ class AttachmentService
}
}
// board_id 설정: postId가 있으면 실제 board_id, 없으면 0(임시 업로드 → p_default 파티션)
// board_id 설정: postId가 있으면 실제 board_id, 없으면 0(임시 업로드)
$boardId = 0;
if ($postId) {
$board = $this->boardRepository->findBySlug($slug);
@@ -142,7 +142,20 @@ class AttachmentService
*/
public function linkTempAttachments(string $slug, string $tempKey, int $postId): int
{
return $this->repository->linkTempAttachments($slug, $tempKey, $postId);
// 연결 대상 후보를 먼저 조회하여 링크 후 재조회 → 훅 발화를 위한 식별자 확보
$tempAttachments = $this->repository->getByTempKey($slug, $tempKey);
$linkedCount = $this->repository->linkTempAttachments($slug, $tempKey, $postId);
// 각 첨부에 대해 after_link 훅 발화 → 카운트 리스너가 post_id 기준으로 동기화 가능
foreach ($tempAttachments as $tempAttachment) {
$linked = $this->repository->getById($slug, $tempAttachment->id);
if ($linked && $linked->post_id === $postId) {
HookManager::doAction('sirsoft-board.attachment.after_link', $linked);
}
}
return $linkedCount;
}
/**
@@ -182,7 +195,7 @@ class AttachmentService
$this->storage->delete('attachments', $attachment->path);
}
// DB 업데이트: board_id 파티션 이동, post_id 설정, temp_key 제거, path 변경
// DB 업데이트: board_id 이동, post_id 설정, temp_key 제거, path 변경
// 임시 첨부파일(board_id=0)을 직접 업데이트 (repository->update()는 board_id=$board->id로 조회하므로 사용 불가)
$attachment->update([
'board_id' => $board->id,
@@ -203,6 +216,11 @@ class AttachmentService
'linked_count' => $linkedCount,
]);
// 각 첨부에 대해 after_link 훅 발화 → 카운트 리스너가 post_id 기준으로 동기화 가능
foreach ($tempAttachments as $attachment) {
HookManager::doAction('sirsoft-board.attachment.after_link', $attachment);
}
return $linkedCount;
}
@@ -290,7 +290,7 @@ class BoardService
* @param array $data 게시판 생성 데이터
* @return Board 생성된 게시판
*
* @throws \Exception 파티션/역할/권한 생성 실패 시 롤백
* @throws \Exception 역할/권한 생성 실패 시 롤백
*/
public function createBoard(array $data): Board
{
@@ -320,23 +320,6 @@ class BoardService
// 게시판 생성
$board = $this->boardRepository->create($data);
// 파티션 추가 (MySQL/MariaDB 전용 DDL - board_id 기준 LIST 파티션)
try {
$this->boardRepository->addBoardPartitions($board->id);
HookManager::doAction('sirsoft-board.partition.after_create', $board);
} catch (\Exception $e) {
Log::error('Board partition creation failed', [
'board_id' => $board->id,
'slug' => $board->slug,
'error' => $e->getMessage(),
'user_id' => Auth::id(),
]);
// 파티션 추가 실패 시 게시판 삭제 (롤백)
$this->boardRepository->delete($board->id);
throw $e;
}
// 게시판별 관리자/스텝 역할 생성
try {
$this->createBoardRoles($board);
@@ -349,8 +332,7 @@ class BoardService
'user_id' => Auth::id(),
]);
// 역할 생성 실패 시 파티션 및 게시판 삭제 (롤백)
$this->boardRepository->dropBoardPartitions($board->id);
// 역할 생성 실패 시 게시판 삭제 (롤백)
$this->boardRepository->delete($board->id);
throw $e;
}
@@ -370,9 +352,8 @@ class BoardService
'user_id' => Auth::id(),
]);
// 권한 생성 실패 시 역할, 파티션 및 게시판 삭제 (롤백)
// 권한 생성 실패 시 역할 및 게시판 삭제 (롤백)
$this->deleteBoardRoles($board);
$this->boardRepository->dropBoardPartitions($board->id);
$this->boardRepository->delete($board->id);
throw $e;
}
@@ -522,19 +503,6 @@ class BoardService
$board->forceDelete();
});
// 파티션 DROP (DDL은 트랜잭션 롤백 불가 → 트랜잭션 외부 처리, MySQL/MariaDB 전용)
try {
$this->boardRepository->dropBoardPartitions($board->id);
HookManager::doAction('sirsoft-board.partition.after_delete', $board->slug);
} catch (\Exception $e) {
Log::error('게시판 파티션 삭제 실패 (계속 진행)', [
'board_id' => $board->id,
'slug' => $board->slug,
'error' => $e->getMessage(),
]);
// 파티션이 존재하지 않을 수 있으므로 예외 발생 시에도 계속 진행
}
Log::info('Board deleted', [
'board_id' => $board->id,
'slug' => $board->slug,
@@ -1104,6 +1072,12 @@ class BoardService
$basicDefaults = g7_module_settings('sirsoft-board', 'basic_defaults', []);
$configDefaults = $basicDefaults['default_board_permissions'] ?? [];
// 모듈 설정이 없으면 config 파일의 권한 정의 키를 기반으로 fallback
if (empty($configDefaults)) {
$permissionDefinitions = config('sirsoft-board.board_permission_definitions', []);
$configDefaults = array_fill_keys(array_keys($permissionDefinitions), []);
}
foreach ($configDefaults as $key => $roles) {
$defaultRoles = is_array($roles) ? $roles : [];
@@ -508,6 +508,22 @@ class PostService
return $post;
}
/**
* 게시글의 공지 여부를 경량 조회합니다.
*
* 권한/스코프 체크를 수행하지 않으므로 이전/다음 네비게이션처럼
* 목록과 동일한 범위의 메타 판별용으로만 사용해야 합니다.
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param int $boardId 게시판 ID
* @return bool|null 공지 여부 또는 미존재 시 null
*/
public function isPostNotice(string $slug, int $id, int $boardId): ?bool
{
return $this->postRepository->isNotice($id, $boardId);
}
/**
* 이전/다음 게시글을 조회합니다.
*
@@ -74,9 +74,6 @@ abstract class BoardTestCase extends ModuleTestCase
$this->getDefaultBoardAttributes($slug)
);
// 파티션 보장 (LIST 파티션 테이블 — board_id별 파티션 필요)
$this->ensureBoardPartitions($this->board->id);
// 해당 게시판의 게시글/댓글/첨부파일/신고 데이터만 삭제
DB::table('boards_reports')->where('board_id', $this->board->id)->delete();
DB::table('board_attachments')->where('board_id', $this->board->id)->delete();
@@ -0,0 +1,341 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 관리자 첨부파일 관리 API 테스트
*
* 검증 목적:
* - 첨부파일 업로드/삭제/순서변경/다운로드 HTTP 레벨 응답 코드 및 DB 반영
* - 권한 부재 시 403, 미인증 시 401 응답
* - 삭제 후 attachments_count 감소 (PostCountSyncListener 동작)
* - 존재하지 않는 파일 삭제/다운로드 → 404
*
* @group board
* @group admin
* @group attachment
*/
class AdminAttachmentManagementTest extends BoardTestCase
{
private User $adminWithUpload;
private User $adminWithDownload;
private User $regularUser;
protected function getTestBoardSlug(): string
{
return 'admin-attach-mgmt';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '관리자 첨부파일 관리 테스트 게시판', 'en' => 'Admin Attachment Management Test Board'],
'is_active' => true,
'use_file_upload' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$slug = $this->board->slug;
$this->adminWithUpload = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.attachments.upload",
"sirsoft-board.{$slug}.admin.attachments.download",
]);
$this->adminWithDownload = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.attachments.download",
]);
$this->regularUser = $this->createUser();
}
// ==========================================
// 업로드 (upload)
// ==========================================
/**
* 권한 있는 관리자가 임시 업로드 → 201 + DB 레코드 생성
*/
public function test_admin_can_upload_attachment_as_temp(): void
{
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->actingAs($this->adminWithUpload)->postJson(
$this->url('/attachments'),
[
'file' => $file,
'temp_key' => 'test-temp-key-001',
]
);
$response->assertStatus(201);
$this->assertDatabaseHas('board_attachments', [
'board_id' => 0,
'original_filename' => 'document.pdf',
]);
}
/**
* 게시글에 직접 첨부파일 업로드 (post_id 있음) → 201
*/
public function test_admin_can_upload_attachment_to_post(): void
{
$postId = $this->createTestPost();
$file = UploadedFile::fake()->image('photo.jpg', 100, 100);
$response = $this->actingAs($this->adminWithUpload)->postJson(
$this->url('/attachments'),
[
'file' => $file,
'post_id' => $postId,
]
);
$response->assertStatus(201);
$this->assertDatabaseHas('board_attachments', [
'post_id' => $postId,
'board_id' => $this->board->id,
'original_filename' => 'photo.jpg',
]);
}
/**
* 미인증 요청 → 401
*/
public function test_unauthenticated_cannot_upload(): void
{
$file = UploadedFile::fake()->create('doc.pdf', 10);
$this->postJson(
$this->url('/attachments'),
['file' => $file, 'temp_key' => 'key']
)->assertStatus(401);
}
/**
* 권한 없는 사용자 → 403
*/
public function test_user_without_permission_cannot_upload(): void
{
$file = UploadedFile::fake()->create('doc.pdf', 10);
$this->actingAs($this->regularUser)->postJson(
$this->url('/attachments'),
['file' => $file, 'temp_key' => 'key']
)->assertStatus(403);
}
// ==========================================
// 삭제 (destroy)
// ==========================================
/**
* 권한 있는 관리자가 첨부파일 삭제 → 200 + soft delete
*/
public function test_admin_can_delete_attachment(): void
{
$postId = $this->createTestPost();
$attachId = $this->createTestAttachment($postId);
$this->actingAs($this->adminWithUpload)->deleteJson(
$this->url("/attachments/{$attachId}")
)->assertStatus(200);
$this->assertSoftDeleted('board_attachments', ['id' => $attachId]);
}
/**
* 삭제 후 attachments_count 감소 (PostCountSyncListener 동작)
*/
public function test_delete_attachment_decrements_attachments_count(): void
{
$postId = $this->createTestPost();
DB::table('board_posts')->where('id', $postId)->update(['attachments_count' => 2]);
$attachId = $this->createTestAttachment($postId);
$this->actingAs($this->adminWithUpload)->deleteJson(
$this->url("/attachments/{$attachId}")
)->assertStatus(200);
// AttachmentCountSyncListener가 실제 카운트로 동기화
$count = DB::table('board_posts')->where('id', $postId)->value('attachments_count');
$this->assertEquals(0, $count);
}
/**
* 존재하지 않는 첨부파일 삭제 → 404
*/
public function test_delete_nonexistent_attachment_returns_404(): void
{
$this->actingAs($this->adminWithUpload)->deleteJson(
$this->url('/attachments/99999')
)->assertStatus(404);
}
/**
* 권한 없는 사용자 삭제 → 403
*/
public function test_user_without_permission_cannot_delete(): void
{
$postId = $this->createTestPost();
$attachId = $this->createTestAttachment($postId);
$this->actingAs($this->regularUser)->deleteJson(
$this->url("/attachments/{$attachId}")
)->assertStatus(403);
}
// ==========================================
// 순서 변경 (reorder)
// ==========================================
/**
* 권한 있는 관리자가 순서 변경 → 200
*/
public function test_admin_can_reorder_attachments(): void
{
$postId = $this->createTestPost();
$id1 = $this->createTestAttachment($postId, ['order' => 0]);
$id2 = $this->createTestAttachment($postId, ['order' => 1]);
$response = $this->actingAs($this->adminWithUpload)->patchJson(
$this->url('/attachments/reorder'),
[
'order' => [
['id' => $id1, 'order' => 1],
['id' => $id2, 'order' => 0],
],
]
);
$response->assertStatus(200);
$this->assertEquals(1, DB::table('board_attachments')->where('id', $id1)->value('order'));
$this->assertEquals(0, DB::table('board_attachments')->where('id', $id2)->value('order'));
}
/**
* 권한 없는 사용자 순서 변경 → 403
*/
public function test_user_without_permission_cannot_reorder(): void
{
$postId = $this->createTestPost();
$id1 = $this->createTestAttachment($postId);
$this->actingAs($this->regularUser)->patchJson(
$this->url('/attachments/reorder'),
['order' => [['id' => $id1, 'order' => 0]]]
)->assertStatus(403);
}
// ==========================================
// 다운로드 (download)
// ==========================================
/**
* 권한 있는 관리자가 해시로 다운로드 → 200 (streamed response)
*/
public function test_admin_can_download_attachment_by_hash(): void
{
$postId = $this->createTestPost();
$hash = 'dlhash123456';
Storage::fake('public');
Storage::disk('public')->put("admin-attach-mgmt/2025/01/01/file.pdf", 'file content');
$this->createTestAttachment($postId, [
'hash' => $hash,
'stored_filename' => 'file.pdf',
'path' => 'admin-attach-mgmt/2025/01/01',
'disk' => 'public',
]);
// 다운로드 엔드포인트 — 파일이 실제로 없어도 서비스가 404를 반환
$response = $this->actingAs($this->adminWithDownload)->getJson(
$this->url("/attachments/download/{$hash}")
);
// 파일이 존재할 경우 200, 없으면 404 (fake storage에서 경로 불일치 시)
$this->assertContains($response->status(), [200, 404]);
}
/**
* 존재하지 않는 해시로 다운로드 → 404
*/
public function test_download_nonexistent_hash_returns_404(): void
{
$this->actingAs($this->adminWithDownload)->getJson(
$this->url('/attachments/download/nonexistenthash')
)->assertStatus(404);
}
/**
* 다운로드 권한 없는 사용자 → 403
*/
public function test_user_without_download_permission_cannot_download(): void
{
$postId = $this->createTestPost();
$hash = 'permtesthash';
$this->createTestAttachment($postId, ['hash' => $hash]);
$this->actingAs($this->regularUser)->getJson(
$this->url("/attachments/download/{$hash}")
)->assertStatus(403);
}
// ==========================================
// Helper
// ==========================================
/**
* Admin attachment API URL 생성
*/
private function url(string $suffix): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/admin/board/{$slug}{$suffix}";
}
/**
* 테스트용 첨부파일을 직접 DB에 생성합니다.
*/
private function createTestAttachment(int $postId, array $attributes = []): int
{
$defaults = [
'board_id' => $this->board->id,
'post_id' => $postId,
'hash' => substr(md5(uniqid()), 0, 12),
'original_filename' => 'test.pdf',
'stored_filename' => 'test_'.uniqid().'.pdf',
'disk' => 'public',
'path' => 'attachments',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 0,
'created_at' => now(),
'updated_at' => now(),
];
return DB::table('board_attachments')->insertGetId(array_merge($defaults, $attributes));
}
}
@@ -0,0 +1,463 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 관리자 댓글 관리 API 테스트
*
* 검증 목적:
* - 댓글 생성/수정/삭제 (CRUD) HTTP 레벨 응답 코드 및 DB 반영
* - 블라인드/복원 기능 동작 및 상태 전이
* - 권한 부재 시 403, 미인증 시 401 응답
* - 블라인드 시 comments_count 무변동, 복원 시 실제 DB 카운트로 동기화
*
* @group board
* @group admin
* @group comment
*/
class AdminCommentManagementTest extends BoardTestCase
{
private User $adminWithManage;
private User $adminWithWriteOnly;
private User $regularUser;
protected function getTestBoardSlug(): string
{
return 'admin-comment-mgmt';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '관리자 댓글 관리 테스트 게시판', 'en' => 'Admin Comment Management Test Board'],
'is_active' => true,
'use_comment' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
$slug = $this->board->slug;
// admin.manage + admin.comments.write 권한 보유 관리자
// createAdminUser는 admin 역할에 권한을 attach하므로 먼저 생성
$this->adminWithManage = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.manage",
"sirsoft-board.{$slug}.admin.comments.write",
]);
// admin.comments.write 전용 관리자 — 별도 역할에 write 권한만 부여
// (createAdminUser는 admin 역할을 공유하므로 manage 권한 오염 방지를 위해 직접 생성)
$this->adminWithWriteOnly = $this->createWriteOnlyAdminUser($slug);
// 권한 없는 일반 사용자
$this->regularUser = $this->createUser();
}
/**
* admin.write + admin.comments.write 권한만 가진 관리자를 생성합니다.
* (manage 권한 없음)
* 별도 역할을 만들어 admin 역할의 권한 오염을 방지합니다.
*
* controller의 authorizeCommentModification은 'admin.write' identifier를 확인하므로
* 'sirsoft-board.{slug}.admin.write' 권한을 부여해야 본인 댓글 수정/삭제 가능.
*/
private function createWriteOnlyAdminUser(string $slug): User
{
$user = User::factory()->create();
// write-only 전용 역할 생성 (type=admin → isAdmin() 통과)
$role = Role::create([
'identifier' => 'board-write-only-' . $slug,
'name' => ['ko' => 'Write Only', 'en' => 'Write Only'],
'type' => 'admin',
]);
foreach ([
"sirsoft-board.{$slug}.admin.write",
"sirsoft-board.{$slug}.admin.comments.write",
] as $identifier) {
$perm = Permission::firstOrCreate(
['identifier' => $identifier],
['name' => ['ko' => $identifier, 'en' => $identifier], 'type' => 'admin']
);
$role->permissions()->attach($perm->id);
}
$user->roles()->attach($role->id);
return $user;
}
// ==========================================
// 댓글 생성 (store)
// ==========================================
/**
* admin.comments.write 권한 보유 시 댓글 생성 → 201
*/
public function test_admin_can_create_comment(): void
{
$postId = $this->createTestPost();
$response = $this->actingAs($this->adminWithManage)->postJson(
$this->url("/{$postId}/comments"),
['content' => '관리자가 작성한 댓글입니다.']
);
$response->assertStatus(201);
$this->assertDatabaseHas('board_comments', [
'post_id' => $postId,
'board_id' => $this->board->id,
'content' => '관리자가 작성한 댓글입니다.',
'status' => 'published',
]);
}
/**
* 미인증 요청 → 401
*/
public function test_unauthenticated_cannot_create_comment(): void
{
$postId = $this->createTestPost();
$this->postJson(
$this->url("/{$postId}/comments"),
['content' => '댓글 내용']
)->assertStatus(401);
}
/**
* 권한 없는 사용자 → 403
*/
public function test_user_without_permission_cannot_create_comment(): void
{
$postId = $this->createTestPost();
$this->actingAs($this->regularUser)->postJson(
$this->url("/{$postId}/comments"),
['content' => '댓글 내용']
)->assertStatus(403);
}
/**
* use_comment=false 게시판 → 403
*/
public function test_cannot_create_comment_when_comments_disabled(): void
{
$this->updateBoardSettings(['use_comment' => false]);
$postId = $this->createTestPost();
$this->actingAs($this->adminWithManage)->postJson(
$this->url("/{$postId}/comments"),
['content' => '댓글 내용']
)->assertStatus(403);
}
// ==========================================
// 댓글 수정 (update)
// ==========================================
/**
* admin.manage 권한으로 다른 사용자 댓글 수정 → 200
*/
public function test_admin_manage_can_update_any_comment(): void
{
$author = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $author->id,
'content' => '원래 내용',
]);
$response = $this->actingAs($this->adminWithManage)->putJson(
$this->url("/{$postId}/comments/{$commentId}"),
['content' => '수정된 내용']
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '수정된 내용',
]);
}
/**
* admin.write 권한으로 본인 댓글 수정 → 200
*/
public function test_admin_write_can_update_own_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->adminWithWriteOnly->id,
'content' => '원래 내용',
]);
$response = $this->actingAs($this->adminWithWriteOnly)->putJson(
$this->url("/{$postId}/comments/{$commentId}"),
['content' => '수정된 내용']
);
$response->assertStatus(200);
}
/**
* admin.write 권한으로 타인 댓글 수정 → 403
*/
public function test_admin_write_cannot_update_others_comment(): void
{
$author = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $author->id,
'content' => '타인 댓글',
]);
$this->actingAs($this->adminWithWriteOnly)->putJson(
$this->url("/{$postId}/comments/{$commentId}"),
['content' => '수정 시도']
)->assertStatus(403);
}
/**
* admin.write 권한으로 비회원 댓글 수정 → 403
*/
public function test_admin_write_cannot_update_guest_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => null,
'content' => '비회원 댓글',
]);
$this->actingAs($this->adminWithWriteOnly)->putJson(
$this->url("/{$postId}/comments/{$commentId}"),
['content' => '수정 시도']
)->assertStatus(403);
}
// ==========================================
// 댓글 삭제 (destroy)
// ==========================================
/**
* admin.manage 권한으로 댓글 삭제 → 200 + soft delete
*/
public function test_admin_manage_can_delete_any_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId);
$this->actingAs($this->adminWithManage)->deleteJson(
$this->url("/{$postId}/comments/{$commentId}")
)->assertStatus(200);
$this->assertSoftDeleted('board_comments', ['id' => $commentId]);
}
/**
* admin.write 권한으로 본인 댓글 삭제 → 200
*/
public function test_admin_write_can_delete_own_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->adminWithWriteOnly->id,
]);
$this->actingAs($this->adminWithWriteOnly)->deleteJson(
$this->url("/{$postId}/comments/{$commentId}")
)->assertStatus(200);
$this->assertSoftDeleted('board_comments', ['id' => $commentId]);
}
/**
* 권한 없는 사용자 삭제 → 403
*/
public function test_user_without_permission_cannot_delete_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId);
$this->actingAs($this->regularUser)->deleteJson(
$this->url("/{$postId}/comments/{$commentId}")
)->assertStatus(403);
}
// ==========================================
// 댓글 블라인드 (blind)
// ==========================================
/**
* admin.manage 권한으로 댓글 블라인드 → 200 + status=blinded
*/
public function test_admin_manage_can_blind_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['status' => 'published']);
$response = $this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/blind"),
['reason' => '운영 정책 위반']
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'status' => 'blinded',
]);
}
/**
* 블라인드 시 comments_count 무변동 (카운트 불변 정책)
*/
public function test_blind_comment_does_not_change_comments_count(): void
{
$postId = $this->createTestPost();
DB::table('board_posts')->where('id', $postId)->update(['comments_count' => 2]);
$commentId = $this->createTestComment($postId, ['status' => 'published']);
$this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/blind"),
[]
)->assertStatus(200);
$count = DB::table('board_posts')->where('id', $postId)->value('comments_count');
$this->assertEquals(2, $count, '블라인드 처리 후 comments_count가 변경되면 안 됩니다.');
}
/**
* admin.comments.write 권한만으로 블라인드 → 403 (manage 필요)
*/
public function test_admin_write_cannot_blind_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId);
$this->actingAs($this->adminWithWriteOnly)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/blind"),
[]
)->assertStatus(403);
}
/**
* use_comment=false 게시판 블라인드 → 403
*/
public function test_cannot_blind_comment_when_comments_disabled(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId);
$this->updateBoardSettings(['use_comment' => false]);
$this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/blind"),
[]
)->assertStatus(403);
}
// ==========================================
// 댓글 복원 (restore)
// ==========================================
/**
* admin.manage 권한으로 블라인드된 댓글 복원 → 200 + status=published
*/
public function test_admin_manage_can_restore_blinded_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['status' => 'blinded']);
$response = $this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/restore"),
[]
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'status' => 'published',
]);
}
/**
* 복원 시 comments_count가 실제 published 댓글 수로 동기화된다
*
* PostCountSyncListener(after_restore, sync:true)가 실제 DB 카운트로 재동기화.
* published 댓글 1개 + blinded 댓글 복원 → published 2개 → comments_count=2
*/
public function test_restore_comment_syncs_comments_count(): void
{
$postId = $this->createTestPost();
// published 댓글 1개 (카운트에 포함)
$this->createTestComment($postId, ['status' => 'published']);
// blinded 댓글 1개 (복원 대상)
$commentId = $this->createTestComment($postId, ['status' => 'blinded']);
$this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/restore"),
[]
)->assertStatus(200);
// 복원 후 published 2개 → comments_count=2
$count = DB::table('board_posts')->where('id', $postId)->value('comments_count');
$this->assertEquals(2, $count, '복원 후 comments_count는 실제 published 댓글 수와 일치해야 합니다.');
}
/**
* admin.write 권한만으로 복원 → 403 (manage 필요)
*/
public function test_admin_write_cannot_restore_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['status' => 'blinded']);
$this->actingAs($this->adminWithWriteOnly)->patchJson(
$this->url("/{$postId}/comments/{$commentId}/restore"),
[]
)->assertStatus(403);
}
/**
* 존재하지 않는 댓글 복원 → 404
*/
public function test_restore_nonexistent_comment_returns_404(): void
{
$postId = $this->createTestPost();
$this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/comments/99999/restore"),
[]
)->assertStatus(404);
}
// ==========================================
// Helper
// ==========================================
/**
* Admin comment API URL 생성
*/
private function url(string $suffix): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/admin/board/{$slug}/posts{$suffix}";
}
}
@@ -0,0 +1,231 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 관리자 게시글 블라인드/복원 API 테스트
*
* 검증 목적:
* - blind: status=blinded, 미인증 401, 권한 없음 403, 이미 블라인드된 게시글 멱등성
* - restore: status=published, 권한 없음 403
* - 존재하지 않는 게시글 → 404
* - 블라인드 시 posts_count 무변동 (카운트 불변 정책)
*
* @group board
* @group admin
* @group post
*/
class AdminPostBlindRestoreTest extends BoardTestCase
{
private User $adminWithManage;
private User $regularUser;
protected function getTestBoardSlug(): string
{
return 'admin-post-blind';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '관리자 게시글 블라인드 테스트 게시판', 'en' => 'Admin Post Blind Test Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
$slug = $this->board->slug;
$this->adminWithManage = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.manage",
]);
$this->regularUser = $this->createUser();
}
// ==========================================
// 블라인드 (blind)
// ==========================================
/**
* admin.manage 권한으로 게시글 블라인드 → 200 + status=blinded
*/
public function test_admin_can_blind_post(): void
{
$postId = $this->createTestPost(['status' => 'published']);
$response = $this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/blind"),
['reason' => '운영 정책 위반']
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_posts', [
'id' => $postId,
'status' => 'blinded',
]);
}
/**
* 미인증 요청 → 401
*/
public function test_unauthenticated_cannot_blind_post(): void
{
$postId = $this->createTestPost();
$this->patchJson(
$this->url("/{$postId}/blind"),
[]
)->assertStatus(401);
}
/**
* 권한 없는 사용자 → 403
*/
public function test_user_without_permission_cannot_blind_post(): void
{
$postId = $this->createTestPost();
$this->actingAs($this->regularUser)->patchJson(
$this->url("/{$postId}/blind"),
[]
)->assertStatus(403);
}
/**
* 존재하지 않는 게시글 → 404
*/
public function test_blind_nonexistent_post_returns_404(): void
{
$this->actingAs($this->adminWithManage)->patchJson(
$this->url('/99999/blind'),
[]
)->assertStatus(404);
}
/**
* 이미 블라인드된 게시글 재블라인드 → 멱등성 보장 (200, status 유지)
*/
public function test_blind_already_blinded_post_is_idempotent(): void
{
$postId = $this->createTestPost(['status' => 'blinded']);
$response = $this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/blind"),
[]
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_posts', [
'id' => $postId,
'status' => 'blinded',
]);
}
/**
* 블라인드 처리 시 boards.posts_count 무변동 (카운트 불변 정책)
*/
public function test_blind_post_does_not_change_posts_count(): void
{
DB::table('boards')->where('id', $this->board->id)->update(['posts_count' => 5]);
$postId = $this->createTestPost(['status' => 'published']);
$this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/blind"),
[]
)->assertStatus(200);
$count = DB::table('boards')->where('id', $this->board->id)->value('posts_count');
$this->assertEquals(5, $count, '블라인드 후 posts_count가 변경되면 안 됩니다.');
}
// ==========================================
// 복원 (restore)
// ==========================================
/**
* admin.manage 권한으로 블라인드된 게시글 복원 → 200 + status=published
*/
public function test_admin_can_restore_blinded_post(): void
{
$postId = $this->createTestPost(['status' => 'blinded']);
$response = $this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/restore"),
[]
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_posts', [
'id' => $postId,
'status' => 'published',
]);
}
/**
* 권한 없는 사용자 복원 → 403
*/
public function test_user_without_permission_cannot_restore_post(): void
{
$postId = $this->createTestPost(['status' => 'blinded']);
$this->actingAs($this->regularUser)->patchJson(
$this->url("/{$postId}/restore"),
[]
)->assertStatus(403);
}
/**
* 존재하지 않는 게시글 복원 → 404
*/
public function test_restore_nonexistent_post_returns_404(): void
{
$this->actingAs($this->adminWithManage)->patchJson(
$this->url('/99999/restore'),
[]
)->assertStatus(404);
}
/**
* 이미 published 상태 게시글 복원 → 멱등성 보장 (200, status 유지)
*/
public function test_restore_already_published_post_is_idempotent(): void
{
$postId = $this->createTestPost(['status' => 'published']);
$response = $this->actingAs($this->adminWithManage)->patchJson(
$this->url("/{$postId}/restore"),
[]
);
$response->assertStatus(200);
$this->assertDatabaseHas('board_posts', [
'id' => $postId,
'status' => 'published',
]);
}
// ==========================================
// Helper
// ==========================================
private function url(string $suffix): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/admin/board/{$slug}/posts{$suffix}";
}
}
@@ -89,8 +89,6 @@ class AutoHideTest extends ModuleTestCase
]
);
$this->ensureBoardPartitions($this->board->id);
// 이전 잔여 데이터 정리
DB::table('boards_report_logs')->delete();
DB::table('boards_reports')->delete();
@@ -0,0 +1,160 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__ . '/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 보드 생성/삭제 회귀 테스트 (이슈 #269 / #12)
*
* 파티션 스키마 폐지(1.0.0-beta.3) 후 회귀 방지:
* - 잔여 파티션 DDL 호출로 인한 보드 생성 실패가 다시 발생하지 않도록 방어
* - BoardService의 create/delete 경로에서 파티션 관련 쿼리가 실행되지 않는지 검증
* - Repository 인터페이스에서 파티션 메서드가 제거되었는지 검증
*/
class BoardCreationRegressionTest extends ModuleTestCase
{
/**
* DatabaseTransactions 비활성화 (수동 정리 경로 보존).
*/
public function beginDatabaseTransaction(): void
{
// 수동 정리 모드
}
protected function setUp(): void
{
parent::setUp();
if (! Schema::hasTable('board_types')) {
$this->artisan('migrate', [
'--path' => $this->getModuleBasePath() . '/database/migrations',
'--realpath' => true,
]);
}
BoardType::firstOrCreate(
['slug' => 'basic'],
['name' => ['ko' => '기본', 'en' => 'Basic'], 'is_active' => true, 'is_default' => true]
);
}
protected function tearDown(): void
{
$slugs = ['regress-create', 'regress-create-delete'];
foreach ($slugs as $slug) {
$permIds = Permission::where('identifier', 'like', "sirsoft-board.{$slug}.%")->pluck('id');
if ($permIds->isNotEmpty()) {
DB::table('role_permissions')->whereIn('permission_id', $permIds)->delete();
Permission::whereIn('id', $permIds)->delete();
}
$roleIds = Role::where('identifier', 'like', "sirsoft-board.{$slug}.%")->pluck('id');
if ($roleIds->isNotEmpty()) {
DB::table('user_roles')->whereIn('role_id', $roleIds)->delete();
Role::whereIn('id', $roleIds)->delete();
}
Board::where('slug', $slug)->forceDelete();
}
parent::tearDown();
}
/**
* 클린 환경에서 보드 생성이 SQL 오류 없이 성공해야 합니다.
*
* beta.2 회귀 시나리오: ALTER TABLE ADD PARTITION 쿼리가 실행되어 실패 → 보드 롤백 → 생성 불가.
* beta.3 기대 동작: 파티션 DDL 미실행, 보드 레코드/역할/권한 정상 생성.
*/
public function test_create_board_succeeds_without_partition_ddl(): void
{
$service = app(BoardService::class);
$queries = [];
DB::listen(function ($query) use (&$queries) {
$queries[] = $query->sql;
});
$board = $service->createBoard([
'slug' => 'regress-create',
'name' => ['ko' => '회귀 테스트', 'en' => 'Regress Test'],
'type' => 'basic',
]);
$this->assertInstanceOf(Board::class, $board);
$this->assertSame('regress-create', $board->slug);
$this->assertDatabaseHas('boards', ['id' => $board->id, 'slug' => 'regress-create']);
// 파티션 관련 DDL이 한 번도 실행되지 않았는지 확인
$partitionQueries = array_filter(
$queries,
fn ($sql) => stripos($sql, 'PARTITION') !== false
);
$this->assertEmpty(
$partitionQueries,
'보드 생성 중 파티션 관련 DDL이 실행되었습니다: '.implode(' | ', $partitionQueries)
);
}
/**
* 보드 삭제도 파티션 DDL 없이 성공해야 합니다.
*/
public function test_delete_board_succeeds_without_partition_ddl(): void
{
$service = app(BoardService::class);
$board = $service->createBoard([
'slug' => 'regress-create-delete',
'name' => ['ko' => '삭제 회귀', 'en' => 'Delete Regress'],
'type' => 'basic',
]);
$queries = [];
DB::listen(function ($query) use (&$queries) {
$queries[] = $query->sql;
});
$service->deleteBoard($board->id);
$this->assertDatabaseMissing('boards', ['id' => $board->id]);
$partitionQueries = array_filter(
$queries,
fn ($sql) => stripos($sql, 'PARTITION') !== false
);
$this->assertEmpty(
$partitionQueries,
'보드 삭제 중 파티션 관련 DDL이 실행되었습니다: '.implode(' | ', $partitionQueries)
);
}
/**
* BoardRepositoryInterface에서 파티션 메서드가 완전히 제거되었는지 확인합니다.
*/
public function test_partition_methods_removed_from_repository_interface(): void
{
$reflection = new \ReflectionClass(BoardRepositoryInterface::class);
$this->assertFalse(
$reflection->hasMethod('addBoardPartitions'),
'BoardRepositoryInterface에 addBoardPartitions 메서드가 남아있습니다.'
);
$this->assertFalse(
$reflection->hasMethod('dropBoardPartitions'),
'BoardRepositoryInterface에 dropBoardPartitions 메서드가 남아있습니다.'
);
}
}
@@ -17,25 +17,21 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase;
*
* 게시판 CRUD 및 is_active 필드 테스트
*
* DDL 주의:
* - BoardService::createBoard()가 내부적으로 ALTER TABLE ADD PARTITION (DDL)을 실행
* - DDL은 MySQL 암묵적 커밋을 유발하여 DatabaseTransactions 롤백을 방해
* - beginDatabaseTransaction()을 비활성화하고 tearDown에서 수동 정리
* Note: 과거 파티션 DDL 호환성을 위해 DatabaseTransactions를 비활성화하고
* tearDown에서 수동 정리 방식을 사용합니다. 파티션 폐지(beta.3) 후에도
* tearDown 정리 경로를 유지해 기존 테스트 호환성을 보존합니다.
* 테스트 인프라 정비는 후속 작업에서 진행합니다.
*/
class BoardManagementTest extends ModuleTestCase
{
protected User $adminUser;
/**
* DatabaseTransactions 비활성화.
*
* BoardService::createBoard() 내부의 ALTER TABLE ADD PARTITION (DDL)이
* MySQL 암묵적 커밋을 유발하여 DatabaseTransactions 롤백을 방해합니다.
* 대신 tearDown()에서 수동 DELETE로 잔여 데이터를 정리합니다.
* DatabaseTransactions 비활성화 유지 (tearDown 수동 정리 경로 보존).
*/
public function beginDatabaseTransaction(): void
{
// DatabaseTransactions 비활성화 (DDL 호환성)
// 수동 정리 모드
}
/**
@@ -26,10 +26,9 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase;
* - admin_posts_write: 사용자가 admin.posts.write 권한 보유 여부
* - admin_manage : 사용자가 admin.manage 권한 보유 여부
*
* DDL 주의:
* - ensureBoardPartitions() (ALTER TABLE)은 MySQL 암묵적 커밋 유발
* - DatabaseTransactions와 충돌하므로 beginDatabaseTransaction()을 비활성화
* - firstOrCreate + DB::table DELETE 패턴으로 수동 정리
* Note: 과거 파티션 DDL 호환성을 위해 DatabaseTransactions 비활성화 +
* 수동 DELETE 정리 방식을 사용합니다. 파티션 폐지(beta.3) 후에도
* 기존 수동 정리 경로를 유지합니다. 테스트 인프라 정비는 후속 작업에서.
*/
class BoardPermissionTest extends ModuleTestCase
{
@@ -54,15 +53,11 @@ class BoardPermissionTest extends ModuleTestCase
private int $otherPostId;
/**
* DatabaseTransactions 비활성화.
*
* ensureBoardPartitions() (ALTER TABLE ADD PARTITION) 이 MySQL 암묵적 커밋을 유발하여
* DatabaseTransactions 트랜잭션 롤백을 방해합니다.
* 대신 setUp()에서 수동 DELETE로 잔여 데이터를 정리합니다.
* DatabaseTransactions 비활성화 유지 (setUp 수동 DELETE 경로 보존).
*/
public function beginDatabaseTransaction(): void
{
// DatabaseTransactions 비활성화 (DDL 호환성)
// 수동 정리 모드
}
/**
@@ -133,8 +128,7 @@ class BoardPermissionTest extends ModuleTestCase
/**
* 테스트 게시판 생성 및 권한 설정
*
* Board::firstOrCreate → ensureBoardPartitions (DDL) → 암묵적 커밋
* 이후 데이터는 DatabaseTransactions로 보호되지 않으므로 수동 정리 필요
* DatabaseTransactions 비활성화 상태이므로 setUp/tearDown에서 수동 정리 필요.
*/
private function createTestBoard(): void
{
@@ -157,9 +151,6 @@ class BoardPermissionTest extends ModuleTestCase
]
);
// 파티션 보장 (LIST 파티션 — board_id별 파티션 필요)
$this->ensureBoardPartitions($this->board->id);
// 권한 생성 (DB::table 직접 삽입 — PermissionType enum cast 우회)
$slug = self::BOARD_SLUG;
$postsRead = $this->firstOrCreatePermission("sirsoft-board.{$slug}.admin.posts.read");
@@ -81,8 +81,6 @@ class GroupingAndTriggerTypeTest extends ModuleTestCase
]
);
$this->ensureBoardPartitions($this->board->id);
DB::table('boards_report_logs')->delete();
DB::table('boards_reports')->delete();
DB::table('board_comments')->where('board_id', $this->board->id)->delete();
@@ -34,17 +34,15 @@ class PostViewCountTest extends ModuleTestCase
private int $postId;
/**
* DatabaseTransactions를 비활성화합니다.
* DatabaseTransactions 비활성화 유지 (수동 정리 경로 보존).
*
* DDL (ALTER TABLE ADD PARTITION)이 MySQL 암묵적 커밋을 유발하여
* DatabaseTransactions와 충돌합니다. 수동 정리 방식을 사용합니다.
* - createTestPost(): 테스트 전 기존 게시글 DELETE
* - createTestBoard(): Board/Permission/Role은 firstOrCreate로 중복 방지
* - createTestUser(): faker로 매번 새 사용자 생성 (이메일 중복 없음)
*/
public function beginDatabaseTransaction(): void
{
// DatabaseTransactions 비활성화 (DDL 호환성)
// 수동 정리 모드
}
protected function setUp(): void
@@ -75,10 +73,8 @@ class PostViewCountTest extends ModuleTestCase
/**
* 테스트 게시판 생성 및 권한 설정
*
* Board::create() → Board::created 이벤트 → ALTER TABLE ADD PARTITION (DDL)
* → MySQL 암묵적 커밋 → DatabaseTransactions 롤백 불가
* 따라서 firstOrCreate로 게시판이 이미 존재하면 재사용하고,
* 권한/역할도 firstOrCreate로 중복 방지합니다.
* DatabaseTransactions 비활성화 상태에서 firstOrCreate로 중복 방지하며
* 게시판/권한/역할을 재사용합니다.
*/
private function createTestBoard(): void
{
@@ -101,9 +97,6 @@ class PostViewCountTest extends ModuleTestCase
]
);
// Board::created 이벤트가 발생하지 않은 경우(이미 존재하는 게시판)에도
// 파티션이 존재하도록 보장합니다.
$this->ensureBoardPartitions($this->board->id);
// 권한 생성: DB::table 직접 삽입으로 Eloquent enum cast 문제 완전 우회
// Permission Eloquent 모델은 type(PermissionType enum) cast 시
@@ -82,10 +82,7 @@ class ReportTest extends ModuleTestCase
]
);
// LIST 파티션 보장 (board_posts INSERT 가능하도록)
$this->ensureBoardPartitions($this->board->id);
// 이전 테스트 잔여 데이터 정리 (DDL commit으로 롤백 안됨)
// 이전 테스트 잔여 데이터 정리 (DatabaseTransactions 비활성 환경 호환)
// API가 전체 신고를 반환하므로 board_id 필터 없이 전체 삭제
DB::table('boards_report_logs')->delete();
DB::table('boards_reports')->delete();
@@ -54,11 +54,11 @@ class BoardPermissionScopeServiceTest extends ModuleTestCase
private array $createdPermissionPatterns = [];
/**
* DatabaseTransactions 비활성화 (DDL implicit commit 호환성)
* DatabaseTransactions 비활성화 유지 (수동 정리 경로 보존).
*/
public function beginDatabaseTransaction(): void
{
// Board 모듈은 ALTER TABLE (파티션) 등 DDL이 트랜잭션을 자동 커밋하므로 비활성화
// 수동 정리 모드
}
protected function setUp(): void
@@ -100,7 +100,6 @@ class BoardPermissionScopeServiceTest extends ModuleTestCase
'created_by' => $this->scopeUser->id,
'is_active' => true,
]);
$this->ensureBoardPartitions($this->board->id);
}
protected function tearDown(): void
@@ -0,0 +1,334 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../ModuleTestCase.php';
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 카운트 동기화 통합 테스트 (이슈 #269 - #11)
*
* Queue::fake() 를 사용해 "큐 워커가 없는 환경"을 재현합니다.
* sync:true 선언으로 Listener 가 즉시 실행되어 DB 카운트가 API 응답 시점에
* 이미 반영되어 있어야 합니다.
*
* 검증 목적:
* - 댓글/게시글/첨부 생성 직후 DB 카운트가 올바른지 (큐 워커 무관)
* - soft-delete 시 카운트가 즉시 감소하는지
* - blind 처리 시 카운트가 유지되는지 (upgrade step SQL 기준에 맞춤)
* - 첨부 임시 업로드 → linkTempAttachments 시 drift 가 발생하지 않는지
*
* @group board
* @group count-sync
*/
class CountSyncIntegrationTest extends ModuleTestCase
{
private Board $board;
private string $slug = 'count-integration';
public function beginDatabaseTransaction(): void
{
// 수동 정리 모드 (다른 count 테스트와 동일한 관행)
}
protected function setUp(): void
{
parent::setUp();
DB::table('board_attachments')->where('board_id', 0)->delete();
DB::table('boards')->where('slug', $this->slug)->delete();
$this->board = Board::factory()->create([
'slug' => $this->slug,
'is_active' => true,
'order_by' => 'created_at',
'order_direction' => 'DESC',
]);
// "큐 워커 없는 환경" 재현 — Job 은 실행되지 않고 저장만 됨
Queue::fake();
}
protected function tearDown(): void
{
DB::table('board_attachments')->where('board_id', $this->board->id)->delete();
DB::table('board_comments')->where('board_id', $this->board->id)->delete();
DB::table('board_posts')->where('board_id', $this->board->id)->delete();
DB::table('boards')->where('id', $this->board->id)->delete();
parent::tearDown();
}
/**
* 큐 워커가 없는 환경에서도 댓글 생성 직후 DB 카운트가 즉시 반영되어야 합니다.
* (sync: true 옵션 실증 — 이슈 #269 작업 3 핵심)
*/
public function test_comment_counts_update_immediately_without_queue_worker(): void
{
$postId = $this->insertPost();
$commentService = app(CommentService::class);
$commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => '테스트 댓글',
'ip_address' => '127.0.0.1',
'password' => null,
]);
// board_posts.comments_count 즉시 반영
$this->assertEquals(
1,
DB::table('board_posts')->where('id', $postId)->value('comments_count'),
'board_posts.comments_count 는 댓글 생성 직후 즉시 1 이어야 합니다 (sync:true).'
);
// boards.comments_count 즉시 반영
$this->assertEquals(
1,
DB::table('boards')->where('id', $this->board->id)->value('comments_count'),
'boards.comments_count 는 댓글 생성 직후 즉시 1 이어야 합니다.'
);
}
/**
* 댓글을 여러 개 연달아 생성해도 모든 생성이 즉시 누적 반영되어야 합니다.
*/
public function test_multiple_comments_accumulate_count(): void
{
$postId = $this->insertPost();
$commentService = app(CommentService::class);
for ($i = 1; $i <= 3; $i++) {
$commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => "테스트 댓글 {$i}",
'ip_address' => '127.0.0.1',
'password' => null,
]);
}
$this->assertEquals(3, DB::table('board_posts')->where('id', $postId)->value('comments_count'));
$this->assertEquals(3, DB::table('boards')->where('id', $this->board->id)->value('comments_count'));
}
/**
* 댓글 soft delete 시 카운트가 즉시 감소해야 합니다.
*/
public function test_comment_delete_decrements_counts_immediately(): void
{
$postId = $this->insertPost();
$commentService = app(CommentService::class);
$c = $commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => '삭제될 댓글',
'ip_address' => '127.0.0.1',
'password' => null,
]);
$this->assertEquals(1, DB::table('board_posts')->where('id', $postId)->value('comments_count'));
$commentService->deleteComment($this->slug, $c->id);
$this->assertEquals(0, DB::table('board_posts')->where('id', $postId)->value('comments_count'));
$this->assertEquals(0, DB::table('boards')->where('id', $this->board->id)->value('comments_count'));
}
/**
* 블라인드 처리는 카운트를 변경하지 않아야 합니다.
* (upgrade step SQL 기준: deleted_at IS NULL 만 필터, status 무관)
*/
public function test_comment_blind_does_not_change_counts(): void
{
$postId = $this->insertPost();
$commentService = app(CommentService::class);
$c = $commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => '블라인드 대상',
'ip_address' => '127.0.0.1',
'password' => null,
]);
$this->assertEquals(1, DB::table('board_posts')->where('id', $postId)->value('comments_count'));
$commentService->blindComment($this->slug, $c->id, '테스트 블라인드');
$this->assertEquals(
1,
DB::table('board_posts')->where('id', $postId)->value('comments_count'),
'블라인드는 deleted_at 변경 없음 → 카운트 유지되어야 합니다.'
);
}
/**
* 대댓글 생성 시 부모 댓글의 replies_count 가 즉시 반영되어야 합니다.
*/
public function test_comment_reply_increments_parent_replies_count(): void
{
$postId = $this->insertPost();
$commentService = app(CommentService::class);
$parent = $commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => '원댓글',
'ip_address' => '127.0.0.1',
'password' => null,
]);
$commentService->createComment($this->slug, [
'post_id' => $postId,
'parent_id' => $parent->id,
'author_name' => '익명',
'content' => '대댓글',
'ip_address' => '127.0.0.1',
'password' => null,
]);
$this->assertEquals(
1,
DB::table('board_comments')->where('id', $parent->id)->value('replies_count'),
'대댓글 생성 시 부모 댓글의 replies_count 는 즉시 1 이어야 합니다.'
);
}
/**
* 게시글 생성 시 boards.posts_count 가 즉시 반영되어야 합니다.
* PostService 를 통한 경로는 after_create 훅을 발화 → Listener 가 COUNT 재계산.
*/
public function test_post_create_increments_board_posts_count(): void
{
$postService = app(PostService::class);
$postService->createPost($this->slug, [
'title' => '서비스 경로',
'content' => '본문',
'content_mode' => 'text',
'author_name' => '익명',
'ip_address' => '127.0.0.1',
'is_notice' => false,
'is_secret' => false,
]);
$this->assertEquals(
1,
DB::table('boards')->where('id', $this->board->id)->value('posts_count'),
'PostService::createPost 호출 직후 boards.posts_count 가 즉시 반영되어야 합니다.'
);
}
/**
* drift 상태에서 댓글 생성 훅이 발화되면 카운트가 정확히 재동기화되어야 합니다.
* (Listener 는 증분 연산이 아닌 COUNT(*) 재계산 방식이라 drift 복구 가능)
*/
public function test_drift_state_is_recovered_by_listener_on_next_event(): void
{
$postId = $this->insertPost();
// 인위적 drift 삽입: 실제 댓글 0건인데 stored 는 99
DB::table('board_posts')->where('id', $postId)->update(['comments_count' => 99]);
DB::table('boards')->where('id', $this->board->id)->update(['comments_count' => 99]);
$commentService = app(CommentService::class);
$commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => 'drift 복구용',
'ip_address' => '127.0.0.1',
'password' => null,
]);
$this->assertEquals(
1,
DB::table('board_posts')->where('id', $postId)->value('comments_count'),
'drift(99) 상태여도 다음 훅 발화 시 실제 개수(1) 로 재동기화되어야 합니다.'
);
$this->assertEquals(
1,
DB::table('boards')->where('id', $this->board->id)->value('comments_count'),
);
}
/**
* 게시글 상세 API 응답의 comment_count 필드가 댓글 생성 직후 최신값이어야 합니다.
* (UI 헤더 바인딩이 실제 최신 카운트를 받는지 실증)
*/
public function test_post_api_response_exposes_up_to_date_comment_count(): void
{
// guest 에게 posts.read 권한 부여 (이미 존재하면 재사용)
$identifier = 'sirsoft-board.'.$this->slug.'.posts.read';
$permId = DB::table('permissions')->where('identifier', $identifier)->value('id');
if (! $permId) {
$permId = DB::table('permissions')->insertGetId([
'identifier' => $identifier,
'name' => json_encode(['ko' => '읽기', 'en' => 'read']),
'type' => 'user',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'resource_route_key' => 'post',
'owner_key' => 'user_id',
'created_at' => now(),
'updated_at' => now(),
]);
}
$guestRoleId = DB::table('roles')->where('identifier', 'guest')->value('id');
DB::table('role_permissions')->updateOrInsert(
['role_id' => $guestRoleId, 'permission_id' => $permId],
['scope_type' => null, 'granted_at' => now(), 'created_at' => now(), 'updated_at' => now()]
);
$postId = $this->insertPost();
$commentService = app(CommentService::class);
$commentService->createComment($this->slug, [
'post_id' => $postId,
'author_name' => '익명',
'content' => '댓글',
'ip_address' => '127.0.0.1',
'password' => null,
]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->slug}/posts/{$postId}");
$response->assertStatus(200);
$this->assertSame(
1,
$response->json('data.comment_count'),
'API 응답의 comment_count 는 댓글 생성 직후 즉시 최신값이어야 합니다.'
);
}
/**
* 테스트용 게시글을 직접 INSERT 합니다.
*/
private function insertPost(array $overrides = []): int
{
$defaults = [
'board_id' => $this->board->id,
'title' => '테스트글',
'content' => '본문',
'content_mode' => 'text',
'author_name' => '작성자',
'ip_address' => '127.0.0.1',
'is_notice' => false,
'is_secret' => false,
'status' => 'published',
'view_count' => 0,
'created_at' => now(),
'updated_at' => now(),
];
return DB::table('board_posts')->insertGetId(array_merge($defaults, $overrides));
}
}
@@ -0,0 +1,303 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
require_once __DIR__.'/../ModuleTestCase.php';
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* FormRequest 검증 규칙 테스트
*
* 검증 목적:
* StorePostRequest:
* - title 누락 → 422
* - content 누락 → 422
* - title 최소 길이 미만 → 422
* - content_mode 유효하지 않은 값 → 422
* - 정상 데이터 → 201
*
* StoreCommentRequest:
* - content 누락 → 422
* - post_id 누락 → 422
* - 비회원이 author_name 누락 → 422
* - 비회원이 password 누락 → 422
* - 비회원 정상 데이터 → 201
*
* StoreReportRequest:
* - reason_type 누락 → 422
* - reason_detail 누락 → 422
* - reason_type 유효하지 않은 값 → 422
* - 정상 데이터 → 201
*
* UpdatePostRequest:
* - title 최소 길이 미만 → 422
*
* @group board
* @group feature
* @group formrequest
*/
class FormRequestValidationTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'form-request-validation';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => 'FormRequest 검증 테스트 게시판', 'en' => 'FormRequest Validation Test Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
'use_comment' => true,
'use_report' => true,
'min_title_length' => 2,
'max_title_length' => 200,
'min_content_length' => 5,
'max_content_length' => 10000,
'min_comment_length' => 2,
'max_comment_length' => 1000,
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.write', 'attachments.upload']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.write', 'attachments.upload']);
}
private function postUrl(): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts";
}
private function commentUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
private function reportUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/reports";
}
// ==========================================
// StorePostRequest
// ==========================================
/**
* title 누락 → 422
*/
public function test_store_post_fails_without_title(): void
{
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), [
'content' => '정상 내용입니다.',
])->assertStatus(422);
}
/**
* content 누락 → 422
*/
public function test_store_post_fails_without_content(): void
{
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), [
'title' => '정상 제목',
])->assertStatus(422);
}
/**
* title 최소 길이 미만 (1자) → 422
*/
public function test_store_post_fails_with_title_too_short(): void
{
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), [
'title' => '가', // min_title_length=2 미만
'content' => '정상적인 내용입니다.',
])->assertStatus(422);
}
/**
* content_mode 유효하지 않은 값 → 422
*/
public function test_store_post_fails_with_invalid_content_mode(): void
{
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), [
'title' => '정상 제목',
'content' => '정상적인 내용입니다.',
'content_mode' => 'markdown', // text|html 만 허용
])->assertStatus(422);
}
/**
* 정상 데이터 → 201
*/
public function test_store_post_succeeds_with_valid_data(): void
{
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), [
'title' => '정상 게시글 제목',
'content' => '정상적인 게시글 내용입니다.',
])->assertStatus(201);
}
// ==========================================
// StoreCommentRequest
// ==========================================
/**
* content 누락 → 422
*/
public function test_store_comment_fails_without_content(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$this->actingAs($user)->postJson($this->commentUrl($postId), [
'post_id' => $postId,
])->assertStatus(422);
}
/**
* post_id 누락 → 422
*/
public function test_store_comment_fails_without_post_id(): void
{
$user = $this->createUser();
$this->actingAs($user)->postJson($this->commentUrl(1), [
'content' => '댓글 내용입니다.',
])->assertStatus(422);
}
/**
* 비회원이 author_name 누락 → 422
*/
public function test_store_comment_guest_fails_without_author_name(): void
{
$postId = $this->createTestPost();
$this->postJson($this->commentUrl($postId), [
'content' => '댓글 내용입니다.',
'post_id' => $postId,
'password' => 'pass1234',
// author_name 누락
])->assertStatus(422);
}
/**
* 비회원이 password 누락 → 422
*/
public function test_store_comment_guest_fails_without_password(): void
{
$postId = $this->createTestPost();
$this->postJson($this->commentUrl($postId), [
'content' => '댓글 내용입니다.',
'post_id' => $postId,
'author_name' => '비회원',
// password 누락
])->assertStatus(422);
}
/**
* 비회원 정상 댓글 데이터 → 201
*/
public function test_store_comment_guest_succeeds_with_valid_data(): void
{
$postId = $this->createTestPost();
$this->postJson($this->commentUrl($postId), [
'content' => '정상적인 댓글 내용입니다.',
'post_id' => $postId,
'author_name' => '비회원',
'password' => 'pass1234',
])->assertStatus(201);
}
// ==========================================
// StoreReportRequest
// ==========================================
/**
* reason_type 누락 → 422
*/
public function test_store_report_fails_without_reason_type(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reporter)->postJson($this->reportUrl($postId), [
'reason_detail' => '신고 상세 내용입니다.',
])->assertStatus(422);
}
/**
* reason_detail 누락 → 422
*/
public function test_store_report_fails_without_reason_detail(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reporter)->postJson($this->reportUrl($postId), [
'reason_type' => 'spam',
])->assertStatus(422);
}
/**
* reason_type 유효하지 않은 값 → 422
*/
public function test_store_report_fails_with_invalid_reason_type(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reporter)->postJson($this->reportUrl($postId), [
'reason_type' => 'not_a_valid_type',
'reason_detail' => '상세 내용입니다.',
])->assertStatus(422);
}
/**
* 정상 신고 데이터 → 201
*/
public function test_store_report_succeeds_with_valid_data(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reporter)->postJson($this->reportUrl($postId), [
'reason_type' => 'spam',
'reason_detail' => '스팸 게시글입니다.',
])->assertStatus(201);
}
// ==========================================
// UpdatePostRequest
// ==========================================
/**
* title 최소 길이 미만으로 수정 → 422
*/
public function test_update_post_fails_with_title_too_short(): void
{
$user = $this->createUser();
$postId = $this->createTestPost(['user_id' => $user->id]);
$this->actingAs($user)->putJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}",
['title' => '가'] // min_title_length=2 미만
)->assertStatus(422);
}
}
@@ -31,8 +31,7 @@ class HookIntegrationTest extends ModuleTestCase
{
parent::setUp();
// 이전 실행에서 DDL 커밋으로 남은 test-board-* 잔여 데이터 정리
// (ensureBoardPartitions DDL → 암묵적 커밋 → DatabaseTransactions 롤백 불가)
// 이전 실행에서 남은 test-board-* 잔여 데이터 정리 (DatabaseTransactions 비활성 환경 호환)
DB::statement('SET FOREIGN_KEY_CHECKS=0');
DB::table('permissions')->where('identifier', 'like', 'sirsoft-board.test-board-%')->delete();
DB::table('boards')->where('slug', 'like', 'test-board-%')->delete();
@@ -81,14 +80,12 @@ class HookIntegrationTest extends ModuleTestCase
Event::forget('hook.sirsoft-board.board.before_create');
Event::forget('hook.sirsoft-board.board.filter_create_data');
Event::forget('hook.sirsoft-board.board.after_create');
Event::forget('hook.sirsoft-board.partition.after_create');
Event::forget('hook.sirsoft-board.permissions.after_create');
Event::forget('hook.sirsoft-board.board.before_update');
Event::forget('hook.sirsoft-board.board.filter_update_data');
Event::forget('hook.sirsoft-board.board.after_update');
Event::forget('hook.sirsoft-board.board.before_delete');
Event::forget('hook.sirsoft-board.board.after_delete');
Event::forget('hook.sirsoft-board.partition.after_delete');
Event::forget('hook.sirsoft-board.permissions.after_delete');
Event::forget('hook.sirsoft-board.board.before_copy');
Event::forget('hook.sirsoft-board.board.filter_copy_data');
@@ -193,28 +190,6 @@ class HookIntegrationTest extends ModuleTestCase
$this->assertNotNull($createdBoardId);
}
/**
* 파티션 추가 후 partition.after_create 훅이 실행되는지 테스트
*/
public function test_table_after_create_hook_is_called(): void
{
$hookCalled = false;
HookManager::addAction('sirsoft-board.partition.after_create', function ($board) use (&$hookCalled) {
$hookCalled = true;
$this->assertInstanceOf(Board::class, $board);
});
$board = $this->boardService->createBoard([
'slug' => 'test-board-4',
'name' => ['ko' => '테스트 게시판 4', 'en' => 'Test Board 4'],
'type' => 'default',
]);
$this->assertInstanceOf(Board::class, $board);
$this->assertTrue($hookCalled, 'partition.after_create 훅이 호출되지 않았습니다.');
}
/**
* 권한 생성 후 permissions.after_create 훅이 실행되는지 테스트
*/
@@ -375,25 +350,6 @@ class HookIntegrationTest extends ModuleTestCase
$this->assertEquals('test-board-11', $deletedSlug);
}
/**
* 파티션 제거 후 partition.after_delete 훅이 실행되는지 테스트
*/
public function test_table_after_delete_hook_is_called(): void
{
$board = $this->createTestBoard('test-board-12');
$hookCalled = false;
HookManager::addAction('sirsoft-board.partition.after_delete', function ($slug) use (&$hookCalled) {
$hookCalled = true;
$this->assertIsString($slug);
});
$this->boardService->deleteBoard($board->id);
$this->assertTrue($hookCalled, 'partition.after_delete 훅이 호출되지 않았습니다.');
}
/**
* 게시판 삭제 후 after_delete 훅이 실행되는지 테스트
*/
@@ -44,7 +44,7 @@ class PermissionScopeTest extends ModuleTestCase
*/
public function beginDatabaseTransaction(): void
{
// Board 모듈은 ALTER TABLE (파티션) 등 DDL이 트랜잭션을 자동 커밋하므로 비활성화
// 수동 정리 모드
}
protected function setUp(): void
@@ -513,9 +513,6 @@ class PermissionScopeTest extends ModuleTestCase
'created_by' => $createdBy,
]);
// Post/Comment INSERT를 위해 LIST 파티션 생성 필수
$this->ensureBoardPartitions($board->id);
return $board;
}
@@ -76,8 +76,6 @@ class ReportNotificationTest extends ModuleTestCase
]
);
$this->ensureBoardPartitions($this->board->id);
DB::table('boards_reports')->delete();
DB::table('board_comments')->where('board_id', $this->board->id)->delete();
DB::table('board_posts')->where('board_id', $this->board->id)->delete();
@@ -0,0 +1,214 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use App\Models\Role;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 첨부파일 CRUD 전수 시나리오 테스트
*
* 검증 목적:
* - 회원/비회원 첨부파일 업로드 권한 분기
* - use_file_upload=false 게시판에서 업로드 차단
* - 업로드 후 게시글 작성 시 attachments_count 반영
* - 업로드 권한 없는 사용자 차단
* - 첨부파일 삭제 및 attachments_count 감소
*
* @group board
* @group attachment
*/
class AttachmentCrudScenarioTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'attachment-crud';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '첨부파일 CRUD 테스트 게시판', 'en' => 'Attachment CRUD Test Board'],
'is_active' => true,
'use_file_upload' => true,
];
}
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->setGuestPermissions(['posts.read', 'posts.write', 'attachments.upload']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'attachments.upload']);
$this->memberUser = User::factory()->create(['email' => 'attach-member@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
// ==========================================
// 첨부파일 업로드 권한 시나리오
// ==========================================
/**
* 업로드 권한 있는 회원은 첨부파일을 업로드할 수 있다
*/
public function test_member_with_upload_permission_can_upload(): void
{
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments", [
'file' => $file,
]);
$response->assertStatus(201)
->assertJsonPath('success', true);
}
/**
* 업로드 권한 없는 사용자는 첨부파일을 업로드할 수 없다
*/
public function test_user_without_upload_permission_cannot_upload(): void
{
$noPermUser = User::factory()->create(['email' => 'no-perm-attach@test.com']);
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->actingAs($noPermUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments", [
'file' => $file,
]);
$response->assertStatus(403);
}
/**
* 비회원도 업로드 권한이 있으면 첨부파일을 업로드할 수 있다
*/
public function test_guest_with_upload_permission_can_upload(): void
{
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments", [
'file' => $file,
]);
$response->assertStatus(201)
->assertJsonPath('success', true);
}
// ==========================================
// use_file_upload 게시판 설정 시나리오
// ==========================================
/**
* use_file_upload=false 게시판에서는 파일 첨부가 차단된다
*/
public function test_upload_blocked_when_board_disables_file_upload(): void
{
$this->updateBoardSettings(['use_file_upload' => false]);
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments", [
'file' => $file,
]);
$response->assertStatus(403);
}
/**
* use_file_upload=false 게시판에서 파일과 함께 게시글 작성이 차단된다
*/
public function test_post_with_file_blocked_when_board_disables_file_upload(): void
{
$this->updateBoardSettings(['use_file_upload' => false]);
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '파일 첨부 게시글',
'content' => '파일을 첨부하려는 게시글 내용입니다.',
'author_name' => '작성자',
'files' => [$file],
]);
$response->assertStatus(403);
}
// ==========================================
// 첨부파일 삭제 시나리오
// ==========================================
/**
* 첨부파일을 삭제하면 DB에서 제거된다
*/
public function test_can_delete_attachment(): void
{
// DB에 직접 첨부파일 레코드 생성
$attachmentId = DB::table('board_attachments')->insertGetId([
'board_id' => $this->board->id,
'post_id' => null,
'created_by' => $this->memberUser->id,
'hash' => 'testhash1234',
'original_filename' => 'test.pdf',
'stored_filename' => 'test.pdf',
'disk' => 'public',
'path' => 'attachments/test.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->actingAs($this->memberUser)
->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments/{$attachmentId}");
$response->assertStatus(200)
->assertJsonPath('success', true);
$this->assertSoftDeleted('board_attachments', ['id' => $attachmentId]);
}
// ==========================================
// attachments_count 연동 시나리오
// ==========================================
/**
* 게시글에 첨부파일이 없으면 attachments_count가 0이다
*/
public function test_post_without_file_has_zero_attachments_count(): void
{
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '첨부파일 없는 게시글',
'content' => '첨부파일이 없는 게시글 본문입니다.',
'author_name' => '작성자',
]);
$response->assertStatus(201);
$postId = $response->json('data.id');
$attachmentsCount = DB::table('board_posts')->where('id', $postId)->value('attachments_count');
$this->assertEquals(0, $attachmentsCount);
}
}
@@ -139,8 +139,6 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: view_count가 다른 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
$this->ensureBoardPartitions($board->id);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Post 1', 'content' => 'Content 1', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Post 2', 'content' => 'Content 2', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
@@ -166,8 +164,6 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 오늘과 어제 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
$this->ensureBoardPartitions($board->id);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Today Post', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Yesterday Post', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()],
@@ -191,8 +187,6 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 최근 1주일과 2주 전 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
$this->ensureBoardPartitions($board->id);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'This Week', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDays(3), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Two Weeks Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subWeeks(2), 'updated_at' => now()],
@@ -217,8 +211,6 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 1년 이내/이전 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
$this->ensureBoardPartitions($board->id);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Six Months Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(6), 'updated_at' => now()],
@@ -291,8 +283,6 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 게시글과 댓글 생성
$board = Board::factory()->create(['is_active' => true]);
$this->ensureBoardPartitions($board->id);
// comments_count 컬럼에 직접 값 설정 (캐시 컬럼 방식)
$postId = DB::table('board_posts')->insertGetId([
'board_id' => $board->id,
@@ -323,8 +313,6 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 게스트가 작성한 게시글
$board = Board::factory()->create(['is_active' => true]);
$this->ensureBoardPartitions($board->id);
DB::table('board_posts')->insert([
'board_id' => $board->id,
'title' => 'Guest Post',
@@ -437,9 +425,6 @@ class BoardPopularApiTest extends ModuleTestCase
]);
}
// LIST 파티션 테이블이므로 board_id 파티션이 없으면 INSERT 오류
$this->ensureBoardPartitions($board->id);
for ($i = 0; $i < $postCount; $i++) {
DB::table('board_posts')->insert([
'board_id' => $board->id,
@@ -215,9 +215,6 @@ class BoardRecentPostsApiTest extends ModuleTestCase
'name' => ['ko' => '비밀게시판', 'en' => 'Secret Board'],
]);
// 파티션 테이블에 insert하기 위해 파티션 보장
$this->ensureBoardPartitions($board->id);
DB::table('board_posts')->insert([
'board_id' => $board->id,
'title' => '비밀 문의입니다',
@@ -283,9 +280,6 @@ class BoardRecentPostsApiTest extends ModuleTestCase
'is_active' => true,
]);
// 파티션 테이블에 insert하기 위해 파티션 보장
$this->ensureBoardPartitions($board->id);
for ($i = 0; $i < $postCount; $i++) {
$isSecret = $includingSecret && ($i % 2 === 0);
@@ -0,0 +1,426 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 댓글/대댓글 CRUD 전수 시나리오 테스트
*
* 검증 목적:
* - 회원/비회원/타인 별 댓글 생성/수정/삭제 권한
* - 대댓글(parent_id) 생성 및 depth 경계
* - 삭제된/블라인드 댓글 처리
* - 비밀번호 기반 비회원 댓글 수정/삭제
*
* @group board
* @group comment
*/
class CommentCrudScenarioTest extends BoardTestCase
{
private User $memberUser;
private User $otherUser;
protected function getTestBoardSlug(): string
{
return 'comment-crud';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 CRUD 테스트 게시판', 'en' => 'Comment CRUD Test Board'],
'is_active' => true,
'max_comment_depth' => 2,
];
}
protected function setUp(): void
{
parent::setUp();
// 댓글 권한도 guest/user 에 부여
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->memberUser = User::factory()->create(['email' => 'comment-member@test.com']);
$this->otherUser = User::factory()->create(['email' => 'comment-other@test.com']);
$userRole = \App\Models\Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
$this->otherUser->roles()->attach($userRole->id);
}
}
// ==========================================
// 댓글 생성 시나리오
// ==========================================
/**
* 회원이 댓글을 생성할 수 있다
*/
public function test_member_can_create_comment(): void
{
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '회원 댓글입니다.',
]);
$response->assertStatus(201)
->assertJsonPath('success', true)
->assertJsonPath('data.content', '회원 댓글입니다.');
$this->assertDatabaseHas('board_comments', [
'post_id' => $postId,
'user_id' => $this->memberUser->id,
'content' => '회원 댓글입니다.',
]);
}
/**
* 비회원이 댓글을 생성할 수 있다
*/
public function test_guest_can_create_comment(): void
{
$postId = $this->createTestPost();
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '비회원 댓글입니다.',
'author_name' => '비회원',
'password' => 'guest1234',
]);
$response->assertStatus(201)
->assertJsonPath('success', true);
$this->assertDatabaseHas('board_comments', [
'post_id' => $postId,
'user_id' => null,
'content' => '비회원 댓글입니다.',
]);
}
/**
* 댓글 쓰기 권한 없는 사용자는 댓글을 생성할 수 없다
*/
public function test_user_without_permission_cannot_create_comment(): void
{
$postId = $this->createTestPost();
$noPermUser = User::factory()->create(['email' => 'no-perm@test.com']);
$response = $this->actingAs($noPermUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '권한 없는 댓글',
]);
$response->assertStatus(403);
}
/**
* 블라인드 게시글에는 댓글을 생성할 수 없다
*/
public function test_cannot_create_comment_on_blinded_post(): void
{
$postId = $this->createTestPost(['status' => 'blinded']);
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '블라인드 게시글 댓글',
]);
$response->assertStatus(422);
}
/**
* 삭제된 게시글에는 댓글을 생성할 수 없다
*/
public function test_cannot_create_comment_on_deleted_post(): void
{
$postId = $this->createTestPost(['deleted_at' => now()]);
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '삭제된 게시글 댓글',
]);
$response->assertStatus(422);
}
// ==========================================
// 대댓글(대댓글) 시나리오
// ==========================================
/**
* 댓글에 대댓글을 생성할 수 있다
*/
public function test_can_create_reply_to_comment(): void
{
$postId = $this->createTestPost();
$parentCommentId = $this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '대댓글입니다.',
'parent_id' => $parentCommentId,
]);
$response->assertStatus(201)
->assertJsonPath('data.parent_id', $parentCommentId);
$this->assertDatabaseHas('board_comments', [
'post_id' => $postId,
'parent_id' => $parentCommentId,
'content' => '대댓글입니다.',
]);
}
/**
* max_comment_depth 초과 depth의 대댓글은 생성할 수 없다
*/
public function test_cannot_create_comment_beyond_max_depth(): void
{
// max_comment_depth = 2 → depth 0(부모) → depth 1(대댓글) → depth 2 불가
$postId = $this->createTestPost();
$depth0 = $this->createTestComment($postId);
$depth1 = $this->createTestComment($postId, ['parent_id' => $depth0, 'depth' => 1]);
$depth2 = $this->createTestComment($postId, ['parent_id' => $depth1, 'depth' => 2]);
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '허용 초과 depth 대댓글',
'parent_id' => $depth2,
]);
$response->assertStatus(422);
}
/**
* 존재하지 않는 parent_id로 대댓글 생성 시 422 반환
*/
public function test_cannot_create_reply_with_invalid_parent_id(): void
{
$postId = $this->createTestPost();
$response = $this->actingAs($this->memberUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments", [
'content' => '잘못된 부모 대댓글',
'parent_id' => 99999999,
]);
$response->assertStatus(422);
}
// ==========================================
// 댓글 수정 시나리오
// ==========================================
/**
* 작성자 회원은 자신의 댓글을 수정할 수 있다
*/
public function test_comment_author_can_update_own_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
$response = $this->actingAs($this->memberUser)
->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}", [
'content' => '수정된 댓글 내용',
]);
$response->assertStatus(200)
->assertJsonPath('data.content', '수정된 댓글 내용');
}
/**
* 타인의 댓글은 수정할 수 없다
*/
public function test_cannot_update_other_users_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
$response = $this->actingAs($this->otherUser)
->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}", [
'content' => '타인이 수정 시도',
]);
$response->assertStatus(403);
}
/**
* 비회원은 올바른 비밀번호로 자신의 댓글을 수정할 수 있다
*/
public function test_guest_can_update_comment_with_correct_password(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'password' => bcrypt('pass1234'),
'author_name' => '비회원',
]);
$response = $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}", [
'content' => '비회원 수정 내용',
'password' => 'pass1234',
]);
$response->assertStatus(200)
->assertJsonPath('data.content', '비회원 수정 내용');
}
/**
* 비회원은 틀린 비밀번호로 댓글을 수정할 수 없다
*/
public function test_guest_cannot_update_comment_with_wrong_password(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'password' => bcrypt('pass1234'),
'author_name' => '비회원',
]);
$response = $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}", [
'content' => '비밀번호 틀린 수정',
'password' => 'wrongpass',
]);
$response->assertStatus(403);
}
// ==========================================
// 댓글 삭제 시나리오
// ==========================================
/**
* 작성자 회원은 자신의 댓글을 삭제할 수 있다
*/
public function test_comment_author_can_delete_own_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
$response = $this->actingAs($this->memberUser)
->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}");
$response->assertStatus(200);
// soft-delete 확인
$this->assertSoftDeleted('board_comments', ['id' => $commentId]);
}
/**
* 타인의 댓글은 삭제할 수 없다
*/
public function test_cannot_delete_other_users_comment(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
$response = $this->actingAs($this->otherUser)
->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}");
$response->assertStatus(403);
$this->assertDatabaseHas('board_comments', ['id' => $commentId, 'deleted_at' => null]);
}
/**
* 비회원은 올바른 비밀번호로 자신의 댓글을 삭제할 수 있다
*/
public function test_guest_can_delete_comment_with_correct_password(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'password' => bcrypt('pass1234'),
'author_name' => '비회원',
]);
$response = $this->deleteJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}",
['password' => 'pass1234']
);
$response->assertStatus(200);
$this->assertSoftDeleted('board_comments', ['id' => $commentId]);
}
/**
* 댓글 삭제 후 comments_count가 감소한다
*/
public function test_deleting_comment_decrements_post_comments_count(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
// 카운트 초기값 설정
DB::table('board_posts')->where('id', $postId)->update(['comments_count' => 1]);
$this->actingAs($this->memberUser)
->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}");
$count = DB::table('board_posts')->where('id', $postId)->value('comments_count');
$this->assertEquals(0, $count);
}
/**
* 댓글 블라인드 처리 시 comments_count는 변동 없다
*/
public function test_blinding_comment_does_not_change_comments_count(): void
{
$postId = $this->createTestPost();
$this->createTestComment($postId, ['user_id' => $this->memberUser->id]);
// 카운트 설정
DB::table('board_posts')->where('id', $postId)->update(['comments_count' => 1]);
// blind 처리는 관리자 API를 통해야 하므로 DB 직접 업데이트로 시뮬레이션
DB::table('board_comments')->where('post_id', $postId)->update(['status' => 'blinded']);
// 카운트 변동 없음 확인
$count = DB::table('board_posts')->where('id', $postId)->value('comments_count');
$this->assertEquals(1, $count);
}
// ==========================================
// 댓글 목록 조회 시나리오
// ==========================================
/**
* 댓글 목록을 조회할 수 있다
*/
public function test_can_list_comments_for_post(): void
{
$postId = $this->createTestPost();
$this->createTestComment($postId, ['content' => '첫 번째 댓글']);
$this->createTestComment($postId, ['content' => '두 번째 댓글']);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments");
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonCount(2, 'data');
}
/**
* soft-delete된 댓글은 목록에 표시되지 않는다
*/
public function test_deleted_comments_are_not_shown_in_list(): void
{
$postId = $this->createTestPost();
$this->createTestComment($postId, ['content' => '정상 댓글']);
$this->createTestComment($postId, ['content' => '삭제된 댓글', 'deleted_at' => now()]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments");
$response->assertStatus(200)
->assertJsonCount(1, 'data');
}
}
@@ -0,0 +1,145 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 비회원 댓글 비밀번호 검증 엔드포인트 테스트
*
* 검증 목적:
* - 올바른 비밀번호 → 200 + verification_token 반환
* - 잘못된 비밀번호 → 401
* - 회원 댓글(user_id 있음)에 verifyPassword 호출 → 400
* - 존재하지 않는 댓글 → 404
* - 비밀번호 필드 누락 → 422
*
* URL: POST /api/modules/sirsoft-board/boards/{slug}/comments/{commentId}/verify-password
*
* @group board
* @group comment
* @group password
*/
class CommentVerifyPasswordTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'comment-verify-pw';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 비밀번호 검증 테스트 게시판', 'en' => 'Comment Verify Password Test Board'],
'is_active' => true,
'use_comment' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
// verifyPassword 라우트: permission:user,sirsoft-board.{slug}.comments.write
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.write']);
}
/**
* 올바른 비밀번호 → 200 + verification_token
*/
public function test_correct_password_returns_token(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => null,
'password' => Hash::make('secret123'),
]);
$response = $this->postJson(
$this->url($commentId),
['password' => 'secret123']
);
$response->assertStatus(200);
$response->assertJsonPath('data.verified', true);
$response->assertJsonStructure(['data' => ['verified', 'comment_id', 'verification_token', 'expires_at']]);
}
/**
* 잘못된 비밀번호 → 401
*/
public function test_wrong_password_returns_401(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => null,
'password' => Hash::make('secret123'),
]);
$this->postJson(
$this->url($commentId),
['password' => 'wrongpassword']
)->assertStatus(401);
}
/**
* 회원 댓글(user_id 있음)에 verifyPassword 호출 → 400
*/
public function test_member_comment_returns_400(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $user->id,
'password' => null,
]);
$this->postJson(
$this->url($commentId),
['password' => 'anypassword']
)->assertStatus(400);
}
/**
* 존재하지 않는 댓글 → 404
*/
public function test_nonexistent_comment_returns_404(): void
{
$this->postJson(
$this->url(99999),
['password' => 'anypassword']
)->assertStatus(404);
}
/**
* password 필드 누락 → 422
*/
public function test_missing_password_returns_422(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => null]);
$this->postJson(
$this->url($commentId),
[]
)->assertStatus(422);
}
// ==========================================
// Helper
// ==========================================
private function url(int $commentId): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/boards/{$slug}/comments/{$commentId}/verify-password";
}
}
@@ -0,0 +1,182 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use Illuminate\Support\Facades\Cache;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 게시글 작성 쿨다운 API 통합 시나리오 테스트
*
* 검증 목적:
* - 쿨다운 비활성(0초): 연속 작성 허용
* - 쿨다운 활성: 첫 작성 성공 후 두 번째 작성 422
* - 쿨다운 활성: 캐시 만료 후 재작성 허용 (단위 테스트에서 검증)
* - 회원은 user_id로, 비회원은 IP로 격리
*
* @group board
* @group feature
* @group cooldown
*/
class PostCooldownTest extends BoardTestCase
{
private array $mockSettings = [];
protected function getTestBoardSlug(): string
{
return 'post-cooldown-test';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '쿨다운 테스트 게시판', 'en' => 'Cooldown Test Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
'min_title_length' => 1,
'max_title_length' => 200,
'min_content_length' => 1,
'max_content_length' => 10000,
];
}
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->grantUserRolePermissions(['posts.read', 'posts.write']);
$this->setGuestPermissions(['posts.read', 'posts.write']);
$this->mockSettings = [
'spam_security' => [
'post_cooldown_seconds' => 0,
'comment_cooldown_seconds' => 0,
'report_cooldown_seconds' => 0,
'view_count_cache_ttl' => 86400,
],
];
}
private function applySettings(): void
{
// g7_module_settings()는 Config::get("g7_settings.modules.sirsoft-board.*")를 읽음
foreach ($this->mockSettings as $category => $values) {
config(["g7_settings.modules.sirsoft-board.{$category}" => $values]);
}
}
private function postUrl(): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts";
}
private function validPostData(): array
{
return [
'title' => '쿨다운 테스트 제목',
'content' => '쿨다운 테스트 내용입니다.',
];
}
// ==========================================
// 쿨다운 비활성 (0초)
// ==========================================
/**
* 쿨다운 0초: 연속 작성 허용
*/
public function test_consecutive_posts_allowed_when_cooldown_disabled(): void
{
$this->mockSettings['spam_security']['post_cooldown_seconds'] = 0;
$this->applySettings();
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
}
// ==========================================
// 쿨다운 활성
// ==========================================
/**
* 쿨다운 활성: 첫 번째 작성 성공
*/
public function test_first_post_succeeds_when_cooldown_active(): void
{
$this->mockSettings['spam_security']['post_cooldown_seconds'] = 60;
$this->applySettings();
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
}
/**
* 쿨다운 활성: 첫 작성 직후 두 번째 작성 422
*/
public function test_second_post_rejected_when_cooldown_active(): void
{
$this->mockSettings['spam_security']['post_cooldown_seconds'] = 60;
$this->applySettings();
$user = $this->createUser();
// 첫 번째 작성 성공 → 쿨다운 캐시 등록
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
// 두 번째 작성 시 422
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(422);
}
/**
* 쿨다운 활성: 다른 사용자는 영향 없음 (user_id 격리)
*/
public function test_cooldown_is_isolated_per_user(): void
{
$this->mockSettings['spam_security']['post_cooldown_seconds'] = 60;
$this->applySettings();
$userA = $this->createUser();
$userB = $this->createUser();
// userA 작성 → 쿨다운 등록
$this->actingAs($userA)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
// userB는 쿨다운 영향 없이 작성 가능
$this->actingAs($userB)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
}
/**
* 쿨다운 오류 응답에 422 상태코드와 errors 필드가 있다
*/
public function test_cooldown_rejection_returns_422_with_errors(): void
{
$this->mockSettings['spam_security']['post_cooldown_seconds'] = 60;
$this->applySettings();
$user = $this->createUser();
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(201);
$this->actingAs($user)->postJson($this->postUrl(), $this->validPostData())
->assertStatus(422)
->assertJsonStructure(['errors']);
}
}
@@ -0,0 +1,430 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\SecretMode;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 게시글 이전/다음 네비게이션 API 회귀 테스트 (이슈 #269)
*
* PostController::navigation() 이 어떤 상황에서도 500 응답을 내지 않고
* graceful degradation 으로 `{prev, next}` 또는 404 만 반환하도록 보장합니다.
*
* @group board
* @group board-navigation
*/
class PostNavigationTest extends ModuleTestCase
{
private string $boardSlug = 'nav-test';
private Board $board;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
DB::table('boards')->where('slug', $this->boardSlug)->delete();
DB::table('boards')->where('slug', 'nav-test-inactive')->delete();
$this->board = Board::factory()->create([
'slug' => $this->boardSlug,
'is_active' => true,
'order_by' => 'created_at',
'order_direction' => 'DESC',
'secret_mode' => SecretMode::Disabled->value,
]);
$this->ensureGuestCanReadPosts();
}
/**
* navigation 라우트는 permission:user,posts.read 로 보호됩니다.
* guest 역할에 해당 권한을 부여해 401 차단을 우회합니다.
*/
private function ensureGuestCanReadPosts(): void
{
$identifier = 'sirsoft-board.'.$this->boardSlug.'.posts.read';
$permId = DB::table('permissions')->where('identifier', $identifier)->value('id');
if (! $permId) {
$permId = DB::table('permissions')->insertGetId([
'identifier' => $identifier,
'name' => json_encode(['ko' => 'navigation 테스트 read', 'en' => 'nav-test read']),
'type' => 'user',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'resource_route_key' => 'post',
'owner_key' => 'user_id',
'created_at' => now(),
'updated_at' => now(),
]);
}
$guestRoleId = DB::table('roles')->where('identifier', 'guest')->value('id');
if (! $guestRoleId) {
return;
}
DB::table('role_permissions')->updateOrInsert(
['role_id' => $guestRoleId, 'permission_id' => $permId],
['scope_type' => null, 'granted_at' => now(), 'created_at' => now(), 'updated_at' => now()]
);
}
protected function tearDown(): void
{
DB::table('board_posts')->where('board_id', $this->board->id)->delete();
DB::table('boards')->whereIn('slug', [$this->boardSlug, 'nav-test-inactive'])->delete();
parent::tearDown();
}
/**
* 존재하지 않는 글 ID → 200 + 빈 값 (500 금지)
*/
public function test_returns_empty_when_post_not_found(): void
{
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/999999/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => ['prev' => null, 'next' => null],
]);
}
/**
* 공지글 → 200 + 빈 값 (네비게이션 미제공)
*/
public function test_returns_empty_for_notice_post(): void
{
$noticeId = $this->insertPost([
'is_notice' => true,
'title' => '공지',
]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$noticeId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => ['prev' => null, 'next' => null],
]);
}
/**
* 일반글 양옆 글 없음 → 200 + 빈 값
*/
public function test_returns_empty_when_no_adjacent_posts(): void
{
$postId = $this->insertPost(['title' => '혼자']);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$postId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => ['prev' => null, 'next' => null],
]);
}
/**
* 일반글 + 이전/다음 글 존재 → 정상 반환 (created_at DESC 정렬)
*
* created_at DESC 정렬에서 prev는 created_at이 더 나중인 글(newer),
* next는 created_at이 더 이전인 글(older).
*/
public function test_returns_adjacent_posts_when_available(): void
{
$olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]);
$currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]);
$newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => [
'prev' => ['id' => $newerId], // DESC 에서 prev = created_at 더 나중
'next' => ['id' => $olderId], // DESC 에서 next = created_at 더 이전
],
]);
}
/**
* 비밀글(secret_mode=always)에서도 navigation 은 is_secret 필터를 적용하지 않고
* 목록과 동일 범위로 prev/next 를 실제로 반환해야 합니다.
*
* getAdjacentPosts 쿼리에 is_secret 필터가 없음을 실증적으로 검증합니다.
*/
public function test_secret_always_board_returns_real_prev_next(): void
{
DB::table('boards')->where('id', $this->board->id)->update([
'secret_mode' => SecretMode::Always->value,
]);
$olderId = $this->insertPost([
'is_secret' => true,
'title' => '비밀 이전글',
'created_at' => now()->subMinutes(30),
]);
$currentId = $this->insertPost([
'is_secret' => true,
'title' => '비밀 현재글',
'created_at' => now()->subMinutes(20),
]);
$newerId = $this->insertPost([
'is_secret' => true,
'title' => '비밀 최신글',
'created_at' => now()->subMinutes(10),
]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => [
'prev' => ['id' => $newerId], // DESC
'next' => ['id' => $olderId],
],
]);
}
/**
* 핵심 회귀 방지: Controller 가 PostService::getPost 를 호출하지 않음을 실증합니다.
*
* beta.2 의 500 경로는 getPost 내부의 PermissionHelper::checkScopeAccess 가 false 를
* 반환하면서 AccessDeniedHttpException 을 throw 할 때 발생했습니다. 수정 코드가
* getPost 호출을 실제로 제거했는지 spy 로 검증합니다.
*/
public function test_controller_does_not_invoke_getpost(): void
{
$postId = $this->insertPost(['title' => 'getPost 미호출 검증']);
$postService = $this->spy(\Modules\Sirsoft\Board\Services\PostService::class);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$postId}/navigation");
$this->assertNotSame(500, $response->status());
$postService->shouldNotHaveReceived('getPost');
}
/**
* 핵심 회귀 방지: getAdjacentPosts 가 예외를 던져도 Controller 는 500 이 아닌
* 200 + 빈 값 을 반환하고 Log::warning 을 한 번 호출해야 합니다.
*/
public function test_controller_degrades_gracefully_when_getadjacent_throws(): void
{
$postId = $this->insertPost(['title' => '예외 유도']);
\Illuminate\Support\Facades\Log::spy();
$postService = $this->mock(\Modules\Sirsoft\Board\Services\PostService::class);
$postService->shouldReceive('isPostNotice')->once()->andReturn(false);
$postService->shouldReceive('getAdjacentPosts')->once()->andThrow(
new \RuntimeException('boom')
);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$postId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => ['prev' => null, 'next' => null],
]);
\Illuminate\Support\Facades\Log::shouldHaveReceived('warning')
->once()
->withArgs(function ($message, $context) use ($postId) {
return str_contains($message, 'Post navigation')
&& ($context['post_id'] ?? null) === $postId
&& ($context['slug'] ?? null) === $this->boardSlug;
});
}
/**
* 정상 경로(예외 없음) 에서는 Log::warning 이 호출되지 않아야 합니다.
*/
public function test_controller_does_not_log_warning_on_success(): void
{
$this->insertPost(['title' => '이전', 'created_at' => now()->subMinutes(20)]);
$currentId = $this->insertPost(['title' => '현재', 'created_at' => now()->subMinutes(15)]);
$this->insertPost(['title' => '최신', 'created_at' => now()->subMinutes(10)]);
\Illuminate\Support\Facades\Log::spy();
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation");
$response->assertStatus(200);
\Illuminate\Support\Facades\Log::shouldNotHaveReceived('warning');
}
/**
* [A-1] 존재하지 않는 게시판은 404 또는 permission 단계의 401 을 반환해야 합니다 (500 금지).
*/
public function test_returns_error_for_nonexistent_board(): void
{
$response = $this->getJson('/api/modules/sirsoft-board/boards/non-existent-board/posts/1/navigation');
$this->assertNotSame(500, $response->status(), '존재하지 않는 게시판에서 500 이 아니어야 합니다.');
$this->assertContains($response->status(), [401, 403, 404], 'permission 또는 board-not-found 경로로 처리되어야 합니다.');
}
/**
* [A-2] 비활성 게시판은 404 또는 permission 단계의 401 을 반환해야 합니다 (500 금지).
*/
public function test_returns_error_for_inactive_board(): void
{
Board::factory()->create([
'slug' => 'nav-test-inactive',
'is_active' => false,
'order_by' => 'created_at',
'order_direction' => 'DESC',
'secret_mode' => SecretMode::Disabled->value,
]);
$response = $this->getJson('/api/modules/sirsoft-board/boards/nav-test-inactive/posts/1/navigation');
$this->assertNotSame(500, $response->status(), '비활성 게시판에서 500 이 아니어야 합니다.');
$this->assertContains($response->status(), [401, 403, 404]);
}
/**
* [B-1] order_direction=ASC 에서 prev 는 created_at 더 이전 글, next 는 더 나중 글이어야 합니다.
*/
public function test_asc_order_flips_prev_next_direction(): void
{
DB::table('boards')->where('id', $this->board->id)->update(['order_direction' => 'ASC']);
$olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]);
$currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]);
$newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => [
'prev' => ['id' => $olderId],
'next' => ['id' => $newerId],
],
]);
}
/**
* [D] 일반글 사이에 공지글이 끼어 있어도 navigation 은 공지를 건너뛰어야 합니다.
*/
public function test_notice_posts_excluded_from_navigation(): void
{
$olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]);
$this->insertPost(['title' => '공지', 'is_notice' => true, 'created_at' => now()->subMinutes(25)]);
$currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]);
$this->insertPost(['title' => '공지2', 'is_notice' => true, 'created_at' => now()->subMinutes(15)]);
$newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => [
'prev' => ['id' => $newerId], // created_at DESC
'next' => ['id' => $olderId],
],
]);
$response->assertJsonMissingPath('data.prev.is_notice');
}
/**
* [G] published 가 아닌(블라인드 등) 글은 navigation 에 포함되면 안 됩니다.
*/
public function test_non_published_posts_excluded_from_navigation(): void
{
$olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]);
$this->insertPost([
'title' => '블라인드글',
'status' => PostStatus::Blinded->value,
'created_at' => now()->subMinutes(25),
]);
$currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]);
$this->insertPost([
'title' => '삭제상태글',
'status' => PostStatus::Deleted->value,
'created_at' => now()->subMinutes(15),
]);
$newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation");
$response->assertStatus(200);
$response->assertJson([
'data' => [
'prev' => ['id' => $newerId],
'next' => ['id' => $olderId],
],
]);
}
/**
* [J-1] 가장 오래된 글(DESC 정렬에서 next 방향 끝)은 next 가 null 이어야 합니다.
*/
public function test_oldest_post_has_no_next(): void
{
$oldestId = $this->insertPost(['title' => '가장 오래된 글', 'created_at' => now()->subMinutes(30)]);
$this->insertPost(['title' => '중간글', 'created_at' => now()->subMinutes(20)]);
$this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$oldestId}/navigation");
$response->assertStatus(200);
$data = $response->json('data');
$this->assertNotNull($data['prev'], 'DESC 정렬 첫글 기준 prev 는 더 이전(older) 이 없으므로 next 가 null 이어야 합니다.');
$this->assertNull($data['next'], 'DESC 정렬에서 가장 오래된 글의 next 는 null 이어야 합니다.');
}
/**
* [J-2] 가장 최신 글(DESC 정렬에서 prev 방향 끝)은 prev 가 null 이어야 합니다.
*/
public function test_newest_post_has_no_prev(): void
{
$this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]);
$this->insertPost(['title' => '중간글', 'created_at' => now()->subMinutes(20)]);
$newestId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$newestId}/navigation");
$response->assertStatus(200);
$data = $response->json('data');
$this->assertNull($data['prev'], 'DESC 정렬에서 가장 최신 글의 prev 는 null 이어야 합니다.');
$this->assertNotNull($data['next']);
}
/**
* board_posts 레코드를 직접 INSERT 합니다.
*
* @param array $overrides 오버라이드할 속성
* @return int 생성된 post id
*/
private function insertPost(array $overrides = []): int
{
$defaults = [
'board_id' => $this->board->id,
'title' => '테스트글',
'content' => '본문',
'content_mode' => 'text',
'author_name' => '작성자',
'ip_address' => '127.0.0.1',
'is_notice' => false,
'is_secret' => false,
'status' => PostStatus::Published->value,
'view_count' => 0,
'created_at' => now(),
'updated_at' => now(),
];
return DB::table('board_posts')->insertGetId(array_merge($defaults, $overrides));
}
}
@@ -78,8 +78,6 @@ class ReportAbusePreventionTest extends ModuleTestCase
]
);
$this->ensureBoardPartitions($this->board->id);
// 이전 잔여 데이터 정리
DB::table('boards_report_logs')->delete();
DB::table('boards_reports')->delete();
@@ -0,0 +1,310 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 비밀글 전수 시나리오 테스트
*
* 검증 목적:
* - secret_mode: disabled / enabled / always 각 모드별 생성/조회 동작
* - posts.read-secret 권한 유무에 따른 비밀글 내용 노출 여부
* - secret_mode=always에서 비회원 접근 동작
* - 비밀번호 기반 비회원 비밀글 접근
*
* @group board
* @group secret
*/
class SecretPostScenarioTest extends BoardTestCase
{
private User $authorUser;
private User $otherUser;
protected function getTestBoardSlug(): string
{
return 'secret-scenario';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '비밀글 시나리오 테스트 게시판', 'en' => 'Secret Scenario Board'],
'is_active' => true,
'secret_mode' => 'enabled',
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write']);
$this->authorUser = User::factory()->create(['email' => 'secret-author@test.com']);
$this->otherUser = User::factory()->create(['email' => 'secret-other@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->authorUser->roles()->attach($userRole->id);
$this->otherUser->roles()->attach($userRole->id);
}
}
// ==========================================
// secret_mode별 게시글 생성 시나리오
// ==========================================
/**
* secret_mode=disabled이면 비밀글 생성을 거부한다 (403)
*/
public function test_secret_mode_disabled_rejects_is_secret_true(): void
{
$this->updateBoardSettings(['secret_mode' => 'disabled']);
$response = $this->actingAs($this->authorUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비밀글 시도',
'content' => '비밀글로 작성하려는 내용입니다.',
'author_name' => '작성자',
'is_secret' => true,
]);
$response->assertStatus(403);
}
/**
* secret_mode=enabled이면 사용자가 비밀글 여부를 선택할 수 있다
*/
public function test_secret_mode_enabled_allows_user_to_choose(): void
{
$this->updateBoardSettings(['secret_mode' => 'enabled']);
// 비밀글 생성
$secretResponse = $this->actingAs($this->authorUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비밀글 제목',
'content' => '비밀글 본문입니다. 충분한 길이.',
'author_name' => '작성자',
'is_secret' => true,
]);
$secretResponse->assertStatus(201)->assertJsonPath('data.is_secret', true);
// 공개글 생성
$publicResponse = $this->actingAs($this->authorUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '공개글 제목',
'content' => '공개글 본문입니다. 충분한 길이.',
'author_name' => '작성자',
'is_secret' => false,
]);
$publicResponse->assertStatus(201)->assertJsonPath('data.is_secret', false);
}
/**
* secret_mode=always이면 모든 게시글이 비밀글로 강제된다
*/
public function test_secret_mode_always_forces_all_posts_secret(): void
{
$this->updateBoardSettings(['secret_mode' => 'always']);
// is_secret=false로 생성해도 true로 강제
$response = $this->actingAs($this->authorUser)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '강제 비밀글 제목',
'content' => '강제 비밀글 본문입니다. 충분한 길이.',
'author_name' => '작성자',
'is_secret' => false,
]);
$response->assertStatus(201)->assertJsonPath('data.is_secret', true);
}
/**
* secret_mode=always이면 비회원도 게시글을 작성하면 자동으로 비밀글이 된다
*/
public function test_secret_mode_always_guest_post_becomes_secret(): void
{
$this->updateBoardSettings(['secret_mode' => 'always']);
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비회원 게시글',
'content' => '비회원이 작성하는 게시글 내용입니다.',
'author_name' => '비회원',
'password' => 'pass1234',
'is_secret' => false,
]);
// always 모드에서는 is_secret=false여도 true로 강제됨
$response->assertStatus(201)->assertJsonPath('data.is_secret', true);
}
// ==========================================
// posts.read-secret 권한 분기 시나리오
// ==========================================
/**
* posts.read-secret 권한이 있는 사용자는 비밀글 내용을 볼 수 있다
*/
public function test_user_with_read_secret_permission_can_view_secret_content(): void
{
$postId = $this->createTestPost([
'user_id' => $this->authorUser->id,
'is_secret' => true,
'title' => '비밀 제목',
'content' => '비밀 내용입니다.',
]);
// otherUser에게 read-secret 권한 부여
$perm = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$this->board->slug}.posts.read-secret"],
['name' => ['ko' => '비밀글 조회', 'en' => 'Read Secret'], 'type' => 'user']
);
$userRole = Role::where('identifier', 'user')->first();
$userRole->permissions()->syncWithoutDetaching([$perm->id]);
$response = $this->actingAs($this->otherUser)
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
$response->assertStatus(200)
->assertJsonPath('data.is_secret', true)
->assertJsonPath('data.content', '비밀 내용입니다.');
}
/**
* posts.read-secret 권한이 없는 타인은 비밀글 내용이 마스킹된다
*/
public function test_user_without_read_secret_sees_masked_content(): void
{
$postId = $this->createTestPost([
'user_id' => $this->authorUser->id,
'is_secret' => true,
'title' => '비밀 제목',
'content' => '비밀 내용입니다.',
]);
$response = $this->actingAs($this->otherUser)
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
$response->assertStatus(200)
->assertJsonPath('data.is_secret', true);
// 내용이 마스킹되거나 빈 값이어야 함
$content = $response->json('data.content');
$this->assertNotEquals('비밀 내용입니다.', $content);
}
/**
* 비밀글 작성자 본인은 항상 내용을 볼 수 있다
*/
public function test_secret_post_author_can_always_view_own_content(): void
{
$postId = $this->createTestPost([
'user_id' => $this->authorUser->id,
'is_secret' => true,
'title' => '내 비밀글',
'content' => '내 비밀 내용입니다.',
]);
$response = $this->actingAs($this->authorUser)
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
$response->assertStatus(200)
->assertJsonPath('data.content', '내 비밀 내용입니다.');
}
/**
* 비회원 비밀글은 올바른 비밀번호로 verify-password API가 content를 반환한다
*/
public function test_guest_secret_post_accessible_with_correct_password(): void
{
$postId = $this->createTestPost([
'is_secret' => true,
'password' => Hash::make('pass1234'),
'author_name' => '비회원',
'title' => '비회원 비밀글',
'content' => '비회원 비밀 내용입니다.',
]);
// 비밀번호 검증 - 성공 시 content 포함하여 반환
$response = $this->postJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
['password' => 'pass1234']
);
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.content', '비회원 비밀 내용입니다.');
}
/**
* 비회원 비밀글은 틀린 비밀번호로 접근이 거부된다 (403)
*/
public function test_guest_secret_post_denied_with_wrong_password(): void
{
$postId = $this->createTestPost([
'is_secret' => true,
'password' => Hash::make('pass1234'),
'author_name' => '비회원',
'title' => '비회원 비밀글',
'content' => '비회원 비밀 내용입니다.',
]);
$response = $this->postJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
['password' => 'wrongpass']
);
$response->assertStatus(403);
}
// ==========================================
// 비밀글 목록 노출 시나리오
// ==========================================
/**
* 비밀글은 목록에 포함되며 is_secret 플래그가 표시된다
*/
public function test_secret_posts_appear_in_list_with_masked_content(): void
{
$this->createTestPost(['is_secret' => false, 'title' => '공개글']);
$this->createTestPost(['is_secret' => true, 'title' => '비밀글', 'user_id' => $this->authorUser->id]);
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
$response->assertStatus(200);
$items = $response->json('data.data');
$this->assertCount(2, $items);
// 비밀글이 목록에 포함되어 있음 (is_secret 플래그)
$secretPost = collect($items)->firstWhere('is_secret', true);
$this->assertNotNull($secretPost);
}
/**
* secret_mode=always 게시판에서 navigation API는 500을 반환하지 않는다
*/
public function test_navigation_does_not_500_on_always_secret_board(): void
{
$this->updateBoardSettings(['secret_mode' => 'always']);
$postId = $this->createTestPost(['is_secret' => true, 'user_id' => $this->authorUser->id]);
$response = $this->getJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/navigation"
);
// 500이 아닌 200 또는 404여야 함
$this->assertNotEquals(500, $response->status());
}
}
@@ -16,8 +16,7 @@ use PHPUnit\Framework\Attributes\Group;
/**
* 사용자 게시글 활동 API 테스트
*
* BoardTestCase를 통해 단일 테이블(board_posts/board_comments) 파티션과
* 데이터 정리를 자동으로 처리합니다.
* BoardTestCase를 통해 테스트 보드와 데이터 정리를 자동으로 처리합니다.
*/
#[Group('board')]
#[Group('user-activities')]
@@ -132,7 +131,6 @@ class UserActivityApiTest extends BoardTestCase
'name' => ['ko' => '다른 게시판', 'en' => 'Another Board'],
'is_active' => true,
]);
$this->ensureBoardPartitions($board2->id);
$post1 = $this->createPost($board1->slug, [
'user_id' => $this->user->id,
@@ -588,7 +586,6 @@ class UserActivityApiTest extends BoardTestCase
'name' => ['ko' => '두 번째 게시판', 'en' => 'Second Board'],
'is_active' => true,
]);
$this->ensureBoardPartitions($board2->id);
$post1 = $this->createPost($this->board->slug, ['user_id' => $this->user->id, 'title' => '게시판1 글']);
$post2 = Post::create([
@@ -669,7 +666,6 @@ class UserActivityApiTest extends BoardTestCase
'name' => ['ko' => '비활성 게시판', 'en' => 'Inactive Board'],
'is_active' => false,
]);
$this->ensureBoardPartitions($inactiveBoard->id);
Post::create([
'board_id' => $inactiveBoard->id,
@@ -703,7 +699,6 @@ class UserActivityApiTest extends BoardTestCase
'name' => ['ko' => '비활성 게시판', 'en' => 'Inactive Board'],
'is_active' => false,
]);
$this->ensureBoardPartitions($inactiveBoard->id);
$post = Post::create([
'board_id' => $inactiveBoard->id,
@@ -16,7 +16,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
* 사용자 공개 게시글 API 테스트
*
* 타인의 공개 프로필에서 게시글 목록과 통계를 조회하는 API 테스트입니다.
* BoardTestCase를 통해 단일 테이블 파티션과 데이터 정리를 자동으로 처리합니다.
* BoardTestCase를 통해 테스트 보드와 데이터 정리를 자동으로 처리합니다.
*
* @group board
* @group user-public-posts
@@ -0,0 +1,289 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 사용자 신고 API 테스트
*
* 검증 목적:
* - 게시글 신고: 201, 미인증 401, use_report=false → 403, 중복 신고 → 409
* - 댓글 신고: 201, 본인 댓글 신고 → 403
* - 블라인드/삭제된 대상 신고 → 403
* - 유효하지 않은 reason_type → 422
*
* URL:
* - POST /api/modules/sirsoft-board/boards/{slug}/posts/{id}/reports
* - POST /api/modules/sirsoft-board/boards/{slug}/comments/{id}/reports
*
* @group board
* @group user
* @group report
*/
class UserReportApiTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'user-report-api';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '신고 API 테스트 게시판', 'en' => 'Report API Test Board'],
'is_active' => true,
'use_report' => true,
'use_comment' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.write']);
}
// ==========================================
// 게시글 신고
// ==========================================
/**
* 회원이 다른 사람의 게시글 신고 → 201
*/
public function test_authenticated_user_can_report_post(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$response = $this->actingAs($reporter)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'spam', 'reason_detail' => '스팸 게시글입니다.']
);
$response->assertStatus(201);
// boards_reports는 집계 레코드, 개별 신고자는 boards_report_logs에 기록
$this->assertDatabaseHas('boards_reports', [
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
]);
}
/**
* 미인증 요청 → 401
*/
public function test_unauthenticated_cannot_report_post(): void
{
$postId = $this->createTestPost();
$this->postJson(
$this->postReportUrl($postId),
['reason_type' => 'spam', 'reason_detail' => '스팸입니다.']
)->assertStatus(401);
}
/**
* use_report=false 게시판 → 403
*/
public function test_report_disabled_board_returns_403(): void
{
$this->updateBoardSettings(['use_report' => false]);
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reporter)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'spam', 'reason_detail' => '스팸입니다.']
)->assertStatus(403);
}
/**
* 본인 게시글 신고 → 403
*/
public function test_cannot_report_own_post(): void
{
$user = $this->createUser();
$postId = $this->createTestPost(['user_id' => $user->id]);
$this->actingAs($user)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'spam', 'reason_detail' => '스팸입니다.']
)->assertStatus(403);
}
/**
* 블라인드된 게시글 신고 → 403
*/
public function test_cannot_report_blinded_post(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id, 'status' => 'blinded']);
$this->actingAs($reporter)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'spam', 'reason_detail' => '스팸입니다.']
)->assertStatus(403);
}
/**
* 동일 게시글 중복 신고 → 409
*
* 첫 신고 성공 후 쿨다운이 기록되므로, 별도 사용자로 각 신고 시도.
* DB에 직접 신고 레코드를 삽입한 후 재시도해 쿨다운 422와 구분.
*/
public function test_duplicate_post_report_returns_409(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
// boards_reports에 직접 신고 집계 레코드 삽입 (쿨다운 우회)
$reportId = \Illuminate\Support\Facades\DB::table('boards_reports')->insertGetId([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'author_id' => $author->id,
'status' => 'pending',
'metadata' => json_encode([]),
'last_reported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
// reporter_id는 boards_report_logs에 기록
\Illuminate\Support\Facades\DB::table('boards_report_logs')->insert([
'report_id' => $reportId,
'reporter_id' => $reporter->id,
'reason_type' => 'spam',
'reason_detail' => '스팸입니다.',
'snapshot' => json_encode([]),
'metadata' => json_encode([]),
'created_at' => now(),
'updated_at' => now(),
]);
// 같은 reporter가 같은 게시글 재신고 → 409
$this->actingAs($reporter)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'abuse', 'reason_detail' => '욕설입니다.']
)->assertStatus(409);
}
/**
* 유효하지 않은 reason_type → 422
*/
public function test_invalid_reason_type_returns_422(): void
{
$reporter = $this->createUser();
$postId = $this->createTestPost();
$this->actingAs($reporter)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'invalid_reason', 'reason_detail' => '상세 내용']
)->assertStatus(422);
}
/**
* reason_detail 누락 → 422
*/
public function test_missing_reason_detail_returns_422(): void
{
$reporter = $this->createUser();
$postId = $this->createTestPost();
$this->actingAs($reporter)->postJson(
$this->postReportUrl($postId),
['reason_type' => 'spam']
)->assertStatus(422);
}
// ==========================================
// 댓글 신고
// ==========================================
/**
* 회원이 다른 사람의 댓글 신고 → 201
*/
public function test_authenticated_user_can_report_comment(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $author->id]);
$response = $this->actingAs($reporter)->postJson(
$this->commentReportUrl($commentId),
['reason_type' => 'abuse', 'reason_detail' => '욕설 댓글입니다.']
);
$response->assertStatus(201);
$this->assertDatabaseHas('boards_reports', [
'board_id' => $this->board->id,
'target_type' => 'comment',
'target_id' => $commentId,
]);
}
/**
* 본인 댓글 신고 → 403
*/
public function test_cannot_report_own_comment(): void
{
$user = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, ['user_id' => $user->id]);
$this->actingAs($user)->postJson(
$this->commentReportUrl($commentId),
['reason_type' => 'abuse', 'reason_detail' => '욕설입니다.']
)->assertStatus(403);
}
/**
* 블라인드된 댓글 신고 → 403
*/
public function test_cannot_report_blinded_comment(): void
{
$author = $this->createUser();
$reporter = $this->createUser();
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $author->id,
'status' => 'blinded',
]);
$this->actingAs($reporter)->postJson(
$this->commentReportUrl($commentId),
['reason_type' => 'abuse', 'reason_detail' => '욕설입니다.']
)->assertStatus(403);
}
// ==========================================
// Helper
// ==========================================
private function postReportUrl(int $postId): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/boards/{$slug}/posts/{$postId}/reports";
}
private function commentReportUrl(int $commentId): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/boards/{$slug}/comments/{$commentId}/reports";
}
}
@@ -3,7 +3,6 @@
namespace Modules\Sirsoft\Board\Tests;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Models\Board;
use Tests\TestCase;
@@ -229,32 +228,4 @@ abstract class ModuleTestCase extends TestCase
return $user;
}
/**
* 게시판 ID에 대한 파티션이 존재하도록 보장합니다.
*
* board_posts/board_comments/board_attachments는 LIST 파티션 테이블이므로
* 게시판 ID에 해당하는 파티션이 없으면 INSERT 시 오류가 발생합니다.
* 파티션이 이미 존재하면 무시합니다.
*
* @param int $boardId 파티션을 추가할 게시판 ID
*/
protected function ensureBoardPartitions(int $boardId): void
{
$prefix = DB::getTablePrefix();
$tables = ['board_posts', 'board_comments', 'board_attachments'];
foreach ($tables as $table) {
try {
DB::statement(
"ALTER TABLE {$prefix}{$table} ADD PARTITION (PARTITION p{$boardId} VALUES IN ({$boardId}))"
);
} catch (\Exception $e) {
// 파티션 이미 존재하거나, 파티셔닝되지 않은 테이블(파티션 제거 후 환경)인 경우 무시
if (! str_contains($e->getMessage(), 'Duplicate partition name')
&& ! str_contains($e->getMessage(), 'not partitioned')) {
throw $e;
}
}
}
}
}
@@ -53,9 +53,6 @@ class AttachmentRepositoryTest extends ModuleTestCase
'notify_author_on_comment' => false,
]
);
// 게시판 파티션 보장
$this->ensureBoardPartitions($this->board->id);
}
/**
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener;
use Modules\Sirsoft\Board\Listeners\PostAttachmentCountSyncListener;
use Modules\Sirsoft\Board\Listeners\PostCountSyncListener;
@@ -36,6 +38,34 @@ class CountSyncListenerTest extends ModuleTestCase
);
}
// ── sync:true 선언 검증 (큐 워커 없을 때도 즉시 실행되는지) ──
/**
* 6개 Listener 전부가 'sync' => true 로 선언되어 있어야 합니다.
* 이 선언이 없으면 큐 워커가 꺼진 환경에서 카운트가 영원히 drift 됩니다.
*/
public function test_all_count_listeners_declare_sync_true(): void
{
$listeners = [
BoardPostsCountSyncListener::class,
BoardCommentsCountSyncListener::class,
PostCountSyncListener::class,
PostReplySyncListener::class,
PostAttachmentCountSyncListener::class,
CommentReplySyncListener::class,
];
foreach ($listeners as $listenerClass) {
$hooks = $listenerClass::getSubscribedHooks();
foreach ($hooks as $hookName => $config) {
$this->assertTrue(
($config['sync'] ?? false) === true,
"{$listenerClass} 의 {$hookName} 훅은 sync:true 로 선언되어야 합니다."
);
}
}
}
// ── PostCountSyncListener (comments_count) ──
/**
@@ -87,19 +117,132 @@ class CountSyncListenerTest extends ModuleTestCase
$this->assertEquals(1, DB::table('board_posts')->where('id', $postId)->value('comments_count'));
}
// ── BoardPostsCountSyncListener (boards.posts_count) ──
/**
* 게시글 생성 시 게시판의 posts_count가 동기화되는지 확인합니다.
*/
public function test_board_posts_count_increases_on_post_create(): void
{
$postId = $this->createTestPost();
$post = Post::find($postId);
$listener = new BoardPostsCountSyncListener();
$listener->syncPostsCount($post, $this->board->slug);
$storedCount = DB::table('boards')->where('id', $this->board->id)->value('posts_count');
$this->assertEquals(1, $storedCount);
}
/**
* 게시글 soft delete 후 동기화 시 boards.posts_count 에서 제외되는지 확인합니다.
*/
public function test_board_posts_count_excludes_soft_deleted(): void
{
$postId1 = $this->createTestPost();
$postId2 = $this->createTestPost();
DB::table('board_posts')->where('id', $postId2)->update(['deleted_at' => now()]);
$post = Post::withTrashed()->find($postId2);
$listener = new BoardPostsCountSyncListener();
$listener->syncPostsCount($post, $this->board->slug);
$storedCount = DB::table('boards')->where('id', $this->board->id)->value('posts_count');
$this->assertEquals(1, $storedCount);
}
// ── BoardCommentsCountSyncListener (boards.comments_count) ──
/**
* 댓글 생성 시 게시판의 comments_count가 동기화되는지 확인합니다.
*/
public function test_board_comments_count_increases_on_comment_create(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId);
$comment = Comment::find($commentId);
$listener = new BoardCommentsCountSyncListener();
$listener->syncCommentsCount($comment, $this->board->slug);
$storedCount = DB::table('boards')->where('id', $this->board->id)->value('comments_count');
$this->assertEquals(1, $storedCount);
}
/**
* 댓글 soft delete 후 boards.comments_count 에서 제외되는지 확인합니다.
*/
public function test_board_comments_count_excludes_soft_deleted(): void
{
$postId = $this->createTestPost();
$c1 = $this->createTestComment($postId);
$c2 = $this->createTestComment($postId);
DB::table('board_comments')->where('id', $c2)->update(['deleted_at' => now()]);
$comment = Comment::withTrashed()->find($c2);
$listener = new BoardCommentsCountSyncListener();
$listener->syncCommentsCount($comment, $this->board->slug);
$storedCount = DB::table('boards')->where('id', $this->board->id)->value('comments_count');
$this->assertEquals(1, $storedCount);
}
// ── PostAttachmentCountSyncListener (attachments_count) ──
/**
* 훅 구독이 올바르게 등록되어 있는지 확인합니다.
* after_link 훅도 반드시 포함되어 있어야 함 (임시 업로드 → 실 게시글 연결 시 카운트 동기화용)
*/
public function test_attachment_count_sync_listener_subscribes_to_attachment_hooks(): void
{
$hooks = PostAttachmentCountSyncListener::getSubscribedHooks();
$this->assertArrayHasKey('sirsoft-board.attachment.after_upload', $hooks);
$this->assertArrayHasKey('sirsoft-board.attachment.after_link', $hooks);
$this->assertArrayHasKey('sirsoft-board.attachment.after_delete', $hooks);
}
/**
* 임시 업로드 → linkTempAttachments 후 after_link 훅으로 카운트가 반영되는지 확인합니다.
* 과거에는 temp 시점의 after_upload 훅만 있고 linkTemp 시점 훅이 없어 drift 발생했음.
*/
public function test_attachments_count_syncs_after_link_hook(): void
{
$postId = $this->createTestPost();
$attachmentId = $this->createTestAttachment($postId);
// 게시글이 attachments_count=0 이라고 가정 (drift 상태 재현)
DB::table('board_posts')->where('id', $postId)->update(['attachments_count' => 0]);
$attachment = Attachment::find($attachmentId);
$listener = new PostAttachmentCountSyncListener();
$listener->syncAttachmentsCount($attachment);
$storedCount = DB::table('board_posts')->where('id', $postId)->value('attachments_count');
$this->assertEquals(1, $storedCount, '임시 업로드 → link 이후 drift 가 해소되어야 합니다.');
}
/**
* 첨부 soft-delete 후 동기화 시 attachments_count 에서 제외되는지 확인합니다.
*/
public function test_attachments_count_excludes_soft_deleted(): void
{
$postId = $this->createTestPost();
$a1 = $this->createTestAttachment($postId);
$a2 = $this->createTestAttachment($postId);
DB::table('board_attachments')->where('id', $a2)->update(['deleted_at' => now()]);
$attachment = Attachment::withTrashed()->find($a2);
$listener = new PostAttachmentCountSyncListener();
$listener->syncAttachmentsCount($attachment);
$storedCount = DB::table('board_posts')->where('id', $postId)->value('attachments_count');
$this->assertEquals(1, $storedCount);
}
/**
* 첨부파일 업로드 시 게시글의 attachments_count가 증가하는지 확인합니다.
*/
@@ -0,0 +1,309 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
require_once __DIR__.'/../../ModuleTestCase.php';
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* EcommerceInquiryHookListener 단위 테스트
*
* 검증 목적:
* - getSubscribedHooks: 7개 훅 등록, 모두 filter 타입
* - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null
* - getByIds: 빈 배열 → carry 그대로, 유효 ID 목록 → 필드 포함 배열 반환
* - getBoardSettings: 존재하지 않는 slug → carry 그대로, 유효 slug → 설정 배열 반환
* - deletePost: 성공 → carry 반환, 존재하지 않는 PostId → RuntimeException
* - deleteReplyPost: reply 없음 → RuntimeException, 성공 → carry 반환
*
* @group board
* @group unit
* @group listener
*/
class EcommerceInquiryHookListenerTest extends BoardTestCase
{
private EcommerceInquiryHookListener $listener;
protected function getTestBoardSlug(): string
{
return 'ecommerce-inquiry-hook';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '이커머스 문의 훅 테스트 게시판', 'en' => 'Ecommerce Inquiry Hook Test Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'use_file_upload' => true,
'max_file_count' => 5,
'max_file_size' => 10485760,
'allowed_extensions' => [],
'min_title_length' => 2,
'max_title_length' => 200,
'min_content_length' => 1,
'max_content_length' => 10000,
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
$this->listener = app(EcommerceInquiryHookListener::class);
}
// ==========================================
// getSubscribedHooks
// ==========================================
/**
* 7개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다.
*/
public function test_subscribed_hooks_registers_all_seven_filter_hooks(): void
{
$hooks = EcommerceInquiryHookListener::getSubscribedHooks();
$expectedHooks = [
'sirsoft-ecommerce.inquiry.create',
'sirsoft-ecommerce.inquiry.update',
'sirsoft-ecommerce.inquiry.delete',
'sirsoft-ecommerce.inquiry.update_reply',
'sirsoft-ecommerce.inquiry.delete_reply',
'sirsoft-ecommerce.inquiry.get_by_ids',
'sirsoft-ecommerce.inquiry.get_settings',
];
foreach ($expectedHooks as $hookName) {
$this->assertArrayHasKey($hookName, $hooks, "훅 {$hookName}이 등록되어 있어야 합니다.");
$this->assertSame('filter', $hooks[$hookName]['type'], "훅 {$hookName}은 filter 타입이어야 합니다.");
}
$this->assertCount(7, $hooks, '총 7개의 훅이 등록되어 있어야 합니다.');
}
// ==========================================
// createAndReturn
// ==========================================
/**
* createAndReturn: 정상 데이터로 게시글 생성 → post_id와 inquirable_type 반환
*/
public function test_create_and_return_returns_post_id_and_inquirable_type(): void
{
$result = $this->listener->createAndReturn(null, $this->board->slug, [
'title' => '문의 테스트 제목',
'content' => '문의 내용입니다.',
'ip_address' => '127.0.0.1',
]);
$this->assertIsArray($result);
$this->assertArrayHasKey('post_id', $result);
$this->assertArrayHasKey('inquirable_type', $result);
$this->assertIsInt($result['post_id']);
$this->assertSame(Post::class, $result['inquirable_type']);
}
/**
* createAndReturn: title 없으면 content 앞부분으로 자동 생성
*/
public function test_create_and_return_auto_generates_title_from_content(): void
{
$content = '이것은 50자가 넘는 긴 내용입니다. 제목이 없을 때 content 앞부분으로 자동 생성됩니다.';
$result = $this->listener->createAndReturn(null, $this->board->slug, [
'content' => $content,
'ip_address' => '127.0.0.1',
]);
$this->assertNotNull($result);
$post = Post::find($result['post_id']);
$this->assertNotEmpty($post->title);
$this->assertLessThanOrEqual(50, mb_strlen($post->title));
}
/**
* createAndReturn: parent_id 있으면 부모 제목에 Re: 접두사 붙임
*/
public function test_create_and_return_prepends_re_prefix_for_reply(): void
{
$parentPostId = $this->createTestPost(['title' => '원본 문의 제목']);
$result = $this->listener->createAndReturn(null, $this->board->slug, [
'content' => '답변 내용입니다.',
'parent_id' => $parentPostId,
'ip_address' => '127.0.0.1',
]);
$this->assertNotNull($result);
$post = Post::find($result['post_id']);
$this->assertStringStartsWith('Re:', $post->title);
$this->assertStringContainsString('원본 문의 제목', $post->title);
}
/**
* createAndReturn: 존재하지 않는 slug → 예외 발생하지 않고 null 반환
*/
public function test_create_and_return_returns_null_on_exception(): void
{
$result = $this->listener->createAndReturn(null, 'nonexistent-slug-xyz', [
'title' => '테스트',
'content' => '내용',
'ip_address' => '127.0.0.1',
]);
$this->assertNull($result);
}
// ==========================================
// getByIds
// ==========================================
/**
* getByIds: 빈 ids → carry 그대로 반환
*/
public function test_get_by_ids_returns_carry_when_ids_empty(): void
{
$carry = ['existing' => 'data'];
$result = $this->listener->getByIds($carry, ['ids' => [], 'slug' => $this->board->slug]);
$this->assertSame($carry, $result);
}
/**
* getByIds: 유효한 IDs → 필수 필드 포함 배열 반환
*/
public function test_get_by_ids_returns_mapped_array_for_valid_ids(): void
{
$postId = $this->createTestPost(['title' => '문의글', 'content' => '문의 내용']);
$result = $this->listener->getByIds([], ['ids' => [$postId], 'slug' => $this->board->slug]);
$this->assertIsArray($result);
$this->assertCount(1, $result);
$item = $result[0];
$this->assertArrayHasKey('id', $item);
$this->assertArrayHasKey('board_id', $item);
$this->assertArrayHasKey('title', $item);
$this->assertArrayHasKey('content', $item);
$this->assertArrayHasKey('status', $item);
$this->assertArrayHasKey('attachments', $item);
$this->assertArrayHasKey('reply', $item);
$this->assertSame($postId, $item['id']);
}
/**
* getByIds: ids에 없는 ID 포함해도 예외 없이 조회된 것만 반환
*/
public function test_get_by_ids_ignores_nonexistent_ids(): void
{
$postId = $this->createTestPost();
$result = $this->listener->getByIds([], [
'ids' => [$postId, 99999999],
'slug' => $this->board->slug,
]);
$this->assertCount(1, $result);
$this->assertSame($postId, $result[0]['id']);
}
// ==========================================
// getBoardSettings
// ==========================================
/**
* getBoardSettings: 존재하지 않는 slug → carry 그대로 반환
*/
public function test_get_board_settings_returns_carry_for_nonexistent_slug(): void
{
$carry = ['fallback' => true];
$result = $this->listener->getBoardSettings($carry, 'nonexistent-slug-xyz');
$this->assertSame($carry, $result);
}
/**
* getBoardSettings: 유효한 slug → 게시판 설정 필드 포함 배열 반환
*/
public function test_get_board_settings_returns_config_for_valid_slug(): void
{
$result = $this->listener->getBoardSettings([], $this->board->slug);
$this->assertIsArray($result);
$this->assertArrayHasKey('secret_mode', $result);
$this->assertArrayHasKey('use_file_upload', $result);
$this->assertArrayHasKey('max_file_count', $result);
$this->assertArrayHasKey('max_file_size', $result);
$this->assertArrayHasKey('min_title_length', $result);
$this->assertArrayHasKey('max_title_length', $result);
$this->assertArrayHasKey('attachment_upload_url', $result);
$this->assertArrayHasKey('attachment_delete_url', $result);
$this->assertStringContainsString($this->board->slug, $result['attachment_upload_url']);
}
// ==========================================
// deletePost
// ==========================================
/**
* deletePost: 성공 → carry 반환
*/
public function test_delete_post_returns_carry_on_success(): void
{
$postId = $this->createTestPost();
$carry = ['some' => 'carry'];
$result = $this->listener->deletePost($carry, $this->board->slug, $postId);
$this->assertSame($carry, $result);
$this->assertSoftDeleted('board_posts', ['id' => $postId]);
}
/**
* deletePost: 존재하지 않는 postId → RuntimeException
*/
public function test_delete_post_throws_runtime_exception_for_nonexistent_post(): void
{
$this->expectException(\RuntimeException::class);
$this->listener->deletePost(null, $this->board->slug, 99999999);
}
// ==========================================
// deleteReplyPost
// ==========================================
/**
* deleteReplyPost: 부모 게시글에 reply 없음 → RuntimeException
*/
public function test_delete_reply_post_throws_runtime_exception_when_no_reply(): void
{
$parentPostId = $this->createTestPost();
$this->expectException(\RuntimeException::class);
$this->listener->deleteReplyPost(null, $this->board->slug, $parentPostId);
}
/**
* deleteReplyPost: reply 있음 → 삭제 성공 + carry 반환
*/
public function test_delete_reply_post_returns_carry_on_success(): void
{
$parentPostId = $this->createTestPost();
$replyPostId = $this->createTestPost(['parent_id' => $parentPostId]);
$carry = ['reply_carry' => true];
$result = $this->listener->deleteReplyPost($carry, $this->board->slug, $parentPostId);
$this->assertSame($carry, $result);
$this->assertSoftDeleted('board_posts', ['id' => $replyPostId]);
}
}
@@ -0,0 +1,314 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Modules\Sirsoft\Board\Listeners\UserNotificationSettingsListener;
use Modules\Sirsoft\Board\Models\UserNotificationSetting;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* UserNotificationSettingsListener 단위 테스트
*
* 검증 목적:
* - getSubscribedHooks: 7개 훅 등록, filter 타입 6개 + action 타입 1개
* - addValidationRules: 4개 알림 필드 규칙 추가
* - filterCreateData: 알림 필드 분리 → 세션 저장 + 나머지 데이터 반환
* - filterCreateData: 알림 필드 없을 때 세션 미저장
* - filterCreateData: 미전송 알림 필드 → false로 채움
* - filterUpdateData: 알림 필드 즉시 저장 + 데이터에서 제거
* - filterUpdateData: 알림 필드 없고 기존 설정 없으면 저장 미발생
* - filterUpdateData: 기존 설정 있으면 미전송 필드 false로 갱신
* - afterCreate: 세션 데이터 → DB 저장 + 세션 삭제
* - afterCreate: 세션 비어있으면 저장 미발생
* - filterResourceData: 설정 있으면 실제 값 반환
* - filterResourceData: 설정 없으면 기본값(false) 반환
*
* @group board
* @group unit
* @group listener
*/
class UserNotificationSettingsListenerTest extends ModuleTestCase
{
private UserNotificationSettingsListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->listener = app(UserNotificationSettingsListener::class);
}
private function makeUser(): User
{
return User::create([
'name' => 'Test User '.uniqid(),
'email' => 'test'.uniqid().'@example.com',
'password' => bcrypt('password'),
]);
}
// ==========================================
// getSubscribedHooks
// ==========================================
/**
* 7개 훅이 등록되어 있어야 합니다.
*/
public function test_subscribed_hooks_returns_seven_hooks(): void
{
$hooks = UserNotificationSettingsListener::getSubscribedHooks();
$this->assertCount(7, $hooks);
}
/**
* filter 타입 훅 6개, action 타입 훅 1개 (after_create)
*/
public function test_subscribed_hooks_has_correct_types(): void
{
$hooks = UserNotificationSettingsListener::getSubscribedHooks();
$filterHooks = array_filter($hooks, fn ($h) => ($h['type'] ?? '') === 'filter');
$actionHooks = array_filter($hooks, fn ($h) => ! isset($h['type']));
$this->assertCount(6, $filterHooks, 'filter 타입 훅은 6개여야 합니다.');
$this->assertCount(1, $actionHooks, 'action 타입 훅(type 없음)은 1개여야 합니다.');
$this->assertArrayHasKey('core.user.after_create', $hooks, 'after_create 훅이 등록되어야 합니다.');
}
// ==========================================
// addValidationRules
// ==========================================
/**
* addValidationRules: 4개 알림 필드 규칙을 기존 규칙에 추가합니다.
*/
public function test_add_validation_rules_merges_notification_fields(): void
{
$existing = ['name' => 'required|string'];
$result = $this->listener->addValidationRules($existing);
$this->assertArrayHasKey('name', $result, '기존 규칙 유지');
$this->assertArrayHasKey('notify_post_complete', $result);
$this->assertArrayHasKey('notify_post_reply', $result);
$this->assertArrayHasKey('notify_comment', $result);
$this->assertArrayHasKey('notify_reply_comment', $result);
$this->assertSame('nullable|boolean', $result['notify_post_complete']);
}
// ==========================================
// filterCreateData
// ==========================================
/**
* filterCreateData: 알림 필드가 요청에 있으면 세션에 저장하고 데이터에서 제거합니다.
*/
public function test_filter_create_data_extracts_notification_fields_to_session(): void
{
$data = [
'name' => '홍길동',
'email' => 'hong@example.com',
'notify_post_complete' => true,
'notify_comment' => false,
];
$result = $this->listener->filterCreateData($data);
// 알림 필드 제거됨
$this->assertArrayNotHasKey('notify_post_complete', $result);
$this->assertArrayNotHasKey('notify_comment', $result);
// 일반 필드 유지됨
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('email', $result);
// 세션에 저장됨
$sessionData = session('board_notification_data');
$this->assertNotEmpty($sessionData);
$this->assertTrue($sessionData['notify_post_complete']);
}
/**
* filterCreateData: 알림 필드가 없으면 세션에 저장하지 않습니다.
*/
public function test_filter_create_data_does_not_set_session_when_no_notification_fields(): void
{
session()->forget('board_notification_data');
$data = ['name' => '홍길동', 'email' => 'hong@example.com'];
$this->listener->filterCreateData($data);
$this->assertNull(session('board_notification_data'));
}
/**
* filterCreateData: 알림 필드 일부만 전송 시 미전송 필드는 false로 채웁니다.
*/
public function test_filter_create_data_fills_missing_notification_fields_with_false(): void
{
$data = [
'name' => '홍길동',
'notify_post_complete' => true,
// notify_post_reply, notify_comment, notify_reply_comment 미전송
];
$this->listener->filterCreateData($data);
$sessionData = session('board_notification_data');
$this->assertFalse($sessionData['notify_post_reply']);
$this->assertFalse($sessionData['notify_comment']);
$this->assertFalse($sessionData['notify_reply_comment']);
}
// ==========================================
// filterUpdateData
// ==========================================
/**
* filterUpdateData: 알림 필드를 즉시 DB에 저장하고 데이터에서 제거합니다.
*/
public function test_filter_update_data_saves_notification_fields_immediately(): void
{
$user = $this->makeUser();
$data = [
'name' => '수정된 이름',
'notify_post_complete' => true,
'notify_post_reply' => true,
];
$result = $this->listener->filterUpdateData($data, $user);
// 알림 필드 제거됨
$this->assertArrayNotHasKey('notify_post_complete', $result);
$this->assertArrayHasKey('name', $result);
// DB에 저장됨
$this->assertDatabaseHas('board_user_notification_settings', [
'user_id' => $user->id,
'notify_post_complete' => true,
]);
}
/**
* filterUpdateData: 알림 필드 없고 기존 설정도 없으면 저장이 발생하지 않습니다.
*/
public function test_filter_update_data_does_not_save_when_no_fields_and_no_existing(): void
{
$user = $this->makeUser();
$data = ['name' => '수정된 이름'];
$this->listener->filterUpdateData($data, $user);
$this->assertDatabaseMissing('board_user_notification_settings', ['user_id' => $user->id]);
}
/**
* filterUpdateData: 기존 설정이 있으면 미전송 알림 필드는 false로 갱신합니다.
*/
public function test_filter_update_data_resets_missing_fields_to_false_when_existing(): void
{
$user = $this->makeUser();
// 기존 설정 생성
UserNotificationSetting::create([
'user_id' => $user->id,
'notify_post_complete' => true,
'notify_post_reply' => true,
'notify_comment' => true,
'notify_reply_comment' => true,
]);
// notify_post_complete만 전송 (나머지 체크박스 해제)
$data = ['notify_post_complete' => true];
$this->listener->filterUpdateData($data, $user);
$this->assertDatabaseHas('board_user_notification_settings', [
'user_id' => $user->id,
'notify_post_complete' => true,
'notify_post_reply' => false,
'notify_comment' => false,
'notify_reply_comment' => false,
]);
}
// ==========================================
// afterCreate
// ==========================================
/**
* afterCreate: 세션 데이터를 DB에 저장하고 세션을 삭제합니다.
*/
public function test_after_create_saves_session_data_and_clears_session(): void
{
$user = $this->makeUser();
session(['board_notification_data' => [
'notify_post_complete' => true,
'notify_post_reply' => false,
'notify_comment' => true,
'notify_reply_comment' => false,
]]);
$this->listener->afterCreate($user, []);
$this->assertDatabaseHas('board_user_notification_settings', [
'user_id' => $user->id,
'notify_post_complete' => true,
]);
$this->assertNull(session('board_notification_data'), '세션이 삭제되어야 합니다.');
}
/**
* afterCreate: 세션이 비어있으면 DB 저장이 발생하지 않습니다.
*/
public function test_after_create_does_not_save_when_session_empty(): void
{
$user = $this->makeUser();
session()->forget('board_notification_data');
$this->listener->afterCreate($user, []);
$this->assertDatabaseMissing('board_user_notification_settings', ['user_id' => $user->id]);
}
// ==========================================
// filterResourceData
// ==========================================
/**
* filterResourceData: 설정 레코드가 있으면 실제 값을 반환합니다.
*/
public function test_filter_resource_data_returns_actual_settings_when_exists(): void
{
$user = $this->makeUser();
UserNotificationSetting::create([
'user_id' => $user->id,
'notify_post_complete' => true,
'notify_post_reply' => false,
'notify_comment' => true,
'notify_reply_comment' => true,
]);
$result = $this->listener->filterResourceData(['name' => '홍길동'], $user);
$this->assertTrue($result['notify_post_complete']);
$this->assertFalse($result['notify_post_reply']);
$this->assertTrue($result['notify_comment']);
$this->assertArrayHasKey('name', $result, '기존 데이터 유지');
}
/**
* filterResourceData: 설정 레코드가 없으면 모든 알림 필드를 false로 반환합니다.
*/
public function test_filter_resource_data_returns_false_defaults_when_no_settings(): void
{
$user = $this->makeUser();
$result = $this->listener->filterResourceData([], $user);
$this->assertFalse($result['notify_post_complete']);
$this->assertFalse($result['notify_post_reply']);
$this->assertFalse($result['notify_comment']);
$this->assertFalse($result['notify_reply_comment']);
}
}
@@ -0,0 +1,240 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Models;
require_once __DIR__.'/../../ModuleTestCase.php';
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Enums\ReportType;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* Report 모델 스코프 단위 테스트
*
* 검증 목적:
* - scopeByStatus: Enum 값으로 필터링
* - scopeByStatus: 문자열로 필터링
* - scopeByType: post/comment 타입 필터링
* - scopeByBoard: 특정 게시판 신고만 조회
* - scopeByDateRange: 날짜 범위 필터링
* - 복합 스코프: 여러 조건 체이닝
*
* @group board
* @group unit
* @group model
*/
class ReportModelScopeTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'report-scope-test';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '신고 스코프 테스트 게시판', 'en' => 'Report Scope Test Board'],
'is_active' => true,
'use_report' => true,
];
}
private function createReport(array $attributes = []): Report
{
$postId = $this->createTestPost();
$defaults = [
'board_id' => $this->board->id,
'target_type' => ReportType::Post,
'target_id' => $postId,
'reporter_id' => null,
'reason_type' => 'spam',
'reason_detail' => '테스트 신고',
'status' => ReportStatus::Pending,
'ip_address' => '127.0.0.1',
];
return Report::create(array_merge($defaults, $attributes));
}
// ==========================================
// scopeByStatus
// ==========================================
/**
* scopeByStatus: Enum 인스턴스로 필터링
*/
public function test_scope_by_status_with_enum(): void
{
$this->createReport(['status' => ReportStatus::Pending]);
$this->createReport(['status' => ReportStatus::Review]);
$results = Report::byStatus(ReportStatus::Pending)->get();
$this->assertTrue($results->every(fn ($r) => $r->status === ReportStatus::Pending));
$this->assertGreaterThanOrEqual(1, $results->count());
}
/**
* scopeByStatus: 문자열로 필터링
*/
public function test_scope_by_status_with_string(): void
{
$this->createReport(['status' => ReportStatus::Rejected]);
$this->createReport(['status' => ReportStatus::Pending]);
$results = Report::byStatus('rejected')
->where('board_id', $this->board->id)
->get();
$this->assertTrue($results->every(fn ($r) => $r->status === ReportStatus::Rejected));
}
/**
* scopeByStatus: 해당 상태 없으면 빈 컬렉션
*/
public function test_scope_by_status_returns_empty_when_no_match(): void
{
$this->createReport(['status' => ReportStatus::Pending]);
$results = Report::byStatus(ReportStatus::Suspended)
->where('board_id', $this->board->id)
->get();
$this->assertCount(0, $results);
}
// ==========================================
// scopeByType
// ==========================================
/**
* scopeByType: Post 타입만 조회
*/
public function test_scope_by_type_post(): void
{
$this->createReport(['target_type' => ReportType::Post]);
// Comment 타입 신고 생성
$commentId = $this->createTestComment($this->createTestPost());
Report::create([
'board_id' => $this->board->id,
'target_type' => ReportType::Comment,
'target_id' => $commentId,
'reporter_id' => null,
'reason_type' => 'spam',
'reason_detail' => '댓글 신고',
'status' => ReportStatus::Pending,
'ip_address' => '127.0.0.1',
]);
$results = Report::byType(ReportType::Post)
->where('board_id', $this->board->id)
->get();
$this->assertTrue($results->every(fn ($r) => $r->target_type === ReportType::Post));
}
/**
* scopeByType: 문자열로도 필터링 가능
*/
public function test_scope_by_type_with_string(): void
{
$this->createReport(['target_type' => ReportType::Post]);
$results = Report::byType('post')
->where('board_id', $this->board->id)
->get();
$this->assertGreaterThanOrEqual(1, $results->count());
$this->assertTrue($results->every(fn ($r) => $r->target_type === ReportType::Post));
}
// ==========================================
// scopeByBoard
// ==========================================
/**
* scopeByBoard: 특정 게시판 신고만 반환
*/
public function test_scope_by_board_filters_correctly(): void
{
$this->createReport(['board_id' => $this->board->id]);
$results = Report::byBoard($this->board->id)->get();
$this->assertTrue($results->every(fn ($r) => $r->board_id === $this->board->id));
$this->assertGreaterThanOrEqual(1, $results->count());
}
/**
* scopeByBoard: 존재하지 않는 board_id → 빈 컬렉션
*/
public function test_scope_by_board_returns_empty_for_nonexistent_board(): void
{
$results = Report::byBoard(99999999)->get();
$this->assertCount(0, $results);
}
// ==========================================
// scopeByDateRange
// ==========================================
/**
* scopeByDateRange: 범위 내 신고만 반환
*/
public function test_scope_by_date_range_includes_records_in_range(): void
{
$report = $this->createReport();
DB::table('boards_reports')
->where('id', $report->id)
->update(['created_at' => '2025-01-15 12:00:00']);
$results = Report::byDateRange('2025-01-01', '2025-01-31')
->where('board_id', $this->board->id)
->get();
$this->assertGreaterThanOrEqual(1, $results->count());
}
/**
* scopeByDateRange: 범위 밖 신고는 제외
*/
public function test_scope_by_date_range_excludes_records_outside_range(): void
{
$report = $this->createReport();
DB::table('boards_reports')
->where('id', $report->id)
->update(['created_at' => '2024-06-01 12:00:00']);
$results = Report::byDateRange('2025-01-01', '2025-12-31')
->where('board_id', $this->board->id)
->get();
$this->assertCount(0, $results);
}
// ==========================================
// 복합 스코프 체이닝
// ==========================================
/**
* 복합 스코프: byBoard + byStatus 체이닝
*/
public function test_chaining_by_board_and_by_status(): void
{
$this->createReport(['status' => ReportStatus::Pending]);
$this->createReport(['status' => ReportStatus::Review]);
$results = Report::byBoard($this->board->id)
->byStatus(ReportStatus::Pending)
->get();
$this->assertTrue($results->every(
fn ($r) => $r->board_id === $this->board->id && $r->status === ReportStatus::Pending
));
}
}
@@ -0,0 +1,110 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit;
require_once __DIR__.'/../ModuleTestCase.php';
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 비회원 게시글 삭제 토큰 단위 테스트
*
* 검증 목적:
* - storeDeleteVerifyToken: 토큰 캐시 저장 + token/expires_at 반환
* - consumeDeleteVerifyToken: 유효 토큰 소비 → true + 재소비 방지 (일회용)
* - 존재하지 않는 토큰 소비 → false
* - 다른 slug/postId 토큰은 소비되지 않음 (격리)
*
* @group board
* @group unit
* @group post
*/
class PostServiceGuestTokenTest extends ModuleTestCase
{
private PostService $postService;
protected function setUp(): void
{
parent::setUp();
$this->postService = app(PostService::class);
}
/**
* storeDeleteVerifyToken: token 문자열 및 expires_at 반환
*/
public function test_store_returns_token_and_expires_at(): void
{
$token = Str::random(32);
$result = $this->postService->storeDeleteVerifyToken('test-board', 1, $token);
$this->assertArrayHasKey('token', $result);
$this->assertArrayHasKey('expires_at', $result);
$this->assertSame($token, $result['token']);
$this->assertNotEmpty($result['expires_at']);
}
/**
* consumeDeleteVerifyToken: 저장된 토큰 소비 → true
*/
public function test_consume_valid_token_returns_true(): void
{
$token = Str::random(32);
$this->postService->storeDeleteVerifyToken('test-board', 10, $token);
$result = $this->postService->consumeDeleteVerifyToken('test-board', 10, $token);
$this->assertTrue($result);
}
/**
* consumeDeleteVerifyToken: 토큰은 일회용 — 두 번째 소비 → false
*/
public function test_consume_token_is_one_time_use(): void
{
$token = Str::random(32);
$this->postService->storeDeleteVerifyToken('test-board', 20, $token);
$this->postService->consumeDeleteVerifyToken('test-board', 20, $token); // 첫 소비
$second = $this->postService->consumeDeleteVerifyToken('test-board', 20, $token); // 재소비
$this->assertFalse($second);
}
/**
* consumeDeleteVerifyToken: 저장하지 않은 토큰 소비 → false
*/
public function test_consume_nonexistent_token_returns_false(): void
{
$result = $this->postService->consumeDeleteVerifyToken('test-board', 99, 'nonexistent-token');
$this->assertFalse($result);
}
/**
* 다른 slug로 저장한 토큰은 소비 불가 (캐시 키 격리)
*/
public function test_token_is_isolated_by_slug(): void
{
$token = Str::random(32);
$this->postService->storeDeleteVerifyToken('board-a', 1, $token);
$result = $this->postService->consumeDeleteVerifyToken('board-b', 1, $token);
$this->assertFalse($result);
}
/**
* 다른 postId로 저장한 토큰은 소비 불가 (캐시 키 격리)
*/
public function test_token_is_isolated_by_post_id(): void
{
$token = Str::random(32);
$this->postService->storeDeleteVerifyToken('board-a', 1, $token);
$result = $this->postService->consumeDeleteVerifyToken('board-a', 2, $token);
$this->assertFalse($result);
}
}
@@ -88,13 +88,6 @@ class ReportServiceTest extends ModuleTestCase
'notify_author_on_comment' => false,
]
);
// Phase 8: 파티션 테이블에 board_id 파티션 추가 (createTestPost/createTestComment에서 board_posts/board_comments INSERT 필요)
try {
$this->boardRepository->addBoardPartitions($this->board->id);
} catch (\Exception $e) {
// 파티션이 이미 존재하면 무시 (DDL implicit commit으로 이전 테스트 파티션이 남아있을 수 있음)
}
}
protected function tearDown(): void
@@ -511,6 +504,141 @@ class ReportServiceTest extends ModuleTestCase
$this->assertGreaterThan(0, $summary['restorable_blind_count']);
}
/**
* createReport() - 동일 대상 중복 신고 시 기존 케이스 재사용 (1케이스 구조)
*/
public function test_create_report_reuses_existing_case_for_same_target(): void
{
// Given: 첫 번째 신고로 케이스 생성
$postId = $this->createTestPost();
$existingReport = new Report;
$existingReport->id = 99;
$existingReport->board_id = $this->board->id;
$existingReport->target_type = \Modules\Sirsoft\Board\Enums\ReportType::Post;
$existingReport->target_id = $postId;
$existingReport->status = \Modules\Sirsoft\Board\Enums\ReportStatus::Pending;
$existingReport->process_histories = [];
// 두 번째 신고 시: created=false (기존 케이스 반환)
$this->reportRepository->shouldReceive('findOrCreateCase')
->once()
->andReturn(['report' => $existingReport, 'created' => false]);
$this->reportRepository->shouldReceive('createLog')
->once()
->andReturn(new ReportLog);
$this->reportRepository->shouldReceive('countActiveReportsByTarget')
->once()
->andReturn(2);
$data = [
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'reporter_id' => $this->user->id,
'reason_type' => ReportReasonType::Spam,
'reason_detail' => '두 번째 신고',
];
// When
$result = $this->service->createReport($data);
// Then: 동일 Report 인스턴스 반환 (새 케이스 미생성)
$this->assertInstanceOf(Report::class, $result);
$this->assertEquals(99, $result->id);
}
/**
* createReport() - rejected 케이스에 재신고 시 pending 재활성화
*/
public function test_create_report_reactivates_rejected_case(): void
{
// Given: rejected 상태의 기존 케이스
$postId = $this->createTestPost();
$rejectedReport = new Report;
$rejectedReport->id = 100;
$rejectedReport->board_id = $this->board->id;
$rejectedReport->target_type = \Modules\Sirsoft\Board\Enums\ReportType::Post;
$rejectedReport->target_id = $postId;
$rejectedReport->status = \Modules\Sirsoft\Board\Enums\ReportStatus::Rejected;
$rejectedReport->process_histories = [['action' => 'rejected']];
// 재신고: created=false, status=Rejected → isReactivation=true
$this->reportRepository->shouldReceive('findOrCreateCase')
->once()
->andReturn(['report' => $rejectedReport, 'created' => false]);
$this->reportRepository->shouldReceive('createLog')
->once()
->andReturn(new ReportLog);
$this->reportRepository->shouldReceive('countActiveReportsByTarget')
->once()
->andReturn(1);
$data = [
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'reporter_id' => $this->user->id,
'reason_type' => ReportReasonType::Spam,
'reason_detail' => '재신고',
];
// When: 예외 없이 처리되어야 함
$result = $this->service->createReport($data);
$this->assertInstanceOf(Report::class, $result);
}
/**
* updateReportStatus() - comment 신고 블라인드 처리 (suspended)
*/
public function test_update_report_status_blinds_comment_content(): void
{
// Given
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId);
$report = Report::factory()->create([
'board_id' => $this->board->id,
'author_id' => $this->user->id,
'target_type' => 'comment',
'target_id' => $commentId,
'status' => ReportStatus::Pending,
]);
$freshReport = $report->fresh();
$freshReport->load('board');
$this->reportRepository->shouldReceive('findOrFail')
->with($report->id)
->twice()
->andReturn($freshReport);
$this->reportRepository->shouldReceive('update')
->once()
->andReturnUsing(function ($id, $data) use ($report) {
$report->status = $data['status'];
return $report;
});
// When
$this->service->updateReportStatus($report->id, [
'status' => ReportStatus::Suspended->value,
'process_note' => '댓글 블라인드',
]);
// Then: 댓글이 blinded 상태로 변경
$comment = DB::table('board_comments')->find($commentId);
$this->assertEquals('blinded', $comment->status);
$this->assertEquals('report', $comment->trigger_type);
}
/**
* 테스트용 댓글 생성 헬퍼
*
@@ -0,0 +1,314 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Rules;
require_once __DIR__.'/../../ModuleTestCase.php';
use Illuminate\Support\Facades\Cache;
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
use Modules\Sirsoft\Board\Rules\CategoryInUseRule;
use Modules\Sirsoft\Board\Rules\CommentValidationRule;
use Modules\Sirsoft\Board\Rules\CooldownRule;
use Modules\Sirsoft\Board\Rules\PermissionRolesRequiredRule;
use Modules\Sirsoft\Board\Rules\SlugUniqueRule;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 게시판 Custom Validation Rule 단위 테스트
*
* 검증 목적:
* - BlockedKeywordsRule: 금지 키워드 포함/미포함/대소문자 무시/null
* - PermissionRolesRequiredRule: roles 누락 permission fail / 정상 통과
* - SlugUniqueRule: 예약어 fail / 중복 slug fail / 새 slug 통과
* - CooldownRule: 쿨다운 0은 통과 / 캐시 키 있으면 fail / 없으면 통과
* - CategoryInUseRule: 제거 카테고리 사용 중이면 fail / 사용 안 하면 통과 / 제거 없으면 통과
* - CommentValidationRule: blinded 게시글 fail / deleted 게시글 fail / 정상 통과
*
* @group board
* @group unit
* @group rules
*/
class BoardRulesTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'board-rules-test';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '규칙 테스트 게시판', 'en' => 'Rules Test Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
'max_comment_depth' => 3,
'categories' => [],
];
}
// ==========================================
// 헬퍼
// ==========================================
private function runRule(\Illuminate\Contracts\Validation\ValidationRule $rule, mixed $value): array
{
$failures = [];
$rule->validate('field', $value, function (string $msg) use (&$failures) {
$failures[] = $msg;
});
return $failures;
}
// ==========================================
// BlockedKeywordsRule
// ==========================================
/**
* 금지 키워드가 없으면 항상 통과합니다.
*/
public function test_blocked_keywords_passes_when_no_keywords(): void
{
$rule = new BlockedKeywordsRule([]);
$this->assertEmpty($this->runRule($rule, '어떤 내용이든 통과'));
}
/**
* null 키워드 목록이면 통과합니다.
*/
public function test_blocked_keywords_passes_when_keywords_null(): void
{
$rule = new BlockedKeywordsRule(null);
$this->assertEmpty($this->runRule($rule, '스팸'));
}
/**
* 금지 키워드가 포함되면 fail합니다.
*/
public function test_blocked_keywords_fails_when_keyword_found(): void
{
$rule = new BlockedKeywordsRule(['스팸', '광고']);
$failures = $this->runRule($rule, '스팸 게시글 내용');
$this->assertNotEmpty($failures);
}
/**
* 대소문자를 무시하여 검사합니다.
*/
public function test_blocked_keywords_is_case_insensitive(): void
{
$rule = new BlockedKeywordsRule(['SPAM']);
$failures = $this->runRule($rule, 'spam 내용');
$this->assertNotEmpty($failures);
}
/**
* 금지 키워드가 없는 내용은 통과합니다.
*/
public function test_blocked_keywords_passes_when_no_match(): void
{
$rule = new BlockedKeywordsRule(['스팸']);
$this->assertEmpty($this->runRule($rule, '정상적인 게시글 내용입니다.'));
}
/**
* 값이 문자열이 아니면 통과합니다.
*/
public function test_blocked_keywords_passes_when_value_not_string(): void
{
$rule = new BlockedKeywordsRule(['스팸']);
$this->assertEmpty($this->runRule($rule, 123));
}
// ==========================================
// PermissionRolesRequiredRule
// ==========================================
/**
* permissions 배열에서 roles가 비어있으면 fail합니다.
*/
public function test_permission_roles_required_fails_when_roles_empty(): void
{
$rule = new PermissionRolesRequiredRule();
$value = [
'posts_read' => ['roles' => []],
'posts_write' => ['roles' => ['user']],
];
$failures = $this->runRule($rule, $value);
$this->assertNotEmpty($failures);
}
/**
* 모든 permission에 role이 있으면 통과합니다.
*/
public function test_permission_roles_required_passes_when_all_have_roles(): void
{
$rule = new PermissionRolesRequiredRule();
$value = [
'posts_read' => ['roles' => ['guest', 'user']],
'posts_write' => ['roles' => ['user']],
];
$this->assertEmpty($this->runRule($rule, $value));
}
/**
* 값이 배열이 아니면 통과합니다.
*/
public function test_permission_roles_required_passes_when_not_array(): void
{
$rule = new PermissionRolesRequiredRule();
$this->assertEmpty($this->runRule($rule, 'not-an-array'));
}
// ==========================================
// SlugUniqueRule
// ==========================================
/**
* 예약된 slug 'boards'는 fail합니다.
*/
public function test_slug_unique_fails_for_reserved_slug(): void
{
$rule = new SlugUniqueRule();
$failures = $this->runRule($rule, 'boards');
$this->assertNotEmpty($failures);
}
/**
* 이미 존재하는 slug는 fail합니다.
*/
public function test_slug_unique_fails_for_existing_slug(): void
{
$rule = new SlugUniqueRule();
// $this->board->slug는 이미 DB에 존재
$failures = $this->runRule($rule, $this->board->slug);
$this->assertNotEmpty($failures);
}
/**
* 새로운 slug는 통과합니다.
*/
public function test_slug_unique_passes_for_new_slug(): void
{
$rule = new SlugUniqueRule();
$this->assertEmpty($this->runRule($rule, 'completely-new-unique-slug-xyz'));
}
// ==========================================
// CooldownRule
// ==========================================
/**
* cooldownSeconds가 0이면 항상 통과합니다.
*/
public function test_cooldown_rule_passes_when_disabled(): void
{
$rule = new CooldownRule('post', 0, $this->board->slug);
$this->assertEmpty($this->runRule($rule, null));
}
/**
* 쿨다운 캐시가 없으면 통과합니다.
*/
public function test_cooldown_rule_passes_when_no_cache(): void
{
// 캐시 초기화
Cache::flush();
$rule = new CooldownRule('post', 60, $this->board->slug);
$this->assertEmpty($this->runRule($rule, null));
}
// ==========================================
// CategoryInUseRule
// ==========================================
/**
* 제거 카테고리가 없으면 통과합니다.
*/
public function test_category_in_use_passes_when_no_removal(): void
{
$rule = new CategoryInUseRule(
$this->board->slug,
['tech', 'news'],
['tech', 'news', 'sports'] // 추가만 있음
);
$this->assertEmpty($this->runRule($rule, null));
}
/**
* 제거 카테고리가 있어도 파티션 테이블이 없으면 통과합니다.
*/
public function test_category_in_use_passes_when_table_not_exists(): void
{
// board_posts_{slug} 파티션 테이블 없음 (비파티션 환경)
$rule = new CategoryInUseRule(
'nonexistent-board-xyz',
['tech', 'news'],
['tech'] // news 제거
);
$this->assertEmpty($this->runRule($rule, null));
}
// ==========================================
// CommentValidationRule
// ==========================================
/**
* blinded 게시글에 댓글 작성 → fail
*/
public function test_comment_validation_fails_for_blinded_post(): void
{
$postId = $this->createTestPost(['status' => 'blinded']);
$rule = new CommentValidationRule($this->board->slug, 'post');
$failures = $this->runRule($rule, $postId);
$this->assertNotEmpty($failures);
}
/**
* 소프트 삭제된 게시글에 댓글 작성 → fail
*/
public function test_comment_validation_fails_for_deleted_post(): void
{
$postId = $this->createTestPost();
// soft delete
\Illuminate\Support\Facades\DB::table('board_posts')
->where('id', $postId)
->update(['deleted_at' => now()]);
$rule = new CommentValidationRule($this->board->slug, 'post');
$failures = $this->runRule($rule, $postId);
$this->assertNotEmpty($failures);
}
/**
* 존재하지 않는 게시글 ID → fail
*/
public function test_comment_validation_fails_for_nonexistent_post(): void
{
$rule = new CommentValidationRule($this->board->slug, 'post');
$failures = $this->runRule($rule, 99999999);
$this->assertNotEmpty($failures);
}
/**
* 정상 게시글에 댓글 작성 → 통과
*/
public function test_comment_validation_passes_for_published_post(): void
{
$postId = $this->createTestPost(['status' => 'published']);
$rule = new CommentValidationRule($this->board->slug, 'post');
$this->assertEmpty($this->runRule($rule, $postId));
}
/**
* 값이 비어있으면 통과합니다 (nullable).
*/
public function test_comment_validation_passes_for_empty_value(): void
{
$rule = new CommentValidationRule($this->board->slug, 'post');
$this->assertEmpty($this->runRule($rule, null));
}
}
@@ -0,0 +1,175 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Seo;
require_once __DIR__.'/../../ModuleTestCase.php';
use Modules\Sirsoft\Board\Seo\BoardSitemapContributor;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* BoardSitemapContributor 단위 테스트
*
* 검증 목적:
* - getIdentifier: 'sirsoft-board' 반환
* - getUrls: /boards 항목 포함
* - getUrls: 활성 게시판 URL 포함
* - getUrls: 비활성 게시판 URL 미포함
* - getUrls: 공개 게시글 URL 포함
* - getUrls: 비밀글 URL 미포함
* - getUrls: blinded/deleted 게시글 URL 미포함
* - getUrls: 각 항목에 url 키 존재
*
* @group board
* @group unit
* @group seo
*/
class BoardSitemapContributorTest extends BoardTestCase
{
private BoardSitemapContributor $contributor;
protected function getTestBoardSlug(): string
{
return 'sitemap-test';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '사이트맵 테스트 게시판', 'en' => 'Sitemap Test Board'],
'is_active' => true,
];
}
protected function setUp(): void
{
parent::setUp();
$this->contributor = new BoardSitemapContributor();
}
/**
* getIdentifier: 'sirsoft-board' 반환
*/
public function test_get_identifier_returns_sirsoft_board(): void
{
$this->assertSame('sirsoft-board', $this->contributor->getIdentifier());
}
/**
* getUrls: /boards 항목이 반드시 포함된다
*/
public function test_get_urls_includes_boards_index(): void
{
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertContains('/boards', $urlPaths);
}
/**
* getUrls: 활성 게시판 URL이 포함된다
*/
public function test_get_urls_includes_active_board(): void
{
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertContains("/board/{$this->board->slug}", $urlPaths);
}
/**
* getUrls: 비활성 게시판은 포함되지 않는다
*/
public function test_get_urls_excludes_inactive_board(): void
{
$this->updateBoardSettings(['is_active' => false]);
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertNotContains("/board/{$this->board->slug}", $urlPaths);
}
/**
* getUrls: 공개(published) 게시글 URL이 포함된다
*/
public function test_get_urls_includes_published_post(): void
{
$postId = $this->createTestPost(['status' => 'published', 'is_secret' => false]);
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
/**
* getUrls: 비밀글은 포함되지 않는다
*/
public function test_get_urls_excludes_secret_post(): void
{
$postId = $this->createTestPost(['status' => 'published', 'is_secret' => true]);
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertNotContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
/**
* getUrls: blinded 게시글은 포함되지 않는다
*/
public function test_get_urls_excludes_blinded_post(): void
{
$postId = $this->createTestPost(['status' => 'blinded', 'is_secret' => false]);
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertNotContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
/**
* getUrls: soft-deleted 게시글은 포함되지 않는다
*/
public function test_get_urls_excludes_deleted_post(): void
{
$postId = $this->createTestPost(['status' => 'published', 'is_secret' => false]);
\Illuminate\Support\Facades\DB::table('board_posts')
->where('id', $postId)
->update(['deleted_at' => now()]);
$urls = $this->contributor->getUrls();
$urlPaths = array_column($urls, 'url');
$this->assertNotContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
/**
* getUrls: 모든 항목에 url 키가 존재한다
*/
public function test_get_urls_all_items_have_url_key(): void
{
$this->createTestPost(['status' => 'published', 'is_secret' => false]);
$urls = $this->contributor->getUrls();
foreach ($urls as $item) {
$this->assertArrayHasKey('url', $item, '모든 항목에 url 키가 있어야 합니다.');
}
}
/**
* getUrls: 게시판 항목에 changefreq와 priority가 있다
*/
public function test_get_urls_board_item_has_changefreq_and_priority(): void
{
$urls = $this->contributor->getUrls();
$boardItem = collect($urls)->firstWhere('url', "/board/{$this->board->slug}");
$this->assertNotNull($boardItem);
$this->assertArrayHasKey('changefreq', $boardItem);
$this->assertArrayHasKey('priority', $boardItem);
}
}
@@ -0,0 +1,233 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Upgrades;
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_0_beta_2;
/**
* Upgrade_1_0_0_beta_2 멱등성 테스트
*
* 검증 목적:
* - syncCountColumns run() 2회 연속 호출 시 동일 결과 보장 (멱등성)
* - 파티션 부재 상황에서 예외 없이 완료 (graceful)
* - 카운트 0인 게시판/게시글에서 run() 후 0 유지
*
* @group board
* @group upgrade
*/
class Upgrade_1_0_0_beta_2_Test extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'upgrade-idempotency';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '업그레이드 멱등성 테스트 게시판', 'en' => 'Upgrade Idempotency Test Board'],
'is_active' => true,
];
}
// ==========================================
// 멱등성 시나리오
// ==========================================
/**
* syncCountColumns은 2회 연속 실행 시 동일한 카운트를 유지한다
*/
public function test_sync_count_columns_is_idempotent(): void
{
// 데이터 준비: 게시글 + 댓글 2개 + 답글게시글 1개
$postId = $this->createUpgradeTestPost(['board_id' => $this->board->id]);
$this->createUpgradeTestComment($postId, ['board_id' => $this->board->id]);
$this->createUpgradeTestComment($postId, ['board_id' => $this->board->id]);
$replyId = $this->createUpgradeTestPost(['board_id' => $this->board->id, 'parent_id' => $postId]);
// 카운트 0으로 리셋 (업그레이드 전 상태)
DB::table('board_posts')->whereIn('id', [$postId, $replyId])->update([
'comments_count' => 0,
'replies_count' => 0,
]);
DB::table('boards')->where('id', $this->board->id)->update([
'posts_count' => 0,
'comments_count' => 0,
]);
// 첫 번째 실행
$this->runUpgrade();
$postAfterFirst = DB::table('board_posts')->where('id', $postId)->first();
$boardAfterFirst = DB::table('boards')->where('id', $this->board->id)->first();
// 두 번째 실행 (멱등성 검증)
$this->runUpgrade();
$postAfterSecond = DB::table('board_posts')->where('id', $postId)->first();
$boardAfterSecond = DB::table('boards')->where('id', $this->board->id)->first();
// 두 번 실행 결과가 동일해야 한다
$this->assertEquals($postAfterFirst->comments_count, $postAfterSecond->comments_count);
$this->assertEquals($postAfterFirst->replies_count, $postAfterSecond->replies_count);
$this->assertEquals($boardAfterFirst->posts_count, $boardAfterSecond->posts_count);
$this->assertEquals($boardAfterFirst->comments_count, $boardAfterSecond->comments_count);
}
/**
* 게시글/댓글/첨부가 없는 빈 게시판에서 run() 후 카운트가 0이다
*/
public function test_empty_board_counts_remain_zero_after_upgrade(): void
{
// 빈 게시판 카운트 리셋
DB::table('boards')->where('id', $this->board->id)->update([
'posts_count' => 999,
'comments_count' => 999,
]);
$this->runUpgrade();
$board = DB::table('boards')->where('id', $this->board->id)->first();
$this->assertEquals(0, $board->posts_count);
$this->assertEquals(0, $board->comments_count);
}
/**
* 삭제된(soft-delete) 게시글은 boards.posts_count에서 제외된다
*/
public function test_soft_deleted_posts_excluded_from_posts_count(): void
{
// 정상 게시글 2개, soft-delete 1개
$this->createUpgradeTestPost(['board_id' => $this->board->id]);
$this->createUpgradeTestPost(['board_id' => $this->board->id]);
$this->createUpgradeTestPost(['board_id' => $this->board->id, 'deleted_at' => now()]);
DB::table('boards')->where('id', $this->board->id)->update(['posts_count' => 0]);
$this->runUpgrade();
$board = DB::table('boards')->where('id', $this->board->id)->first();
$this->assertEquals(2, $board->posts_count);
}
/**
* 삭제된(soft-delete) 댓글은 board_posts.comments_count에서 제외된다
*/
public function test_soft_deleted_comments_excluded_from_comments_count(): void
{
$postId = $this->createUpgradeTestPost(['board_id' => $this->board->id]);
$this->createUpgradeTestComment($postId, ['board_id' => $this->board->id]);
$this->createUpgradeTestComment($postId, [
'board_id' => $this->board->id,
'deleted_at' => now(),
]);
DB::table('board_posts')->where('id', $postId)->update(['comments_count' => 0]);
$this->runUpgrade();
$post = DB::table('board_posts')->where('id', $postId)->first();
$this->assertEquals(1, $post->comments_count);
}
/**
* run()은 파티션이 없는 환경에서도 예외 없이 완료된다
*/
public function test_run_completes_without_exception_when_no_partitions(): void
{
// 파티션이 없는 표준 환경에서 예외 발생 없이 완료되어야 함
$this->expectNotToPerformAssertions();
try {
$this->runUpgrade();
} catch (\Throwable $e) {
$this->fail("run() should not throw in partition-free environment: " . $e->getMessage());
}
}
/**
* run() 2회 실행 시 boards.comments_count가 중복 누적되지 않는다
*/
public function test_boards_comments_count_does_not_accumulate_on_double_run(): void
{
$postId = $this->createUpgradeTestPost(['board_id' => $this->board->id]);
$this->createUpgradeTestComment($postId, ['board_id' => $this->board->id]);
$this->createUpgradeTestComment($postId, ['board_id' => $this->board->id]);
DB::table('boards')->where('id', $this->board->id)->update(['comments_count' => 0]);
$this->runUpgrade();
$countAfterFirst = DB::table('boards')->where('id', $this->board->id)->value('comments_count');
$this->runUpgrade();
$countAfterSecond = DB::table('boards')->where('id', $this->board->id)->value('comments_count');
// 두 번째 실행에서 중복 누적 없이 동일값
$this->assertEquals($countAfterFirst, $countAfterSecond);
$this->assertEquals(2, $countAfterFirst);
}
// ==========================================
// Helper
// ==========================================
private function runUpgrade(): void
{
$context = new UpgradeContext(
fromVersion: '1.0.0-beta.1',
toVersion: '1.0.0-beta.2',
currentStep: '1.0.0-beta.2',
);
$upgrade = new Upgrade_1_0_0_beta_2();
$upgrade->run($context);
}
private function createUpgradeTestPost(array $attributes = []): int
{
$defaults = [
'board_id' => $this->board->id,
'title' => '업그레이드 테스트 게시글',
'content' => '테스트 내용입니다.',
'user_id' => null,
'author_name' => '테스트',
'ip_address' => '127.0.0.1',
'is_notice' => false,
'is_secret' => false,
'status' => 'published',
'trigger_type' => 'admin',
'view_count' => 0,
'replies_count' => 0,
'comments_count' => 0,
'attachments_count' => 0,
'created_at' => now(),
'updated_at' => now(),
];
return DB::table('board_posts')->insertGetId(array_merge($defaults, $attributes));
}
private function createUpgradeTestComment(int $postId, array $attributes = []): int
{
$defaults = [
'board_id' => $this->board->id,
'post_id' => $postId,
'user_id' => null,
'author_name' => '테스트',
'content' => '테스트 댓글입니다.',
'ip_address' => '127.0.0.1',
'status' => 'published',
'replies_count' => 0,
'created_at' => now(),
'updated_at' => now(),
];
return DB::table('board_comments')->insertGetId(array_merge($defaults, $attributes));
}
}