From b253d6de1ccb1a4a74f8adb4b1324db8146971bb Mon Sep 17 00:00:00 2001 From: SeheeKim Date: Mon, 27 Apr 2026 12:44:06 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=9A=94=EC=B2=AD=EC=84=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90,=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=84=9C=20css,=20js=EB=A5=BC=20=EA=B3=B5=EC=9A=A9=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../static/css/review-request-modal.css | 122 ++++++++++++ src/main/resources/static/js/chat.js | 153 ++------------- .../static/js/review-request-modal.js | 175 ++++++++++++++++++ .../resources/templates/chat/chatrooms.html | 2 + .../templates/workspace/workspace.html | 32 ++++ 5 files changed, 351 insertions(+), 133 deletions(-) create mode 100644 src/main/resources/static/css/review-request-modal.css create mode 100644 src/main/resources/static/js/review-request-modal.js diff --git a/src/main/resources/static/css/review-request-modal.css b/src/main/resources/static/css/review-request-modal.css new file mode 100644 index 0000000..ab63092 --- /dev/null +++ b/src/main/resources/static/css/review-request-modal.css @@ -0,0 +1,122 @@ +/* 공용: 리뷰 요청서 모달 (chat fragment 재사용) */ +.pmt-modal { + display: none; + position: fixed; + inset: 0; + z-index: 1100; + align-items: center; + justify-content: center; + padding: 1rem; +} +.pmt-modal.is-open { display: flex; } + +.pmt-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.82); +} + +.pmt-modal-card { + position: relative; + width: min(560px, calc(100% - 2rem)); + background: linear-gradient(160deg, #0e1c35 0%, #091526 60%, #0c1a30 100%); + border: 1px solid #1e3a5a; + border-radius: 20px; + box-shadow: 0 24px 60px rgba(0,0,0,0.65); + padding: 1.6rem 1.6rem 1.2rem; + color: #fff; +} + +.pmt-modal-header { + display: flex; + align-items: flex-start; + gap: 0.85rem; + margin-bottom: 1.1rem; +} +.pmt-modal-icon { + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(59, 130, 246, 0.12); + color: #93c5fd; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} +.pmt-modal-title { + margin: 0 0 0.25rem 0; + font-size: 1.2rem; + font-weight: 800; +} +.pmt-modal-subtitle { + margin: 0; + font-size: 0.8rem; + color: #7aa8cc; + line-height: 1.55; +} + +.review-req-label { + display: block; + margin: 0 0 8px; + font-size: 0.72rem; + letter-spacing: 0.08em; + font-weight: 800; + color: rgba(148, 163, 184, 0.95); + text-transform: uppercase; +} +.review-req-input, +.review-req-textarea { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.6); + color: #e5e7eb; + border-radius: 12px; + padding: 0.75rem 0.85rem; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.review-req-input:focus, +.review-req-textarea:focus { + border-color: rgba(96, 165, 250, 0.55); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12); +} +.review-req-textarea { resize: vertical; min-height: 120px; line-height: 1.35; } + +.pmt-error { + margin: 0.75rem 0 0 0; + padding: 0.6rem 0.75rem; + font-size: 0.78rem; + background: rgba(255, 82, 82, 0.1); + border: 1px solid rgba(255, 82, 82, 0.35); + border-radius: 8px; + color: #ff7a7a; +} + +.pmt-divider { height: 1px; background: #2a2d3e; margin: 1rem 0 0.9rem 0; } +.pmt-modal-footer { display: flex; align-items: center; justify-content: flex-end; gap: 0.75rem; } +.pmt-btn { cursor: pointer; border: none; font-weight: 800; } +.pmt-btn:disabled { opacity: 0.6; cursor: not-allowed; } +.pmt-btn-text { + background: transparent; + color: #9ca3af; + padding: 0.6rem 1rem; + font-size: 0.85rem; + border-radius: 10px; +} +.pmt-btn-text:hover:not(:disabled) { color: #fff; } +.pmt-btn-primary { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); + color: #0b1220; + padding: 0.7rem 1.25rem; + font-size: 0.9rem; + border-radius: 999px; + box-shadow: 0 6px 18px rgba(59, 130, 246, 0.42); +} +.pmt-btn-primary:hover:not(:disabled) { filter: brightness(1.05); } +.pmt-btn-arrow { font-size: 1.1rem; line-height: 1; font-weight: 900; transform: translateY(-1px); } + diff --git a/src/main/resources/static/js/chat.js b/src/main/resources/static/js/chat.js index ffee7b7..9761af0 100644 --- a/src/main/resources/static/js/chat.js +++ b/src/main/resources/static/js/chat.js @@ -510,129 +510,20 @@ function closePaymentDetailModal() { setPaymentDetailError(''); } -// --- 주니어: 리뷰 요청서 모달 open / close --- - -function setReviewRequestError(msg) { - const errEl = document.getElementById('reviewRequestError'); - if (!errEl) return; - if (msg) { - errEl.textContent = msg; - errEl.style.display = 'block'; - } else { - errEl.textContent = ''; - errEl.style.display = 'none'; - } -} +// --- 주니어: 리뷰 요청서 모달 (공용 모달 컨트롤러 사용) --- function openReviewRequestModal() { - const modal = document.getElementById('reviewRequestModal'); - if (!modal) return; - - setReviewRequestError(''); - - modal.classList.add('is-open'); - modal.setAttribute('aria-hidden', 'false'); - document.body.style.overflow = 'hidden'; - - const firstInput = document.getElementById('reviewRequestGithubPrUrl'); - // 모달 애니메이션 직후 포커스 (즉시 포커스하면 iOS/Safari에서 스크롤 튐) - setTimeout(() => { if (firstInput) firstInput.focus(); }, 50); -} - -function closeReviewRequestModal() { - const modal = document.getElementById('reviewRequestModal'); - if (!modal) return; - - modal.classList.remove('is-open'); - modal.setAttribute('aria-hidden', 'true'); - document.body.style.overflow = ''; - setReviewRequestError(''); -} - -function isValidGithubPrUrl(url) { - // 허용 예시: - // - https://github.com/{owner}/{repo}/pull/{number} - // - https://github.com/{owner}/{repo}/pull/{number}/files - // - https://github.com/{owner}/{repo}/pull/{number}?something - const value = String(url || '').trim(); - if (!value) return false; - return /^https?:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+(?:[/?#].*)?$/i.test(value); -} - -function setReviewRequestSubmitLoading(loading) { - const btn = document.getElementById('reviewRequestSubmitBtn'); - if (!btn) return; - - btn.disabled = !!loading; - - // 버튼 텍스트 토글 (초기 텍스트 보존) - if (!btn.dataset.originalText) { - btn.dataset.originalText = btn.textContent || '제출하고 워크스페이스 입장'; - } - btn.textContent = loading ? '제출 중...' : btn.dataset.originalText; -} - -async function submitReviewRequest() { - const orderIdEl = document.getElementById('reviewRequestOrderId'); - const prEl = document.getElementById('reviewRequestGithubPrUrl'); - const ctxEl = document.getElementById('reviewRequestProjectContext'); - const conEl = document.getElementById('reviewRequestConcernPoint'); - - const orderIdStr = orderIdEl ? String(orderIdEl.value || '').trim() : ''; - const orderId = Number(orderIdStr); - - const githubPrUrl = prEl ? String(prEl.value || '').trim() : ''; - const projectContext = ctxEl ? String(ctxEl.value || '').trim() : ''; - const concernPoint = conEl ? String(conEl.value || '').trim() : ''; - - // 필수값 검증 - if (!orderIdStr || !Number.isFinite(orderId) || orderId <= 0) { - return setReviewRequestError('주문 정보를 찾지 못했어요. 다시 시도해 주세요.'); - } - if (!githubPrUrl) return setReviewRequestError('GitHub PR 링크를 입력해 주세요.'); - if (!isValidGithubPrUrl(githubPrUrl)) return setReviewRequestError('GitHub PR 링크 형식이 올바르지 않아요.'); - if (!projectContext) return setReviewRequestError('배경/비즈니스 로직을 입력해 주세요.'); - if (!concernPoint) return setReviewRequestError('질문/고민 포인트를 입력해 주세요.'); - - // 최소 길이 (너무 짧은 텍스트 방어) - if (projectContext.length < 10) return setReviewRequestError('배경/비즈니스 로직은 10자 이상 입력해 주세요.'); - if (concernPoint.length < 10) return setReviewRequestError('질문/고민 포인트는 10자 이상 입력해 주세요.'); - - setReviewRequestError(''); - setReviewRequestSubmitLoading(true); - - try { - const res = await fetch('/reviews/request', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ - orderId: orderId, - githubPrUrl: githubPrUrl, - projectContext: projectContext, - concernPoint: concernPoint, - }), - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - // 서버가 표준 에러 JSON을 내리는지 불명확하므로 텍스트 기반 fallback - throw new Error(text || `서버 응답 오류 (${res.status})`); - } - - const data = await res.json().catch(() => ({})); - const nextOrderId = data && data.orderId ? String(data.orderId) : orderIdStr; - - // 성공: 통합 리뷰 워크스페이스로 이동 - window.location.href = `/orders/${nextOrderId}`; - } catch (e) { - console.error('[리뷰 요청서 제출 실패]', e); - setReviewRequestError('제출에 실패했어요. 잠시 후 다시 시도해 주세요.'); - setReviewRequestSubmitLoading(false); - } + if (!window.ReviewRequestModal) return; + // orderId는 시스템 메시지 버튼(.action-review) 클릭 시 chat.js가 hidden input에 채워둔다. + const orderId = document.getElementById('reviewRequestOrderId')?.value || ''; + window.ReviewRequestModal.open({ + mode: 'create', + method: 'POST', + orderId: orderId, + githubPrUrl: '', + projectContext: '', + concernPoint: '' + }); } // prepare 실패 직전에 성공했던 주문 데이터가 화면에 남지 않도록 @@ -898,17 +789,7 @@ async function submitPaymentRequest() { // --- 주니어: 리뷰 요청서 모달 이벤트 바인딩 (IIFE로 스코프 격리) --- // 닫기 트리거: [data-review-close] (백드롭 + 취소 버튼). 열기/닫기, 스크롤 락, ESC 는 // openReviewRequestModal, closeReviewRequestModal, bindModalEscape 에서 처리. -(function bindReviewRequestModalEvents() { - const modal = document.getElementById('reviewRequestModal'); - if (!modal) return; // 시니어 화면 등 모달이 없는 경우 - - modal.querySelectorAll('[data-review-close]').forEach(function (el) { - el.addEventListener('click', closeReviewRequestModal); - }); - - const submitBtn = document.getElementById('reviewRequestSubmitBtn'); - if (submitBtn) submitBtn.addEventListener('click', submitReviewRequest); -})(); +// close/submit/ESC 바인딩은 review-request-modal.js에서 처리 // ESC로 열린 모달 닫기 (같은 페이지에 시니어/주니어용 모달 DOM은 둘 다 없고, 둘 중 하나만 존재) (function bindModalEscape() { @@ -918,7 +799,13 @@ async function submitPaymentRequest() { const review = document.getElementById('reviewRequestModal'); if (review && review.classList.contains('is-open')) { e.preventDefault(); - closeReviewRequestModal(); + if (window.ReviewRequestModal && typeof window.ReviewRequestModal.close === 'function') { + window.ReviewRequestModal.close(); + } else { + review.classList.remove('is-open'); + review.setAttribute('aria-hidden', 'true'); + document.body.style.overflow = ''; + } return; } diff --git a/src/main/resources/static/js/review-request-modal.js b/src/main/resources/static/js/review-request-modal.js new file mode 100644 index 0000000..6cf4d3e --- /dev/null +++ b/src/main/resources/static/js/review-request-modal.js @@ -0,0 +1,175 @@ +// 리뷰 요청서 모달 공용 컨트롤러 (create=POST, edit=PATCH) +// - UI 마크업: templates/chat/fragments/review-request-modal.html +// - 스타일: /css/review-request-modal.css +(function () { + function $(id) { return document.getElementById(id); } + + function normalizeStr(v) { return String(v || '').trim(); } + + function setError(msg) { + const errEl = $('reviewRequestError'); + if (!errEl) return; + if (msg) { + errEl.textContent = msg; + errEl.style.display = 'block'; + } else { + errEl.textContent = ''; + errEl.style.display = 'none'; + } + } + + function isValidGithubPrUrl(url) { + const value = normalizeStr(url); + if (!value) return false; + return /^https?:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+(?:[/?#].*)?$/i.test(value); + } + + function setLoading(loading, defaultText) { + const btn = $('reviewRequestSubmitBtn'); + if (!btn) return; + btn.disabled = !!loading; + if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || defaultText || ''; + btn.textContent = loading ? '제출 중...' : (btn.dataset.originalText || defaultText || btn.textContent); + } + + function openModal() { + const modal = $('reviewRequestModal'); + if (!modal) return; + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + document.body.style.overflow = 'hidden'; + } + + function closeModal() { + const modal = $('reviewRequestModal'); + if (!modal) return; + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + document.body.style.overflow = ''; + setError(''); + setLoading(false); + } + + function bindCloseOnce() { + const modal = $('reviewRequestModal'); + if (!modal) return; + if (modal.dataset.boundClose === 'true') return; + modal.dataset.boundClose = 'true'; + + modal.querySelectorAll('[data-review-close]').forEach(function (el) { + el.addEventListener('click', function () { closeModal(); }); + }); + + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + const m = $('reviewRequestModal'); + if (m && m.classList.contains('is-open')) { + e.preventDefault(); + closeModal(); + } + }); + } + + function rebindSubmit(handler) { + const btn = $('reviewRequestSubmitBtn'); + if (!btn) return; + // 기존 리스너 제거(간단/확실): 버튼을 clone 해서 교체 + const clone = btn.cloneNode(true); + btn.replaceWith(clone); + clone.addEventListener('click', handler); + } + + function readPayload() { + const orderIdStr = normalizeStr($('reviewRequestOrderId')?.value); + const orderId = Number(orderIdStr); + const githubPrUrl = normalizeStr($('reviewRequestGithubPrUrl')?.value); + const projectContext = normalizeStr($('reviewRequestProjectContext')?.value); + const concernPoint = normalizeStr($('reviewRequestConcernPoint')?.value); + return { orderIdStr, orderId, githubPrUrl, projectContext, concernPoint }; + } + + async function submitWith(opts) { + const method = (opts && opts.method) ? String(opts.method).toUpperCase() : 'POST'; + const url = opts && opts.url ? String(opts.url) : '/reviews/request'; + + const { orderIdStr, orderId, githubPrUrl, projectContext, concernPoint } = readPayload(); + + if (!orderIdStr || !Number.isFinite(orderId) || orderId <= 0) { + return setError('주문 정보를 찾지 못했어요. 다시 시도해 주세요.'); + } + if (!githubPrUrl) return setError('GitHub PR 링크를 입력해 주세요.'); + if (!isValidGithubPrUrl(githubPrUrl)) return setError('GitHub PR 링크 형식이 올바르지 않아요.'); + if (!projectContext) return setError('배경/비즈니스 로직을 입력해 주세요.'); + if (!concernPoint) return setError('질문/고민 포인트를 입력해 주세요.'); + if (projectContext.length < 10) return setError('배경/비즈니스 로직은 10자 이상 입력해 주세요.'); + if (concernPoint.length < 10) return setError('질문/고민 포인트는 10자 이상 입력해 주세요.'); + + setError(''); + setLoading(true, opts?.submitText); + + try { + const res = await fetch(url, { + method, + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ orderId, githubPrUrl, projectContext, concernPoint }) + }); + if (!res.ok) throw new Error('request failed'); + const data = await res.json().catch(function () { return {}; }); + const nextOrderId = (data && data.orderId) ? String(data.orderId) : orderIdStr; + window.location.href = '/orders/' + encodeURIComponent(nextOrderId); + } catch (e) { + setError(opts?.failMessage || '제출에 실패했어요. 잠시 후 다시 시도해 주세요.'); + setLoading(false, opts?.submitText); + } + } + + window.ReviewRequestModal = { + close: closeModal, + open: function (opts) { + bindCloseOnce(); + setError(''); + + const mode = opts && opts.mode ? String(opts.mode) : 'create'; + const title = mode === 'edit' ? '리뷰 요청서 수정' : '리뷰 요청서 작성'; + const submitText = mode === 'edit' ? '수정해서 다시 제출' : '제출하고 워크스페이스 입장'; + + const titleEl = $('reviewRequestModalTitle'); + if (titleEl) titleEl.textContent = title; + + const orderId = opts && opts.orderId != null ? String(opts.orderId) : ''; + const orderEl = $('reviewRequestOrderId'); + if (orderEl) orderEl.value = orderId; + + const prEl = $('reviewRequestGithubPrUrl'); + const ctxEl = $('reviewRequestProjectContext'); + const conEl = $('reviewRequestConcernPoint'); + if (prEl) prEl.value = opts?.githubPrUrl || ''; + if (ctxEl) ctxEl.value = opts?.projectContext || ''; + if (conEl) conEl.value = opts?.concernPoint || ''; + + // submit 버튼 텍스트 초기화 + const btn = $('reviewRequestSubmitBtn'); + if (btn) { + btn.dataset.originalText = submitText; + btn.textContent = submitText; + } + + // submit handler를 현재 mode에 맞게 재바인딩 + rebindSubmit(function () { + submitWith({ + method: opts?.method || (mode === 'edit' ? 'PATCH' : 'POST'), + url: opts?.url || '/reviews/request', + submitText, + failMessage: opts?.failMessage || (mode === 'edit' + ? '수정에 실패했어요. 리포트가 작성되었거나 권한이 없을 수 있어요.' + : '제출에 실패했어요. 잠시 후 다시 시도해 주세요.') + }); + }); + + openModal(); + setTimeout(function () { if (prEl) prEl.focus(); }, 50); + } + }; +})(); + diff --git a/src/main/resources/templates/chat/chatrooms.html b/src/main/resources/templates/chat/chatrooms.html index a9d4d41..6548641 100644 --- a/src/main/resources/templates/chat/chatrooms.html +++ b/src/main/resources/templates/chat/chatrooms.html @@ -10,6 +10,7 @@ + @@ -245,6 +246,7 @@ + diff --git a/src/main/resources/templates/workspace/workspace.html b/src/main/resources/templates/workspace/workspace.html index 2faa5af..528f0e8 100644 --- a/src/main/resources/templates/workspace/workspace.html +++ b/src/main/resources/templates/workspace/workspace.html @@ -11,6 +11,7 @@ + @@ -318,6 +320,17 @@ th:text="${workspace.hasReviewRequest ? 'PR 파일 정보를 불러오지 못했습니다.' : '아직 제출된 리뷰 요청이 없습니다.'}">
+ + +
+ +
+ PR 링크를 수정하고 다시 제출하면 변경 파일을 불러올 수 있어요. +
+
@@ -579,6 +592,9 @@
+ + + @@ -591,6 +607,9 @@ const orderId = [[${workspace.orderId}]]; const currentNickname = [[${workspace.currentNickname}]]; const chatRoomActive = [[${workspace.chatRoomActive}]]; + const initialGithubPrUrl = /*[[${workspace.githubPrUrl}]]*/ null; + const initialProjectContext = /*[[${workspace.projectContext}]]*/ null; + const initialConcernPoint = /*[[${workspace.concernPoint}]]*/ null; let cursor = null; const initMsgs = /*[[${workspace.initialMessages}]]*/ []; @@ -1022,7 +1041,20 @@ } } catch (e) {} } + + function openReviewRequestEditModal() { + if (!window.ReviewRequestModal) return; + window.ReviewRequestModal.open({ + mode: 'edit', + method: 'PATCH', + orderId: orderId, + githubPrUrl: initialGithubPrUrl || '', + projectContext: initialProjectContext || '', + concernPoint: initialConcernPoint || '' + }); + } + \ No newline at end of file From f1a72dd50142409813e93d28485d727b940d0774 Mon Sep 17 00:00:00 2001 From: SeheeKim Date: Mon, 27 Apr 2026 13:57:32 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EC=A3=BC=EB=8B=88=EC=96=B4/?= =?UTF-8?q?=EC=8B=9C=EB=8B=88=EC=96=B4=20=EB=B6=84=EA=B8=B0=20=ED=9B=84=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=88=98=EC=A0=95=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/workspace/workspace.html | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/resources/templates/workspace/workspace.html b/src/main/resources/templates/workspace/workspace.html index 528f0e8..09b1d6f 100644 --- a/src/main/resources/templates/workspace/workspace.html +++ b/src/main/resources/templates/workspace/workspace.html @@ -317,9 +317,13 @@
📂
+ th:text="${workspace.hasReviewRequest ? 'PR 정보를 불러오지 못했습니다.' : '아직 제출된 리뷰 요청이 없습니다.'}">
+ th:text="${ + workspace.hasReviewRequest + ? (workspace.isSenior ? 'PR 링크가 정상화되면 변경 파일을 확인할 수 있습니다.' : 'PR 링크를 확인한 뒤 다시 제출해 주세요.') + : '주니어가 리뷰 요청서를 제출하면 변경 파일을 확인할 수 있습니다.' + }">
리뷰 요청서 수정 -
- PR 링크를 수정하고 다시 제출하면 변경 파일을 불러올 수 있어요. -
From f20aaaa67e2874d5db3f4502ef0d874d9d7ad5f5 Mon Sep 17 00:00:00 2001 From: SeheeKim Date: Mon, 27 Apr 2026 14:39:31 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=84=9C=20=EC=88=98=EC=A0=95=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/knoc/global/exception/ErrorCode.java | 1 + .../controller/ReviewRequestController.java | 25 +++++---- .../dto/ReviewRequestUpdateRequest.java | 18 ++++++ .../com/knoc/review/entity/ReviewRequest.java | 6 ++ .../repository/ReviewReportRepository.java | 2 + .../review/service/ReviewRequestService.java | 56 ++++++++++++++----- 6 files changed, 82 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/knoc/review/dto/ReviewRequestUpdateRequest.java diff --git a/src/main/java/com/knoc/global/exception/ErrorCode.java b/src/main/java/com/knoc/global/exception/ErrorCode.java index 3e345ee..21059a6 100644 --- a/src/main/java/com/knoc/global/exception/ErrorCode.java +++ b/src/main/java/com/knoc/global/exception/ErrorCode.java @@ -45,6 +45,7 @@ public enum ErrorCode { // 리뷰 요청서 관련 (Review Request) REVIEW_REQUEST_ALREADY_EXISTS(409, "이미 해당 주문에 대한 리뷰 요청서가 존재합니다."), REVIEW_REQUEST_NOT_ALLOWED(403, "결제 완료된 주문만 리뷰 요청서를 작성할 수 있습니다."), + REVIEW_REQUEST_NOT_FOUND(404, "리뷰 요청서가 존재하지 않습니다."), // 리뷰 리포트 관련 (Review Report) REVIEW_REQUEST_REQUIRED_FOR_REPORT(400, "리뷰 요청서가 제출된 경우에만 리포트를 작성할 수 있습니다."), diff --git a/src/main/java/com/knoc/review/controller/ReviewRequestController.java b/src/main/java/com/knoc/review/controller/ReviewRequestController.java index 87771fc..fa0e4b4 100644 --- a/src/main/java/com/knoc/review/controller/ReviewRequestController.java +++ b/src/main/java/com/knoc/review/controller/ReviewRequestController.java @@ -1,11 +1,8 @@ package com.knoc.review.controller; -import com.knoc.global.exception.BusinessException; -import com.knoc.global.exception.ErrorCode; -import com.knoc.member.Member; -import com.knoc.member.MemberRepository; import com.knoc.review.dto.ReviewRequestCreateRequest; import com.knoc.review.dto.ReviewRequestCreateResponse; +import com.knoc.review.dto.ReviewRequestUpdateRequest; import com.knoc.review.service.ReviewRequestService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -15,10 +12,8 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import java.util.Map; @Tag(name="Review-Request-Controller",description = "리뷰 요청서 발행 API") @RestController @@ -26,16 +21,22 @@ @PreAuthorize("hasRole('USER')") @RequestMapping(value = "/reviews") public class ReviewRequestController { - private final MemberRepository memberRepository; private final ReviewRequestService reviewRequestService; @Operation(summary = "리뷰 요청서 생성", description = "주니어가 결제 완료된 주문에 대해 리뷰 요청서를 생성합니다.") @PostMapping(value = "/request") public ResponseEntity request(@AuthenticationPrincipal UserDetails userDetails, @RequestBody @Valid ReviewRequestCreateRequest dto) { - Member junior = memberRepository.findByEmail(userDetails.getUsername()) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, junior.getId()); + ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, userDetails.getUsername()); return ResponseEntity.ok(response); } + + @Operation(summary = "리뷰 요청서 수정", description = "주니어가 리포트 작성 전까지 리뷰 요청서(PR 링크/요청 정보)를 수정합니다.") + @PatchMapping("/request") + @PreAuthorize("hasRole('JUNIOR')") + public ResponseEntity> update(@AuthenticationPrincipal UserDetails userDetails, + @RequestBody @Valid ReviewRequestUpdateRequest req) { + Long orderId = reviewRequestService.updateReviewRequest(userDetails.getUsername(), req); + return ResponseEntity.ok(Map.of("orderId", orderId)); + } } diff --git a/src/main/java/com/knoc/review/dto/ReviewRequestUpdateRequest.java b/src/main/java/com/knoc/review/dto/ReviewRequestUpdateRequest.java new file mode 100644 index 0000000..c6f0dd9 --- /dev/null +++ b/src/main/java/com/knoc/review/dto/ReviewRequestUpdateRequest.java @@ -0,0 +1,18 @@ +package com.knoc.review.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ReviewRequestUpdateRequest( + @NotNull(message = "orderId는 필수입니다.") + Long orderId, + + @NotBlank(message = "githubPrUrl은 필수입니다.") + String githubPrUrl, + + @NotBlank(message = "projectContext는 필수입니다.") + String projectContext, + + @NotBlank(message = "concernPoint는 필수입니다.") + String concernPoint +) {} diff --git a/src/main/java/com/knoc/review/entity/ReviewRequest.java b/src/main/java/com/knoc/review/entity/ReviewRequest.java index 14b9ed9..a851d09 100644 --- a/src/main/java/com/knoc/review/entity/ReviewRequest.java +++ b/src/main/java/com/knoc/review/entity/ReviewRequest.java @@ -62,4 +62,10 @@ public ReviewRequest(Order order, String githubPrUrl, String projectContext, this.additions = additions; this.deletions = deletions; } + + public void update(String githubPrUrl, String projectContext, String concernPoint) { + this.githubPrUrl = githubPrUrl; + this.projectContext = projectContext; + this.concernPoint = concernPoint; + } } \ No newline at end of file diff --git a/src/main/java/com/knoc/review/repository/ReviewReportRepository.java b/src/main/java/com/knoc/review/repository/ReviewReportRepository.java index 71524aa..715f743 100644 --- a/src/main/java/com/knoc/review/repository/ReviewReportRepository.java +++ b/src/main/java/com/knoc/review/repository/ReviewReportRepository.java @@ -8,4 +8,6 @@ public interface ReviewReportRepository extends JpaRepository { Optional findByReviewRequest(ReviewRequest reviewRequest); + + boolean existsByReviewRequest_Order_Id(Long id); } \ No newline at end of file diff --git a/src/main/java/com/knoc/review/service/ReviewRequestService.java b/src/main/java/com/knoc/review/service/ReviewRequestService.java index 4efa57f..d824844 100644 --- a/src/main/java/com/knoc/review/service/ReviewRequestService.java +++ b/src/main/java/com/knoc/review/service/ReviewRequestService.java @@ -4,12 +4,16 @@ import com.knoc.chat.entity.MessageType; import com.knoc.global.exception.BusinessException; import com.knoc.global.exception.ErrorCode; +import com.knoc.member.Member; +import com.knoc.member.MemberRepository; import com.knoc.order.entity.Order; import com.knoc.order.entity.OrderStatus; import com.knoc.order.repository.OrderRepository; import com.knoc.review.dto.ReviewRequestCreateRequest; import com.knoc.review.dto.ReviewRequestCreateResponse; +import com.knoc.review.dto.ReviewRequestUpdateRequest; import com.knoc.review.entity.ReviewRequest; +import com.knoc.review.repository.ReviewReportRepository; import com.knoc.review.repository.ReviewRequestRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -24,21 +28,11 @@ public class ReviewRequestService { private final OrderRepository orderRepository; private final ReviewRequestRepository reviewRequestRepository; private final ApplicationEventPublisher eventPublisher; + private final MemberRepository memberRepository; + private final ReviewReportRepository reviewReportRepository; - public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateRequest dto, Long juniorId) { - // 해당 주문 가져오기 - Order order = orderRepository.findById(dto.getOrderId()) - .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND)); - - // 주니어 소유 검증 - if (!order.getJunior().getId().equals(juniorId)) { - throw new BusinessException(ErrorCode.NOT_JUNIOR_FOR_ORDER); - } - - // 상태 검증 - if (order.getStatus() != OrderStatus.PAID) { - throw new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED); - } + public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateRequest dto, String email) { + Order order = juniorAndOrderValidation(email, dto.getOrderId()); // 중복 검증 if (reviewRequestRepository.existsByOrderId(order.getId())) { @@ -71,4 +65,38 @@ public ReviewRequestCreateResponse createReviewRequest(ReviewRequestCreateReques // 6. 저장된 주문을 클라이언트에게 보여줄 전용 응답 객체(DTO)로 변환 return ReviewRequestCreateResponse.from(reviewRequest); } + + @Transactional + public Long updateReviewRequest(String email, ReviewRequestUpdateRequest req) { + Order order = juniorAndOrderValidation(email, req.orderId()); + if (reviewReportRepository.existsByReviewRequest_Order_Id(order.getId())) { + throw new BusinessException(ErrorCode.REVIEW_REPORT_ALREADY_EXISTS); + } + ReviewRequest rr = reviewRequestRepository.findByOrder(order) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_FOUND)); + rr.update(req.githubPrUrl(), req.projectContext(), req.concernPoint()); + return order.getId(); + } + + private Order juniorAndOrderValidation(String email, Long orderId) { + // juniorId 가져오기 + Long juniorId = memberRepository.findByEmail(email) + .map(Member::getId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 해당 주문 가져오기 + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND)); + + // 주니어 소유 검증 + if (!order.getJunior().getId().equals(juniorId)) { + throw new BusinessException(ErrorCode.NOT_JUNIOR_FOR_ORDER); + } + + // 상태 검증 + if (order.getStatus() != OrderStatus.PAID) { + throw new BusinessException(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED); + } + return order; + } } From da09a7f573cfdeb8a2918b802bff12fef4179e11 Mon Sep 17 00:00:00 2001 From: SeheeKim Date: Mon, 27 Apr 2026 14:39:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=84=9C=20=EC=88=98=EC=A0=95=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReviewRequestServiceTest.java | 157 +++++++++++++++++- 1 file changed, 151 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/knoc/review/service/ReviewRequestServiceTest.java b/src/test/java/com/knoc/review/service/ReviewRequestServiceTest.java index af86640..4c29644 100644 --- a/src/test/java/com/knoc/review/service/ReviewRequestServiceTest.java +++ b/src/test/java/com/knoc/review/service/ReviewRequestServiceTest.java @@ -6,12 +6,15 @@ import com.knoc.global.exception.BusinessException; import com.knoc.global.exception.ErrorCode; import com.knoc.member.Member; +import com.knoc.member.MemberRepository; import com.knoc.order.entity.Order; import com.knoc.order.entity.OrderStatus; import com.knoc.order.repository.OrderRepository; import com.knoc.review.dto.ReviewRequestCreateRequest; import com.knoc.review.dto.ReviewRequestCreateResponse; +import com.knoc.review.dto.ReviewRequestUpdateRequest; import com.knoc.review.entity.ReviewRequest; +import com.knoc.review.repository.ReviewReportRepository; import com.knoc.review.repository.ReviewRequestRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,10 +50,17 @@ class ReviewRequestServiceTest { @Mock private ApplicationEventPublisher eventPublisher; + @Mock + private MemberRepository memberRepository; + + @Mock + private ReviewReportRepository reviewReportRepository; + @Test @DisplayName("리뷰 요청서 작성 성공: PAID 주문에 대해 리뷰 요청서를 저장하고 REVIEW_SUBMITTED 이벤트를 발행한다.") void createReviewRequest_Success() { // given + String email = "junior@test.com"; Long juniorId = 1L; Long orderId = 10L; Long chatRoomId = 3L; @@ -64,6 +74,7 @@ void createReviewRequest_Success() { Member junior = mock(Member.class); given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); ChatRoom chatRoom = mock(ChatRoom.class); given(chatRoom.getId()).willReturn(chatRoomId); @@ -84,7 +95,7 @@ void createReviewRequest_Success() { given(reviewRequestRepository.saveAndFlush(any(ReviewRequest.class))).willAnswer(inv -> inv.getArgument(0)); // when - ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, juniorId); + ReviewRequestCreateResponse response = reviewRequestService.createReviewRequest(dto, email); // then assertThat(response.getOrderId()).isEqualTo(orderId); @@ -104,6 +115,9 @@ void createReviewRequest_Success() { @DisplayName("리뷰 요청서 작성 실패: 주문이 없으면 ORDER_NOT_FOUND 예외가 발생한다.") void createReviewRequest_Fail_OrderNotFound() { // given + String email = "junior@test.com"; + given(memberRepository.findByEmail(email)).willReturn(Optional.of(mock(Member.class))); + ReviewRequestCreateRequest dto = new ReviewRequestCreateRequest( 999L, "https://github.com/user/repo/pull/1", @@ -113,7 +127,7 @@ void createReviewRequest_Fail_OrderNotFound() { given(orderRepository.findById(999L)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, 1L)) + assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, email)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.ORDER_NOT_FOUND.getMessage()); @@ -125,6 +139,7 @@ void createReviewRequest_Fail_OrderNotFound() { @DisplayName("리뷰 요청서 작성 실패: 요청자가 해당 주문의 주니어가 아니면 NOT_JUNIOR_FOR_ORDER 예외가 발생한다.") void createReviewRequest_Fail_NotJuniorForOrder() { // given + String email = "junior@test.com"; Long orderId = 10L; ReviewRequestCreateRequest dto = new ReviewRequestCreateRequest( @@ -137,6 +152,10 @@ void createReviewRequest_Fail_NotJuniorForOrder() { Member orderJunior = mock(Member.class); given(orderJunior.getId()).willReturn(1L); + Member requester = mock(Member.class); + given(requester.getId()).willReturn(999L); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(requester)); + Order order = Order.builder() .orderNumber("ORD-TEST-KEY") .chatRoom(mock(ChatRoom.class)) @@ -150,7 +169,7 @@ void createReviewRequest_Fail_NotJuniorForOrder() { given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); // when & then - assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, 999L)) + assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, email)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.NOT_JUNIOR_FOR_ORDER.getMessage()); @@ -162,6 +181,7 @@ void createReviewRequest_Fail_NotJuniorForOrder() { @DisplayName("리뷰 요청서 작성 실패: 주문 상태가 PAID가 아니면 REVIEW_REQUEST_NOT_ALLOWED 예외가 발생한다.") void createReviewRequest_Fail_OrderNotPaid() { // given + String email = "junior@test.com"; Long juniorId = 1L; Long orderId = 10L; @@ -174,6 +194,7 @@ void createReviewRequest_Fail_OrderNotPaid() { Member junior = mock(Member.class); given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); Order order = Order.builder() .orderNumber("ORD-TEST-KEY") @@ -187,7 +208,7 @@ void createReviewRequest_Fail_OrderNotPaid() { given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); // when & then - assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, juniorId)) + assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, email)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.REVIEW_REQUEST_NOT_ALLOWED.getMessage()); @@ -199,6 +220,7 @@ void createReviewRequest_Fail_OrderNotPaid() { @DisplayName("리뷰 요청서 작성 실패: 이미 리뷰 요청서가 존재하면 REVIEW_REQUEST_ALREADY_EXISTS 예외가 발생한다.") void createReviewRequest_Fail_AlreadyExists() { // given + String email = "junior@test.com"; Long juniorId = 1L; Long orderId = 10L; @@ -211,6 +233,7 @@ void createReviewRequest_Fail_AlreadyExists() { Member junior = mock(Member.class); given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); Order order = Order.builder() .orderNumber("ORD-TEST-KEY") @@ -226,7 +249,7 @@ void createReviewRequest_Fail_AlreadyExists() { given(reviewRequestRepository.existsByOrderId(orderId)).willReturn(true); // when & then - assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, juniorId)) + assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, email)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.REVIEW_REQUEST_ALREADY_EXISTS.getMessage()); @@ -238,6 +261,7 @@ void createReviewRequest_Fail_AlreadyExists() { @DisplayName("리뷰 요청서 작성 실패: 저장 중 UNIQUE 제약 위반이면 REVIEW_REQUEST_ALREADY_EXISTS 예외로 변환한다.") void createReviewRequest_Fail_DataIntegrityViolation() { // given + String email = "junior@test.com"; Long juniorId = 1L; Long orderId = 10L; @@ -250,6 +274,7 @@ void createReviewRequest_Fail_DataIntegrityViolation() { Member junior = mock(Member.class); given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); Order order = Order.builder() .orderNumber("ORD-TEST-KEY") @@ -267,11 +292,131 @@ void createReviewRequest_Fail_DataIntegrityViolation() { .willThrow(new DataIntegrityViolationException("unique constraint")); // when & then - assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, juniorId)) + assertThatThrownBy(() -> reviewRequestService.createReviewRequest(dto, email)) .isInstanceOf(BusinessException.class) .hasMessage(ErrorCode.REVIEW_REQUEST_ALREADY_EXISTS.getMessage()); verify(eventPublisher, never()).publishEvent(any()); } + + @Test + @DisplayName("리뷰 요청서 수정 성공: 리포트가 없고 요청서가 존재하면 요청서를 업데이트한다.") + void updateReviewRequest_Success() { + // given + String email = "junior@test.com"; + Long juniorId = 1L; + Long orderId = 10L; + + Member junior = mock(Member.class); + given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); + + Order order = Order.builder() + .orderNumber("ORD-TEST-KEY") + .chatRoom(mock(ChatRoom.class)) + .junior(junior) + .senior(mock(Member.class)) + .amount(15000) + .build(); + ReflectionTestUtils.setField(order, "id", orderId); + order.updateStatus(OrderStatus.PAID); + + ReviewRequestUpdateRequest req = new ReviewRequestUpdateRequest( + orderId, + "https://github.com/user/repo/pull/2", + "수정된 프로젝트 배경", + "수정된 고민 포인트" + ); + + ReviewRequest rr = mock(ReviewRequest.class); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(reviewReportRepository.existsByReviewRequest_Order_Id(orderId)).willReturn(false); + given(reviewRequestRepository.findByOrder(order)).willReturn(Optional.of(rr)); + + // when + Long result = reviewRequestService.updateReviewRequest(email, req); + + // then + assertThat(result).isEqualTo(orderId); + verify(rr, times(1)).update(req.githubPrUrl(), req.projectContext(), req.concernPoint()); + } + + @Test + @DisplayName("리뷰 요청서 수정 실패: 요청서가 없으면 REVIEW_REQUEST_NOT_FOUND 예외가 발생한다.") + void updateReviewRequest_Fail_RequestNotFound() { + // given + String email = "junior@test.com"; + Long juniorId = 1L; + Long orderId = 10L; + + Member junior = mock(Member.class); + given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); + + Order order = Order.builder() + .orderNumber("ORD-TEST-KEY") + .chatRoom(mock(ChatRoom.class)) + .junior(junior) + .senior(mock(Member.class)) + .amount(15000) + .build(); + ReflectionTestUtils.setField(order, "id", orderId); + order.updateStatus(OrderStatus.PAID); + + ReviewRequestUpdateRequest req = new ReviewRequestUpdateRequest( + orderId, + "https://github.com/user/repo/pull/2", + "수정된 프로젝트 배경", + "수정된 고민 포인트" + ); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(reviewReportRepository.existsByReviewRequest_Order_Id(orderId)).willReturn(false); + given(reviewRequestRepository.findByOrder(order)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> reviewRequestService.updateReviewRequest(email, req)) + .isInstanceOf(BusinessException.class) + .hasMessage(ErrorCode.REVIEW_REQUEST_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("리뷰 요청서 수정 실패: 리포트가 이미 존재하면 REVIEW_REPORT_ALREADY_EXISTS 예외가 발생한다.") + void updateReviewRequest_Fail_ReportAlreadyExists() { + // given + String email = "junior@test.com"; + Long juniorId = 1L; + Long orderId = 10L; + + Member junior = mock(Member.class); + given(junior.getId()).willReturn(juniorId); + given(memberRepository.findByEmail(email)).willReturn(Optional.of(junior)); + + Order order = Order.builder() + .orderNumber("ORD-TEST-KEY") + .chatRoom(mock(ChatRoom.class)) + .junior(junior) + .senior(mock(Member.class)) + .amount(15000) + .build(); + ReflectionTestUtils.setField(order, "id", orderId); + order.updateStatus(OrderStatus.PAID); + + ReviewRequestUpdateRequest req = new ReviewRequestUpdateRequest( + orderId, + "https://github.com/user/repo/pull/2", + "수정된 프로젝트 배경", + "수정된 고민 포인트" + ); + + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(reviewReportRepository.existsByReviewRequest_Order_Id(orderId)).willReturn(true); + + // when & then + assertThatThrownBy(() -> reviewRequestService.updateReviewRequest(email, req)) + .isInstanceOf(BusinessException.class) + .hasMessage(ErrorCode.REVIEW_REPORT_ALREADY_EXISTS.getMessage()); + } } From 02e4c8c23bc9e06f3bd8f3460220f25f5c1ba95d Mon Sep 17 00:00:00 2001 From: SeheeKim Date: Mon, 27 Apr 2026 14:47:25 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/knoc/review/controller/ReviewRequestController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/knoc/review/controller/ReviewRequestController.java b/src/main/java/com/knoc/review/controller/ReviewRequestController.java index fa0e4b4..cbbf844 100644 --- a/src/main/java/com/knoc/review/controller/ReviewRequestController.java +++ b/src/main/java/com/knoc/review/controller/ReviewRequestController.java @@ -33,7 +33,6 @@ public ResponseEntity request(@AuthenticationPrinci @Operation(summary = "리뷰 요청서 수정", description = "주니어가 리포트 작성 전까지 리뷰 요청서(PR 링크/요청 정보)를 수정합니다.") @PatchMapping("/request") - @PreAuthorize("hasRole('JUNIOR')") public ResponseEntity> update(@AuthenticationPrincipal UserDetails userDetails, @RequestBody @Valid ReviewRequestUpdateRequest req) { Long orderId = reviewRequestService.updateReviewRequest(userDetails.getUsername(), req);