fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정

중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의
하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로
통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가
서로 다른 강도로 검증하던 부분도 맞췄다.

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

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

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

공개 이슈: gnuboard/g7
This commit is contained in:
HeuJung
2026-07-24 16:17:44 +09:00
parent 9dda22f8e4
commit abb40ad210
74 changed files with 3880 additions and 192 deletions
@@ -29,6 +29,11 @@
- 게시판 이름을 바꿨을 때 활동 로그의 변경 항목 이름이 비어 보이던 문제를 수정했습니다.
- 게시판 환경설정 조회·저장·일괄 적용과 관리자 대시보드 조회 화면에서 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다.
- 환경설정을 여러 게시판에 일괄 적용할 때, 개별 저장 화면과 달리 글자 수·표시 시간 등의 값 범위 제한이 적용되지 않던 문제를 수정했습니다. 이제 일괄 적용도 개별 저장과 동일한 범위를 벗어난 값을 거부합니다.
- 다른 게시글의 주소로 댓글을 수정하거나 삭제할 수 있던 문제를 수정했습니다. 이제 댓글은 자기가 달린 게시글의 주소로만 수정·삭제됩니다. 관리자 화면의 댓글 수정·삭제·블라인드·복원도 동일하게 바로잡았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 댓글 수정 시 본문에 함께 보낸 값으로 댓글이 다른 게시글로 옮겨지거나 작성자·작성 시각 정보가 바뀔 수 있던 문제를 수정했습니다. 수정으로 반영되는 항목은 내용·비밀글 여부·상태뿐입니다.
- 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다.
## [1.0.2] - 2026-07-14
@@ -1525,7 +1525,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 게시판 관리자가 댓글 1건을 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `comments.write` 또는 `manage`입니다. 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글(비회원 댓글 포함), `admin.write`는 본인 댓글만 삭제할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `CommentService::deleteComment()`가 'admin' 컨텍스트로 삭제를 수행합니다.
**설명** 게시판 관리자가 댓글 1건을 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `comments.write` 또는 `manage`입니다. 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글(비회원 댓글 포함), `admin.write`는 본인 댓글만 삭제할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `CommentService::deleteComment()`가 'admin' 컨텍스트로 삭제를 수행합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PUT /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}
@@ -1572,7 +1572,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 게시판 관리자가 댓글 1건을 수정합니다. `auth:sanctum` + admin + 게시판별 `comments.write` 권한이 필요하며, 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글, `admin.write`는 본인 댓글만 수정할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `UpdateCommentRequest` 검증 값으로 `CommentService::updateComment()`가 갱신을 수행합니다.
**설명** 게시판 관리자가 댓글 1건을 수정합니다. `auth:sanctum` + admin + 게시판별 `comments.write` 권한이 필요하며, 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글, `admin.write`는 본인 댓글만 수정할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `UpdateCommentRequest` 검증 값으로 `CommentService::updateComment()`가 갱신을 수행합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}/blind
@@ -1700,7 +1700,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 게시판 관리자가 댓글을 블라인드 처리합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 선택적 `reason`(최대 1000자)을 사유로 받아 `CommentService::blindComment()`가 댓글을 숨김 처리하되 관리 목적으로 보존하며, 복원(restore)으로 되돌릴 수 있습니다.
**설명** 게시판 관리자가 댓글을 블라인드 처리합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 선택적 `reason`(최대 1000자)을 사유로 받아 `CommentService::blindComment()`가 댓글을 숨김 처리하되 관리 목적으로 보존하며, 복원(restore)으로 되돌릴 수 있습니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}/restore
@@ -1744,6 +1744,6 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 게시판 관리자가 블라인드 처리된 댓글을 복원합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 요청 본문의 선택적 `reason`을 사유로 받아 `CommentService::restoreComment()`가 블라인드 상태를 해제해 댓글을 다시 노출합니다.
**설명** 게시판 관리자가 블라인드 처리된 댓글을 복원합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 요청 본문의 선택적 `reason`을 사유로 받아 `CommentService::restoreComment()`가 블라인드 상태를 해제해 댓글을 다시 노출합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
@@ -3648,7 +3648,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
<!-- @generated:end -->
**설명** 게시글에 댓글(또는 대댓글)을 작성합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 현재 로그인 사용자를 작성자로 저장하고 요청 IP를 기록하며, 스팸 방지 쿨다운이 설정되어 있으면 작성 성공 후 쿨다운을 기록합니다.
**설명** 게시글에 댓글(또는 대댓글)을 작성합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 현재 로그인 사용자를 작성자로 저장하고 요청 IP를 기록하며, 스팸 방지 쿨다운이 설정되어 있으면 작성 성공 후 쿨다운을 기록합니다. `parent_id`는 경로의 `{postId}`에 속한 댓글이어야 하며, 다른 게시글의 댓글을 지정하면 422를 반환합니다. 저장되는 `depth`는 부모 댓글 `depth + 1`이고, 게시판 설정 `max_comment_depth`를 넘는 답글은 422로 거절됩니다.
### DELETE /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments/{commentId}
@@ -3703,7 +3703,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 댓글을 삭제합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canDelete` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 조건 미충족 시 403을 반환합니다.
**설명** 댓글을 삭제합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canDelete` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 조건 미충족 시 403을 반환합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PUT /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments/{commentId}
@@ -3749,7 +3749,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
<!-- @generated:end -->
**설명** 댓글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canUpdate` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 검증용 `password` 는 저장에서 제외됩니다.
**설명** 댓글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canUpdate` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 검증용 `password` 는 저장에서 제외됩니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다. 요청 본문에서는 `content`·`is_secret`·`status`만 반영되며, `post_id`·`parent_id`·`depth` 등 그 밖의 필드를 함께 보내도 무시됩니다.
### POST /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/reports
@@ -127,8 +127,8 @@ class CommentController extends AdminBaseController
public function update(UpdateCommentRequest $request, string $slug, int $postId, int $id): JsonResponse
{
try {
// 댓글 조회
$comment = $this->commentService->getComment($slug, $id);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $id, $postId);
// 권한 체크: 본인 댓글 또는 admin.manage
if ($response = $this->authorizeCommentModification($slug, $comment->user_id)) {
@@ -146,7 +146,7 @@ class CommentController extends AdminBaseController
}
$data = $request->validated();
$updatedComment = $this->commentService->updateComment($slug, $id, $data);
$updatedComment = $this->commentService->updateComment($slug, $id, $data, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.update_success',
@@ -170,8 +170,8 @@ class CommentController extends AdminBaseController
public function destroy(string $slug, int $postId, int $id): JsonResponse
{
try {
// 댓글 조회
$comment = $this->commentService->getComment($slug, $id);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $id, $postId);
// 권한 체크: 본인 댓글 또는 admin.manage
if ($response = $this->authorizeCommentModification($slug, $comment->user_id)) {
@@ -188,7 +188,7 @@ class CommentController extends AdminBaseController
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$this->commentService->deleteComment($slug, $id, 'admin');
$this->commentService->deleteComment($slug, $id, 'admin', $postId);
return $this->success('sirsoft-board::messages.comment.delete_success');
} catch (ModelNotFoundException) {
@@ -223,7 +223,7 @@ class CommentController extends AdminBaseController
$validated = $request->validated();
$reason = $validated['reason'] ?? '';
$comment = $this->commentService->blindComment($slug, $id, $reason);
$comment = $this->commentService->blindComment($slug, $id, $reason, null, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.blind_success',
@@ -259,7 +259,7 @@ class CommentController extends AdminBaseController
}
$reason = $request->validated()['reason'] ?? null;
$comment = $this->commentService->restoreComment($slug, $id, $reason);
$comment = $this->commentService->restoreComment($slug, $id, $reason, null, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.restore_success',
@@ -5,14 +5,14 @@ namespace Modules\Sirsoft\Board\Http\Controllers\User;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest;
use Modules\Sirsoft\Board\Http\Resources\CommentResource;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
/**
@@ -26,9 +26,11 @@ class CommentController extends PublicBaseController
* CommentController 생성자
*
* @param CommentService $commentService 댓글 서비스
* @param BoardService $boardService 게시판 서비스
*/
public function __construct(
private CommentService $commentService
private CommentService $commentService,
private BoardService $boardService
) {
parent::__construct();
}
@@ -43,7 +45,11 @@ class CommentController extends PublicBaseController
public function index(string $slug, int $postId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
@@ -73,13 +79,19 @@ class CommentController extends PublicBaseController
public function store(StoreCommentRequest $request, string $slug, int $postId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$data = $request->validated();
// 경로의 게시글을 SSoT 로 고정 (요청 본문 값이 경로와 갈라지지 않도록)
$data['post_id'] = $postId;
$data['ip_address'] = $request->ip();
// user_id 설정 (인증 필수)
@@ -119,13 +131,18 @@ class CommentController extends PublicBaseController
public function update(UpdateCommentRequest $request, string $slug, int $postId, int $commentId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$comment = $this->commentService->getComment($slug, $commentId);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $commentId, $postId);
// 권한 확인 (Service에서 처리)
$canUpdate = $this->commentService->canUpdate(
@@ -139,9 +156,9 @@ class CommentController extends PublicBaseController
return $this->forbidden('sirsoft-board::messages.comment.update_forbidden');
}
// password는 검증용이므로 제거
$data = $request->except('password');
$updatedComment = $this->commentService->updateComment($slug, $commentId, $data);
// 검증된 필드만 반영 (미검증 입력의 대량 할당 차단). password는 검증용이므로 제거
$data = collect($request->validated())->except('password')->toArray();
$updatedComment = $this->commentService->updateComment($slug, $commentId, $data, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.update_success',
@@ -165,13 +182,18 @@ class CommentController extends PublicBaseController
public function destroy(string $slug, int $postId, int $commentId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$comment = $this->commentService->getComment($slug, $commentId);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $commentId, $postId);
// 비회원인 경우 password 파라미터 필요
$password = request()->input('password');
@@ -188,7 +210,7 @@ class CommentController extends PublicBaseController
return $this->forbidden('sirsoft-board::messages.comment.delete_forbidden');
}
$this->commentService->deleteComment($slug, $commentId, 'user');
$this->commentService->deleteComment($slug, $commentId, 'user', $postId);
return $this->success(
'sirsoft-board::messages.comment.delete_success'
@@ -203,17 +225,13 @@ class CommentController extends PublicBaseController
/**
* 비회원 댓글 비밀번호를 검증합니다.
*
* @param Request $request 요청
* @param VerifyCommentPasswordRequest $request 비밀번호 확인 요청
* @param string $slug 게시판 slug
* @param int $commentId 댓글 ID
* @return JsonResponse 비밀번호 검증 결과 응답
*/
public function verifyPassword(Request $request, string $slug, int $commentId): JsonResponse
public function verifyPassword(VerifyCommentPasswordRequest $request, string $slug, int $commentId): JsonResponse
{
$request->validate([
'password' => ['required', 'string'],
]);
try {
$comment = $this->commentService->getComment($slug, $commentId);
@@ -76,7 +76,8 @@ class StoreCommentRequest extends FormRequest
'parent_id' => [
'nullable',
'integer',
new CommentValidationRule($slug, 'parent_comment'),
// 부모 댓글은 요청 경로의 게시글에 속해야 한다 (교차 게시글 부모 차단)
new CommentValidationRule($slug, 'parent_comment', (int) $this->route('postId')),
],
// depth는 서비스에서 자동 계산되므로 요청 데이터에서 받지 않음 (보안상 이유)
'is_secret' => ['boolean'],
@@ -0,0 +1,57 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 비회원 댓글 비밀번호 확인 요청 폼 검증
*/
class VerifyCommentPasswordRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool 권한 검증 결과 (권한 체크는 미들웨어 위임, 항상 true)
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
return [
'password' => ['required', 'string'],
];
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string> 규칙별 다국어 메시지
*/
public function messages(): array
{
return [
'password.required' => __('sirsoft-board::validation.comment.password.required'),
];
}
/**
* 검증할 필드의 이름을 커스터마이징
*
* @return array<string, string> 필드별 표시명
*/
public function attributes(): array
{
return [
'password' => __('sirsoft-board::validation.attributes.comment.password'),
];
}
}
@@ -252,16 +252,20 @@ class CommentRepository implements CommentRepositoryInterface
/**
* ID로 댓글을 조회합니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment|null 댓글 또는 null
*/
public function find(string $slug, int $id): ?Comment
public function find(string $slug, int $id, ?int $postId = null): ?Comment
{
$board = Board::where('slug', $slug)->first();
return Comment::query()
->where('board_id', $board?->id)
->when($postId !== null, fn ($query) => $query->where('post_id', $postId))
->withTrashed()
->with(['user'])
->find($id);
@@ -270,18 +274,22 @@ class CommentRepository implements CommentRepositoryInterface
/**
* ID로 댓글을 조회하며, 없으면 예외를 발생시킵니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment 댓글 모델
*
* @throws ModelNotFoundException
*/
public function findOrFail(string $slug, int $id): Comment
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
{
$board = Board::where('slug', $slug)->firstOrFail();
return Comment::query()
->where('board_id', $board->id)
->when($postId !== null, fn ($query) => $query->where('post_id', $postId))
->withTrashed()
->with(['user'])
->findOrFail($id);
@@ -19,6 +19,8 @@ interface CommentRepositoryInterface
* @param int $postId 게시글 ID
* @param bool $withTrashed 삭제된 댓글 포함 여부 (기본값: false)
* @param string $orderDirection 정렬 방향 (ASC 또는 DESC, 기본값: DESC)
* @param string|null $scopePermission 스코프 권한 식별자 (null 이면 스코프 필터 미적용)
* @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @return Collection 정렬된 댓글 컬렉션
*/
public function getByPostId(string $slug, int $postId, bool $withTrashed = false, string $orderDirection = 'DESC', ?string $scopePermission = null, ?int $boardId = null): Collection;
@@ -35,22 +37,28 @@ interface CommentRepositoryInterface
/**
* ID로 댓글을 조회합니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment|null 댓글 모델 또는 null
*/
public function find(string $slug, int $id): ?Comment;
public function find(string $slug, int $id, ?int $postId = null): ?Comment;
/**
* ID로 댓글을 조회하며, 없으면 예외를 발생시킵니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment 댓글 모델
*
* @throws ModelNotFoundException
*/
public function findOrFail(string $slug, int $id): Comment;
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment;
/**
* 댓글을 수정합니다.
@@ -94,6 +102,7 @@ interface CommentRepositoryInterface
* @param string $status 변경할 상태 (blinded/deleted)
* @param array $actionLog 작업 이력 데이터
* @param string|null $triggerType 트리거 유형 (report, admin, auto_hide 등)
* @return Comment 상태가 변경된 댓글 모델
*
* @throws ModelNotFoundException
*/
@@ -23,10 +23,12 @@ class CommentValidationRule implements ValidationRule
*
* @param string $slug 게시판 슬러그
* @param string $validationType 검증 타입 ('post' 또는 'parent_comment')
* @param int|null $postId 상위 게시글 ID (parent_comment 검증 시 부모 댓글의 소속 게시글 확인용)
*/
public function __construct(
private string $slug,
private string $validationType = 'post'
private string $validationType = 'post',
private ?int $postId = null
) {}
/**
@@ -89,7 +91,10 @@ class CommentValidationRule implements ValidationRule
{
$board = Board::where('slug', $this->slug)->first();
$parentComment = $board
? Comment::where('board_id', $board->id)->withTrashed()->find($parentId)
? Comment::where('board_id', $board->id)
->when($this->postId !== null, fn ($query) => $query->where('post_id', $this->postId))
->withTrashed()
->find($parentId)
: null;
if (! $parentComment) {
@@ -89,12 +89,14 @@ class CommentService
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 댓글 모델
*
* @throws ModelNotFoundException
*/
public function getComment(string $slug, int $id): Comment
public function getComment(string $slug, int $id, ?int $postId = null): Comment
{
return $this->commentRepository->findOrFail($slug, $id);
return $this->commentRepository->findOrFail($slug, $id, $postId);
}
/**
@@ -228,6 +230,7 @@ class CommentService
*
* @param string $slug 게시판 슬러그
* @param array $data 댓글 생성 데이터
* @return Comment 생성된 댓글 모델
*
* @throws \Exception 블라인드/삭제된 게시글에 댓글 작성 시
*/
@@ -251,10 +254,11 @@ class CommentService
// depth 자동 계산 (답글인 경우)
if (! empty($data['parent_id'])) {
$parentComment = $this->commentRepository->find($slug, $data['parent_id']);
// 같은 게시글에 속한 부모 댓글만 인정 (교차 게시글 부모 차단)
$parentComment = $this->commentRepository->find($slug, $data['parent_id'], (int) $data['post_id']);
if ($parentComment) {
// 부모 댓글의 depth + 1 (최대 5까지)
$data['depth'] = min(($parentComment->depth ?? 0) + 1, 5);
// 부모 댓글의 depth + 1 — 상한은 CommentValidationRule 이 게시판 설정으로 검증
$data['depth'] = ($parentComment->depth ?? 0) + 1;
} else {
// 부모 댓글을 찾을 수 없으면 0으로 설정
$data['depth'] = 0;
@@ -282,12 +286,14 @@ class CommentService
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param array $data 수정할 데이터
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 수정된 댓글 모델
*
* @throws ModelNotFoundException
*/
public function updateComment(string $slug, int $id, array $data): Comment
public function updateComment(string $slug, int $id, array $data, ?int $postId = null): Comment
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 훅: before_update
HookManager::doAction('sirsoft-board.comment.before_update', $comment, $data, $slug);
@@ -312,12 +318,14 @@ class CommentService
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param string|null $triggerType 트리거 유형 (admin, user, report 등)
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return bool 삭제 성공 여부
*
* @throws ModelNotFoundException
*/
public function deleteComment(string $slug, int $id, ?string $triggerType = null): bool
public function deleteComment(string $slug, int $id, ?string $triggerType = null, ?int $postId = null): bool
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 훅: before_delete
HookManager::doAction('sirsoft-board.comment.before_delete', $comment, $slug);
@@ -345,12 +353,14 @@ class CommentService
* @param int $id 댓글 ID
* @param string $reason 블라인드 사유
* @param string|null $triggerType 트리거 유형 (report, admin, auto_hide 등)
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 블라인드 처리된 댓글 모델
*
* @throws ModelNotFoundException
*/
public function blindComment(string $slug, int $id, string $reason, ?string $triggerType = null): Comment
public function blindComment(string $slug, int $id, string $reason, ?string $triggerType = null, ?int $postId = null): Comment
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 멱등성: 이미 블라인드 상태이면 중복 처리 방지
if ($comment->status === PostStatus::Blinded) {
@@ -379,12 +389,14 @@ class CommentService
* @param int $id 댓글 ID
* @param string|null $reason 복원 사유
* @param string|null $triggerType 트리거 유형 (report, admin, auto_hide 등)
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 복원된 댓글 모델
*
* @throws ModelNotFoundException
*/
public function restoreComment(string $slug, int $id, ?string $reason = null, ?string $triggerType = null): Comment
public function restoreComment(string $slug, int $id, ?string $reason = null, ?string $triggerType = null, ?int $postId = null): Comment
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 멱등성: 이미 게시됨 상태이면 중복 처리 방지
if ($comment->status === PostStatus::Published) {
@@ -0,0 +1,205 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 관리자 댓글 API 상위 게시글 스코프 검증 테스트
*
* 검증 목적:
* - update / destroy / blind / restore 4종 모두 교차 게시글 URL 로 접근할 수 없다
* - 정상 경로로는 기존과 동일하게 동작한다
*
* @group board
* @group admin
* @group comment
* @group scope
*/
class CommentScopeTest extends BoardTestCase
{
private User $adminWithManage;
protected function getTestBoardSlug(): string
{
return 'admin-comment-scope';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '관리자 댓글 스코프 게시판', 'en' => 'Admin Comment Scope Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$slug = $this->board->slug;
$this->adminWithManage = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.manage",
"sirsoft-board.{$slug}.admin.comments.write",
]);
}
/**
* 교차 게시글 URL 로 댓글 수정 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_update_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA, ['content' => '원래 내용']);
$this->actingAs($this->adminWithManage)
->putJson($this->url($postB, $commentId), ['content' => '교차 수정 시도'])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '원래 내용',
]);
}
/**
* 교차 게시글 URL 로 댓글 삭제 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_delete_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA);
$this->actingAs($this->adminWithManage)
->deleteJson($this->url($postB, $commentId))
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'deleted_at' => null,
]);
}
/**
* 교차 게시글 URL 로 댓글 블라인드 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_blind_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA, ['status' => 'published']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postB, $commentId).'/blind', ['reason' => '교차 블라인드 시도'])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'status' => 'published',
]);
}
/**
* 교차 게시글 URL 로 댓글 복원 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_restore_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA, ['status' => 'blinded']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postB, $commentId).'/restore', [])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'status' => 'blinded',
]);
}
/**
* 정상 경로로는 4종 모두 동작한다 (회귀 방지)
*
* @scenario resource=board_comment_admin, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_own_post_url_still_works_for_all_operations(): void
{
$postId = $this->createTestPost();
$updateTarget = $this->createTestComment($postId, ['content' => '원래 내용']);
$this->actingAs($this->adminWithManage)
->putJson($this->url($postId, $updateTarget), ['content' => '정상 수정'])
->assertStatus(200);
$blindTarget = $this->createTestComment($postId, ['status' => 'published']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postId, $blindTarget).'/blind', ['reason' => '정상 블라인드'])
->assertStatus(200);
$restoreTarget = $this->createTestComment($postId, ['status' => 'blinded']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postId, $restoreTarget).'/restore', [])
->assertStatus(200);
$deleteTarget = $this->createTestComment($postId);
$this->actingAs($this->adminWithManage)
->deleteJson($this->url($postId, $deleteTarget))
->assertStatus(200);
$this->assertDatabaseHas('board_comments', ['id' => $updateTarget, 'content' => '정상 수정']);
$this->assertDatabaseHas('board_comments', ['id' => $blindTarget, 'status' => 'blinded']);
$this->assertDatabaseHas('board_comments', ['id' => $restoreTarget, 'status' => 'published']);
$this->assertSoftDeleted('board_comments', ['id' => $deleteTarget]);
}
/**
* 서로 다른 두 게시글을 생성합니다.
*
* @return array{0: int, 1: int} [게시글 A ID, 게시글 B ID]
*/
private function createTwoPosts(): array
{
return [
$this->createTestPost(['title' => '게시글 A']),
$this->createTestPost(['title' => '게시글 B']),
];
}
/**
* 관리자 댓글 API URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return string 댓글 엔드포인트 URL
*/
private function url(int $postId, int $commentId): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/admin/board/{$slug}/posts/{$postId}/comments/{$commentId}";
}
}
@@ -0,0 +1,172 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
require_once __DIR__.'/../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 대댓글 깊이 제한 테스트
*
* 검증 목적:
* - 저장되는 depth 가 항상 부모 depth + 1 (하드코딩 상한 없음)
* - 게시판 설정 max_comment_depth 를 넘는 답글은 422
* - max 를 낮게(3) / 높게(10) 설정한 두 경우 모두 설정값을 따른다
*
* @group board
* @group comment
* @group depth
*/
class CommentDepthLimitTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'comment-depth-limit';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 깊이 제한 게시판', 'en' => 'Comment Depth Limit Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$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-depth@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* max_comment_depth=10 게시판에서 depth 는 1..10 까지 정확히 증가한다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_equals_parent_depth_plus_one, depth_follows_board_setting_not_literal
*/
public function test_depth_increments_up_to_configured_max(): void
{
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 0]);
for ($expectedDepth = 1; $expectedDepth <= 10; $expectedDepth++) {
$response = $this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => "{$expectedDepth}단계 답글",
'parent_id' => $parentId,
]);
$response->assertStatus(201);
$parentId = (int) $response->json('data.id');
$this->assertDatabaseHas('board_comments', [
'id' => $parentId,
'depth' => $expectedDepth,
]);
}
}
/**
* max_comment_depth=10 게시판에서 11단계 답글은 422
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_beyond_board_max_rejected_422
*/
public function test_reply_beyond_configured_max_is_rejected(): void
{
$postId = $this->createTestPost();
// depth 10 인 부모 댓글을 직접 생성 → 11단계 시도
$parentId = $this->createTestComment($postId, ['depth' => 10]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '11단계 답글 시도',
'parent_id' => $parentId,
])
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
}
/**
* max_comment_depth=3 게시판에서 4단계 답글은 422
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_beyond_board_max_rejected_422, depth_follows_board_setting_not_literal
*/
public function test_reply_beyond_lowered_max_is_rejected(): void
{
$this->updateBoardSettings(['max_comment_depth' => 3]);
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 3]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '4단계 답글 시도',
'parent_id' => $parentId,
])
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
}
/**
* max_comment_depth=3 게시판에서 3단계까지는 정상 생성된다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_equals_parent_depth_plus_one, depth_follows_board_setting_not_literal
*/
public function test_reply_within_lowered_max_is_allowed(): void
{
$this->updateBoardSettings(['max_comment_depth' => 3]);
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 2]);
$response = $this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '3단계 답글',
'parent_id' => $parentId,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('board_comments', [
'id' => (int) $response->json('data.id'),
'depth' => 3,
]);
}
/**
* 댓글 생성 엔드포인트 URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @return string 댓글 생성 URL
*/
private function storeUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
}
@@ -0,0 +1,160 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3;
/**
* 1.0.3 댓글 계층 오염 데이터 정리 업그레이드 스텝 테스트
*
* 검증 목적:
* - 교차 게시글 부모를 가진 고아 댓글이 최상위로 승격된다 (본문 보존)
* - depth 가 루트부터 부모 depth + 1 로 재계산된다
* - 재실행해도 결과가 동일하다 (멱등)
*
* @group board
* @group upgrade
*/
class CommentHierarchyCleanupTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'comment-hierarchy-cleanup';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 계층 정리 게시판', 'en' => 'Comment Hierarchy Cleanup Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
/**
* 교차 게시글 부모 댓글이 최상위로 승격되고 본문은 보존된다
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects existing_cross_post_parent_detached_content_preserved
*/
public function test_cross_post_parent_is_detached_and_content_preserved(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInA = $this->createTestComment($postA, ['depth' => 0]);
$orphan = $this->createTestComment($postB, [
'parent_id' => $parentInA,
'depth' => 1,
'content' => '보존되어야 하는 본문',
]);
$this->runCleanup();
$row = DB::table('board_comments')->where('id', $orphan)->first();
$this->assertNull($row->parent_id, '교차 게시글 부모는 해제되어야 합니다.');
$this->assertSame(0, (int) $row->depth, '승격된 댓글의 depth 는 0 이어야 합니다.');
$this->assertSame('보존되어야 하는 본문', $row->content, '본문은 보존되어야 합니다.');
$this->assertSame($postB, (int) $row->post_id, 'post_id 는 바뀌지 않아야 합니다.');
}
/**
* 클램프로 뭉개진 depth 가 부모 depth + 1 로 재계산된다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects existing_clamped_depth_recalculated
*/
public function test_clamped_depth_is_recalculated_from_root(): void
{
$postId = $this->createTestPost();
// 8단계 체인을 만들되, 6단계 이후 depth 를 5 로 뭉개 둔다 (구 클램프 재현)
$parentId = null;
$ids = [];
for ($i = 0; $i < 8; $i++) {
$storedDepth = min($i, 5);
$parentId = $this->createTestComment($postId, [
'parent_id' => $parentId,
'depth' => $storedDepth,
]);
$ids[$i] = $parentId;
}
$this->runCleanup();
foreach ($ids as $expectedDepth => $id) {
$this->assertSame(
$expectedDepth,
(int) DB::table('board_comments')->where('id', $id)->value('depth'),
"{$expectedDepth} 단계 댓글의 depth 가 재계산되어야 합니다."
);
}
}
/**
* 재실행해도 결과가 동일하다 (멱등)
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects cleanup_is_idempotent
*/
public function test_cleanup_is_idempotent(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInA = $this->createTestComment($postA, ['depth' => 0]);
$this->createTestComment($postB, ['parent_id' => $parentInA, 'depth' => 1]);
$chainRoot = $this->createTestComment($postA, ['parent_id' => $parentInA, 'depth' => 5]);
$this->createTestComment($postA, ['parent_id' => $chainRoot, 'depth' => 5]);
$this->runCleanup();
$first = $this->snapshot();
$this->runCleanup();
$second = $this->snapshot();
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
}
/**
* 1.0.3 정리 마이그레이션을 실행합니다.
*/
private function runCleanup(): void
{
$context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade');
$step = new Upgrade_1_0_3;
$step->run($context);
}
/**
* 이 게시판 댓글의 (id, parent_id, depth) 스냅샷을 반환합니다.
*
* @return array<int, array{id: int, parent_id: int|null, depth: int}> 스냅샷 배열
*/
private function snapshot(): array
{
return DB::table('board_comments')
->where('board_id', $this->board->id)
->orderBy('id')
->get(['id', 'parent_id', 'depth'])
->map(fn ($row) => [
'id' => (int) $row->id,
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
])
->all();
}
}
@@ -0,0 +1,131 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 댓글 수정 mass-assignment 방어 테스트
*
* 검증 목적:
* - UpdateCommentRequest 가 정의하지 않은 필드(post_id, board_id, user_id, parent_id,
* author_name, trigger_type, action_logs, depth, ip_address, replies_count)를 요청에
* 동봉해도 저장되지 않는다 — Comment::$fillable 의 초과 10개 전수
* - 정의된 필드(content, is_secret, status)는 정상 반영된다
*
* @group board
* @group comment
* @group security
*/
class CommentMassAssignmentTest extends BoardTestCase
{
private User $memberUser;
private User $otherUser;
protected function getTestBoardSlug(): string
{
return 'comment-mass-assignment';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 대량할당 게시판', 'en' => 'Comment Mass Assignment Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$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' => 'mass-assign-owner@test.com']);
$this->otherUser = User::factory()->create(['email' => 'mass-assign-other@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
$this->otherUser->roles()->attach($userRole->id);
}
}
/**
* 검증되지 않은 필드는 저장되지 않는다
*
* @scenario resource=board_comment_user, parent_scope=matching, actor=member
*
* @effects unvalidated_fields_not_persisted, sibling_resource_state_unchanged
*/
public function test_update_ignores_fields_outside_form_request(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$siblingComment = $this->createTestComment($postA, ['depth' => 0]);
$commentId = $this->createTestComment($postA, [
'user_id' => $this->memberUser->id,
'author_name' => '원래 작성자',
'content' => '원래 내용',
'depth' => 0,
'ip_address' => '127.0.0.1',
]);
$before = DB::table('board_comments')->where('id', $commentId)->first();
$this->actingAs($this->memberUser)
->putJson($this->url($postA, $commentId), [
'content' => '수정된 내용',
// 아래는 UpdateCommentRequest 에 정의되지 않은 필드 — 전부 무시되어야 함
'post_id' => $postB,
'board_id' => $this->board->id + 999,
'user_id' => $this->otherUser->id,
'parent_id' => $siblingComment,
'author_name' => '탈취된 작성자',
'trigger_type' => 'admin',
'action_logs' => [['action' => 'forged']],
'depth' => 9,
'ip_address' => '10.0.0.1',
'replies_count' => 100,
])
->assertStatus(200);
$after = DB::table('board_comments')->where('id', $commentId)->first();
$this->assertSame('수정된 내용', $after->content, '검증된 필드는 반영되어야 합니다.');
$this->assertEquals($before->post_id, $after->post_id, 'post_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->board_id, $after->board_id, 'board_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->user_id, $after->user_id, 'user_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->parent_id, $after->parent_id, 'parent_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->author_name, $after->author_name, 'author_name 이 변경되면 안 됩니다.');
$this->assertEquals($before->trigger_type, $after->trigger_type, 'trigger_type 이 변경되면 안 됩니다.');
$this->assertEquals($before->action_logs, $after->action_logs, 'action_logs 가 변경되면 안 됩니다.');
$this->assertEquals($before->depth, $after->depth, 'depth 가 변경되면 안 됩니다.');
$this->assertEquals($before->ip_address, $after->ip_address, 'ip_address 가 변경되면 안 됩니다.');
$this->assertEquals($before->replies_count, $after->replies_count, 'replies_count 가 변경되면 안 됩니다.');
}
/**
* 사용자 댓글 API URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return string 댓글 수정 URL
*/
private function url(int $postId, int $commentId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}";
}
}
@@ -0,0 +1,157 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 대댓글 부모-자식 무결성 테스트
*
* 검증 목적:
* - 다른 게시글의 댓글을 parent_id 로 지정할 수 없다 (HTTP 경로 422)
* - Service 직접 호출 경로에서도 타 게시글 부모로 depth 가 계산되지 않는다
*
* @group board
* @group comment
* @group integrity
*/
class CommentParentIntegrityTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'comment-parent-integrity';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 부모 무결성 게시판', 'en' => 'Comment Parent Integrity Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$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' => 'parent-integrity@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* 다른 게시글의 댓글을 parent_id 로 지정하면 422
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects foreign_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_reply_to_comment_of_other_post(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInPostA = $this->createTestComment($postA, ['depth' => 0]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postB), [
'content' => '교차 부모 답글 시도',
'parent_id' => $parentInPostA,
])
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertDatabaseMissing('board_comments', [
'post_id' => $postB,
'parent_id' => $parentInPostA,
]);
}
/**
* 같은 게시글의 댓글을 parent_id 로 지정하면 201 (회귀 방지)
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds, depth_equals_parent_depth_plus_one
*/
public function test_can_reply_to_comment_of_same_post(): void
{
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 0]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '정상 답글',
'parent_id' => $parentId,
])
->assertStatus(201);
$this->assertDatabaseHas('board_comments', [
'post_id' => $postId,
'parent_id' => $parentId,
'depth' => 1,
]);
}
/**
* Service 직접 호출 시에도 타 게시글 부모로 depth 가 계산되지 않는다
*
* FormRequest 를 우회하는 훅/내부 호출 경로에 대한 2차 방어를 검증합니다.
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects foreign_parent_rejected_422
*/
public function test_service_does_not_inherit_depth_from_other_post_parent(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
// 게시글 A 에 depth 3 인 부모 댓글
$parentInPostA = $this->createTestComment($postA, ['depth' => 3]);
/** @var CommentService $service */
$service = app(CommentService::class);
$comment = $service->createComment($this->board->slug, [
'post_id' => $postB,
'parent_id' => $parentInPostA,
'content' => 'Service 직접 호출 교차 부모',
'author_name' => '테스트',
'ip_address' => '127.0.0.1',
]);
$this->assertSame(
0,
(int) $comment->depth,
'타 게시글 부모의 depth(3)를 상속해서는 안 됩니다.'
);
}
/**
* 댓글 생성 엔드포인트 URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @return string 댓글 생성 URL
*/
private function storeUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
}
@@ -0,0 +1,168 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 사용자 댓글 API 상위 게시글 스코프 검증 테스트
*
* 검증 목적:
* - 게시글 A 의 댓글을 게시글 B 의 URL 로 수정/삭제할 수 없다 (교차 경로 차단)
* - 정상 경로(자기 게시글 URL)로는 기존과 동일하게 수정/삭제된다
*
* @group board
* @group comment
* @group scope
*/
class CommentScopeTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'comment-scope';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 스코프 테스트 게시판', 'en' => 'Comment Scope Test Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$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-scope-member@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* 게시글 B 의 URL 로 게시글 A 의 댓글을 수정할 수 없다 → 404
*
* @scenario resource=board_comment_user, parent_scope=mismatched, actor=member
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_update_comment_through_other_post_url(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$commentId = $this->createTestComment($postA, [
'user_id' => $this->memberUser->id,
'content' => '원래 내용',
]);
$this->actingAs($this->memberUser)
->putJson($this->url($postB, $commentId), ['content' => '교차 경로 수정 시도'])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '원래 내용',
]);
}
/**
* 게시글 B 의 URL 로 게시글 A 의 댓글을 삭제할 수 없다 → 404
*
* @scenario resource=board_comment_user, parent_scope=mismatched, actor=member
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_delete_comment_through_other_post_url(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$commentId = $this->createTestComment($postA, [
'user_id' => $this->memberUser->id,
]);
$this->actingAs($this->memberUser)
->deleteJson($this->url($postB, $commentId))
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'deleted_at' => null,
]);
}
/**
* 정상 경로(자기 게시글 URL)로는 댓글이 수정된다 → 200
*
* @scenario resource=board_comment_user, parent_scope=matching, actor=member
*
* @effects matching_scope_still_succeeds
*/
public function test_can_update_comment_through_own_post_url(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->memberUser->id,
'content' => '원래 내용',
]);
$this->actingAs($this->memberUser)
->putJson($this->url($postId, $commentId), ['content' => '정상 수정'])
->assertStatus(200);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '정상 수정',
]);
}
/**
* 정상 경로(자기 게시글 URL)로는 댓글이 삭제된다 → 200
*
* @scenario resource=board_comment_user, parent_scope=matching, actor=member
*
* @effects matching_scope_still_succeeds
*/
public function test_can_delete_comment_through_own_post_url(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->memberUser->id,
]);
$this->actingAs($this->memberUser)
->deleteJson($this->url($postId, $commentId))
->assertStatus(200);
$this->assertSoftDeleted('board_comments', ['id' => $commentId]);
}
/**
* 사용자 댓글 API URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return string 댓글 수정/삭제 엔드포인트 URL
*/
private function url(int $postId, int $commentId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}";
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Sirsoft\Board\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Board 모듈 1.0.3 업그레이드 스텝
*
* 댓글 계층 오염 데이터를 정리한다.
*
* - 다른 게시글의 댓글을 부모로 가진 고아 댓글: 부모 연결 해제(최상위로 승격)
* - 부모 depth + 1 과 어긋난 depth: 루트부터 재계산
* - 게시판 설정 max_comment_depth 초과 댓글: 리포트만 (자동 이동 없음)
*
* 모든 비즈니스 로직은 data/1.0.3/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path A
*/
class Upgrade_1_0_3 extends AbstractUpgradeStep {}
@@ -0,0 +1,89 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 다른 게시글의 댓글을 부모로 가진 고아 댓글의 부모 연결을 해제합니다.
*
* 부모 댓글의 소속 게시글을 검증하지 않던 시기에, 게시글 A 의 댓글을 게시글 B 댓글의
* parent_id 로 지정하면 그대로 저장되었습니다. 이렇게 만들어진 댓글은 어느 게시글의
* 목록에도 나타나지 않는 고아 상태가 됩니다.
*
* 본문 손실을 피하기 위해 삭제하지 않고 parent_id 를 NULL 로 되돌려 최상위 댓글로
* 승격시킵니다. depth 는 후속 마이그레이션(02_RecalculateCommentDepth)이 재계산합니다.
*
* idempotent: 교차 부모가 없으면 no-op. V-1 안전: Facades\DB/Schema 만 사용.
*/
class ReparentCrossPostComments implements DataMigration
{
private const TABLE = 'board_comments';
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'ReparentCrossPostComments';
}
/**
* 교차 게시글 부모를 가진 댓글의 부모 연결을 해제합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE)
|| ! Schema::hasColumn(self::TABLE, 'parent_id')
|| ! Schema::hasColumn(self::TABLE, 'post_id')) {
$context->logger->info('[board:1.0.3] 댓글 부모 정리 — 대상 스키마 부재로 스킵');
return;
}
$orphans = DB::table(self::TABLE.' as c')
->join(self::TABLE.' as p', 'c.parent_id', '=', 'p.id')
->whereColumn('c.post_id', '<>', 'p.post_id')
->select('c.id', 'c.post_id', 'c.parent_id', 'c.depth', 'p.post_id as parent_post_id')
->orderBy('c.id')
->get();
if ($orphans->isEmpty()) {
$context->logger->info('[board:1.0.3] 교차 게시글 부모 댓글 없음 — 정리 스킵');
return;
}
// 변경 전 스냅샷 기록 (복구 근거)
foreach ($orphans as $row) {
$context->logger->info(sprintf(
'[board:1.0.3] 스냅샷 comment_id=%d post_id=%d parent_id=%d parent_post_id=%d depth=%d',
$row->id,
$row->post_id,
$row->parent_id,
$row->parent_post_id,
(int) $row->depth
));
}
$ids = $orphans->pluck('id')->all();
foreach (array_chunk($ids, 500) as $chunk) {
DB::table(self::TABLE)
->whereIn('id', $chunk)
->update(['parent_id' => null, 'depth' => 0]);
}
$context->logger->info(sprintf(
'[board:1.0.3] 교차 게시글 부모 댓글 %d 건을 최상위 댓글로 승격 완료',
count($ids)
));
}
}
@@ -0,0 +1,205 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 댓글 depth 를 루트부터 BFS 로 재계산합니다.
*
* depth 를 5 로 고정 클램프하던 시기에 6단계 이후 답글이 모두 depth 5 로 저장되어,
* 화면상 들여쓰기가 무너지고 부모 depth + 1 규칙이 깨진 행이 남아 있습니다.
*
* 최상위 댓글(parent_id IS NULL)을 depth 0 으로 두고 자식 세대마다 +1 을 부여합니다.
* 콘텐츠는 건드리지 않으며 depth 컬럼만 갱신하므로 결정론적입니다.
*
* idempotent: 이미 정합한 행은 갱신 대상에서 제외되어 재실행해도 결과가 같습니다.
* 순환 참조가 남아 있어도 방문 ID 집합으로 유한 종료합니다.
* V-1 안전: Facades\DB/Schema 만 사용.
*/
class RecalculateCommentDepth implements DataMigration
{
private const TABLE = 'board_comments';
/** @var int 세대 순회 상한 (순환 참조 방어) */
private const MAX_LEVELS = 1000;
/** @var int whereIn 청크 크기 */
private const CHUNK = 500;
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'RecalculateCommentDepth';
}
/**
* 댓글 depth 를 루트부터 재계산합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE)
|| ! Schema::hasColumn(self::TABLE, 'parent_id')
|| ! Schema::hasColumn(self::TABLE, 'depth')) {
$context->logger->info('[board:1.0.3] depth 재계산 — 대상 스키마 부재로 스킵');
return;
}
$updated = $this->assignRootDepth($context);
$visited = [];
$parentIds = DB::table(self::TABLE)->whereNull('parent_id')->pluck('id')->all();
foreach ($parentIds as $id) {
$visited[(int) $id] = true;
}
$level = 0;
while ($parentIds !== [] && $level < self::MAX_LEVELS) {
$level++;
$childIds = $this->childIdsOf($parentIds, $visited);
if ($childIds === []) {
break;
}
$updated += $this->assignDepth($childIds, $level, $context);
$parentIds = $childIds;
}
if ($level >= self::MAX_LEVELS) {
$context->logger->warning(sprintf(
'[board:1.0.3] depth 재계산이 세대 상한(%d)에 도달 — 순환 참조 잔존 가능. 수동 확인 필요',
self::MAX_LEVELS
));
}
$context->logger->info(sprintf(
'[board:1.0.3] depth 재계산 완료 — %d 건 갱신 (최대 세대 %d)',
$updated,
$level
));
}
/**
* 최상위 댓글의 depth 를 0 으로 맞춥니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 갱신된 행 수
*/
private function assignRootDepth(UpgradeContext $context): int
{
$stale = DB::table(self::TABLE)
->whereNull('parent_id')
->where('depth', '<>', 0)
->pluck('id')
->all();
if ($stale === []) {
return 0;
}
$this->logSnapshot($context, 0, $stale);
return DB::table(self::TABLE)
->whereNull('parent_id')
->where('depth', '<>', 0)
->update(['depth' => 0]);
}
/**
* 주어진 부모 ID 목록의 자식 ID 를 조회합니다 (이미 방문한 ID 는 제외).
*
* @param array<int, int> $parentIds 부모 댓글 ID 목록
* @param array<int, bool> $visited 방문 ID 집합 (참조 전달, 갱신됨)
* @return array<int, int> 자식 댓글 ID 목록
*/
private function childIdsOf(array $parentIds, array &$visited): array
{
$children = [];
foreach (array_chunk($parentIds, self::CHUNK) as $chunk) {
$ids = DB::table(self::TABLE)
->whereIn('parent_id', $chunk)
->pluck('id')
->all();
foreach ($ids as $id) {
$id = (int) $id;
if (isset($visited[$id])) {
continue;
}
$visited[$id] = true;
$children[] = $id;
}
}
return $children;
}
/**
* 주어진 댓글들의 depth 를 지정 값으로 갱신합니다.
*
* @param array<int, int> $ids 댓글 ID 목록
* @param int $depth 설정할 depth
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 갱신된 행 수
*/
private function assignDepth(array $ids, int $depth, UpgradeContext $context): int
{
$updated = 0;
foreach (array_chunk($ids, self::CHUNK) as $chunk) {
$stale = DB::table(self::TABLE)
->whereIn('id', $chunk)
->where('depth', '<>', $depth)
->pluck('id')
->all();
if ($stale === []) {
continue;
}
$this->logSnapshot($context, $depth, $stale);
$updated += DB::table(self::TABLE)
->whereIn('id', $stale)
->update(['depth' => $depth]);
}
return $updated;
}
/**
* 갱신 대상의 변경 전 상태를 로그로 남깁니다 (복구 근거).
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @param int $newDepth 새로 부여할 depth
* @param array<int, int> $ids 갱신 대상 댓글 ID 목록
*/
private function logSnapshot(UpgradeContext $context, int $newDepth, array $ids): void
{
$before = DB::table(self::TABLE)
->whereIn('id', $ids)
->pluck('depth', 'id')
->map(fn ($depth) => (int) $depth)
->all();
$context->logger->info(sprintf(
'[board:1.0.3] 스냅샷 depth→%d %d 건 — 변경 전 %s',
$newDepth,
count($ids),
json_encode($before, JSON_UNESCAPED_UNICODE)
));
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 게시판 설정 max_comment_depth 를 초과한 기존 댓글을 리포트합니다.
*
* 자동 이동/삭제는 하지 않습니다. 관리자가 나중에 max_comment_depth 를 낮춘 결과일 수도
* 있어 데이터가 오염이라 단정할 수 없고, 자동 재부모화는 대화 맥락을 파괴하기 때문입니다.
* 규모만 로그로 남겨 운영자가 판단할 근거를 제공합니다.
*
* idempotent: 읽기 전용. V-1 안전: Facades\DB/Schema 만 사용.
*/
class ReportCommentsExceedingMaxDepth implements DataMigration
{
private const COMMENTS_TABLE = 'board_comments';
private const BOARDS_TABLE = 'boards';
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'ReportCommentsExceedingMaxDepth';
}
/**
* 설정 초과 depth 댓글 규모를 게시판별로 로그에 남깁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::COMMENTS_TABLE)
|| ! Schema::hasTable(self::BOARDS_TABLE)
|| ! Schema::hasColumn(self::BOARDS_TABLE, 'max_comment_depth')) {
$context->logger->info('[board:1.0.3] 설정 초과 depth 리포트 — 대상 스키마 부재로 스킵');
return;
}
// 별칭 조인 대신 게시판 단위 순회 — DB 프리픽스가 별칭에도 적용되어
// raw 표현식의 별칭 참조가 깨지는 것을 회피한다 (게시판 수는 소규모).
$boards = DB::table(self::BOARDS_TABLE)
->select('id', 'slug', 'max_comment_depth')
->orderBy('id')
->get();
$reported = 0;
foreach ($boards as $board) {
$max = (int) $board->max_comment_depth;
$query = DB::table(self::COMMENTS_TABLE)
->where('board_id', $board->id)
->where('depth', '>', $max);
$overCount = (clone $query)->count();
if ($overCount === 0) {
continue;
}
$reported++;
$context->logger->warning(sprintf(
'[board:1.0.3] 게시판 %s(id=%d) 설정 max_comment_depth=%d 초과 댓글 %d 건 (최대 depth=%d) — 자동 이동하지 않음, 운영자 확인 필요',
$board->slug,
$board->id,
$max,
$overCount,
(int) (clone $query)->max('depth')
));
}
if ($reported === 0) {
$context->logger->info('[board:1.0.3] 게시판 설정을 초과한 depth 댓글 없음');
}
}
}
@@ -29,6 +29,11 @@
- 검색 프리셋의 대상 화면 이름(상품 목록·주문 목록·고객 목록)이 표시되지 않던 문제를 수정했습니다.
- 상품 문의 답변 상태와 배송정책이 없는 배송 그룹의 표시명이 비어 보이던 문제를 수정했습니다.
- 상품 옵션 설정, 주문·배송정책·쿠폰·브랜드·카테고리 관리 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제와, 다크 모드에서 일부 버튼의 색 대비가 낮던 문제를 수정했습니다.
- 주문 상세에서 상품 옵션 상태를 일괄 변경할 때 다른 주문의 옵션이 함께 변경될 수 있던 문제를 수정했습니다. 이제 해당 주문에 속하지 않은 옵션이 포함되면 변경이 거절됩니다.
- 상품의 대표 이미지를 지정할 때 다른 상품의 이미지가 대표로 바뀔 수 있던 문제를 수정했습니다.
- 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다.
- 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다.
- 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다.
## [1.0.4] - 2026-07-16
@@ -413,7 +413,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다.
**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다. `child_menus`의 각 항목은 지정한 부모의 조상이 될 수 없습니다 — 순환이 만들어지는 이동은 422(`child_menus.{parentId}.{index}.id`)로 거절됩니다.
### GET /api/modules/sirsoft-ecommerce/admin/categories/tree
@@ -722,7 +722,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다.
**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. `parent_id`에는 자기 자신이나 자신의 하위 카테고리를 지정할 수 없습니다 — 순환이 만들어지는 요청은 422(`parent_id`)로 거절됩니다.
### GET /api/modules/sirsoft-ecommerce/admin/categories/{id}
@@ -1039,7 +1039,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다.
**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다. `items[].option_id`는 경로의 `{order}`에 속한 옵션이어야 합니다. 다른 주문의 옵션 ID를 포함하면 422(`items.N.option_id`)로 거절되며, 요청 전체가 반영되지 않습니다. 검증을 거치지 않는 내부·훅 호출 경로에서 같은 불일치가 감지되면 400으로 응답합니다.
### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/reset-guest-lookup-password
@@ -1708,7 +1708,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다.
**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다. `{imageId}`는 경로의 `{productId}`에 속한 이미지여야 합니다. 다른 상품의 이미지 ID를 지정하면 400을 반환하며, 두 상품의 대표 이미지 상태는 모두 그대로 유지됩니다.
### DELETE /api/modules/sirsoft-ecommerce/admin/products/{product}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 카테고리 조작 실패 예외
*
* 카테고리를 찾을 수 없거나, 하위 카테고리·연결 상품이 있어 삭제할 수 없는 경우에 사용합니다.
* 컨트롤러의 기존 catch (\Exception) 흐름에서 400 응답으로 처리됩니다.
*/
class CategoryOperationException extends RuntimeException
{
/**
* CategoryOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $replace 메시지 치환 파라미터
*/
public function __construct(string $messageKey, array $replace = [])
{
parent::__construct(__($messageKey, $replace));
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 하위 리소스가 요청 경로의 상위 리소스에 속하지 않을 때 발생하는 예외
*
* 주문 옵션이 다른 주문에 속하거나, 상품 이미지가 다른 상품에 속하는 등
* 중첩 리소스의 소유권이 어긋난 경우에 사용합니다.
* 컨트롤러의 기존 catch (\Exception) 흐름에서 400 응답으로 처리됩니다.
*/
class ResourceScopeMismatchException extends RuntimeException
{
/**
* ResourceScopeMismatchException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
*/
public function __construct(string $messageKey)
{
parent::__construct(__($messageKey));
}
}
@@ -10,8 +10,10 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkChangeOrderOptionStatusRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateOrdersRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CancelOrderRequest;
@@ -221,6 +223,7 @@ class OrderController extends AdminBaseController
$result = $this->orderOptionService->bulkChangeStatusWithQuantity(
$validated['items'],
$newStatus,
$order->id,
$metadata
);
@@ -235,6 +238,15 @@ class OrderController extends AdminBaseController
'messages.orders.option_status_change_failed',
422
);
} catch (ResourceScopeMismatchException $e) {
// 상위 주문 스코프 불일치는 잘못된 요청이다. FormRequest 가 1차로 422 를 내지만,
// 훅·내부 호출로 Service 2차 방어가 트립한 경우에도 500 이 아닌 400 으로 응답해
// 같은 모듈의 ProductController::setThumbnail 과 계약을 일치시킨다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.orders.option_status_change_failed',
400
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
@@ -32,9 +32,16 @@ class BulkChangeOrderOptionStatusRequest extends FormRequest
*/
public function rules(): array
{
// 경로의 주문에 속한 옵션만 허용 (교차 주문 옵션 변경 차단)
$orderId = $this->route('order')?->id;
return [
'items' => ['required', 'array', 'min:1'],
'items.*.option_id' => ['required', 'integer', Rule::exists(OrderOption::class, 'id')],
'items.*.option_id' => [
'required',
'integer',
Rule::exists(OrderOption::class, 'id')->where('order_id', $orderId),
],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'status' => ['required', 'string', Rule::in(OrderStatusEnum::values())],
'carrier_id' => ['nullable', 'integer', Rule::exists(ShippingCarrier::class, 'id')],
@@ -8,6 +8,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Rules\NotCircularCategoryParent;
/**
* 카테고리 생성 요청
@@ -18,6 +19,8 @@ class CreateCategoryRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -43,14 +46,16 @@ class CreateCategoryRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
$rules = [
'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 100)],
'description' => ['nullable', 'array', new TranslatableField()],
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],
'description' => ['nullable', 'array', new TranslatableField],
// 생성 시에는 자기 자신이 아직 없어 순환이 불가하나, 수정 요청과 규칙 구성을
// 동일하게 유지해 두 엔드포인트의 검증 강도가 갈라지는 것을 막는다.
'parent_id' => ['nullable', Rule::exists(Category::class, 'id'), new NotCircularCategoryParent],
'slug' => [
'required',
'string',
@@ -71,7 +76,7 @@ class CreateCategoryRequest extends FormRequest
/**
* 검증 오류 메시지 커스터마이징
*
* @return array
* @return array<string, string> 규칙별 다국어 에러 메시지
*/
public function messages(): array
{
@@ -5,7 +5,9 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Rules\NotCircularCategoryParent;
/**
* 카테고리 순서 변경 요청
@@ -20,6 +22,8 @@ class ReorderCategoriesRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -29,7 +33,7 @@ class ReorderCategoriesRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
@@ -46,10 +50,41 @@ class ReorderCategoriesRequest extends FormRequest
return HookManager::applyFilters('sirsoft-ecommerce.category.reorder_validation_rules', $rules, $this);
}
/**
* 추가 검증 — 자식으로 지정된 카테고리가 대상 부모의 조상이면 순환이 된다.
*
* 개별 이동 단위로 현재 DB 상태와 대조한다. 한 요청 안의 여러 이동이 누적되어 만들어지는
* 순환은 CategoryService 의 방문 ID 가드가 최종 방어한다.
*
* @param Validator $validator 검증기
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
foreach ((array) $this->input('child_menus', []) as $parentId => $children) {
foreach ((array) $children as $index => $child) {
$childId = $child['id'] ?? null;
if ($childId === null) {
continue;
}
$attribute = "child_menus.{$parentId}.{$index}.id";
(new NotCircularCategoryParent((int) $childId))->validate(
$attribute,
(int) $parentId,
fn (string $message) => $validator->errors()->add($attribute, $message)
);
}
}
});
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array
* @return array<string, string> 규칙별 다국어 에러 메시지
*/
public function messages(): array
{
@@ -8,6 +8,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Rules\NotCircularCategoryParent;
/**
* 카테고리 수정 요청
@@ -18,6 +19,8 @@ class UpdateCategoryRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -43,7 +46,7 @@ class UpdateCategoryRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
@@ -51,8 +54,12 @@ class UpdateCategoryRequest extends FormRequest
$rules = [
'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 100)],
'description' => ['nullable', 'array', new TranslatableField()],
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],
'description' => ['nullable', 'array', new TranslatableField],
'parent_id' => [
'nullable',
Rule::exists(Category::class, 'id'),
new NotCircularCategoryParent($categoryId !== null ? (int) $categoryId : null),
],
'slug' => [
'required',
'string',
@@ -73,7 +80,7 @@ class UpdateCategoryRequest extends FormRequest
/**
* 검증 오류 메시지 커스터마이징
*
* @return array
* @return array<string, string> 규칙별 다국어 에러 메시지
*/
public function messages(): array
{
@@ -85,4 +92,4 @@ class UpdateCategoryRequest extends FormRequest
'parent_id.exists' => __('sirsoft-ecommerce::validation.category.parent_not_found'),
];
}
}
}
@@ -0,0 +1,117 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Sirsoft\Ecommerce\Models\Category;
/**
* 카테고리 순환 참조를 방지하는 검증 규칙
*
* 카테고리를 이동할 때 자기 자신 또는 자신의 자손(descendant)을 부모로 지정하는 것을 막습니다.
* 순환이 만들어지면 path 재계산 재귀가 종료되지 않아 요청이 실패하고, 해당 노드가 트리에서
* 사라져 관리자 화면으로 복구할 수 없게 됩니다.
*
* 자손 판정은 머티리얼라이즈드 path 컬럼의 prefix 매칭(쿼리 1회)으로 수행하며,
* path 가 비어 있는 레거시 행에 대비해 parent_id 재귀 fallback 을 둡니다.
*/
class NotCircularCategoryParent implements ValidationRule
{
/**
* NotCircularCategoryParent 생성자
*
* @param int|null $categoryId 이동하려는 카테고리 ID (생성 시 null → 자기참조 불가하므로 통과)
*/
public function __construct(
private ?int $categoryId = null
) {}
/**
* 검증을 수행합니다.
*
* @param string $attribute 검증 대상 속성명
* @param mixed $value 검증 대상 값 (새 부모 ID)
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// 최상위로 이동 → 순환 없음
if ($value === null || $value === '') {
return;
}
// 신규 생성 → 자기 자신이 아직 없으므로 순환 불가
if ($this->categoryId === null) {
return;
}
$newParentId = (int) $value;
if ($newParentId === $this->categoryId) {
$fail(__('sirsoft-ecommerce::validation.category.parent_id.self'));
return;
}
if ($this->isDescendant($newParentId)) {
$fail(__('sirsoft-ecommerce::validation.category.parent_id.circular'));
}
}
/**
* 지정한 카테고리가 이동 대상의 자손인지 판정합니다.
*
* @param int $candidateId 새 부모 후보 카테고리 ID
* @return bool 자손이면 true
*/
private function isDescendant(int $candidateId): bool
{
$self = Category::find($this->categoryId);
if (! $self) {
return false;
}
// path 가 채워져 있으면 prefix 매칭 1회로 판정
if (! empty($self->path)) {
return Category::where('id', $candidateId)
->where('path', 'like', $self->path.'/%')
->exists();
}
// path 가 비어 있는 레거시 행 — parent_id 를 거슬러 올라가며 판정
return $this->hasAncestor($candidateId, $this->categoryId);
}
/**
* 후보 카테고리의 조상 중에 대상 ID 가 있는지 확인합니다.
*
* @param int $candidateId 탐색 시작 카테고리 ID
* @param int $ancestorId 찾으려는 조상 ID
* @return bool 조상에 포함되면 true
*/
private function hasAncestor(int $candidateId, int $ancestorId): bool
{
$visited = [];
$currentId = $candidateId;
while ($currentId !== null && ! isset($visited[$currentId])) {
$visited[$currentId] = true;
$parentId = Category::where('id', $currentId)->value('parent_id');
if ($parentId === null) {
return false;
}
if ((int) $parentId === $ancestorId) {
return true;
}
$currentId = (int) $parentId;
}
return false;
}
}
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Services;
use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Exceptions\CategoryOperationException;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
@@ -22,8 +24,8 @@ class CategoryService
/**
* 계층형 카테고리 목록 조회
*
* @param array $filters 필터 조건
* @return Collection
* @param array $filters 필터 조건
* @return Collection 계층 구조가 적용된 카테고리 컬렉션
*/
public function getHierarchicalCategories(array $filters = []): Collection
{
@@ -34,9 +36,9 @@ class CategoryService
$filters = HookManager::applyFilters('sirsoft-ecommerce.category.filter_list_query', $filters);
// hierarchical 플래그가 true이면 전체 트리 구조 반환
if (!empty($filters['hierarchical'])) {
if (! empty($filters['hierarchical'])) {
$categories = Category::getTree(null, $filters['is_active'] ?? false);
} elseif (!empty($filters['flat'])) {
} elseif (! empty($filters['flat'])) {
// flat 플래그가 true이면 평면 리스트 반환 (TagInput 등에 사용)
$categories = $this->repository->getFlatList($filters, ['images']);
} else {
@@ -57,7 +59,7 @@ class CategoryService
*
* 프론트엔드 사용자 페이지에서 카테고리 필터에 사용합니다.
*
* @return Collection
* @return Collection 활성 카테고리로만 구성된 공개 트리
*/
public function getPublicCategoryTree(): Collection
{
@@ -75,8 +77,8 @@ class CategoryService
/**
* slug로 공개 카테고리 조회 (활성 카테고리만)
*
* @param string $slug 카테고리 slug
* @return Category|null
* @param string $slug 카테고리 slug
* @return Category|null 활성 카테고리 또는 null (비활성·미존재)
*/
public function getPublicCategoryBySlug(string $slug): ?Category
{
@@ -104,8 +106,8 @@ class CategoryService
/**
* 카테고리 상세 조회
*
* @param int $id 카테고리 ID
* @return Category|null
* @param int $id 카테고리 ID
* @return Category|null 카테고리 모델 또는 null (미존재)
*/
public function getCategory(int $id): ?Category
{
@@ -132,8 +134,8 @@ class CategoryService
/**
* 카테고리 생성
*
* @param array $data 카테고리 데이터
* @return Category
* @param array $data 카테고리 데이터
* @return Category 생성된 카테고리 모델
*/
public function createCategory(array $data): Category
{
@@ -150,7 +152,7 @@ class CategoryService
$data['path'] = $depthAndPath['path'];
// sort_order가 없으면 자동 계산
if (!isset($data['sort_order'])) {
if (! isset($data['sort_order'])) {
$data['sort_order'] = $this->repository->getNextSortOrder($data['parent_id'] ?? null);
}
@@ -162,7 +164,7 @@ class CategoryService
$this->updatePath($category);
// 이미지 처리 (temp_key 방식)
if (!empty($data['temp_key'])) {
if (! empty($data['temp_key'])) {
$this->imageRepository->linkTempImages($data['temp_key'], $category->id);
}
@@ -178,16 +180,16 @@ class CategoryService
/**
* 카테고리 수정
*
* @param int $id 카테고리 ID
* @param array $data 수정할 데이터
* @return Category
* @param int $id 카테고리 ID
* @param array $data 수정할 데이터
* @return Category 수정된 카테고리 모델
*/
public function updateCategory(int $id, array $data): Category
{
$category = $this->repository->findById($id);
if (!$category) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]));
if (! $category) {
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]);
}
// Before 훅
@@ -201,7 +203,8 @@ class CategoryService
$category = DB::transaction(function () use ($category, $data) {
// parent_id가 변경되었으면 depth/path 재계산
if (isset($data['parent_id']) && $data['parent_id'] !== $category->parent_id) {
// 루트로 이동(parent_id = null)도 변경이므로 array_key_exists 사용 (isset 은 null 을 미설정으로 취급)
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== $category->parent_id) {
$depthAndPath = $this->calculateDepthAndPath($data['parent_id']);
$data['depth'] = $depthAndPath['depth'];
$data['path'] = $depthAndPath['path'];
@@ -211,15 +214,15 @@ class CategoryService
$categoryData = collect($data)->except(['temp_key'])->toArray();
$category = $this->repository->update($category->id, $categoryData);
// path 업데이트 (parent_id 변경 시)
if (isset($data['parent_id'])) {
// path 업데이트 (parent_id 변경 시 — 루트 이동 포함)
if (array_key_exists('parent_id', $data)) {
$this->updatePath($category);
// 하위 카테고리들의 path도 재계산
$this->updateDescendantsPaths($category);
}
// 이미지 처리
if (!empty($data['temp_key'])) {
if (! empty($data['temp_key'])) {
$this->imageRepository->linkTempImages($data['temp_key'], $category->id);
}
@@ -235,33 +238,34 @@ class CategoryService
/**
* 카테고리 삭제
*
* @param int $id 카테고리 ID
* @param int $id 카테고리 ID
* @return array 삭제 결과 정보
* @throws \Exception
*
* @throws CategoryOperationException 카테고리 미존재 또는 삭제 불가 조건
*/
public function deleteCategory(int $id): array
{
$category = $this->repository->findById($id);
if (!$category) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]));
if (! $category) {
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]);
}
// 하위 카테고리 확인
if ($this->repository->hasChildren($id)) {
$childrenCount = $category->children()->count();
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_has_children', [
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_has_children', [
'category_id' => $id,
'count' => $childrenCount,
]));
]);
}
// 연결된 상품 수 확인 - 상품이 있으면 삭제 차단
$productsCount = $this->repository->getProductCount($id);
if ($productsCount > 0) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_has_products', [
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_has_products', [
'count' => $productsCount,
]));
]);
}
// Before 훅
@@ -286,7 +290,7 @@ class CategoryService
/**
* depth와 Materialized Path를 계산합니다.
*
* @param int|null $parentId 부모 카테고리 ID
* @param int|null $parentId 부모 카테고리 ID
* @return array ['depth' => int, 'path' => string]
*/
private function calculateDepthAndPath(?int $parentId): array
@@ -297,7 +301,7 @@ class CategoryService
$parent = $this->repository->findById($parentId);
if (!$parent) {
if (! $parent) {
return ['depth' => 0, 'path' => ''];
}
@@ -309,9 +313,6 @@ class CategoryService
/**
* 카테고리의 path를 업데이트합니다 (ID 포함).
*
* @param Category $category
* @return void
*/
private function updatePath(Category $category): void
{
@@ -329,39 +330,52 @@ class CategoryService
/**
* 하위 카테고리들의 path를 재계산합니다 (재귀).
*
* @param Category $category
* @return void
* 검증(NotCircularCategoryParent)을 우회한 경로(시더/훅/기존 오염 데이터)에서
* 순환 참조가 남아 있어도 무한 재귀 대신 유한 실패로 끝나도록 방문 ID 를 추적한다.
*
* @param Category $category 기준 카테고리
* @param array<int, bool> $visited 방문한 카테고리 ID 집합 (재귀 내부 전달용)
*/
private function updateDescendantsPaths(Category $category): void
private function updateDescendantsPaths(Category $category, array $visited = []): void
{
$children = $category->children;
$visited[$category->id] = true;
foreach ($category->children as $child) {
if (isset($visited[$child->id])) {
Log::warning('[sirsoft-ecommerce] 카테고리 순환 참조 감지 — path 재계산 중단', [
'category_id' => $category->id,
'child_id' => $child->id,
]);
continue;
}
foreach ($children as $child) {
$this->updatePath($child);
$this->updateDescendantsPaths($child); // 재귀 호출
$this->updateDescendantsPaths($child, $visited);
}
}
/**
* 카테고리 상태 토글
*
* @param int $id 카테고리 ID
* @return Category
* @throws \Exception
* @param int $id 카테고리 ID
* @return Category 상태가 토글된 카테고리 모델
*
* @throws CategoryOperationException 카테고리 미존재 또는 삭제 불가 조건
*/
public function toggleStatus(int $id): Category
{
$category = $this->repository->findById($id);
if (!$category) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]));
if (! $category) {
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]);
}
// Before 훅
HookManager::doAction('sirsoft-ecommerce.category.before_toggle_status', $category);
$category = $this->repository->update($id, [
'is_active' => !$category->is_active,
'is_active' => ! $category->is_active,
]);
// After 훅
@@ -373,8 +387,7 @@ class CategoryService
/**
* 카테고리 순서 변경
*
* @param array $orders 순서 변경 데이터 [['id' => 1, 'parent_id' => null, 'sort_order' => 0], ...]
* @return void
* @param array $orders 순서 변경 데이터 [['id' => 1, 'parent_id' => null, 'sort_order' => 0], ...]
*/
public function reorder(array $orders): void
{
@@ -384,7 +397,7 @@ class CategoryService
DB::transaction(function () use ($orders) {
foreach ($orders as $order) {
$category = $this->repository->findById($order['id']);
if (!$category) {
if (! $category) {
continue;
}
@@ -8,6 +8,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderOptionSourceTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
@@ -295,18 +296,26 @@ class OrderOptionService
*
* @param array $items [{option_id, quantity}] 변경 대상
* @param OrderStatusEnum $newStatus 변경할 상태
* @param int $orderId 상위 주문 ID (옵션 소속 검증에 사용)
* @param array $metadata 추가 정보
* @return array 변경 결과
*
* @throws ResourceScopeMismatchException 옵션이 상위 주문에 속하지 않는 경우
*/
public function bulkChangeStatusWithQuantity(
array $items,
OrderStatusEnum $newStatus,
int $orderId,
array $metadata = []
): array {
// 스냅샷 캡처 (ChangeDetector용)
$optionIds = collect($items)->pluck('option_id')->filter()->unique()->toArray();
$snapshots = $this->orderOptionRepository->getSnapshotsByIds($optionIds);
// 상위 주문 스코프 2차 방어 — FormRequest 를 우회한 내부/훅 호출도 차단.
// 선택 파라미터로 두면 신규 호출처가 빠뜨렸을 때 방어가 조용히 꺼지므로 필수로 받는다.
$this->assertOptionsBelongToOrder($optionIds, $orderId);
// 결제완료(payment_complete) 목표 전이 시 본인인증(IDV) 정책 가드 (A8 / N4).
// 관리자 주문상세 "주문상태 변경"(옵션 일괄변경)이 결제완료로 부모 주문을 전이시키는,
// 실무에서 가장 흔한 결제승인 경로다. OrderService::update / bulkUpdate 와 동일하게
@@ -351,8 +360,8 @@ class OrderOptionService
// 부모 주문 상태 동기화
$orderIds = $this->orderOptionRepository->getOrderIdsByOptionIds($optionIds);
foreach ($orderIds as $orderId) {
$this->syncParentOrderStatus($orderId);
foreach ($orderIds as $parentOrderId) {
$this->syncParentOrderStatus($parentOrderId);
}
// after 훅 (스냅샷 전달)
@@ -365,6 +374,29 @@ class OrderOptionService
];
}
/**
* 주어진 옵션들이 모두 지정 주문에 속하는지 검증합니다.
*
* @param array<int, int> $optionIds 검증할 주문 옵션 ID 목록
* @param int $orderId 상위 주문 ID
*
* @throws ResourceScopeMismatchException 하나라도 다른 주문에 속하는 경우
*/
private function assertOptionsBelongToOrder(array $optionIds, int $orderId): void
{
if ($optionIds === []) {
return;
}
$orderIds = $this->orderOptionRepository->getOrderIdsByOptionIds($optionIds);
foreach ($orderIds as $foundOrderId) {
if ((int) $foundOrderId !== $orderId) {
throw new ResourceScopeMismatchException('sirsoft-ecommerce::exceptions.order_option_not_in_order');
}
}
}
/**
* 옵션 금액 필드를 캡처합니다.
*
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\ProductImageCollection;
use Modules\Sirsoft\Ecommerce\Exceptions\ProductImageUploadLimitException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
@@ -306,11 +307,19 @@ class ProductImageService
* @param int $productId 상품 ID
* @param int $imageId 이미지 ID
* @return bool 성공 여부
*
* @throws ResourceScopeMismatchException 이미지가 해당 상품에 속하지 않는 경우
*/
public function setThumbnail(int $productId, int $imageId): bool
{
// 기존 대표 이미지 해제
$images = $this->repository->getByProductId($productId);
// 대상 이미지가 이 상품에 속하는지 먼저 확인 (교차 상품 오염 차단)
if ($images->firstWhere('id', $imageId) === null) {
throw new ResourceScopeMismatchException('sirsoft-ecommerce::exceptions.product_image_not_in_product');
}
// 기존 대표 이미지 해제
foreach ($images as $image) {
if ($image->is_thumbnail) {
$this->repository->update($image->id, ['is_thumbnail' => false]);
@@ -60,6 +60,8 @@ return [
'order_already_cancelled' => 'This order has already been cancelled.',
'order_already_paid' => 'This order has already been paid.',
'order_option_not_found' => 'Order option not found.',
'order_option_not_in_order' => 'This order option does not belong to the specified order.',
'product_image_not_in_product' => 'This image does not belong to the specified product.',
'order_option_already_cancelled' => 'This order option has already been cancelled.',
'order_option_already_confirmed' => 'This order option has already been confirmed.',
'order_option_cannot_confirm' => 'Cannot confirm purchase in current status.',
@@ -454,6 +454,10 @@ return [
'slug_unique' => 'This slug is already in use.',
'slug_format' => 'The slug must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens (-).',
'parent_not_found' => 'Parent category not found.',
'parent_id' => [
'self' => 'A category cannot be its own parent.',
'circular' => 'A descendant category cannot be set as the parent.',
],
],
// Product image validation messages
@@ -60,6 +60,8 @@ return [
'order_already_cancelled' => '이미 취소된 주문입니다.',
'order_already_paid' => '이미 결제가 완료된 주문입니다.',
'order_option_not_found' => '주문 옵션을 찾을 수 없습니다.',
'order_option_not_in_order' => '해당 주문에 속하지 않는 주문 옵션입니다.',
'product_image_not_in_product' => '해당 상품에 속하지 않는 이미지입니다.',
'order_option_already_cancelled' => '이미 취소된 주문 옵션입니다.',
'order_option_already_confirmed' => '이미 구매확정된 주문 옵션입니다.',
'order_option_cannot_confirm' => '현재 상태에서는 구매확정할 수 없습니다.',
@@ -454,6 +454,10 @@ return [
'slug_unique' => '이미 사용중인 슬러그입니다.',
'slug_format' => '슬러그는 영문 소문자로 시작해야 하며, 영문 소문자/숫자/하이픈(-)만 사용 가능합니다.',
'parent_not_found' => '상위 카테고리를 찾을 수 없습니다.',
'parent_id' => [
'self' => '자기 자신을 상위 카테고리로 지정할 수 없습니다.',
'circular' => '하위 카테고리를 상위 카테고리로 지정할 수 없습니다.',
],
],
// 상품 이미지 검증 메시지
@@ -0,0 +1,250 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 카테고리 순환 참조 방지 테스트
*
* 검증 목적:
* - 자기 자신을 부모로 지정할 수 없다 (422)
* - 직계 자식 / 손자를 부모로 지정할 수 없다 (422)
* - 무관한 카테고리로의 정상 이동은 허용되고 path 가 갱신된다 (200)
* - 최상위로 이동(parent_id = null)은 허용된다 (200)
*/
class CategoryCycleTest extends ModuleTestCase
{
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.categories.update',
'sirsoft-ecommerce.categories.create',
]);
}
/**
* 자기 자신을 부모로 지정하면 422
*
* @scenario entity=ec_category, parent_target=self
*
* @effects self_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_self_as_parent(): void
{
$category = $this->makeCategory('self-parent');
$this->actingAs($this->adminUser)
->putJson($this->url($category), $this->payload($category, $category->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($category->fresh()->parent_id, 'parent_id 가 변경되면 안 됩니다.');
}
/**
* 직계 자식을 부모로 지정하면 422
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_direct_child_as_parent(): void
{
$root = $this->makeCategory('cycle-root');
$child = $this->makeCategory('cycle-child', $root);
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $child->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($root->fresh()->parent_id, 'root 의 parent_id 가 변경되면 안 됩니다.');
}
/**
* 손자(3노드 사이클)를 부모로 지정하면 422
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_grandchild_as_parent(): void
{
$root = $this->makeCategory('deep-root');
$child = $this->makeCategory('deep-child', $root);
$grandChild = $this->makeCategory('deep-grandchild', $child);
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $grandChild->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($root->fresh()->parent_id);
$this->assertSame($child->id, $grandChild->fresh()->parent_id);
}
/**
* 무관한 카테고리로의 이동은 허용되고 path 가 갱신된다
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds, descendant_paths_rebuilt_on_valid_move
*/
public function test_can_move_under_unrelated_category(): void
{
$target = $this->makeCategory('move-target');
$moving = $this->makeCategory('move-source');
$movingChild = $this->makeCategory('move-source-child', $moving);
$this->actingAs($this->adminUser)
->putJson($this->url($moving), $this->payload($moving, $target->id))
->assertStatus(200);
$moved = $moving->fresh();
$this->assertSame($target->id, $moved->parent_id);
$this->assertSame("{$target->id}/{$moved->id}", $moved->path, '이동된 카테고리의 path 가 갱신되어야 합니다.');
$this->assertSame(
"{$target->id}/{$moved->id}/{$movingChild->id}",
$movingChild->fresh()->path,
'자손의 path 도 함께 갱신되어야 합니다.'
);
}
/**
* 최상위로 이동(parent_id = null)은 허용된다
*
* @scenario entity=ec_category, parent_target=none
*
* @effects move_to_root_succeeds, path_and_depth_rebuilt_on_root_move, descendant_paths_rebuilt_on_root_move
*/
public function test_can_move_to_root(): void
{
$root = $this->makeCategory('to-root-parent');
$child = $this->makeCategory('to-root-child', $root);
$grandChild = $this->makeCategory('to-root-grandchild', $child);
$this->actingAs($this->adminUser)
->putJson($this->url($child), $this->payload($child, null))
->assertStatus(200);
$movedChild = $child->fresh();
$this->assertNull($movedChild->parent_id);
// 루트 이동 시 path/depth 가 재계산되어야 한다 (isset 이 null 을 미설정으로 취급하던 회귀 방지)
$this->assertSame((string) $child->id, $movedChild->path, '루트로 이동한 카테고리의 path 는 자기 ID 여야 합니다.');
$this->assertSame(0, $movedChild->depth, '루트로 이동한 카테고리의 depth 는 0 이어야 합니다.');
// 자손 path 도 새 루트 기준으로 재계산되어야 한다
$this->assertSame("{$child->id}/{$grandChild->id}", $grandChild->fresh()->path, '루트 이동 시 자손 path 도 갱신되어야 합니다.');
}
/**
* 순서 변경 엔드포인트로도 조상을 자식으로 만들 수 없다 (동일 리소스 검증 강도 일치)
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_reorder_cannot_create_cycle(): void
{
$root = $this->makeCategory('reorder-root');
$child = $this->makeCategory('reorder-child', $root);
// root 를 child 의 자식으로 밀어 넣으려는 시도
$this->actingAs($this->adminUser)
->putJson('/api/modules/sirsoft-ecommerce/admin/categories/order', [
'child_menus' => [
(string) $child->id => [
['id' => $root->id, 'order' => 1],
],
],
])
->assertStatus(422)
->assertJsonValidationErrors(["child_menus.{$child->id}.0.id"]);
$this->assertNull($root->fresh()->parent_id, 'root 의 parent_id 가 변경되면 안 됩니다.');
}
/**
* 순서 변경으로 무관한 카테고리를 자식으로 지정하는 것은 허용된다 (회귀 방지)
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds
*/
public function test_reorder_allows_valid_move(): void
{
$target = $this->makeCategory('reorder-target');
$moving = $this->makeCategory('reorder-moving');
$this->actingAs($this->adminUser)
->putJson('/api/modules/sirsoft-ecommerce/admin/categories/order', [
'child_menus' => [
(string) $target->id => [
['id' => $moving->id, 'order' => 1],
],
],
])
->assertStatus(200);
$this->assertSame($target->id, $moving->fresh()->parent_id);
}
/**
* 테스트용 카테고리를 생성합니다.
*
* @param string $slug 카테고리 슬러그
* @param Category|null $parent 부모 카테고리
* @return Category 생성된 카테고리
*/
private function makeCategory(string $slug, ?Category $parent = null): Category
{
$category = Category::create([
'name' => ['ko' => $slug, 'en' => $slug],
'slug' => $slug,
'parent_id' => $parent?->id,
'path' => '',
'depth' => $parent ? $parent->depth + 1 : 0,
'is_active' => true,
'sort_order' => 0,
]);
$category->update([
'path' => $parent ? $parent->path.'/'.$category->id : (string) $category->id,
]);
return $category->fresh();
}
/**
* 카테고리 수정 요청 본문을 생성합니다.
*
* @param Category $category 대상 카테고리
* @param int|null $parentId 지정할 부모 ID
* @return array<string, mixed> 요청 본문
*/
private function payload(Category $category, ?int $parentId): array
{
return [
'name' => ['ko' => $category->slug, 'en' => $category->slug],
'slug' => $category->slug,
'parent_id' => $parentId,
];
}
/**
* 카테고리 수정 엔드포인트 URL 을 생성합니다.
*
* @param Category $category 대상 카테고리
* @return string 엔드포인트 URL
*/
private function url(Category $category): string
{
return "/api/modules/sirsoft-ecommerce/admin/categories/{$category->id}";
}
}
@@ -0,0 +1,160 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 주문 옵션 일괄 상태 변경의 상위 주문 스코프 검증 테스트
*
* PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/options/bulk-status
*
* 검증 목적:
* - 주문 A 의 경로로 주문 B 의 옵션을 일괄 변경할 수 없다 (422)
* - FormRequest 를 우회한 Service 직접 호출도 2차 방어로 차단된다
* - 대상 주문의 옵션은 기존과 동일하게 변경된다
*/
class OrderOptionScopeTest extends ModuleTestCase
{
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
}
/**
* 다른 주문의 옵션 ID 를 넣으면 422 이고 그 옵션은 변경되지 않는다
*
* @scenario resource=order_option, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_array_item_returns_422, target_resource_unchanged_on_rejection
*/
public function test_cannot_change_option_of_other_order(): void
{
$orderA = $this->makeOrder();
$orderB = $this->makeOrder();
$optionOfB = OrderOptionFactory::new()->forOrder($orderB)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 2,
]);
$this->actingAs($this->adminUser)
->patchJson($this->url($orderA), [
'items' => [
['option_id' => $optionOfB->id, 'quantity' => 1],
],
'status' => OrderStatusEnum::PREPARING->value,
])
->assertStatus(422)
->assertJsonValidationErrors(['items.0.option_id']);
$this->assertSame(
OrderStatusEnum::PAYMENT_COMPLETE->value,
$optionOfB->fresh()->option_status->value,
'다른 주문의 옵션 상태가 변경되면 안 됩니다.'
);
}
/**
* FormRequest 를 우회한 Service 직접 호출도 2차 방어로 차단된다
*
* 컨트롤러 경로는 FormRequest 가 422 로 선차단하므로, 훅·내부 호출처럼 검증을 거치지
* 않는 경로에서도 스코프가 지켜지는지 Service 를 직접 호출해 확인한다.
*
* @scenario resource=order_option, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_service_rejects_option_of_other_order_when_called_directly(): void
{
$orderA = $this->makeOrder();
$orderB = $this->makeOrder();
$optionOfB = OrderOptionFactory::new()->forOrder($orderB)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 2,
]);
$this->expectException(ResourceScopeMismatchException::class);
try {
app(OrderOptionService::class)->bulkChangeStatusWithQuantity(
[['option_id' => $optionOfB->id, 'quantity' => 1]],
OrderStatusEnum::PREPARING,
$orderA->id,
);
} finally {
$this->assertSame(
OrderStatusEnum::PAYMENT_COMPLETE->value,
$optionOfB->fresh()->option_status->value,
'Service 직접 호출에서도 다른 주문의 옵션이 변경되면 안 됩니다.'
);
}
}
/**
* 자기 주문의 옵션은 정상 변경된다 (회귀 방지)
*
* @scenario resource=order_option, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_change_option_of_own_order(): void
{
$order = $this->makeOrder();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 2,
]);
$this->actingAs($this->adminUser)
->patchJson($this->url($order), [
'items' => [
['option_id' => $option->id, 'quantity' => 2],
],
'status' => OrderStatusEnum::PREPARING->value,
])
->assertStatus(200);
$this->assertSame(
OrderStatusEnum::PREPARING->value,
$option->fresh()->option_status->value,
'자기 주문의 옵션은 변경되어야 합니다.'
);
}
/**
* 테스트용 주문을 생성합니다.
*
* @return Order 생성된 주문
*/
private function makeOrder(): Order
{
return OrderFactory::new()->create([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
])->fresh();
}
/**
* 일괄 상태 변경 엔드포인트 URL 을 생성합니다.
*
* @param Order $order 대상 주문
* @return string 엔드포인트 URL
*/
private function url(Order $order): string
{
return "/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/options/bulk-status";
}
}
@@ -0,0 +1,130 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 대표 이미지 설정의 상위 상품 스코프 검증 테스트
*
* PATCH /api/modules/sirsoft-ecommerce/admin/products/{productId}/images/{imageId}/thumbnail
*
* 검증 목적:
* - 상품 A 의 경로로 상품 B 의 이미지를 대표로 지정할 수 없다
* - 상품 B 의 대표 이미지 상태가 오염되지 않는다
*/
class ProductImageScopeTest extends ModuleTestCase
{
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.products.update']);
}
/**
* 다른 상품의 이미지를 대표로 지정할 수 없다
*
* @scenario resource=product_image, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection, sibling_resource_state_unchanged
*/
public function test_cannot_set_thumbnail_of_other_product(): void
{
$productA = $this->makeProduct();
$productB = $this->makeProduct();
$imageOfA = $this->makeImage($productA, isThumbnail: true);
$imageOfB = $this->makeImage($productB, isThumbnail: true);
$otherImageOfB = $this->makeImage($productB, isThumbnail: false);
$this->actingAs($this->adminUser)
->patchJson($this->url($productA->id, $otherImageOfB->id))
->assertStatus(400);
$this->assertTrue(
(bool) $imageOfB->fresh()->is_thumbnail,
'상품 B 의 기존 대표 이미지가 유지되어야 합니다.'
);
$this->assertFalse(
(bool) $otherImageOfB->fresh()->is_thumbnail,
'상품 B 의 다른 이미지가 대표로 바뀌면 안 됩니다.'
);
$this->assertTrue(
(bool) $imageOfA->fresh()->is_thumbnail,
'상품 A 의 대표 이미지도 해제되면 안 됩니다.'
);
}
/**
* 자기 상품의 이미지는 대표로 지정된다 (회귀 방지)
*
* @scenario resource=product_image, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_set_thumbnail_of_own_product(): void
{
$product = $this->makeProduct();
$current = $this->makeImage($product, isThumbnail: true);
$target = $this->makeImage($product, isThumbnail: false);
$this->actingAs($this->adminUser)
->patchJson($this->url($product->id, $target->id))
->assertStatus(200);
$this->assertTrue((bool) $target->fresh()->is_thumbnail, '대상 이미지가 대표가 되어야 합니다.');
$this->assertFalse((bool) $current->fresh()->is_thumbnail, '기존 대표는 해제되어야 합니다.');
}
/**
* 테스트용 상품을 생성합니다.
*
* @return Product 생성된 상품
*/
private function makeProduct(): Product
{
return ProductFactory::new()->create();
}
/**
* 테스트용 상품 이미지를 생성합니다.
*
* @param Product $product 소속 상품
* @param bool $isThumbnail 대표 이미지 여부
* @return ProductImage 생성된 이미지
*/
private function makeImage(Product $product, bool $isThumbnail): ProductImage
{
return ProductImage::create([
'product_id' => $product->id,
'original_filename' => 'test.jpg',
'stored_filename' => 'test-'.uniqid().'.jpg',
'disk' => 'public',
'path' => 'ecommerce/products/test.jpg',
'mime_type' => 'image/jpeg',
'file_size' => 1024,
'is_thumbnail' => $isThumbnail,
'sort_order' => 0,
]);
}
/**
* 대표 이미지 설정 엔드포인트 URL 을 생성합니다.
*
* @param int $productId 상품 ID
* @param int $imageId 이미지 ID
* @return string 엔드포인트 URL
*/
private function url(int $productId, int $imageId): string
{
return "/api/modules/sirsoft-ecommerce/admin/products/{$productId}/images/{$imageId}/thumbnail";
}
}
@@ -0,0 +1,167 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Upgrade;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_1_0_5;
/**
* 1.0.5 카테고리 순환 데이터 정리 업그레이드 스텝 테스트
*
* 검증 목적:
* - 자기참조(parent_id = id) 노드가 절단된다
* - 다중 노드 사이클이 절단되어 트리에 복귀한다
* - 정상 트리의 depth/path 가 재구축된다
* - 재실행해도 결과가 동일하다 (멱등)
*/
class CategoryCycleCleanupTest extends ModuleTestCase
{
/**
* 자기참조 노드가 절단되고 최상위로 복귀한다
*
* @scenario entity=ec_category, parent_target=self
*
* @effects existing_cycle_broken_and_paths_rebuilt
*/
public function test_self_referencing_node_is_detached(): void
{
$id = $this->insertCategory('self-cycle', parentId: null, depth: 3, path: 'garbage');
DB::table('ecommerce_categories')->where('id', $id)->update(['parent_id' => $id]);
$this->runCleanup();
$row = DB::table('ecommerce_categories')->where('id', $id)->first();
$this->assertNull($row->parent_id, '자기참조는 절단되어야 합니다.');
$this->assertSame(0, (int) $row->depth);
$this->assertSame((string) $id, $row->path);
}
/**
* 3노드 사이클이 절단되고 계층이 복구된다
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects existing_cycle_broken_and_paths_rebuilt, path_recursion_terminates_on_polluted_data
*/
public function test_multi_node_cycle_is_broken(): void
{
$a = $this->insertCategory('cycle-a');
$b = $this->insertCategory('cycle-b', parentId: $a);
$c = $this->insertCategory('cycle-c', parentId: $b);
// a 의 부모를 c 로 만들어 a → b → c → a 사이클 구성
DB::table('ecommerce_categories')->where('id', $a)->update(['parent_id' => $c]);
$this->runCleanup();
$rows = DB::table('ecommerce_categories')
->whereIn('id', [$a, $b, $c])
->pluck('parent_id', 'id')
->all();
$rootCount = count(array_filter($rows, fn ($parentId) => $parentId === null));
$this->assertSame(1, $rootCount, '사이클은 정확히 한 지점에서 절단되어야 합니다.');
// 절단 후 모든 노드가 루트에서 도달 가능해야 한다 (path 가 루트 ID 로 시작)
foreach ([$a, $b, $c] as $id) {
$path = DB::table('ecommerce_categories')->where('id', $id)->value('path');
$this->assertNotSame('', (string) $path, "category {$id} 의 path 가 재구축되어야 합니다.");
}
}
/**
* 정상 트리의 depth/path 가 재구축된다
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects descendant_paths_rebuilt_on_valid_move
*/
public function test_healthy_tree_paths_are_rebuilt(): void
{
$root = $this->insertCategory('healthy-root', depth: 9, path: 'wrong');
$child = $this->insertCategory('healthy-child', parentId: $root, depth: 9, path: 'wrong');
$this->runCleanup();
$this->assertSame(0, (int) DB::table('ecommerce_categories')->where('id', $root)->value('depth'));
$this->assertSame((string) $root, DB::table('ecommerce_categories')->where('id', $root)->value('path'));
$this->assertSame(1, (int) DB::table('ecommerce_categories')->where('id', $child)->value('depth'));
$this->assertSame("{$root}/{$child}", DB::table('ecommerce_categories')->where('id', $child)->value('path'));
}
/**
* 재실행해도 결과가 동일하다 (멱등)
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects cleanup_is_idempotent
*/
public function test_cleanup_is_idempotent(): void
{
$a = $this->insertCategory('idem-a');
$b = $this->insertCategory('idem-b', parentId: $a);
DB::table('ecommerce_categories')->where('id', $a)->update(['parent_id' => $b]);
$this->runCleanup();
$first = $this->snapshot();
$this->runCleanup();
$second = $this->snapshot();
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
}
/**
* 1.0.5 정리 마이그레이션을 실행합니다.
*/
private function runCleanup(): void
{
(new Upgrade_1_0_5)->run(new UpgradeContext('1.0.4', '1.0.5', '1.0.5', 'extension-upgrade'));
}
/**
* 테스트용 카테고리를 직접 삽입합니다.
*
* @param string $slug 슬러그
* @param int|null $parentId 부모 ID
* @param int $depth 초기 depth
* @param string $path 초기 path
* @return int 생성된 카테고리 ID
*/
private function insertCategory(string $slug, ?int $parentId = null, int $depth = 0, string $path = ''): int
{
return DB::table('ecommerce_categories')->insertGetId([
'name' => json_encode(['ko' => $slug, 'en' => $slug], JSON_UNESCAPED_UNICODE),
'slug' => $slug,
'parent_id' => $parentId,
'depth' => $depth,
'path' => $path,
'sort_order' => 0,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* 전체 카테고리의 (id, parent_id, depth, path) 스냅샷을 반환합니다.
*
* @return array<int, array<string, mixed>> 스냅샷 배열
*/
private function snapshot(): array
{
return DB::table('ecommerce_categories')
->orderBy('id')
->get(['id', 'parent_id', 'depth', 'path'])
->map(fn ($row) => [
'id' => (int) $row->id,
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
'path' => (string) $row->path,
])
->all();
}
}
@@ -74,7 +74,6 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
*
* @param int $quantity 옵션 수량
* @param float $earnPoints 적립예정 포인트 소계
* @return Order
*/
private function makeDeliveredOrder(int $quantity, float $earnPoints): Order
{
@@ -98,7 +97,6 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
* 해당 주문의 구매적립(purchase_earn) 총액을 반환합니다.
*
* @param int $orderId 주문 ID
* @return float
*/
private function purchaseEarnTotal(int $orderId): float
{
@@ -123,7 +121,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
// 1차: 1개만 구매확정 → 분할 + 100P 적립
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(100.0, $this->purchaseEarnTotal($order->id), 0.5, '1차 확정 후 100P 적립되어야 함');
@@ -135,7 +134,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $remaining->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(200.0, $this->purchaseEarnTotal($order->id), 0.5, '나눠 확정 총합은 200P 여야 함');
@@ -157,7 +157,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->assertNotNull($target, "{$i}회차 확정 대상(배송완료)이 있어야 함");
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $target->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
}
@@ -176,7 +177,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(150.0, $this->purchaseEarnTotal($order->id), 0.5);
@@ -200,7 +202,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertSame(0.0, $this->purchaseEarnTotal($order->id), '기능 OFF 면 즉시적립도 발생하지 않아야 함');
@@ -220,7 +223,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(120.0, $this->purchaseEarnTotal($order->id), 0.5, '기능 ON 이면 즉시적립 정상');
@@ -78,7 +78,6 @@ class OrderOptionServiceConfirmTest extends ModuleTestCase
*
* @param int $quantity 옵션 수량
* @param int|null $userId 주문자 (마일리지 적립 대상, null 이면 게스트)
* @return Order
*/
private function makeDeliveredOrder(int $quantity = 1, ?int $userId = null): Order
{
@@ -210,7 +209,8 @@ class OrderOptionServiceConfirmTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertSame(1, MileageTransaction::where('order_option_id', $option->id)
@@ -238,7 +238,8 @@ class OrderOptionServiceConfirmTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$freshOrder = $order->fresh();
@@ -8,6 +8,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
@@ -65,7 +66,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
// 취소 옵션 전체(3개)를 결제완료로 되돌림
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$orderOption->order_id,
);
// 재차감으로 재고 -3 (8 → 5)
@@ -85,7 +87,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
// 취소 옵션 3개 중 2개만 결제완료로 되돌림 (부분 분할)
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 2]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$orderOption->order_id,
);
// 되돌린 수량(2)만 재차감 (8 → 6)
@@ -117,7 +120,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
try {
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$orderOption->order_id,
);
$this->fail('재고 부족 시 InsufficientStockException 이 발생해야 합니다.');
} catch (InsufficientStockException $e) {
@@ -140,7 +144,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
$productOption->refresh();
@@ -183,7 +188,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
// PG 환불 동반 경로가 아니어도 재고가 복원되어야 한다 (5 → 8)
@@ -203,7 +209,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 2]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
// 취소 수량(2)만 복원 (5 → 7)
@@ -228,14 +235,15 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
public function test_per_line_cancel_does_not_restore_when_setting_off(): void
{
// 설정 OFF 시 per-line 취소해도 재고 복원 안 함 (취소 모달 경로와 동일 정책)
app(\Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService::class)
app(EcommerceSettingsService::class)
->saveSettings(['order_settings' => ['stock_restore_on_cancel' => false]]);
[, $orderOption, $productOption] = $this->createPaidOrderOption(stock: 5, quantity: 3);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
// 설정 OFF → 재고 불변 (5), 플래그도 차감 유지
@@ -72,7 +72,7 @@ class OrderOptionServiceTest extends ModuleTestCase
$order = $this->makePaidOrderWithOptions(2);
$items = $order->options->map(fn ($o) => ['option_id' => $o->id, 'quantity' => $o->quantity])->all();
$this->service->bulkChangeStatusWithQuantity($items, OrderStatusEnum::SHIPPING);
$this->service->bulkChangeStatusWithQuantity($items, OrderStatusEnum::SHIPPING, $order->id);
$this->assertContains(OrderStatusEnum::PAYMENT_COMPLETE->value, $this->fired);
$this->assertEquals(OrderStatusEnum::SHIPPING, $order->fresh()->order_status);
@@ -93,7 +93,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $firstOption->id, 'quantity' => $firstOption->quantity]],
OrderStatusEnum::SHIPPING
OrderStatusEnum::SHIPPING,
$order->id,
);
$this->assertSame([], $this->fired, '부모가 전이되지 않으면 after_status_change 미발화여야 함');
@@ -121,6 +122,7 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::SHIPPING,
$order->id,
['carrier_id' => 1, 'tracking_number' => 'SAVED-TRACK-108']
);
@@ -153,6 +155,7 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
['carrier_id' => 1, 'tracking_number' => 'SHOULD-NOT-SAVE']
);
@@ -211,7 +214,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
@@ -241,7 +245,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
@@ -271,7 +276,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
@@ -296,7 +302,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::SHIPPING
OrderStatusEnum::SHIPPING,
$order->id,
);
$cleanup();
@@ -0,0 +1,19 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Ecommerce 모듈 1.0.5 업그레이드 스텝
*
* 카테고리 계층의 순환 참조를 절단하고 path 를 전면 재구축한다.
*
* 순환이 남아 있으면 카테고리 수정·순서 변경 요청이 path 재계산 재귀에서 종료되지 않아
* 실패하고, 해당 노드가 트리에서 사라져 관리자 화면으로 복구할 수 없다.
*
* 모든 비즈니스 로직은 data/1.0.5/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path A
*/
class Upgrade_1_0_5 extends AbstractUpgradeStep {}
@@ -0,0 +1,230 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 카테고리 순환 참조를 절단하고 depth/path 를 전면 재구축합니다.
*
* 순환 참조를 막는 검증이 없던 시기에 자기참조(parent_id = id) 또는 다중 노드 사이클이
* 저장될 수 있었습니다. 사이클이 남아 있으면 카테고리 수정·순서 변경이 path 재계산 재귀에서
* 종료되지 않아 요청이 실패합니다.
*
* 처리 순서:
* 1. 루트에서 도달 가능한 노드를 수집하며 depth/path 재계산
* 2. 루트에서 도달하지 못한 노드(= 사이클 구성원)의 parent_id 를 NULL 로 절단 후 다시 재계산
*
* 카테고리는 삭제하지 않으며 계층만 복구합니다.
*
* idempotent: 사이클이 없고 path 가 정합하면 no-op. V-1 안전: Facades\DB/Schema 만 사용.
*/
class BreakCategoryCyclesAndRebuildPaths implements DataMigration
{
private const TABLE = 'ecommerce_categories';
/** @var int 세대 순회 상한 (방어적 종료 조건) */
private const MAX_LEVELS = 1000;
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'BreakCategoryCyclesAndRebuildPaths';
}
/**
* 순환을 절단하고 계층 컬럼을 재구축합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE)
|| ! Schema::hasColumn(self::TABLE, 'parent_id')
|| ! Schema::hasColumn(self::TABLE, 'path')
|| ! Schema::hasColumn(self::TABLE, 'depth')) {
$context->logger->info('[ecommerce:1.0.5] 카테고리 계층 재구축 — 대상 스키마 부재로 스킵');
return;
}
$rows = DB::table(self::TABLE)
->orderBy('id')
->get(['id', 'parent_id', 'depth', 'path']);
if ($rows->isEmpty()) {
$context->logger->info('[ecommerce:1.0.5] 카테고리 없음 — 계층 재구축 스킵');
return;
}
$nodes = [];
foreach ($rows as $row) {
$nodes[(int) $row->id] = [
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
'path' => (string) $row->path,
];
}
$detached = $this->breakCycles($nodes, $context);
$updated = $this->rebuild($nodes, $context);
$context->logger->info(sprintf(
'[ecommerce:1.0.5] 카테고리 계층 재구축 완료 — 순환 절단 %d 건, depth/path 갱신 %d 건',
$detached,
$updated
));
}
/**
* 루트에서 도달하지 못하는 노드(순환 구성원)의 부모 연결을 끊습니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵 (참조 전달, 갱신됨)
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 절단된 노드 수
*/
private function breakCycles(array &$nodes, UpgradeContext $context): int
{
$detached = 0;
// 존재하지 않는 부모를 가리키는 행도 고아이므로 함께 절단 대상에 포함된다.
while (($orphan = $this->findUnreachableNode($nodes)) !== null) {
$context->logger->warning(sprintf(
'[ecommerce:1.0.5] 스냅샷 순환 절단 category_id=%d parent_id=%s depth=%d path=%s',
$orphan,
var_export($nodes[$orphan]['parent_id'], true),
$nodes[$orphan]['depth'],
$nodes[$orphan]['path']
));
DB::table(self::TABLE)->where('id', $orphan)->update(['parent_id' => null]);
$nodes[$orphan]['parent_id'] = null;
$detached++;
if ($detached >= self::MAX_LEVELS) {
$context->logger->warning('[ecommerce:1.0.5] 순환 절단이 상한에 도달 — 수동 확인 필요');
break;
}
}
return $detached;
}
/**
* 루트에서 도달할 수 없는 노드 하나를 찾습니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵
* @return int|null 도달 불가 노드 ID (없으면 null)
*/
private function findUnreachableNode(array $nodes): ?int
{
$reachable = $this->reachableIds($nodes);
foreach (array_keys($nodes) as $id) {
if (! isset($reachable[$id])) {
return $id;
}
}
return null;
}
/**
* 루트(parent_id = null)에서 도달 가능한 노드 ID 집합을 반환합니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵
* @return array<int, bool> 도달 가능 ID 집합
*/
private function reachableIds(array $nodes): array
{
$childrenOf = [];
$reachable = [];
$queue = [];
foreach ($nodes as $id => $node) {
if ($node['parent_id'] === null) {
$reachable[$id] = true;
$queue[] = $id;
continue;
}
$childrenOf[$node['parent_id']][] = $id;
}
while ($queue !== []) {
$current = array_shift($queue);
foreach ($childrenOf[$current] ?? [] as $childId) {
if (isset($reachable[$childId])) {
continue;
}
$reachable[$childId] = true;
$queue[] = $childId;
}
}
return $reachable;
}
/**
* 루트부터 depth/path 를 재계산해 저장합니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 갱신된 행 수
*/
private function rebuild(array $nodes, UpgradeContext $context): int
{
$childrenOf = [];
$queue = [];
foreach ($nodes as $id => $node) {
if ($node['parent_id'] === null) {
$queue[] = [$id, 0, (string) $id];
continue;
}
$childrenOf[$node['parent_id']][] = $id;
}
$updated = 0;
while ($queue !== []) {
[$id, $depth, $path] = array_shift($queue);
if ($nodes[$id]['depth'] !== $depth || $nodes[$id]['path'] !== $path) {
$context->logger->info(sprintf(
'[ecommerce:1.0.5] 스냅샷 category_id=%d depth %d→%d path %s→%s',
$id,
$nodes[$id]['depth'],
$depth,
$nodes[$id]['path'],
$path
));
DB::table(self::TABLE)
->where('id', $id)
->update(['depth' => $depth, 'path' => $path]);
$updated++;
}
foreach ($childrenOf[$id] ?? [] as $childId) {
$queue[] = [$childId, $depth + 1, $path.'/'.$childId];
}
}
return $updated;
}
}
@@ -19,6 +19,8 @@
### Fixed
- 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다.
- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다.
- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다.
## [1.0.1] - 2026-07-08
@@ -795,7 +795,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 특정 버전의 상세 스냅샷을 조회합니다. `{page}` 는 페이지 ID, `{versionId}` 는 버전 ID 입니다. 해당 버전의 전체 본문(`content`)과 메타를 담은 `PageVersionResource` 를 반환하며, 복원 전 미리보기·버전 간 비교에 사용합니다. `{versionId}` 는 라우트-모델 바인딩이 아니어서 문서 생성 시 자동 실측에서 제외되며, 응답 shape 은 `versions.index` 항목과 동일합니다. 미존재 404, 스코프 밖 403.
**설명** 특정 버전의 상세 스냅샷을 조회합니다. `{page}` 는 페이지 ID, `{versionId}` 는 버전 ID 입니다. 해당 버전의 전체 본문(`content`)과 메타를 담은 `PageVersionResource` 를 반환하며, 복원 전 미리보기·버전 간 비교에 사용합니다. `{versionId}` 는 라우트-모델 바인딩이 아니어서 문서 생성 시 자동 실측에서 제외되며, 응답 shape 은 `versions.index` 항목과 동일합니다. 미존재 404, 스코프 밖 403. `{versionId}`는 경로의 `{page}`에 속한 버전이어야 합니다 — 다른 페이지의 버전 ID를 지정하면 404를 반환합니다.
### POST /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}/restore
@@ -838,7 +838,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 페이지를 특정 버전 시점으로 복원합니다. `{page}` 는 페이지 ID, `{versionId}` 는 복원 대상 버전 ID 입니다. `PageService::restoreVersion()` 이 그 버전의 내용을 현재 페이지에 반영하고(복원 자체도 새 버전으로 적재), 복원된 `PageResource` 를 반환합니다. 편집 화면 버전 이력 패널의 "이 버전으로 복원" 액션에 사용합니다. 미존재 404, 스코프 밖 403.
**설명** 페이지를 특정 버전 시점으로 복원합니다. `{page}` 는 페이지 ID, `{versionId}` 는 복원 대상 버전 ID 입니다. `PageService::restoreVersion()` 이 그 버전의 내용을 현재 페이지에 반영하고(복원 자체도 새 버전으로 적재), 복원된 `PageResource` 를 반환합니다. 편집 화면 버전 이력 패널의 "이 버전으로 복원" 액션에 사용합니다. 미존재 404, 스코프 밖 403. `{versionId}`는 경로의 `{page}`에 속한 버전이어야 합니다 — 다른 페이지의 버전 ID를 지정하면 `versions.show` 와 동일하게 404를 반환합니다.
### GET /api/modules/sirsoft-page/pages/attachment/{hash}
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Page\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Modules\Sirsoft\Page\Http\Requests\BulkChangePageStatusRequest;
use Modules\Sirsoft\Page\Http\Requests\ChangePageStatusRequest;
@@ -97,7 +98,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.fetch_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -124,7 +125,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.update_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -146,7 +147,7 @@ class PageController extends AdminBaseController
$this->pageService->deletePage($page);
return $this->success('sirsoft-page::messages.page.delete_success');
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -191,7 +192,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.publish_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -239,7 +240,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.fetch_success',
PageVersionResource::collection($versions)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -259,14 +260,15 @@ class PageController extends AdminBaseController
{
try {
$this->pageService->getPage($id);
$version = $this->pageService->getVersion($versionId);
// 경로의 페이지에 속한 버전만 조회 (교차 페이지 접근 차단)
$version = $this->pageService->getVersion($id, $versionId);
$version->load('creator');
return $this->successWithResource(
'sirsoft-page::messages.page.fetch_success',
new PageVersionResource($version)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -293,7 +295,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.restore_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Page\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
@@ -42,7 +43,18 @@ interface PageVersionRepositoryInterface
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): PageVersion;
/**
* 페이지에 속한 버전을 ID로 조회하며, 없으면 예외를 발생시킵니다.
*
* @param int $pageId 페이지 ID
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws ModelNotFoundException
*/
public function findForPage(int $pageId, int $id): PageVersion;
}
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Page\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface;
@@ -59,10 +60,26 @@ class PageVersionRepository implements PageVersionRepositoryInterface
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): PageVersion
{
return PageVersion::findOrFail($id);
}
/**
* 페이지에 속한 버전을 ID로 조회하며, 없으면 예외를 발생시킵니다.
*
* @param int $pageId 페이지 ID
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws ModelNotFoundException
*/
public function findForPage(int $pageId, int $id): PageVersion
{
return PageVersion::where('page_id', $pageId)
->where('id', $id)
->firstOrFail();
}
}
@@ -259,6 +259,8 @@ class PageService
* @param Page $page 페이지 모델
* @param int $versionId 복원할 버전 ID
* @return Page 복원된 페이지 모델
*
* @throws ModelNotFoundException 버전이 해당 페이지에 속하지 않을 때
*/
public function restoreVersion(Page $page, int $versionId): Page
{
@@ -266,13 +268,10 @@ class PageService
throw new AccessDeniedHttpException(__('auth.scope_denied'));
}
$version = $this->pageVersionRepository->findOrFail($versionId);
if ($version->page_id !== $page->id) {
throw new \InvalidArgumentException(
__('sirsoft-page::messages.errors.version_belongs_to_different_page')
);
}
// 상위 페이지 스코프를 Repository where 절에 반영한다 (SSoT).
// 사후 비교로 처리하면 예외 타입이 컨트롤러 catch 사슬과 어긋나 500 이 되고,
// 같은 위반에 404 를 내는 조회(getVersion)와 계약이 갈린다.
$version = $this->pageVersionRepository->findForPage($page->id, $versionId);
return DB::transaction(function () use ($page, $version) {
$userId = Auth::id();
@@ -350,16 +349,17 @@ class PageService
}
/**
* 버전 ID로 페이지 버전을 조회합니다.
* 페이지에 속한 버전을 조회합니다.
*
* @param int $pageId 페이지 ID
* @param int $versionId 버전 ID
* @return PageVersion 버전 모델
*
* @throws ModelNotFoundException
*/
public function getVersion(int $versionId): PageVersion
public function getVersion(int $pageId, int $versionId): PageVersion
{
return $this->pageVersionRepository->findOrFail($versionId);
return $this->pageVersionRepository->findForPage($pageId, $versionId);
}
/**
@@ -0,0 +1,207 @@
<?php
namespace Modules\Sirsoft\Page\Tests\Feature\Admin;
// FeatureTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../FeatureTestCase.php';
use App\Models\User;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
/**
* 페이지 버전 엔드포인트의 상위 페이지 스코프 검증 테스트
*
* GET /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}
* POST /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}/restore
*
* 검증 목적:
* - 페이지 A 의 경로로 페이지 B 의 버전을 조회·복원할 수 없다 (404)
* - 자기 페이지의 버전은 기존과 동일하게 조회·복원된다 (200)
* - 조회와 복원 두 엔드포인트가 같은 위반에 같은 상태코드를 낸다 (계약 일치)
*/
class PageVersionScopeTest extends FeatureTestCase
{
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-page.pages.read',
'sirsoft-page.pages.update',
]);
}
protected function tearDown(): void
{
Page::where('slug', 'like', 'test-version-scope-%')->forceDelete();
parent::tearDown();
}
/**
* 다른 페이지의 버전은 조회할 수 없다
*
* @scenario resource=page_version, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_read_returns_404
*/
public function test_cannot_show_version_of_other_page(): void
{
$pageA = $this->makePage('test-version-scope-a');
$pageB = $this->makePage('test-version-scope-b');
$versionOfB = $this->makeVersion($pageB);
$this->actingAs($this->adminUser)
->getJson($this->url($pageA->id, $versionOfB->id))
->assertStatus(404);
}
/**
* 자기 페이지의 버전은 조회된다 (회귀 방지)
*
* @scenario resource=page_version, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_show_version_of_own_page(): void
{
$page = $this->makePage('test-version-scope-own');
$version = $this->makeVersion($page);
$this->actingAs($this->adminUser)
->getJson($this->url($page->id, $version->id))
->assertStatus(200)
->assertJsonPath('data.id', $version->id);
}
/**
* 다른 페이지의 버전으로 복원할 수 없다
*
* 조회(showVersion)와 동일한 위반이므로 동일한 404 여야 한다. 종전에는
* Service 의 사후 비교가 `\InvalidArgumentException` 을 던지고 컨트롤러
* catch 사슬에 그 타입이 없어 500 으로 떨어졌다.
*
* @scenario resource=page_version, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected
*/
public function test_cannot_restore_version_of_other_page(): void
{
$pageA = $this->makePage('test-version-scope-restore-a');
$pageB = $this->makePage('test-version-scope-restore-b');
$versionOfB = $this->makeVersion($pageB, [
'title' => ['ko' => '페이지 B 의 버전 제목', 'en' => 'Version title of page B'],
]);
$this->actingAs($this->adminUser)
->postJson($this->url($pageA->id, $versionOfB->id).'/restore')
->assertStatus(404);
// 페이지 A 가 B 의 내용으로 오염되지 않았다
$pageA->refresh();
$this->assertNotSame('페이지 B 의 버전 제목', $pageA->getRawOriginal('title'));
$this->assertStringNotContainsString('페이지 B 의 버전 제목', (string) $pageA->getRawOriginal('title'));
$this->assertSame(1, $pageA->current_version);
}
/**
* 조회와 복원 두 엔드포인트가 같은 위반에 같은 상태코드를 낸다
*
* @scenario resource=page_version, parent_scope=mismatched, actor=admin
*
* @effects sibling_endpoints_share_scope_contract
*/
public function test_show_and_restore_endpoints_reject_cross_page_alike(): void
{
$pageA = $this->makePage('test-version-scope-parity-a');
$pageB = $this->makePage('test-version-scope-parity-b');
$versionOfB = $this->makeVersion($pageB);
$showStatus = $this->actingAs($this->adminUser)
->getJson($this->url($pageA->id, $versionOfB->id))
->getStatusCode();
$restoreStatus = $this->actingAs($this->adminUser)
->postJson($this->url($pageA->id, $versionOfB->id).'/restore')
->getStatusCode();
$this->assertSame($showStatus, $restoreStatus);
}
/**
* 자기 페이지의 버전으로는 복원된다 (과차단 회귀 방지)
*
* @scenario resource=page_version, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_restore_version_of_own_page(): void
{
$page = $this->makePage('test-version-scope-restore-own');
$version = $this->makeVersion($page, [
'title' => ['ko' => '복원 대상 제목', 'en' => 'Restore target title'],
]);
$this->actingAs($this->adminUser)
->postJson($this->url($page->id, $version->id).'/restore')
->assertStatus(200);
$page->refresh();
$this->assertStringContainsString('복원 대상 제목', (string) $page->getRawOriginal('title'));
$this->assertSame(2, $page->current_version);
}
/**
* 테스트용 페이지를 생성합니다.
*
* @param string $slug 페이지 슬러그
* @return Page 생성된 페이지
*/
private function makePage(string $slug): Page
{
return Page::factory()->create([
'slug' => $slug,
'current_version' => 1,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
}
/**
* 테스트용 페이지 버전을 생성합니다.
*
* @param Page $page 소속 페이지
* @param array<string, mixed> $overrides 덮어쓸 속성
* @return PageVersion 생성된 버전
*/
private function makeVersion(Page $page, array $overrides = []): PageVersion
{
return PageVersion::create(array_merge([
'page_id' => $page->id,
'version' => 1,
'title' => $page->title,
'content' => $page->content,
'content_mode' => $page->content_mode,
'created_by' => $this->adminUser->id,
], $overrides));
}
/**
* 버전 상세 조회 엔드포인트 URL 을 생성합니다.
*
* @param int $pageId 페이지 ID
* @param int $versionId 버전 ID
* @return string 엔드포인트 URL
*/
private function url(int $pageId, int $versionId): string
{
return "/api/modules/sirsoft-page/admin/pages/{$pageId}/versions/{$versionId}";
}
}