diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index fbf38475..2e51e682 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -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 ### ⚠️ 업데이트 주의사항 diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index 27df750f..b06228c9 100644 --- a/modules/_bundled/sirsoft-board/composer.json +++ b/modules/_bundled/sirsoft-board/composer.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 38213305..a7288ae1 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,7 +5,7 @@ "ko": "게시판", "en": "Board" }, - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index 0c9be6d9..a1d27cb7 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -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", diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php index 3599a9c0..6f75c42f 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php @@ -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'] ?? [] ); diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php index 07d4c375..20131018 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php @@ -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); } diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php index 4fc1ca43..99d6134d 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php @@ -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); } /** diff --git a/modules/_bundled/sirsoft-board/src/Listeners/BoardCommentsCountSyncListener.php b/modules/_bundled/sirsoft-board/src/Listeners/BoardCommentsCountSyncListener.php index ef850a96..076c37e2 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/BoardCommentsCountSyncListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/BoardCommentsCountSyncListener.php @@ -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], ]; } diff --git a/modules/_bundled/sirsoft-board/src/Listeners/BoardPostsCountSyncListener.php b/modules/_bundled/sirsoft-board/src/Listeners/BoardPostsCountSyncListener.php index f270ce11..3dba6df4 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/BoardPostsCountSyncListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/BoardPostsCountSyncListener.php @@ -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], ]; } diff --git a/modules/_bundled/sirsoft-board/src/Listeners/CommentReplySyncListener.php b/modules/_bundled/sirsoft-board/src/Listeners/CommentReplySyncListener.php index d3198295..6f2239cb 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/CommentReplySyncListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/CommentReplySyncListener.php @@ -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], ]; } diff --git a/modules/_bundled/sirsoft-board/src/Listeners/PostAttachmentCountSyncListener.php b/modules/_bundled/sirsoft-board/src/Listeners/PostAttachmentCountSyncListener.php index f76079d1..974f27d6 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/PostAttachmentCountSyncListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/PostAttachmentCountSyncListener.php @@ -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], ]; } diff --git a/modules/_bundled/sirsoft-board/src/Listeners/PostCountSyncListener.php b/modules/_bundled/sirsoft-board/src/Listeners/PostCountSyncListener.php index cfa61944..448b8185 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/PostCountSyncListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/PostCountSyncListener.php @@ -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], ]; } diff --git a/modules/_bundled/sirsoft-board/src/Listeners/PostReplySyncListener.php b/modules/_bundled/sirsoft-board/src/Listeners/PostReplySyncListener.php index 3db26764..68fdc717 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/PostReplySyncListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/PostReplySyncListener.php @@ -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], ]; } diff --git a/modules/_bundled/sirsoft-board/src/Models/Attachment.php b/modules/_bundled/sirsoft-board/src/Models/Attachment.php index 2f7ffc8e..93e21b73 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Attachment.php +++ b/modules/_bundled/sirsoft-board/src/Models/Attachment.php @@ -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'; diff --git a/modules/_bundled/sirsoft-board/src/Models/Comment.php b/modules/_bundled/sirsoft-board/src/Models/Comment.php index ce771463..3dd97ff6 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Comment.php +++ b/modules/_bundled/sirsoft-board/src/Models/Comment.php @@ -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'; diff --git a/modules/_bundled/sirsoft-board/src/Models/Post.php b/modules/_bundled/sirsoft-board/src/Models/Post.php index 8f9d1250..29e4faf8 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Post.php +++ b/modules/_bundled/sirsoft-board/src/Models/Post.php @@ -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 */ diff --git a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php index 4c7d1f3b..e7f1a6bc 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php @@ -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 임시 업로드 키 diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php index f5e89dba..f8ea673e 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php @@ -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}"); - } } diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php index 2e10938a..654a6cd2 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php @@ -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; } \ No newline at end of file diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php index 42d957c6..8072e000 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php @@ -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; + /** * 신고 처리를 위한 게시글 상태를 일괄 업데이트합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index e020e25b..79aac9f5 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -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; + } + /** * 신고 처리를 위한 게시글 상태를 일괄 업데이트합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php index 1555b213..859f0562 100644 --- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php @@ -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; } diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardService.php b/modules/_bundled/sirsoft-board/src/Services/BoardService.php index e3f20986..fc2a7519 100644 --- a/modules/_bundled/sirsoft-board/src/Services/BoardService.php +++ b/modules/_bundled/sirsoft-board/src/Services/BoardService.php @@ -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 : []; diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php index 7b60461c..1dff9924 100644 --- a/modules/_bundled/sirsoft-board/src/Services/PostService.php +++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php @@ -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); + } + /** * 이전/다음 게시글을 조회합니다. * diff --git a/modules/_bundled/sirsoft-board/tests/BoardTestCase.php b/modules/_bundled/sirsoft-board/tests/BoardTestCase.php index a6b4f2f8..f6a7129e 100644 --- a/modules/_bundled/sirsoft-board/tests/BoardTestCase.php +++ b/modules/_bundled/sirsoft-board/tests/BoardTestCase.php @@ -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(); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminAttachmentManagementTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminAttachmentManagementTest.php new file mode 100644 index 00000000..5b651aac --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminAttachmentManagementTest.php @@ -0,0 +1,341 @@ + $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)); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminCommentManagementTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminCommentManagementTest.php new file mode 100644 index 00000000..b7cff944 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminCommentManagementTest.php @@ -0,0 +1,463 @@ + $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}"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminPostBlindRestoreTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminPostBlindRestoreTest.php new file mode 100644 index 00000000..9e9f8106 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminPostBlindRestoreTest.php @@ -0,0 +1,231 @@ + $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}"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/AutoHideTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AutoHideTest.php index 81cc5e0f..574e42cb 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/AutoHideTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/AutoHideTest.php @@ -89,8 +89,6 @@ class AutoHideTest extends ModuleTestCase ] ); - $this->ensureBoardPartitions($this->board->id); - // 이전 잔여 데이터 정리 DB::table('boards_report_logs')->delete(); DB::table('boards_reports')->delete(); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardCreationRegressionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardCreationRegressionTest.php new file mode 100644 index 00000000..0417d782 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardCreationRegressionTest.php @@ -0,0 +1,160 @@ +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 메서드가 남아있습니다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php index de5fa787..113936e6 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php @@ -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 호환성) + // 수동 정리 모드 } /** diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardPermissionTest.php index 10d3c07d..ed109c37 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardPermissionTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardPermissionTest.php @@ -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"); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/GroupingAndTriggerTypeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/GroupingAndTriggerTypeTest.php index 65129d17..261fa347 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/GroupingAndTriggerTypeTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/GroupingAndTriggerTypeTest.php @@ -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(); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostViewCountTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostViewCountTest.php index 9e25a77b..01b323ad 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostViewCountTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/PostViewCountTest.php @@ -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 시 diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReportTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReportTest.php index 7e52db90..3eb62dc9 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReportTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReportTest.php @@ -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(); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/BoardPermissionScopeServiceTest.php b/modules/_bundled/sirsoft-board/tests/Feature/BoardPermissionScopeServiceTest.php index 5c91bab3..a791e906 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/BoardPermissionScopeServiceTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/BoardPermissionScopeServiceTest.php @@ -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 diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CountSyncIntegrationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CountSyncIntegrationTest.php new file mode 100644 index 00000000..66fd6798 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/CountSyncIntegrationTest.php @@ -0,0 +1,334 @@ +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)); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php new file mode 100644 index 00000000..39500b8d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php @@ -0,0 +1,303 @@ + $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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/HookIntegrationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/HookIntegrationTest.php index 9c6d7bc1..432b7ace 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/HookIntegrationTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/HookIntegrationTest.php @@ -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 훅이 실행되는지 테스트 */ diff --git a/modules/_bundled/sirsoft-board/tests/Feature/PermissionScopeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/PermissionScopeTest.php index 1505f4b4..278a2c8a 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/PermissionScopeTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/PermissionScopeTest.php @@ -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; } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php index 52bd5c2c..6929634e 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php @@ -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(); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentCrudScenarioTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentCrudScenarioTest.php new file mode 100644 index 00000000..65612dd3 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentCrudScenarioTest.php @@ -0,0 +1,214 @@ + $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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php index 5cbfee96..7eae95c7 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php @@ -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, diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php index 2acc70a3..260393a6 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php @@ -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); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentCrudScenarioTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentCrudScenarioTest.php new file mode 100644 index 00000000..4e7db325 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentCrudScenarioTest.php @@ -0,0 +1,426 @@ + $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'); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentVerifyPasswordTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentVerifyPasswordTest.php new file mode 100644 index 00000000..40235dba --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentVerifyPasswordTest.php @@ -0,0 +1,145 @@ + $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"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostCooldownTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostCooldownTest.php new file mode 100644 index 00000000..1abae618 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostCooldownTest.php @@ -0,0 +1,182 @@ + $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']); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationTest.php new file mode 100644 index 00000000..3f78a453 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationTest.php @@ -0,0 +1,430 @@ +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)); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/ReportAbusePreventionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/ReportAbusePreventionTest.php index 628c76fd..2cc0d6fc 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/ReportAbusePreventionTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/ReportAbusePreventionTest.php @@ -78,8 +78,6 @@ class ReportAbusePreventionTest extends ModuleTestCase ] ); - $this->ensureBoardPartitions($this->board->id); - // 이전 잔여 데이터 정리 DB::table('boards_report_logs')->delete(); DB::table('boards_reports')->delete(); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostScenarioTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostScenarioTest.php new file mode 100644 index 00000000..f7667284 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostScenarioTest.php @@ -0,0 +1,310 @@ + $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()); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php index 71923af1..82dfe6df 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php @@ -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, diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php index 9e959f35..8f42c40d 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php @@ -16,7 +16,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase; * 사용자 공개 게시글 API 테스트 * * 타인의 공개 프로필에서 게시글 목록과 통계를 조회하는 API 테스트입니다. - * BoardTestCase를 통해 단일 테이블 파티션과 데이터 정리를 자동으로 처리합니다. + * BoardTestCase를 통해 테스트 보드와 데이터 정리를 자동으로 처리합니다. * * @group board * @group user-public-posts diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/UserReportApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/UserReportApiTest.php new file mode 100644 index 00000000..c045c123 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/UserReportApiTest.php @@ -0,0 +1,289 @@ + $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"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php index b4e89f5e..9070a89b 100644 --- a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php +++ b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php @@ -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; - } - } - } - } } \ No newline at end of file diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php index c91ab2b5..e6ce5b96 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php @@ -53,9 +53,6 @@ class AttachmentRepositoryTest extends ModuleTestCase 'notify_author_on_comment' => false, ] ); - - // 게시판 파티션 보장 - $this->ensureBoardPartitions($this->board->id); } /** diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/CountSyncListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/CountSyncListenerTest.php index 114c3428..60e4bc14 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/CountSyncListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/CountSyncListenerTest.php @@ -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가 증가하는지 확인합니다. */ diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php new file mode 100644 index 00000000..943f992d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php @@ -0,0 +1,309 @@ + $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]); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/UserNotificationSettingsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/UserNotificationSettingsListenerTest.php new file mode 100644 index 00000000..4d7dca42 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/UserNotificationSettingsListenerTest.php @@ -0,0 +1,314 @@ +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']); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Models/ReportModelScopeTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Models/ReportModelScopeTest.php new file mode 100644 index 00000000..1a89272e --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Models/ReportModelScopeTest.php @@ -0,0 +1,240 @@ + $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 + )); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/PostServiceGuestTokenTest.php b/modules/_bundled/sirsoft-board/tests/Unit/PostServiceGuestTokenTest.php new file mode 100644 index 00000000..36e7f325 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/PostServiceGuestTokenTest.php @@ -0,0 +1,110 @@ +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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/ReportServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/ReportServiceTest.php index 3c8099f3..ff22b110 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/ReportServiceTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/ReportServiceTest.php @@ -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); + } + /** * 테스트용 댓글 생성 헬퍼 * diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Rules/BoardRulesTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Rules/BoardRulesTest.php new file mode 100644 index 00000000..e55b8c28 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Rules/BoardRulesTest.php @@ -0,0 +1,314 @@ + $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)); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php new file mode 100644 index 00000000..f0ab54cd --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php @@ -0,0 +1,175 @@ + $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); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Upgrades/Upgrade_1_0_0_beta_2_Test.php b/modules/_bundled/sirsoft-board/tests/Unit/Upgrades/Upgrade_1_0_0_beta_2_Test.php new file mode 100644 index 00000000..1f0b6d74 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Upgrades/Upgrade_1_0_0_beta_2_Test.php @@ -0,0 +1,233 @@ + $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)); + } +}