diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 00000000..37542fba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,55 @@ +name: πŸ› Bug +description: 버그 리포트 +title: "제λͺ©" +labels: ["bug"] + +body: + - type: textarea + id: reproduce + attributes: + label: 문제 상황 및 μž¬ν˜„ 방법 (ν•„μˆ˜) + description: 버그λ₯Ό κ°„λž΅νžˆ μ„€λͺ…ν•˜κ³ , μž¬ν˜„ν•  수 μžˆλŠ” μ΅œμ†Œ 단계λ₯Ό μž‘μ„±ν•΄μ£Όμ„Έμš”. + placeholder: | + (μ˜ˆμ‹œ) + 정원이 가득 μ°¬ 일정에 μ°Έμ—¬μ‹ μ²­ μ‹œ 500 μ—λŸ¬ λ°œμƒ + + 1. 정원이 가득 μ°¬ 일정 선택 + 2. POST /events/{ν•΄λ‹Ή 일정 id}/registrations 호좜 + 3. 500 μ—λŸ¬ λ°œμƒ + validations: + required: true + + - type: textarea + id: expected + attributes: + label: κΈ°λŒ€ λ™μž‘ (ν•„μˆ˜) + description: 정상이라면 μ–΄λ–€ μƒνƒœ/응닡이 λ‚˜μ™€μ•Ό ν•˜λ‚˜μš”? + placeholder: | + (μ˜ˆμ‹œ) + - 정원 초과 μ‹œ WAITLIST둜 처리 + validations: + required: true + + - type: textarea + id: attachments + attributes: + label: μŠ€ν¬λ¦°μƒ· / 둜그 (선택) + description: κ°€λŠ₯ν•˜λ©΄ μ²¨λΆ€ν•΄μ£Όμ„Έμš”. 이미지/νŒŒμΌμ€ 이 μž…λ ₯칸에 λ“œλž˜κ·Έ&λ“œλ‘­ν•˜λ©΄ μ—…λ‘œλ“œλ©λ‹ˆλ‹€. + placeholder: | + (μ˜ˆμ‹œ) + - Swagger 응닡 캑처 첨뢀 + - μ„œλ²„ 둜그 일뢀 λΆ™μ—¬λ„£κΈ° + validations: + required: false + + - type: textarea + id: suspected_cause + attributes: + label: μ˜ˆμƒ 원인 (선택) + description: 원인을 μΆ”μΈ‘ν•  수 μžˆλ‹€λ©΄ κ°„λ‹¨νžˆ μž‘μ„±ν•΄μ£Όμ„Έμš”. (λͺ¨λ₯΄κ² λ‹€λ©΄ λΉ„μ›Œλ„ λ©λ‹ˆλ‹€) + placeholder: | + (μ˜ˆμ‹œ) + - null 검증이 λˆ„λ½λœ κ²ƒμœΌλ‘œ λ³΄μž…λ‹ˆλ‹€. + - μ˜ˆμ™Έκ°€ GlobalExceptionHandlerμ—μ„œ μ²˜λ¦¬λ˜μ§€ μ•ŠλŠ” 것 κ°™μŠ΅λ‹ˆλ‹€. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/work.yaml b/.github/ISSUE_TEMPLATE/work.yaml new file mode 100644 index 00000000..12293f44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/work.yaml @@ -0,0 +1,52 @@ +name: 🧩 Work +description: κΈ°λŠ₯ μΆ”κ°€, λ¦¬νŒ©ν„°λ§, 인프라, λ¬Έμ„œ λ“± μž‘μ—… 이슈 +title: "제λͺ©" +labels: ["work"] + +body: + - type: textarea + id: summary + attributes: + label: μž‘μ—… λ‚΄μš© (ν•„μˆ˜) + description: 무엇을 λ³€κ²½/μΆ”κ°€ν•˜λŠ”μ§€μ™€ 핡심 λ‚΄μš©μ„ κ°„λ‹¨νžˆ μž‘μ„±ν•΄μ£Όμ„Έμš”. + placeholder: | + (μ˜ˆμ‹œ) + νšŒμ› νƒˆν‡΄ APIλ₯Ό κ΅¬ν˜„ν•©λ‹ˆλ‹€. + - 둜그인된 μ‚¬μš©μžκ°€ νƒˆν‡΄ μš”μ²­ μ‹œ 계정을 μ‚­μ œν•©λ‹ˆλ‹€. + validations: + required: true + + - type: textarea + id: background + attributes: + label: λ°°κ²½ / 문제점 (선택) + description: ν˜„μž¬ μƒνƒœμ™€ 이 μž‘μ—…μ΄ ν•„μš”ν•œ 이유λ₯Ό κ°„λ‹¨νžˆ μž‘μ„±ν•΄μ£Όμ„Έμš”. + placeholder: | + (μ˜ˆμ‹œ) + - νƒˆν‡΄ 둜직의 λΆ€μž¬λ‘œ μƒˆλ‘œμš΄ 계정 생성에 μ œμ•½μ΄ μžˆμ–΄ ν…ŒμŠ€νŠΈκ°€ μ–΄λ ΅μŠ΅λ‹ˆλ‹€. + - μ‚¬μš©μžκ°€ νƒˆν‡΄ν•  수 μžˆλŠ” κΈ°λŠ₯이 ν•„μš”ν•©λ‹ˆλ‹€. + validations: + required: false + + - type: textarea + id: goal + attributes: + label: λͺ©ν‘œ / κΈ°λŒ€ κ²°κ³Ό (ν•„μˆ˜) + description: 이 μž‘μ—…μ΄ μ™„λ£Œλ˜λ©΄ 무엇이 λ‹¬λΌμ§€λ‚˜μš”? + placeholder: | + (μ˜ˆμ‹œ) + - 둜그인된 μ‚¬μš©μžκ°€ νƒˆν‡΄ μš”μ²­ μ‹œ 계정이 μ‚­μ œλ©λ‹ˆλ‹€. + - νƒˆν‡΄ ν›„ 동일 μ΄λ©”μΌλ‘œ μž¬κ°€μž…μ΄ κ°€λŠ₯ν•©λ‹ˆλ‹€. + validations: + required: true + + - type: checkboxes + id: impact + attributes: + label: 영ν–₯ λ²”μœ„ (ν•΄λ‹Ή μ‹œ 체크) + description: ν•΄λ‹Ή μž‘μ—…μ΄ μ‹œμŠ€ν…œμ— λ―ΈμΉ˜λŠ” 영ν–₯이 μžˆλ‹€λ©΄ μ²΄ν¬ν•΄μ£Όμ„Έμš”. + options: + - label: DB μŠ€ν‚€λ§ˆ λ³€κ²½ + - label: API μŠ€νŽ™ λ³€κ²½ + - label: ν™˜κ²½ λ³€μˆ˜ μΆ”κ°€/λ³€κ²½ + - label: λ™μ‹œμ„± / νŠΈλžœμž­μ…˜ 처리 영ν–₯ diff --git a/.github/PULL_REQUEST_TEMPLATE/dev.md b/.github/PULL_REQUEST_TEMPLATE/dev.md new file mode 100644 index 00000000..558f1a66 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/dev.md @@ -0,0 +1,35 @@ +## ✨ Feature PR (to dev) + +### πŸ§ͺ 둜컬 ν…ŒμŠ€νŠΈ μ—¬λΆ€ (μž‘μ—…μž 체크) +- [ ] λ‘œμ»¬μ—μ„œ Swagger ν˜Ήμ€ ν…ŒμŠ€νŠΈ μ½”λ“œλ‘œ λ™μž‘μ„ ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€. +- [ ] API 변경이 μžˆλ‹€λ©΄ `src/main/resources/static/docs/openapi.yaml`을 ν•¨κ»˜ κ°±μ‹ ν–ˆμŠ΅λ‹ˆλ‹€. +- [ ] `/docs/swagger-ui.html`μ—μ„œ λ³€κ²½ APIκ°€ μ˜λ„λŒ€λ‘œ λ³΄μ΄λŠ”μ§€ ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€. + +### πŸ“„ documentation μ΅œμ‹ ν™” μ—¬λΆ€ +> 변경사항과 κ΄€λ ¨λœ API의 swagger documentation이 μ‹€μ œ λ™μž‘κ³Ό μΌμΉ˜ν•˜λŠ”μ§€ ν™•μΈν•©λ‹ˆλ‹€. +- [ ] (μž‘μ—…μž) ν™•μΈν•˜μ˜€μŠ΅λ‹ˆλ‹€. +- [ ] (리뷰어) ν™•μΈν•˜μ˜€μŠ΅λ‹ˆλ‹€. + +### πŸ“Œ μž‘μ—… λ‚΄μš©(what & why) + +- + +### πŸ”Ž 영ν–₯ λ²”μœ„ + +- DB μŠ€ν‚€λ§ˆ λ³€κ²½ +- API μŠ€νŽ™ λ³€κ²½ +- ν™˜κ²½ λ³€μˆ˜ μΆ”κ°€/λ³€κ²½ +- λ™μ‹œμ„± / νŠΈλžœμž­μ…˜ 처리 영ν–₯ + +### πŸ“‘ API 변경사항 (μžˆλ‹€λ©΄) + +| Method | URL | λ³€κ²½ λ‚΄μš© | +|-----------|-----|----------| +| (μ˜ˆμ‹œ) POST | /events | capacity ν•„λ“œ μΆ”κ°€ | + +### πŸ‘€ 집쀑 리뷰 μš”μ²­μ‚¬ν•­ (μžˆλ‹€λ©΄) + +- + +### πŸ”₯ κ΄€λ ¨ 이슈 +- closes # diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE/main.md new file mode 100644 index 00000000..15cd0692 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/main.md @@ -0,0 +1,17 @@ +## πŸš€ Production PR (to main) + +### πŸ§ͺ ν…ŒμŠ€νŠΈ μ—¬λΆ€ +- [ ] dev μ„œλ²„ 배포 성곡 +- [ ] CI 성곡 +- [ ] ν™˜κ²½ λ³€μˆ˜ μ„€μ • 확인 +- [ ] dev μ„œλ²„μ—μ„œ 변경사항에 λŒ€ν•΄ μ˜λ„λŒ€λ‘œ λ™μž‘ν•¨μ„ 확인 +- [ ] API 변경이 μžˆλ‹€λ©΄ `src/main/resources/static/docs/openapi.yaml` 반영 μ™„λ£Œ + +### πŸ“ μ—…λ°μ΄νŠΈ 사항 μš”μ•½ + +- + +### πŸ”₯ κ΄€λ ¨ 이슈 + +- closes # + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5d6af1b7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +## PR ν…œν”Œλ¦Ώ 선택 +>preview 클릭 ν›„ μ›ν•˜λŠ” ν…œν”Œλ¦ΏμœΌλ‘œ μ΄λ™ν•˜μ„Έμš”. + +- ✨ [dev PR ν…œν”Œλ¦Ώ](?expand=1&template=dev.md) +- πŸš€ [main PR ν…œν”Œλ¦Ώ](?expand=1&template=main.md) diff --git a/.github/scripts/slack-dm-notify.mjs b/.github/scripts/slack-dm-notify.mjs new file mode 100644 index 00000000..685d29f7 --- /dev/null +++ b/.github/scripts/slack-dm-notify.mjs @@ -0,0 +1,309 @@ +import fs from "fs"; + +/* ===================== + 0. Guard: secrets μ—†μŒ β†’ 쑰용히 μ’…λ£Œ +===================== */ +const token = process.env.SLACK_BOT_TOKEN; +if (!token) { + console.log("No SLACK_BOT_TOKEN (likely fork PR). Skipping."); + process.exit(0); +} + +/* ===================== + 1. Load event + user map +===================== */ +const event = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8") +); + +// user map: Secret μš°μ„ , μ—†μœΌλ©΄ 파일 fallback +let userMap = {}; +if (process.env.SLACK_USER_MAP) { + userMap = JSON.parse(process.env.SLACK_USER_MAP); +} else if (fs.existsSync(".github/slack-map.json")) { + userMap = JSON.parse( + fs.readFileSync(".github/slack-map.json", "utf8") + ); +} + +/* ===================== + 2. Utilities +===================== */ +const uniq = (arr) => [...new Set(arr.filter(Boolean))]; + +const extractMentions = (text) => { + if (!text) return []; + const re = /@([a-zA-Z0-9-]+)/g; + const out = []; + let m; + while ((m = re.exec(text)) !== null) out.push(m[1]); + return out; +}; + +const ghToSlack = (login) => userMap[login] || null; + +/* ===================== + 3. Slack API helpers +===================== */ +async function slackPost(endpoint, body) { + const res = await fetch(`https://slack.com/api/${endpoint}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (res.status === 429) { + const err = new Error("ratelimited"); + err.retryAfterMs = parseInt(res.headers.get("Retry-After") || "5", 10) * 1000; + throw err; + } + const json = await res.json(); + if (!json.ok) throw new Error(json.error); + return json; +} + +async function sendDM(userId, text) { + const { channel } = await slackPost("conversations.open", { users: userId }); + await slackPost("chat.postMessage", { channel: channel.id, text }); +} + +/* ===================== + 4. GitHub API helper +===================== */ + +// λŒ€κΈ° 쀑인 reviewer(requested_reviewers) + 이미 리뷰 μ œμΆœν•œ reviewer λͺ¨λ‘ λ°˜ν™˜ +async function fetchPRReviewers(prNumber) { + const repo = process.env.GITHUB_REPOSITORY; + const headers = { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github.v3+json", + }; + + const [prRes, reviewsRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}`, { headers }), + fetch(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, { headers }), + ]); + + const pr = await prRes.json(); + const reviews = await reviewsRes.json(); + + const reviewers = new Set( + (pr.requested_reviewers || []).map((r) => r.login) + ); + (Array.isArray(reviews) ? reviews : []).forEach((r) => { + if (r.user?.login) reviewers.add(r.user.login); + }); + + return [...reviewers]; +} + +/* ===================== + 5. Event handlers + 각 ν•Έλ“€λŸ¬λŠ” { title, body, url, recipients } λ₯Ό λ°˜ν™˜ +===================== */ + +// pull_request: opened, reopened, ready_for_review, review_requested, assigned +function handlePullRequest(e) { + const pr = e.pull_request; + const title = `PR #${pr.number}: ${pr.title}`; + const url = pr.html_url; + const recipients = []; + + // 리뷰 μš”μ²­: μƒˆλ‘œ μš”μ²­λœ reviewerμ—κ²Œλ§Œ + if (e.action === "review_requested" && e.requested_reviewer) { + recipients.push(e.requested_reviewer.login); + } + + // assign: μƒˆλ‘œ assign된 μ‚¬λžŒμ—κ²Œλ§Œ + if (e.action === "assigned") { + recipients.push(e.assignee?.login); + } + + // λ³Έλ¬Έ λ©˜μ…˜: 처음 열릴 λ•Œλ§Œ + if (e.action === "opened") { + recipients.push(...extractMentions(pr.body)); + } + + return { title, body: pr.body, url, recipients }; +} + +// pull_request_review: submitted +function handlePullRequestReview(e) { + const pr = e.pull_request; + const title = `PR #${pr.number}: ${pr.title}의 μƒˆ 리뷰 (${e.review.state})`; + const url = e.review.html_url || pr.html_url; + const recipients = [ + pr.user?.login, + ...extractMentions(e.review.body), + ]; + + return { title, body: e.review.body, url, recipients }; +} + +// pull_request_review_comment: created +async function handlePullRequestReviewComment(e) { + const pr = e.pull_request; + const title = `PR #${pr.number} ${pr.title}의 μƒˆ μ½”λ©˜νŠΈ`; + const url = e.comment.html_url; + const reviewers = await fetchPRReviewers(pr.number); + const recipients = [ + pr.user?.login, + ...reviewers, + ...extractMentions(e.comment.body), + ]; + + return { title, body: e.comment.body, url, recipients }; +} + +// issue_comment on PR: created +async function handleIssueCommentOnPR(e) { + const is = e.issue; + const title = `PR #${is.number}: ${is.title}의 μƒˆ μ½”λ©˜νŠΈ`; + const url = e.comment.html_url; + const reviewers = await fetchPRReviewers(is.number); + const recipients = [ + is.user?.login, + ...reviewers, + ...extractMentions(e.comment.body), + ]; + + return { title, body: e.comment.body, url, recipients }; +} + +// issue_comment on Issue: created +function handleIssueComment(e) { + const is = e.issue; + const title = `Issue #${is.number}: ${is.title}의 μƒˆ μ½”λ©˜νŠΈ`; + const url = e.comment.html_url; + const recipients = [ + is.user?.login, + ...extractMentions(e.comment.body), + ]; + + return { title, body: e.comment.body, url, recipients }; +} + +// issues: opened, assigned +function handleIssues(e) { + const is = e.issue; + const title = `Issue #${is.number}: ${is.title}`; + const url = is.html_url; + const recipients = []; + + // assign: μƒˆλ‘œ assign된 μ‚¬λžŒμ—κ²Œλ§Œ + if (e.action === "assigned") { + recipients.push(e.assignee?.login); + } + + // λ³Έλ¬Έ λ©˜μ…˜: 처음 열릴 λ•Œλ§Œ + if (e.action === "opened") { + recipients.push(...extractMentions(is.body)); + } + + return { title, body: is.body, url, recipients }; +} + +/* ===================== + 6. Notification builder (이벀트 λ””μŠ€νŒ¨μ²˜) +===================== */ +async function buildNotification(e) { + let result; + + if (e.review && e.pull_request) { + result = handlePullRequestReview(e); + } else if (e.comment && e.pull_request) { + result = await handlePullRequestReviewComment(e); + } else if (e.comment && e.issue?.pull_request) { + result = await handleIssueCommentOnPR(e); + } else if (e.comment && e.issue) { + result = handleIssueComment(e); + } else if (e.pull_request) { + result = handlePullRequest(e); + } else if (e.issue) { + result = handleIssues(e); + } else { + return { recipients: [], text: "" }; + } + + const { title, body, url, recipients } = result; + const repo = process.env.GITHUB_REPOSITORY; + const action = e.action ? ` (${e.action})` : ""; + const actor = e.sender?.login ? `@${e.sender.login}` : "someone"; + + const text = + `πŸ”” GitHub μ•Œλ¦Ό${action}\n` + + `Repo: ${repo}\n` + + `By: ${actor}\n` + + `${title}\n` + + (body ? `${body}\n` : "") + + `πŸ”— ${url}`; + + const sender = e.sender?.login; + return { recipients: uniq(recipients).filter((r) => r !== sender), text }; +} + +/* ===================== + 7. Main +===================== */ + +// 토큰/μŠ€μ½”ν”„/계정 문제 λ“± λͺ¨λ“  μš”μ²­μ— 영ν–₯을 μ£ΌλŠ” μ „μ—­ 였λ₯˜ β†’ μ¦‰μ‹œ job μ‹€νŒ¨ +const FATAL_SLACK_ERRORS = new Set([ + "invalid_auth", + "not_authed", + "account_inactive", + "token_revoked", + "token_expired", + "no_permission", + "missing_scope", +]); + +const MAX_ATTEMPTS = 3; + +async function main() { + const { recipients, text } = await buildNotification(event); + + const slackUsers = uniq( + recipients.map(ghToSlack).filter(Boolean) + ); + + if (slackUsers.length === 0) { + console.log("No matching Slack users. Skipping."); + return; + } + + let successCount = 0; + for (const uid of slackUsers) { + let sent = false; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + await sendDM(uid, text); + sent = true; + break; + } catch (err) { + // μ „μ—­ 였λ₯˜: μž¬μ‹œλ„ 없이 μ¦‰μ‹œ throw β†’ job μ‹€νŒ¨ + if (FATAL_SLACK_ERRORS.has(err.message)) throw err; + + if (attempt === MAX_ATTEMPTS) { + console.error(`Failed to DM ${uid} after ${MAX_ATTEMPTS} attempts: ${err.message}`); + break; + } + const delay = err.retryAfterMs ?? 1000 * 2 ** (attempt - 1); + console.warn(`Retry ${attempt}/${MAX_ATTEMPTS} for ${uid} in ${delay}ms: ${err.message}`); + await new Promise((r) => setTimeout(r, delay)); + } + } + if (sent) successCount++; + } + + if (successCount === 0) { + throw new Error(`All ${slackUsers.length} DM(s) failed β€” check bot token and user mappings.`); + } + console.log(`Sent Slack DM to ${successCount}/${slackUsers.length} user(s).`); +} + +main().catch((err) => { + console.error("Slack notify failed:", err.message); + process.exit(1); +}); diff --git a/.github/workflows/_deploy.yml b/.github/workflows/_deploy.yml new file mode 100644 index 00000000..3e26e43f --- /dev/null +++ b/.github/workflows/_deploy.yml @@ -0,0 +1,52 @@ +name: deploy-template + +on: + workflow_call: #직접싀행x + inputs: + ocir_repository: + required: true + type: string + dockerfile: + required: true + type: string + description: "Dockerfile path (e.g., api/Dockerfile)" + secrets: + OCI_AUTH_TOKEN: + required: true + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-24.04-arm + permissions: + packages: read + contents: read + + env: + IMAGE_TAG: ${{ github.run_number }} #github.* λ³€μˆ˜λ“€μ€ github 자체 λ‚΄μž₯ + BUILD_NUMBER: ${{ github.run_number }} + OCIR_REGISTRY: yny.ocir.io #동일 + OCIR_NAMESPACE: ax1dvc8vmenm # 같은 ocirλ‚΄μ—μ„œλŠ” 동일 + OCIR_REPOSITORY: ${{ inputs.ocir_repository }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to OCIR + run: echo "${{ secrets.OCI_AUTH_TOKEN }}" | docker login $OCIR_REGISTRY -u ax1dvc8vmenm/members/waffle-deployer --password-stdin + #μ•Œλ§žκ²Œ μˆ˜μ • + + - name: Docker build, tag, and push image to OCIR + id: build-push-image + run: | + echo "${{ github.token }}" > .github_token + docker build \ + --secret id=github_token,src=./.github_token \ + -f ${{ inputs.dockerfile }} \ + -t $OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG \ + . \ + --platform linux/arm64 + rm -f .github_token + docker push $OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG + echo "image=$OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml new file mode 100644 index 00000000..3b20f82e --- /dev/null +++ b/.github/workflows/cd-dev.yml @@ -0,0 +1,127 @@ +name: CD Pipeline - Dev Deploy + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cd-dev + cancel-in-progress: true + +jobs: + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_SHA }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: picokosmos + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + docker.io/picokosmos/spring-app:dev + docker.io/picokosmos/spring-app:dev-${{ env.DEPLOY_SHA }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to Dev Server + uses: appleboy/ssh-action@v1.0.3 + env: + APP_DIR: ${{ secrets.APP_DIR }} + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + port: ${{ secrets.DEV_SSH_PORT }} + envs: APP_DIR + script: | + set -euo pipefail + if [ -n "${APP_DIR:-}" ]; then + cd "$APP_DIR" + else + cd ~/app + fi + + # Create/update .env file with secrets + cat > .env << 'EOF' + SPRING_MAIL_USERNAME=${{ secrets.SPRING_MAIL_USERNAME }} + SPRING_MAIL_PASSWORD=${{ secrets.SPRING_MAIL_PASSWORD }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + SENDGRID_API_KEY=${{ secrets.SENDGRID_API_KEY }} + GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REDIRECT_URI=${{ secrets.DEV_GOOGLE_REDIRECT_URI }} + EMAIL_SERVICE_DOMAIN=${{ secrets.DEV_EMAIL_SERVICE_DOMAIN }} + EOF + + # Pull latest docker-compose file + curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ env.DEPLOY_SHA }}/docker-compose.dev.yml -o docker-compose.yml + + # Pull latest images and restart + docker compose pull + docker compose up -d + + # Clean up old images + docker image prune -f + + # Show status + echo "=== Container Status ===" + docker compose ps + + # Wait for app to be healthy + echo "=== Waiting for app to start ===" + sleep 30 + docker compose logs --tail=50 app + + - name: Health check + uses: appleboy/ssh-action@v1.0.3 + env: + APP_DIR: ${{ secrets.APP_DIR }} + with: + host: ${{ secrets.DEV_HOST }} + username: ${{ secrets.DEV_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + port: ${{ secrets.DEV_SSH_PORT }} + envs: APP_DIR + script: | + set -euo pipefail + if [ -n "${APP_DIR:-}" ]; then + cd "$APP_DIR" + else + cd ~/app + fi + + echo "=== Waiting for app to be healthy ===" + for i in {1..30}; do + if curl -sf http://localhost:8080/actuator/health > /dev/null 2>&1; then + echo "App is healthy!" + exit 0 + fi + echo "Attempt $i/30: App not ready yet, waiting 30s..." + sleep 30 + done + + echo "App failed to start. Logs:" + docker compose logs --tail=100 + exit 1 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index dd1523dd..53f745b0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,18 +1,27 @@ name: CD Pipeline - EC2 Deploy on: - push: - branches: - - main workflow_dispatch: +permissions: + contents: read + +concurrency: + group: cd-main + cancel-in-progress: false + jobs: deploy: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} steps: - name: Checkout code uses: actions/checkout@v4 + with: + ref: ${{ env.DEPLOY_SHA }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -30,7 +39,7 @@ jobs: push: true tags: | docker.io/picokosmos/spring-app:latest - docker.io/picokosmos/spring-app:${{ github.sha }} + docker.io/picokosmos/spring-app:${{ env.DEPLOY_SHA }} cache-from: type=gha cache-to: type=gha,mode=max @@ -66,7 +75,7 @@ jobs: EOF # Pull latest docker-compose file - curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/main/docker-compose.prod.yml -o docker-compose.yml + curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ env.DEPLOY_SHA }}/docker-compose.prod.yml -o docker-compose.yml # Pull latest images and restart docker compose pull diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58f21177..80cda61f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,36 +1,59 @@ -name: ci.yml +name: CI on: pull_request: branches: - main + - dev push: branches: - main + - dev + +permissions: + contents: read jobs: - build: + ci: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Lint OpenAPI YAML + run: npx @redocly/cli@latest lint src/main/resources/static/docs/openapi.yaml + + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - - name: Build with Gradle - run: ./gradlew build -x test - - name: Run ktlint - run: ./gradlew ktlintCheck - continue-on-error: true + run: ./gradlew ktlintCheck --no-daemon + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run tests (includes integration tests) + run: ./gradlew test --no-daemon + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Run integration tests - run: ./gradlew test - continue-on-error: true + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-test-report-${{ github.run_id }} + path: | + build/reports/tests/test + build/test-results/test + if-no-files-found: warn diff --git a/.github/workflows/github-dm-notify.yml b/.github/workflows/github-dm-notify.yml new file mode 100644 index 00000000..2c3dde16 --- /dev/null +++ b/.github/workflows/github-dm-notify.yml @@ -0,0 +1,35 @@ +name: GitHub -> Slack DM Notify + +on: + pull_request: + types: [opened, reopened, ready_for_review, review_requested, assigned] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: read + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Send Slack DMs + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_USER_MAP: ${{ secrets.SLACK_USER_MAP }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/slack-dm-notify.mjs diff --git a/.github/workflows/oci-deploy-dev.yml b/.github/workflows/oci-deploy-dev.yml new file mode 100644 index 00000000..24aee498 --- /dev/null +++ b/.github/workflows/oci-deploy-dev.yml @@ -0,0 +1,14 @@ +name: Deploy-api-dev + +on: + push: + branches: [dev] # μ›ν•˜λŠ” 브랜치둜 μ„€μ • + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: moiming-dev/moiming-server # ocir λ ˆν¬μ— 맞게 μ„€μ • + dockerfile: Dockerfile #dockerfile 경둜 μ„€μ • + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} # μ™€ν”Œ deployer 계정 토큰 ν•„μš” diff --git a/.github/workflows/oci-deploy-prod.yml b/.github/workflows/oci-deploy-prod.yml new file mode 100644 index 00000000..17d17afa --- /dev/null +++ b/.github/workflows/oci-deploy-prod.yml @@ -0,0 +1,29 @@ +name: Deploy-api-prod + +on: + push: + branches: [main] # μ›ν•˜λŠ” 브랜치 μ„€μ • + +jobs: + deploy: + uses: ./.github/workflows/_deploy.yml + with: + ocir_repository: moiming-prod/moiming-server + dockerfile: Dockerfile + secrets: + OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }} + + notify: + needs: deploy + runs-on: ubuntu-latest + steps: + - name: Slack Notify + uses: rtCamp/action-slack-notify@v2.3.3 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: team-moiming-github + SLACK_TITLE: NEW RELEASE + SLACK_USERNAME: moiming-server + SLACK_ICON: https://user-images.githubusercontent.com/35535636/103177470-2237cb00-48be-11eb-9211-3ffa567c8ac3.png + #μ›ν•˜λŠ” μ•„μ΄μ½˜μœΌλ‘œ λ°”κΎΈκΈ° + SLACK_MESSAGE: Check for updated environment diff --git a/Dockerfile b/Dockerfile index be00e2a1..61a5b703 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,16 @@ -FROM gradle:8.5-jdk17 AS build +FROM gradle:8.5-jdk21 AS build WORKDIR /app COPY build.gradle.kts settings.gradle.kts ./ COPY gradle ./gradle COPY src ./src -RUN gradle build --no-daemon -x test +RUN gradle build --no-daemon -x test && \ + BOOT_JAR=$(ls build/libs/*.jar | grep -v plain) && \ + jar tf "$BOOT_JAR" | grep -q "oci-java-sdk-secrets" || (echo "ERROR: OCI SDK missing from fat JAR" && exit 1) +#RUN --mount=type=secret,id=github_token GITHUB_TOKEN=$(cat /run/secrets/github_token) ./gradlew :api:bootJar +#github_token을 λ°›μ•„μ„œ waffle-springμ—μ„œ oci vault 라이브러리λ₯Ό λ°›μ•„μ˜΄. -FROM eclipse-temurin:17-jre + +FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=build /app/build/libs/*.jar app.jar EXPOSE 8080 diff --git a/build.gradle.kts b/build.gradle.kts index e669d039..5b8d2200 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,8 @@ import java.util.Properties plugins { - kotlin("jvm") version "1.9.25" - kotlin("plugin.spring") version "1.9.25" + kotlin("jvm") version "2.2.0" + kotlin("plugin.spring") version "2.2.0" id("org.springframework.boot") version "3.5.5" id("io.spring.dependency-management") version "1.1.7" id("org.flywaydb.flyway") version "9.22.3" @@ -15,7 +15,7 @@ description = "seminar-2025-springboot-assignment" java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -30,6 +30,13 @@ buildscript { repositories { mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/wafflestudio/spring-waffle") + credentials { + username = "wafflestudio" + password = System.getenv("GITHUB_TOKEN") + } + } } dependencies { @@ -37,7 +44,6 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.mindrot:jbcrypt:0.4") implementation("io.jsonwebtoken:jjwt-api:0.11.5") @@ -56,11 +62,20 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:mysql") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // profile_image μ—…λ‘œλ“œ κ΄€λ ¨ μ˜μ‘΄μ„± μΆ”κ°€ implementation(platform("software.amazon.awssdk:bom:2.41.19")) implementation("software.amazon.awssdk:s3") + + // OCI Vault - SDK 직접 μ‚¬μš© (waffle-oci-vault 2.1.0은 Spring Boot 3.5와 ν˜Έν™˜ μ•ˆλ¨) + implementation("com.oracle.oci.sdk:oci-java-sdk-secrets:3.80.1") + implementation("com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.80.1") + + // prometheus μ˜μ‘΄μ„± μΆ”κ°€ + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") } kotlin { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..b723e04d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,98 @@ +version: "3.8" + +services: + app: + image: picokosmos/spring-app:dev + container_name: spring-app-dev + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI} + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/moisha?allowPublicKeyRetrieval=true + SPRING_DATASOURCE_USERNAME: user + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + SPRING_FLYWAY_ENABLED: "true" + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRATION_IN_MS: 86400000 + SENDGRID_API_KEY: ${SENDGRID_API_KEY} + EMAIL_FROM_ADDRESS: picokosmos@gmail.com + EMAIL_FROM_NAME: λͺ¨μ΄λ° + EMAIL_SERVICE_DOMAIN: ${EMAIL_SERVICE_DOMAIN} + CORS_ALLOWED_ORIGINS: https://dev.moiming.app + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + memory: 512M + + mysql: + image: mysql:8.4 + container_name: mysql-db-dev + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: moisha + MYSQL_USER: user + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - mysql_data_dev:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + memory: 384M + + redis: + image: redis:7-alpine + container_name: redis-cache-dev + restart: unless-stopped + command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru + volumes: + - redis_data_dev:/data + deploy: + resources: + limits: + memory: 64M + + prometheus: + image: prom/prometheus:latest + container_name: prometheus-dev + restart: unless-stopped + ports: + - "127.0.0.1:9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data_dev:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + depends_on: + app: + condition: service_healthy + +volumes: + mysql_data_dev: + redis_data_dev: + prometheus_data_dev: diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt index 5dd82a1b..a76969dc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/JavaMailEmailClient.kt @@ -1,8 +1,12 @@ package com.wafflestudio.spring2025.common.email.client +import com.wafflestudio.spring2025.common.email.exception.EmailErrorCode +import com.wafflestudio.spring2025.common.email.exception.EmailServiceUnavailableException +import jakarta.mail.MessagingException import jakarta.mail.internet.MimeMessage import org.slf4j.LoggerFactory import org.springframework.context.annotation.Primary +import org.springframework.mail.MailException import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.MimeMessageHelper import org.springframework.stereotype.Component @@ -21,26 +25,24 @@ class JavaMailEmailClient( fromEmail: String, fromName: String, ) { - logger.info("메일링 μž„μ‹œ μ€‘λ‹¨μœΌλ‘œ λ°œμ†‘ μƒλž΅: $to") - return + // OCI Email Delivery의 SMTP μ—”λ“œν¬μΈνŠΈλ₯Ό μ‚¬μš© (SDKκ°€ μ•„λ‹Œ SMTP 방식) + // λ°œμ‹  μ£Όμ†ŒλŠ” OCI μ½˜μ†” Approved Senders에 λ“±λ‘λ˜μ–΄ μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€. try { val message: MimeMessage = javaMailSender.createMimeMessage() - - val helper = MimeMessageHelper(message, true, "UTF-8") + val helper = MimeMessageHelper(message, "UTF-8") helper.setTo(to) helper.setSubject(subject) - helper.setText(htmlContent, true) - helper.setFrom("$fromName <$fromEmail>") javaMailSender.send(message) - - logger.info("Gmail을 톡해 메일 λ°œμ†‘ 성곡: $to") - } catch (e: Exception) { - logger.error("Gmail 메일 λ°œμ†‘ μ‹€νŒ¨: $to", e) - throw RuntimeException("Email sending failed via Gmail", e) + } catch (e: MessagingException) { + logger.error("메일 ꡬ성 μ‹€νŒ¨: to={}, subject={}", to, subject, e) + throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) + } catch (e: MailException) { + logger.error("메일 전솑 μ‹€νŒ¨: to={}, subject={}", to, subject, e) + throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) } } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt new file mode 100644 index 00000000..fc357e1b --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/client/NoopEmailClient.kt @@ -0,0 +1,22 @@ +package com.wafflestudio.spring2025.common.email.client + +import org.slf4j.LoggerFactory +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.stereotype.Component + +@Component +class NoopEmailClient( + private val javaMailSender: JavaMailSender, +) : EmailClient { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun sendEmail( + to: String, + subject: String, + htmlContent: String, + fromEmail: String, + fromName: String, + ) { + logger.info("메일링 μ€‘λ‹¨μœΌλ‘œ λ°œμ†‘ μƒλž΅: $to") + } +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index ee4e4b88..f05cf475 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -1,16 +1,10 @@ package com.wafflestudio.spring2025.common.email.service -import com.wafflestudio.spring2025.common.email.exception.EmailErrorCode -import com.wafflestudio.spring2025.common.email.exception.EmailServiceUnavailableException +import com.wafflestudio.spring2025.common.email.client.EmailClient import com.wafflestudio.spring2025.config.EmailConfig import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import jakarta.mail.MessagingException -import jakarta.mail.internet.MimeMessage import org.slf4j.LoggerFactory import org.springframework.core.io.ClassPathResource -import org.springframework.mail.MailException -import org.springframework.mail.javamail.JavaMailSender -import org.springframework.mail.javamail.MimeMessageHelper import org.springframework.stereotype.Service import java.nio.charset.StandardCharsets import java.time.Instant @@ -19,8 +13,8 @@ import java.time.format.DateTimeFormatter @Service class EmailService( - private val javaMailSender: JavaMailSender, private val emailConfig: EmailConfig, + private val emailClient: EmailClient, ) { private val logger = LoggerFactory.getLogger(javaClass) @@ -52,24 +46,13 @@ class EmailService( subject: String, htmlContent: String, ) { - try { - val message: MimeMessage = javaMailSender.createMimeMessage() - val helper = MimeMessageHelper(message, "UTF-8") - - helper.setTo(to) - helper.setSubject(subject) - helper.setText(htmlContent, true) - helper.setFrom("${emailConfig.fromName} <${emailConfig.fromEmail}>") - - // AWS SES μƒŒλ“œλ°•μŠ€ λͺ¨λ“œμ—μ„œλŠ” λ°œμ‹ /μˆ˜μ‹  μ£Όμ†Œ λͺ¨λ‘ 사전 인증(Verified)이 ν•„μš”ν•©λ‹ˆλ‹€. - javaMailSender.send(message) - } catch (e: MessagingException) { - logger.error("메일 ꡬ성 μ‹€νŒ¨: to={}, subject={}", to, subject, e) - throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) - } catch (e: MailException) { - logger.error("메일 전솑 μ‹€νŒ¨: to={}, subject={}", to, subject, e) - throw EmailServiceUnavailableException(EmailErrorCode.EMAIL_SERVICE_UNAVAILABLE) - } + emailClient.sendEmail( + to = to, + subject = subject, + htmlContent = htmlContent, + fromEmail = emailConfig.fromEmail, + fromName = emailConfig.fromName, + ) } /** @@ -116,6 +99,35 @@ class EmailService( val registrationStartsAt: Instant?, val registrationEndsAt: Instant?, val description: String?, + val publicId: String?, + ) + + data class EventCancellationEmailData( + val toEmail: String, + val name: String, + val eventTitle: String?, + val startsAt: Instant?, + val endsAt: Instant?, + val location: String?, + val description: String?, + val hostEmail: String, + ) + + data class DemotionEmailData( + val toEmail: String, + val name: String, + val eventTitle: String?, + val startsAt: Instant?, + val endsAt: Instant?, + val location: String?, + val newCapacity: Int, + val totalCount: Int, + val registrationStartsAt: Instant?, + val registrationEndsAt: Instant, + val description: String?, + val publicId: String, + val registrationPublicId: String, + val waitingNum: Int?, ) fun sendRegistrationStatusEmail(data: RegistrationStatusEmailData) { @@ -123,12 +135,12 @@ class EmailService( RegistrationStatus.CONFIRMED -> { val htmlContent = loadTemplate("registration-confirmed.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -138,7 +150,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "λͺ¨μ΄λ° μ°Έμ—¬ μ‹ μ²­ ν™•μ •", + subject = emailName("μ°Έμ—¬ ν™•μ •", data.eventTitle), htmlContent = htmlContent, ) @@ -148,13 +160,13 @@ class EmailService( RegistrationStatus.WAITLISTED -> { val htmlContent = loadTemplate("registration-waitlisted.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -164,7 +176,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "λͺ¨μ΄λ° μ°Έμ—¬ μ‹ μ²­ λŒ€κΈ°", + subject = emailName("λŒ€κΈ° 등둝", data.eventTitle), htmlContent = htmlContent, ) @@ -177,8 +189,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), @@ -186,7 +197,7 @@ class EmailService( sendHtmlEmail( to = data.toEmail, - subject = "λͺ¨μ΄λ° μ°Έμ—¬ μ‹ μ²­ κ°•μ œ μ·¨μ†Œ", + subject = emailName("κ°•μ œ μ·¨μ†Œ", data.eventTitle), htmlContent = htmlContent, ) @@ -201,26 +212,72 @@ class EmailService( fun sendRegistrationDeleteEmail(data: RegistrationDeleteEmailData) { val htmlContent = loadTemplate("registration-delete.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", data.name) .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{totalCount}", data.totalCount?.toString() ?: "null") - .replace("{capacity}", data.capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), ).replace("{description}", formatDescription(data.description)) + .replace("{publicId}", data.publicId ?: "-") sendHtmlEmail( to = data.toEmail, - subject = "λͺ¨μ΄λ° μ°Έμ—¬ μ‹ μ²­ μ·¨μ†Œ", + subject = emailName("μ°Έμ—¬ μ‹ μ²­ μ·¨μ†Œ", data.eventTitle), htmlContent = htmlContent, ) logger.info("μ‹ μ²­ μ‚­μ œ 정보가 ${data.toEmail} 둜 μ „λ‹¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.") } + fun sendEventCancellationEmail(data: EventCancellationEmailData) { + val htmlContent = + loadTemplate("event-cancelled.html") + .replace("{name}", data.name) + .replace("{eventTitle}", formatEventTitle(data.eventTitle)) + .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) + .replace("{location}", formatLocation(data.location)) + .replace("{hostEmail}", data.hostEmail) + .replace("{description}", formatDescription(data.description)) + + sendHtmlEmail( + to = data.toEmail, + subject = emailName("일정 μ·¨μ†Œ", data.eventTitle), + htmlContent = htmlContent, + ) + + logger.info("일정 μ·¨μ†Œ 정보가 ${data.toEmail} 둜 μ „λ‹¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.") + } + + fun sendDemotionEmail(data: DemotionEmailData) { + val htmlContent = + loadTemplate("registration-demoted.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) + .replace("{name}", data.name) + .replace("{waitingNum}", data.waitingNum?.toString() ?: "-") + .replace("{capacityDisplay}", formatCapacity(data.totalCount, data.newCapacity)) + .replace("{eventTitle}", formatEventTitle(data.eventTitle)) + .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) + .replace("{location}", formatLocation(data.location)) + .replace( + "{registrationDateRange}", + formatRegistrationDateRange(data.registrationStartsAt, data.registrationEndsAt), + ).replace("{description}", formatDescription(data.description)) + .replace("{publicId}", data.publicId) + .replace("{registrationPublicId}", data.registrationPublicId) + + sendHtmlEmail( + to = data.toEmail, + subject = emailName("λŒ€κΈ°μžλ‘œ μ „ν™˜", data.eventTitle), + htmlContent = htmlContent, + ) + + logger.info("정원 μΆ•μ†Œ λŒ€κΈ° λ³€κ²½ μ•Œλ¦Όμ΄ ${data.toEmail} 둜 μ „λ‹¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€.") + } + fun sendWaitlistPromotionEmail( toEmail: String, eventTitle: String?, @@ -239,13 +296,13 @@ class EmailService( ) { val htmlContent = loadTemplate("registration-waitlist-promoted.html") + .replace("{serviceDomain}", emailConfig.serviceDomain) .replace("{name}", name) .replace("{waitingNum}", waitingNum?.toString() ?: "-") .replace("{eventTitle}", formatEventTitle(eventTitle)) .replace("{eventDateRange}", formatEventDateRange(startsAt, endsAt, "~")) .replace("{location}", formatLocation(location)) - .replace("{totalCount}", totalCount?.toString() ?: "null") - .replace("{capacity}", capacity?.toString() ?: "null") + .replace("{capacityDisplay}", formatCapacity(totalCount, capacity)) .replace( "{registrationDateRange}", formatRegistrationDateRange(registrationStartsAt, registrationEndsAt), @@ -255,7 +312,7 @@ class EmailService( sendHtmlEmail( to = toEmail, - subject = "λͺ¨μ΄λ° μ°Έμ—¬ μ‹ μ²­ λŒ€κΈ° ν›„, ν™•μ •", + subject = emailName("μ°Έμ—¬ ν™•μ •", eventTitle), htmlContent = htmlContent, ) @@ -274,6 +331,14 @@ class EmailService( location } + private fun emailName( + title: String, + eventTitle: String?, + ): String { + val safeEventTitle = if (eventTitle.isNullOrBlank()) "제λͺ© λ―Έμ • λͺ¨μž„" else eventTitle + return "[λͺ¨μ΄λ°] $title: $safeEventTitle" + } + private fun formatEventDateRange( start: Instant?, end: Instant?, @@ -302,6 +367,20 @@ class EmailService( } } + private fun formatCapacity( + totalCount: Int?, + capacity: Int?, + ): String { + if (capacity == null) return "${totalCount ?: "-"}λͺ…" + val confirmed = if (totalCount != null) minOf(totalCount, capacity) else capacity + val waitlisted = if (totalCount != null) maxOf(0, totalCount - capacity) else 0 + return if (waitlisted > 0) { + "$confirmed/${capacity}λͺ… (λŒ€κΈ°μž ${waitlisted}λͺ…)" + } else { + "$confirmed/${capacity}λͺ…" + } + } + private fun formatDescription(description: String?): String { if (description == null) return "-" return description.replace("\r\n", "\n").replace("\n", "
") diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt index d774bdb0..1cdb9ea8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/image/controller/ImageController.kt @@ -4,10 +4,6 @@ import com.wafflestudio.spring2025.common.image.dto.ImageUploadResponse import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.auth.LoggedInUser import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -22,21 +18,12 @@ import org.springframework.web.multipart.MultipartFile class ImageController( private val imageService: ImageService, ) { - @Operation(summary = "이미지 μ—…λ‘œλ“œ", description = "μ„œλ²„ μ €μž₯μ†Œμ— 이미지λ₯Ό μ—…λ‘œλ“œν•©λ‹ˆλ‹€.") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "이미지 μ—…λ‘œλ“œ 성곡"), - ApiResponse(responseCode = "400", description = "잘λͺ»λœ μš”μ²­ (파일 λˆ„λ½/ν˜•μ‹ 였λ₯˜ λ“±)"), - ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰)"), - ], - ) @PostMapping( consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], ) fun uploadImage( - @Parameter(hidden = true) @LoggedInUser user: User?, + @LoggedInUser user: User?, @RequestPart("image") image: MultipartFile, - @Parameter(description = "이미지λ₯Ό μ €μž₯ν•  μƒμœ„ 경둜", required = false) @RequestParam(name = "prefix", required = false) prefix: String?, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt new file mode 100644 index 00000000..aaac34e7 --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/OciVaultPostProcessorBridge.kt @@ -0,0 +1,72 @@ +package com.wafflestudio.spring2025.config + +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.oracle.bmc.Region +import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider +import com.oracle.bmc.secrets.SecretsClient +import com.oracle.bmc.secrets.model.Base64SecretBundleContentDetails +import com.oracle.bmc.secrets.requests.GetSecretBundleRequest +import org.springframework.boot.SpringApplication +import org.springframework.boot.env.EnvironmentPostProcessor +import org.springframework.core.env.ConfigurableEnvironment +import org.springframework.core.env.MapPropertySource +import java.util.Base64 + +/** + * OCI Vault EnvironmentPostProcessor that replaces the broken waffle-oci-vault library. + * The library (2.1.0) implements org.springframework.boot.EnvironmentPostProcessor (wrong package) + * instead of org.springframework.boot.env.EnvironmentPostProcessor, making it incompatible + * with Spring Boot 3.x. + */ +class OciVaultPostProcessorBridge : EnvironmentPostProcessor { + private val objectMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + override fun postProcessEnvironment( + environment: ConfigurableEnvironment, + application: SpringApplication, + ) { + val secretIds = environment.getProperty("oci.vault.secret-ids") ?: return + val regionId = environment.getProperty("oci.vault.region", "ap-chuncheon-1") + val region = Region.fromRegionId(regionId) + + val authProvider = + try { + InstancePrincipalsAuthenticationDetailsProvider.builder().build() + } catch (e: Exception) { + System.err.println("[OciVault] Instance Principal auth failed: ${e.message}") + return + } + + val secrets = mutableMapOf() + + SecretsClient.builder().region(region).build(authProvider).use { client -> + for (secretId in secretIds.split(",").map { it.trim() }) { + try { + val request = + GetSecretBundleRequest + .builder() + .secretId(secretId) + .build() + val response = client.getSecretBundle(request) + val content = + response.secretBundle.secretBundleContent + as? Base64SecretBundleContentDetails ?: continue + val decoded = String(Base64.getDecoder().decode(content.content)) + val parsed: Map = objectMapper.readValue(decoded) + + secrets.putAll(parsed) + } catch (e: Exception) { + System.err.println("[OciVault] Failed to fetch secret $secretId: ${e.message}") + } + } + } + + if (secrets.isNotEmpty()) { + environment.propertySources.addFirst(MapPropertySource("oci-vault-secrets", secrets)) + } + } +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt b/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt index ead78181..9d44a658 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/config/WebConfig.kt @@ -21,6 +21,8 @@ class WebConfig( } override fun addCorsMappings(registry: CorsRegistry) { + val origins = corsProperties.getAllowedOriginsList() + if (origins.isEmpty()) return registry .addMapping("/**") .allowedOrigins(*corsProperties.getAllowedOriginsList().toTypedArray()) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt index fae13d16..f35d874e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/AuthController.kt @@ -5,10 +5,6 @@ import com.wafflestudio.spring2025.domain.auth.dto.LoginResponse import com.wafflestudio.spring2025.domain.auth.dto.SignupRequest import com.wafflestudio.spring2025.domain.auth.service.AuthService import com.wafflestudio.spring2025.domain.auth.service.EmailVerificationService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable @@ -19,23 +15,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/auth") -@Tag(name = "Auth", description = "인증 API") class AuthController( private val authService: AuthService, private val emailVerificationService: EmailVerificationService, ) { - @Operation( - summary = "이메일 νšŒμ›κ°€μž…", - description = "μƒˆλ‘œμš΄ μ‚¬μš©μžλ₯Ό λ“±λ‘ν•˜κ³  인증 이메일을 λ°œμ†‘ν•©λ‹ˆλ‹€. 이메일 인증 ν›„ κ°€μž…μ΄ μ™„λ£Œλ©λ‹ˆλ‹€.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "인증 이메일 λ°œμ†‘ 성곡"), - ApiResponse(responseCode = "400", description = "잘λͺ»λœ μš”μ²­ (μœ νš¨ν•˜μ§€ μ•Šμ€ email/password/name)"), - ApiResponse(responseCode = "409", description = "이미 μ‘΄μž¬ν•˜λŠ” email"), - ApiResponse(responseCode = "503", description = "이메일 μ„œλΉ„μŠ€ μž₯μ• "), - ], - ) @PostMapping("/signup") fun signup( @RequestBody signupRequest: SignupRequest, @@ -51,16 +34,6 @@ class AuthController( return ResponseEntity.noContent().build() } - @Operation( - summary = "이메일 인증", - description = "μ΄λ©”μΌλ‘œ 받은 인증 μ½”λ“œλ₯Ό κ²€μ¦ν•˜κ³  νšŒμ›κ°€μž…μ„ μ™„λ£Œν•©λ‹ˆλ‹€", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "인증 성곡, μœ μ € 등둝 μ™„λ£Œ"), - ApiResponse(responseCode = "400", description = "μœ νš¨ν•˜μ§€ μ•Šκ±°λ‚˜ 만료된 인증 μ½”λ“œ"), - ], - ) @PostMapping("/email-verification/{verificationCode}") fun verifyEmail( @PathVariable verificationCode: String, @@ -69,13 +42,6 @@ class AuthController( return ResponseEntity.ok().build() } - @Operation(summary = "둜그인", description = "email둜 λ‘œκ·ΈμΈν•˜μ—¬ JWT 토큰을 λ°œκΈ‰λ°›μŠ΅λ‹ˆλ‹€") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "둜그인 성곡, JWT 토큰 λ°˜ν™˜"), - ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨"), - ], - ) @PostMapping("/login") fun login( @RequestBody loginRequest: LoginRequest, @@ -84,12 +50,6 @@ class AuthController( return ResponseEntity.ok(LoginResponse(token)) } - @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "ν˜„μž¬ JWT 토큰을 λΈ”λž™λ¦¬μŠ€νŠΈμ— μΆ”κ°€ν•˜μ—¬ λ‘œκ·Έμ•„μ›ƒν•©λ‹ˆλ‹€") - @ApiResponses( - value = [ - ApiResponse(responseCode = "204", description = "λ‘œκ·Έμ•„μ›ƒ 성곡"), - ], - ) @PostMapping("/logout") fun logout(request: HttpServletRequest): ResponseEntity { val token = resolveToken(request) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt index b43a4ced..14da8c08 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/controller/SocialAuthController.kt @@ -4,9 +4,6 @@ import com.wafflestudio.spring2025.domain.auth.dto.LoginResponse import com.wafflestudio.spring2025.domain.auth.dto.SocialLoginRequest import com.wafflestudio.spring2025.domain.auth.model.SocialProvider import com.wafflestudio.spring2025.domain.auth.service.SocialAuthService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -14,18 +11,10 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("api/auth/social") +@RequestMapping("/api/auth/social") class SocialAuthController( private val socialAuthService: SocialAuthService, ) { - @Operation(summary = "μ†Œμ…œ 둜그인", description = "μ†Œμ…œ λ‘œκ·ΈμΈμ„ μš”μ²­ν•©λ‹ˆλ‹€.") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "μ†Œμ…œ 둜그인 성곡"), - ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨"), - ApiResponse(responseCode = "409", description = "이미 κ°€μž…λœ 이메일"), - ], - ) @PostMapping suspend fun social( @RequestBody socialLoginRequest: SocialLoginRequest, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt index bf17a881..0d5ffea7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/LoginRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "둜그인 μš”μ²­") data class LoginRequest( - @Schema(description = "μ‚¬μš©μž 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "μ‚¬μš©μž λΉ„λ°€λ²ˆν˜Έ", example = "mypassword", required = true) val password: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt deleted file mode 100644 index 470bcb7f..00000000 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/RegisterRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.wafflestudio.spring2025.domain.auth.dto - -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "νšŒμ›κ°€μž… μš”μ²­") -data class RegisterRequest( - @Schema(description = "μ‚¬μš©μž 이메일", example = "user@example.com", required = true) - val email: String, - @Schema(description = "μ‚¬μš©μž 이름", example = "홍길동", required = true) - val name: String, - @Schema(description = "λΉ„λ°€λ²ˆν˜Έ", example = "password123", required = true) - val password: String, - @Schema(description = "ν”„λ‘œν•„ 이미지 URL", example = "https://cdn.example.com/profile.png") - val profileImage: String? = null, -) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt index 94c273ff..53618934 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SignupRequest.kt @@ -1,15 +1,8 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "νšŒμ›κ°€μž… μš”μ²­") data class SignupRequest( - @Schema(description = "μ‚¬μš©μž 이메일", example = "user@example.com", required = true) val email: String, - @Schema(description = "μ‚¬μš©μž 이름", example = "홍길동", required = true) val name: String, - @Schema(description = "λΉ„λ°€λ²ˆν˜Έ", example = "password123", required = true) val password: String, - @Schema(description = "ν”„λ‘œν•„ 이미지 URL", example = "https://cdn.example.com/profile.png") val profileImage: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt index f83e60f3..28fc2bad 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/dto/SocialLoginRequest.kt @@ -1,11 +1,6 @@ package com.wafflestudio.spring2025.domain.auth.dto -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "μ†Œμ…œ 둜그인 μš”μ²­") data class SocialLoginRequest( - @Schema(description = "μ†Œμ…œλ‘œκ·ΈμΈ 제곡자", example = "GOOGLE", required = true) val provider: String, - @Schema(description = "인가 μ½”λ“œ (authorization code)", required = true) val code: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt index de4141b4..ceedbc89 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/AuthService.kt @@ -25,7 +25,7 @@ class AuthService( password: String, ): String { val user: User = userRepository.findByEmail(email) ?: throw LoginFailedException() - if (BCrypt.checkpw(password, user.passwordHash).not()) { + if (user.passwordHash == null || !BCrypt.checkpw(password, user.passwordHash)) { throw LoginFailedException() } val accessToken = jwtTokenProvider.createToken(user.id!!) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt index 2bca98f4..f119bc12 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/auth/service/SocialAuthService.kt @@ -32,7 +32,7 @@ class SocialAuthService( User( email = userInfo.email, name = userInfo.name, - profileImage = userInfo.profilePicture, + profileImage = null, passwordHash = null, ), ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt index 7ba5fae8..7e1a9de5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/controller/EventController.kt @@ -10,8 +10,6 @@ import com.wafflestudio.spring2025.domain.event.dto.response.MyEventsInfiniteRes import com.wafflestudio.spring2025.domain.event.dto.response.UpdateEventResponse import com.wafflestudio.spring2025.domain.event.service.EventService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -26,12 +24,10 @@ import java.time.Instant @RestController @RequestMapping("/api/events") -@Tag(name = "Event", description = "이벀트 관리 API") class EventController( private val eventService: EventService, ) { @AuthRequired - @Operation(summary = "이벀트 생성", description = "μƒˆλ‘œμš΄ 이벀트λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€") @PostMapping // POST /api/events fun create( @LoggedInUser user: User, @@ -54,7 +50,6 @@ class EventController( return ResponseEntity.ok(CreateEventResponse(publicId)) } - @Operation(summary = "이벀트 상세 쑰회", description = "이벀트 상세 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€") @GetMapping("/{publicId}") // GET /api/events/{publicId} fun getById( @LoggedInUser user: User?, // nullable @@ -69,10 +64,6 @@ class EventController( } @AuthRequired - @Operation( - summary = "λ‚΄κ°€ λ§Œλ“  이벀트 λͺ©λ‘ 쑰회 (λ¬΄ν•œ 슀크둀)", - description = "둜그인된 μ‚¬μš©μžκ°€ μƒμ„±ν•œ 이벀트 λͺ©λ‘μ„ λ¬΄ν•œ 슀크둀 방식(cursor)으둜 μ‘°νšŒν•©λ‹ˆλ‹€", - ) @GetMapping("/me") fun getMyEvents( @LoggedInUser user: User, @@ -89,7 +80,6 @@ class EventController( } @AuthRequired - @Operation(summary = "이벀트 μˆ˜μ •", description = "이벀트λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€") @PutMapping("/{publicId}") // PUT /api/events/{publicId} fun update( @LoggedInUser user: User, @@ -116,7 +106,6 @@ class EventController( } @AuthRequired - @Operation(summary = "이벀트 μ‚­μ œ", description = "이벀트λ₯Ό μ‚­μ œν•©λ‹ˆλ‹€") @DeleteMapping("/{publicId}") // DELETE /api/events/{publicId} fun delete( @LoggedInUser user: User, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt index 6b0259f8..778ea5ca 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt @@ -1,35 +1,20 @@ package com.wafflestudio.spring2025.domain.event.dto.core import com.wafflestudio.spring2025.domain.event.model.Event -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "일정 정보") data class EventDto( - @Schema(description = "일정 ID (publicId)") val id: String, - @Schema(description = "이벀트 제λͺ©") val title: String, - @Schema(description = "이벀트 μ„€λͺ…") val description: String?, - @Schema(description = "μž₯μ†Œ") val location: String?, - @Schema(description = "μ‹œμž‘ μ‹œκ°„ (epoch milliseconds)") val startsAt: Long?, - @Schema(description = "μ’…λ£Œ μ‹œκ°„ (epoch milliseconds)") val endsAt: Long?, - @Schema(description = "정원") val capacity: Int?, - @Schema(description = "λŒ€κΈ° λͺ…단 μ‚¬μš© μ—¬λΆ€") val waitlistEnabled: Boolean, - @Schema(description = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (epoch milliseconds)") val registrationStartsAt: Long?, - @Schema(description = "μ‹ μ²­ 마감 μ‹œκ°„ (epoch milliseconds)") - val registrationEndsAt: Long?, - @Schema(description = "μž‘μ„±μž ID") + val registrationEndsAt: Long, val createdBy: Long, - @Schema(description = "생성 μΌμ‹œ (epoch milliseconds)") val createdAt: Long?, - @Schema(description = "μˆ˜μ • μΌμ‹œ (epoch milliseconds)") val updatedAt: Long?, ) { constructor(event: Event) : this( @@ -42,7 +27,7 @@ data class EventDto( capacity = event.capacity, waitlistEnabled = event.waitlistEnabled, registrationStartsAt = event.registrationStartsAt?.toEpochMilli(), - registrationEndsAt = event.registrationEndsAt?.toEpochMilli(), + registrationEndsAt = event.registrationEndsAt.toEpochMilli(), createdBy = event.createdBy, createdAt = event.createdAt?.toEpochMilli(), updatedAt = event.updatedAt?.toEpochMilli(), diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt index dbd1f9d6..1c46a314 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/CreateEventRequest.kt @@ -1,38 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.request -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 생성 μš”μ²­") data class CreateEventRequest( - @Schema(description = "이벀트 제λͺ©", example = "μ •κΈ° λͺ¨μž„") val title: String, - @Schema(description = "이벀트 μ„€λͺ…", example = "μ •κΈ° λͺ¨μž„μž…λ‹ˆλ‹€") val description: String? = null, - @Schema(description = "μž₯μ†Œ", example = "κ°•μ˜μ‹€ 101") val location: String? = null, - @Schema( - description = "μ‹œμž‘ μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T18:00:00Z", - ) val startsAt: Instant? = null, - @Schema( - description = "μ’…λ£Œ μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T20:00:00Z", - ) val endsAt: Instant? = null, - @Schema(description = "정원", example = "30") val capacity: Int, - @Schema(description = "λŒ€κΈ° λͺ…단 μ‚¬μš© μ—¬λΆ€", example = "true") val waitlistEnabled: Boolean, - @Schema( - description = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T17:00:00Z", - ) val registrationStartsAt: Instant? = null, - @Schema( - description = "μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T17:30:00Z", - ) - val registrationEndsAt: Instant? = null, + val registrationEndsAt: Instant, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt index e237b56c..2206d5c5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/request/UpdateEventRequest.kt @@ -1,38 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.request -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 μˆ˜μ • μš”μ²­") data class UpdateEventRequest( - @Schema(description = "이벀트 제λͺ©", example = "μ •κΈ° λͺ¨μž„") val title: String? = null, - @Schema(description = "이벀트 μ„€λͺ…", example = "μ •κΈ° λͺ¨μž„μž…λ‹ˆλ‹€") val description: String? = null, - @Schema(description = "μž₯μ†Œ", example = "κ°•μ˜μ‹€ 101") val location: String? = null, - @Schema( - description = "μ‹œμž‘ μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T18:00:00Z", - ) val startsAt: Instant? = null, - @Schema( - description = "μ’…λ£Œ μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T20:00:00Z", - ) val endsAt: Instant? = null, - @Schema(description = "정원", example = "30") val capacity: Int? = null, - @Schema(description = "λŒ€κΈ° λͺ…단 μ‚¬μš© μ—¬λΆ€", example = "true") val waitlistEnabled: Boolean? = null, - @Schema( - description = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T17:00:00Z", - ) val registrationStartsAt: Instant? = null, - @Schema( - description = "μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601)", - example = "2026-02-02T17:30:00Z", - ) val registrationEndsAt: Instant? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index d2850400..685cfd04 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -1,97 +1,46 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "일정 상세 쑰회 응닡") data class EventDetailResponse( - @Schema(description = "이벀트 정보") val event: EventInfo, - @Schema(description = "이벀트 μƒμ„±μž 정보") val creator: CreatorInfo, - @Schema(description = "쑰회자(viewer) 정보") val viewer: ViewerInfo, - @Schema(description = "μ‘°νšŒμžκ°€ ν•  수 μžˆλŠ” λ™μž‘") val capabilities: CapabilitiesInfo, - @Schema(description = "μ°Έμ—¬μž 미리보기 λͺ©λ‘") val guestsPreview: List, ) -@Schema(description = "이벀트 정보 블둝") data class EventInfo( - @Schema(description = "이벀트 publicId", example = "dj8sadj10djnkas") val publicId: String, - @Schema(description = "이벀트 제λͺ©", example = "제2회 기획 μ„Έλ―Έλ‚˜") val title: String, - @Schema(description = "이벀트 μ„€λͺ…", example = "일정 상세 μ„€λͺ…...", nullable = true) val description: String?, - @Schema(description = "μž₯μ†Œ", example = "μ„œμšΈλŒ€", nullable = true) val location: String?, - @Schema(description = "μ‹œμž‘ μ‹œκ°„ (ISO-8601)", example = "2026-02-02T18:00:00Z", nullable = true) val startsAt: Instant?, - @Schema(description = "μ’…λ£Œ μ‹œκ°„ (ISO-8601)", example = "2026-02-02T20:00:00Z", nullable = true) val endsAt: Instant?, - @Schema(description = "정원", example = "10", nullable = true) val capacity: Int?, - @Schema(description = "총 μ‹ μ²­μž 수(ν™•μ •+λŒ€κΈ° λ“±)", example = "8") - val totalApplicants: Int, - @Schema(description = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) + val confirmedCount: Int, + val waitlistCount: Int, val registrationStartsAt: Instant?, - @Schema(description = "μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601)", example = "2026-02-02T17:00:00Z", nullable = true) - val registrationEndsAt: Instant?, + val registrationEndsAt: Instant, ) -@Schema(description = "μƒμ„±μž 정보 블둝") data class CreatorInfo( - @Schema(description = "이름", example = "홍길동") val name: String, - @Schema(description = "이메일", example = "user@example.com") val email: String, - @Schema(description = "ν”„λ‘œν•„ 이미지", example = "https://example.com/profile.png", nullable = true) val profileImage: String?, ) -@Schema(description = "쑰회자(viewer) 정보 블둝") data class ViewerInfo( - @Schema( - description = "viewer의 등둝 μƒνƒœ (RegistrationStatus μ‚¬μš©)", - example = "WAITLISTED", - ) val status: ViewerStatus, - @Schema( - description = "viewer 이름 (λΉ„νšŒμ›μ΄λ©΄ null)", - example = "홍길동", - nullable = true, - ) val name: String?, - @Schema( - description = "λŒ€κΈ° 순번 (WAITLISTED일 λ•Œλ§Œ 숫자, μ•„λ‹ˆλ©΄ null)", - example = "3", - nullable = true, - ) val waitlistPosition: Int?, - @Schema( - description = "registrationPublicId (게슀트면 κ°’, μ•„λ‹ˆλ©΄ null)", - example = "1", - nullable = true, - ) val registrationPublicId: String?, - @Schema( - description = "μ˜ˆμ•½ 이메일(게슀트면 guestEmail, 둜그인 μœ μ €λ©΄ user email λ“± 정책에 따름)", - example = "skdfsj@gmaldkl", - nullable = true, - ) val reservationEmail: String?, ) -@Schema(description = "κ°€λŠ₯ν•œ λ™μž‘ 블둝") data class CapabilitiesInfo( - @Schema(description = "곡유 링크 κ°€λŠ₯ μ—¬λΆ€", example = "false") val shareLink: Boolean, - @Schema(description = "μ°Έμ—¬ μ‹ μ²­ κ°€λŠ₯ μ—¬λΆ€(ν™•μ • 자리 μ‹ μ²­)", example = "false") val apply: Boolean, - @Schema(description = "λŒ€κΈ° μ‹ μ²­ κ°€λŠ₯ μ—¬λΆ€(정원 찼을 λ•Œ)", example = "false") val wait: Boolean, - @Schema(description = "μ‹ μ²­ μ·¨μ†Œ κ°€λŠ₯ μ—¬λΆ€", example = "true") val cancel: Boolean, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt index 01781f48..2f3314a7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/GuestPreview.kt @@ -1,21 +1,11 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 이벀트 μ°Έμ—¬μž 미리보기 정보 * (νšŒμ›/λΉ„νšŒμ› 포함) */ -@Schema(description = "μ°Έμ—¬μž 미리보기 정보") data class GuestPreview( - @Schema(description = "μ°Έμ—¬μž ID (λΉ„νšŒμ›μ΄λ©΄ null)", example = "1", nullable = true) val id: Long?, - @Schema(description = "μ°Έμ—¬μž 이름", example = "홍길동") val name: String, - @Schema( - description = "ν”„λ‘œν•„ 이미지 URL", - example = "https://example.com/profile.png", - nullable = true, - ) val profileImage: String?, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt index 26d15f6c..1cac6c53 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyEventsInfiniteResponse.kt @@ -14,7 +14,8 @@ data class MyEventResponse( val startsAt: Instant?, val endsAt: Instant?, val registrationStartsAt: Instant?, - val registrationEndsAt: Instant?, + val registrationEndsAt: Instant, val capacity: Int?, - val totalApplicants: Int, + val confirmedCount: Int, + val waitlistCount: Int, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt index d1a8b89a..c06024ed 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/MyRole.kt @@ -1,21 +1,15 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * μš”μ²­μž μ—­ν•  * - CREATOR: 이벀트 μƒμ„±μž * - PARTICIPANT: 이벀트 μ°Έμ—¬μž (CONFIRMED / WAITLISTED) * - NONE: λ‘œκ·ΈμΈμ€ ν–ˆμ§€λ§Œ μ°Έμ—¬ν•˜μ§€ μ•ŠμŒ */ -@Schema(description = "μš”μ²­μž μ—­ν• ") enum class MyRole { - @Schema(description = "이벀트 μƒμ„±μž") CREATOR, - @Schema(description = "이벀트 μ°Έμ—¬μž") PARTICIPANT, - @Schema(description = "이벀트 λ―Έμ°Έμ—¬μž") NONE, } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt index cad30758..6b0a937d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/UpdateEventResponse.kt @@ -12,7 +12,7 @@ data class UpdateEventResponse( val capacity: Int?, val waitlistEnabled: Boolean, val registrationStartsAt: Instant?, - val registrationEndsAt: Instant?, + val registrationEndsAt: Instant, ) { companion object { fun from(event: Event): UpdateEventResponse = diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt index a250a6db..d85fe72a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/ViewerStatus.kt @@ -1,25 +1,17 @@ package com.wafflestudio.spring2025.domain.event.dto.response -import io.swagger.v3.oas.annotations.media.Schema - /** * 이벀트 상세 쑰회 μ‹œ, * "viewer(μš”μ²­μž)"κ°€ ν•΄λ‹Ή μ΄λ²€νŠΈμ™€ μ–΄λ–€ 관계에 μžˆλŠ”μ§€λ₯Ό λ‚˜νƒ€λ‚΄λŠ” μƒνƒœ */ -@Schema(description = "이벀트 쑰회자(Viewer)의 μƒνƒœ") enum class ViewerStatus { - @Schema(description = "이벀트 μƒμ„±μž") HOST, - @Schema(description = "μ°Έμ—¬ ν™•μ •λœ μ‚¬μš©μž") CONFIRMED, - @Schema(description = "λŒ€κΈ° 쀑인 μ‚¬μš©μž") WAITLISTED, - @Schema(description = "κ°•μ œ μ·¨μ†Œ(차단)된 μ‚¬μš©μž") BANNED, - @Schema(description = "μ΄λ²€νŠΈμ™€ 아무 관계 μ—†λŠ” μ‚¬μš©μž") NONE, } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt index a5a14793..9d77e7c4 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/exception/EventErrorCode.kt @@ -48,16 +48,6 @@ enum class EventErrorCode( ), // λͺ¨μž„ μ‹œκ°„ 검증 - EVENT_STARTS_IN_PAST( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "λͺ¨μž„ μ‹œμž‘ μ‹œκ°„μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", - message = "λͺ¨μž„ μ‹œμž‘ μ‹œκ°„μ€\nν˜„μž¬ μ‹œκ°„ 이후여야 ν•©λ‹ˆλ‹€.", - ), - EVENT_ENDS_IN_PAST( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "λͺ¨μž„ μ’…λ£Œ μ‹œκ°„μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", - message = "λͺ¨μž„ μ’…λ£Œ μ‹œκ°„μ€\nν˜„μž¬ μ‹œκ°„ 이후여야 ν•©λ‹ˆλ‹€.", - ), EVENT_TIME_RANGE_INVALID( httpStatusCode = HttpStatus.BAD_REQUEST, title = "λͺ¨μž„ μ‹œκ°„μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", @@ -65,11 +55,6 @@ enum class EventErrorCode( ), // μ‹ μ²­ κΈ°κ°„ 검증 - REGISTRATION_STARTS_IN_PAST( - httpStatusCode = HttpStatus.BAD_REQUEST, - title = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", - message = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„μ€\nν˜„μž¬ μ‹œκ°„ 이후여야 ν•©λ‹ˆλ‹€.", - ), REGISTRATION_ENDS_IN_PAST( httpStatusCode = HttpStatus.BAD_REQUEST, title = "μ‹ μ²­ 마감 μ‹œκ°„μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", @@ -90,6 +75,11 @@ enum class EventErrorCode( title = "μ‹ μ²­ 기간이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", message = "μ‹ μ²­ 마감 μ‹œκ°„μ΄ λͺ¨μž„ μ‹œμž‘ μ‹œκ°„λ³΄λ‹€\n빨라야 ν•©λ‹ˆλ‹€.", ), + REGISTRATION_ENDS_AFTER_EVENT_END( + httpStatusCode = HttpStatus.BAD_REQUEST, + title = "μ‹ μ²­ 기간이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", + message = "μ‹ μ²­ 마감 μ‹œκ°„μ΄ λͺ¨μž„ μ’…λ£Œ μ‹œκ°„λ³΄λ‹€\n빨라야 ν•©λ‹ˆλ‹€.", + ), // 23xx - Conflict EVENT_FULL( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt index 683ef8b8..3f635d9d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt @@ -26,7 +26,7 @@ class Event( @Column("registration_starts_at") var registrationStartsAt: Instant? = null, @Column("registration_ends_at") - var registrationEndsAt: Instant? = null, + var registrationEndsAt: Instant, @Column("created_by") var createdBy: Long, @CreatedDate diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt index 00c251ab..e9ff2f04 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventLockRepository.kt @@ -7,11 +7,21 @@ import org.springframework.stereotype.Repository class EventLockRepository( private val jdbcTemplate: JdbcTemplate, ) { - fun lockById(eventId: Long) { - jdbcTemplate.queryForObject( - "SELECT id FROM events WHERE id = ? FOR UPDATE", - Long::class.java, - eventId, - ) + fun lockIdByPublicId(publicId: String): Long? = + jdbcTemplate + .query( + "SELECT id FROM events WHERE public_id = ? FOR UPDATE", + { rs, _ -> rs.getLong("id") }, + publicId, + ).firstOrNull() + + fun lockById(eventId: Long): Boolean { + val result = + jdbcTemplate.query( + "SELECT id FROM events WHERE id = ? FOR UPDATE", + { rs, _ -> rs.getLong("id") }, + eventId, + ) + return result.isNotEmpty() } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index c76a6237..7d41b3e9 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -1,5 +1,6 @@ package com.wafflestudio.spring2025.domain.event.service +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.event.dto.response.CapabilitiesInfo import com.wafflestudio.spring2025.domain.event.dto.response.CreatorInfo @@ -15,22 +16,31 @@ import com.wafflestudio.spring2025.domain.event.exception.EventForbiddenExceptio import com.wafflestudio.spring2025.domain.event.exception.EventNotFoundException import com.wafflestudio.spring2025.domain.event.exception.EventValidationException import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventLockRepository import com.wafflestudio.spring2025.domain.event.repository.EventRepository import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import com.wafflestudio.spring2025.domain.registration.service.WaitlistReconciliationService import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.Instant import java.util.UUID @Service class EventService( private val eventRepository: EventRepository, + private val eventLockRepository: EventLockRepository, private val registrationRepository: RegistrationRepository, + private val waitlistReconciliationService: WaitlistReconciliationService, private val userRepository: UserRepository, private val imageService: ImageService, + private val emailService: EmailService, ) { /** * 일정 생성 @@ -44,15 +54,18 @@ class EventService( capacity: Int?, waitlistEnabled: Boolean, registrationStartsAt: Instant?, - registrationEndsAt: Instant?, + registrationEndsAt: Instant, createdBy: Long, ): String { - validateCreateOrUpdate( + val actualRegistrationStartsAt = registrationStartsAt ?: Instant.now() + + validateCreateConstraints(registrationEndsAt) + validateInvariantConstraints( title = title, + capacity = capacity, startsAt = startsAt, endsAt = endsAt, - capacity = capacity, - registrationStartsAt = registrationStartsAt, + registrationStartsAt = actualRegistrationStartsAt, registrationEndsAt = registrationEndsAt, ) @@ -66,7 +79,7 @@ class EventService( endsAt = endsAt, capacity = capacity, waitlistEnabled = waitlistEnabled, - registrationStartsAt = registrationStartsAt, + registrationStartsAt = actualRegistrationStartsAt, registrationEndsAt = registrationEndsAt, createdBy = createdBy, ) @@ -84,10 +97,9 @@ class EventService( val event = getEventByPublicId(publicId) val eventId = requireNotNull(event.id) { "Event id is null: publicId=$publicId" } - val creatorUser = - userRepository.findById(event.createdBy).orElseThrow { - EventNotFoundException() - } + val userIdsToFetch = listOfNotNull(event.createdBy, requesterId).distinct() + val usersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! } + val creatorUser = usersById[event.createdBy] ?: throw EventNotFoundException() val myReg = if (requesterId == null) { @@ -109,17 +121,16 @@ class EventService( .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.WAITLISTED) .toInt() - val totalApplicants = confirmedCount + waitlistedCount - val waitlistPosition: Int? = if (myReg?.status == RegistrationStatus.WAITLISTED) { - val waitlistedRegs = - registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( - eventID = eventId, - registrationStatus = RegistrationStatus.WAITLISTED, - ) - val idx = waitlistedRegs.indexOfFirst { it.id == myReg.id } - if (idx >= 0) idx + 1 else null + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = listOf(myReg.registrationPublicId), + ).firstOrNull() + ?.waitlistNumber + ?.toInt() } else { null } @@ -138,10 +149,7 @@ class EventService( } } - val viewerName: String? = - requesterId?.let { - userRepository.findById(it).orElse(null)?.name - } + val viewerName: String? = requesterId?.let { usersById[it]?.name } val viewer = if (viewerStatus == ViewerStatus.NONE) { @@ -172,18 +180,17 @@ class EventService( registrationEndsAt = event.registrationEndsAt, ) - val confirmedRegs = + val previewRegs = registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED, + pageable = Pageable.ofSize(5), ) - val previewRegs = confirmedRegs.take(5) - val previewUserIds = previewRegs.mapNotNull { it.userId }.distinct() - val usersById = + val previewUsersById = userRepository.findAllById(previewUserIds).associateBy { it.id!! } val guestsPreview = @@ -196,7 +203,7 @@ class EventService( profileImage = null, ) } else { - usersById[uid]?.let { + previewUsersById[uid]?.let { GuestPreview( id = it.id!!, name = it.name, @@ -216,7 +223,8 @@ class EventService( startsAt = event.startsAt, endsAt = event.endsAt, capacity = event.capacity, - totalApplicants = totalApplicants, + confirmedCount = confirmedCount, + waitlistCount = waitlistedCount, registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, ), @@ -263,19 +271,31 @@ class EventService( val sliced = fetched.take(pageSize) val nextCursor = sliced.lastOrNull()?.createdAt + val eventIds = sliced.map { requireNotNull(it.id) } + + val confirmedCounts = + if (eventIds.isEmpty()) { + emptyMap() + } else { + registrationRepository + .countByEventIdsAndStatuses(eventIds = eventIds, listOf(RegistrationStatus.CONFIRMED)) + .associate { it.eventId to it.totalCount.toInt() } + } + + val waitlistedCounts = + if (eventIds.isEmpty()) { + emptyMap() + } else { + registrationRepository + .countByEventIdsAndStatuses(eventIds = eventIds, listOf(RegistrationStatus.WAITLISTED)) + .associate { it.eventId to it.totalCount.toInt() } + } + val responses = sliced.map { event -> val eventId = requireNotNull(event.id) - - val confirmedCount = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.CONFIRMED) - .toInt() - - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(eventID = eventId, registrationStatus = RegistrationStatus.WAITLISTED) - .toInt() + val confirmedCount = confirmedCounts[eventId] ?: 0 + val waitlistedCount = waitlistedCounts[eventId] ?: 0 MyEventResponse( publicId = event.publicId, @@ -285,7 +305,8 @@ class EventService( registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, capacity = event.capacity, - totalApplicants = confirmedCount + waitlistedCount, + confirmedCount = confirmedCount, + waitlistCount = waitlistedCount, ) } @@ -296,6 +317,7 @@ class EventService( ) } + @Transactional fun update( publicId: String, title: String?, @@ -309,41 +331,96 @@ class EventService( registrationEndsAt: Instant?, requesterId: Long, ): Event { - val event = getEventByPublicId(publicId) - requireCreator(event, requesterId) + val eventForAuth = getEventByPublicId(publicId) + requireCreator(eventForAuth, requesterId) + val eventId = requireNotNull(eventForAuth.id) { "Event id is null: publicId=$publicId" } + if (!eventLockRepository.lockById(eventId)) throw EventNotFoundException() + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } + val previousCapacity = event.capacity + + val mergedTitle = title?.trim() ?: event.title + val mergedCapacity = capacity ?: event.capacity + val mergedStartsAt = startsAt ?: event.startsAt + val mergedEndsAt = endsAt ?: event.endsAt + val mergedRegistrationStartsAt = registrationStartsAt ?: event.registrationStartsAt + val mergedRegistrationEndsAt = registrationEndsAt ?: event.registrationEndsAt + + validateInvariantConstraints( + title = mergedTitle, + capacity = mergedCapacity, + startsAt = mergedStartsAt, + endsAt = mergedEndsAt, + registrationStartsAt = mergedRegistrationStartsAt, + registrationEndsAt = mergedRegistrationEndsAt, + ) - title?.let { - if (it.isBlank()) throw EventValidationException(EventErrorCode.EVENT_TITLE_BLANK) - event.title = it.trim() - } + event.title = mergedTitle description?.let { event.description = it } location?.let { event.location = it } - startsAt?.let { event.startsAt = it } - endsAt?.let { event.endsAt = it } - capacity?.let { event.capacity = it } + event.startsAt = mergedStartsAt + event.endsAt = mergedEndsAt + event.capacity = mergedCapacity waitlistEnabled?.let { event.waitlistEnabled = it } - registrationStartsAt?.let { event.registrationStartsAt = it } - registrationEndsAt?.let { event.registrationEndsAt = it } - - validateCreateOrUpdate( - title = event.title, - startsAt = event.startsAt, - endsAt = event.endsAt, - capacity = event.capacity, - registrationStartsAt = event.registrationStartsAt, - registrationEndsAt = event.registrationEndsAt, - ) + event.registrationStartsAt = mergedRegistrationStartsAt + event.registrationEndsAt = mergedRegistrationEndsAt - return eventRepository.save(event) + val savedEvent = eventRepository.save(event) + if (isCapacityIncreased(previousCapacity, savedEvent.capacity)) { + waitlistReconciliationService.reconcileWaitlist(eventId) + } + if (isCapacityDecreased(previousCapacity, savedEvent.capacity)) { + waitlistReconciliationService.demoteToWaitlist(eventId, savedEvent.capacity!!) + } + return savedEvent } + @Transactional fun delete( publicId: String, requesterId: Long, ) { - val event = getEventByPublicId(publicId) + val eventId = eventLockRepository.lockIdByPublicId(publicId) ?: throw EventNotFoundException() + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } requireCreator(event, requesterId) - eventRepository.deleteById(requireNotNull(event.id)) + + // μ•Œλ¦Ό λŒ€μƒ: CONFIRMED + WAITLISTED (BANNED μ œμ™Έ) + val registrationsToNotify = + registrationRepository.findByEventIdAndStatusIn( + eventID = eventId, + statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + ) + + // 이메일 데이터 ꡬ성 (μ‚­μ œ 전에 user 정보 쑰회) + val hostUser = userRepository.findById(event.createdBy).orElse(null) + val userIds = registrationsToNotify.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + registrationsToNotify.mapNotNull { reg -> + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.EventCancellationEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "μ°Έμ—¬μž", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + description = event.description, + hostEmail = hostUser?.email ?: "", + ) + } + + // FK μ œμ•½μœΌλ‘œ 인해 event μ‚­μ œ μ „ registrations λ¨Όμ € μ‚­μ œ + registrationRepository.deleteByEventId(eventId) + eventRepository.deleteById(eventId) + + afterCommit { + emailDataList.forEach { data -> + emailService.sendEventCancellationEmail(data) + } + } } private fun getEventByPublicId(publicId: String): Event = @@ -359,14 +436,22 @@ class EventService( } } - private fun validateCreateOrUpdate( + private fun validateCreateConstraints( + registrationEndsAt: Instant, + now: Instant = Instant.now(), + ) { + if (registrationEndsAt.isBefore(now)) { + throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_IN_PAST) + } + } + + private fun validateInvariantConstraints( title: String, + capacity: Int?, startsAt: Instant?, endsAt: Instant?, - capacity: Int?, registrationStartsAt: Instant?, - registrationEndsAt: Instant?, - now: Instant = Instant.now(), + registrationEndsAt: Instant, ) { // 제λͺ© 검증 if (title.isBlank()) { @@ -381,42 +466,51 @@ class EventService( throw EventValidationException(EventErrorCode.EVENT_CAPACITY_INVALID) } - // λͺ¨μž„ μ‹œκ°„ 검증 - if (startsAt != null && startsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.EVENT_STARTS_IN_PAST) - } - if (endsAt != null && endsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.EVENT_ENDS_IN_PAST) - } - if (startsAt != null && endsAt != null && !startsAt.isBefore(endsAt)) { + // λͺ¨μž„ μ‹œκ°„ 검증: 일정 μ‹œμž‘ ≀ 일정 끝 + if (startsAt != null && endsAt != null && startsAt.isAfter(endsAt)) { throw EventValidationException(EventErrorCode.EVENT_TIME_RANGE_INVALID) } - // μ‹ μ²­ κΈ°κ°„ 검증 - if (registrationStartsAt != null && registrationStartsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.REGISTRATION_STARTS_IN_PAST) - } - if (registrationEndsAt != null && registrationEndsAt.isBefore(now)) { - throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_IN_PAST) - } - if (registrationStartsAt != null && - registrationEndsAt != null && - registrationStartsAt.isAfter(registrationEndsAt) - ) { + // μ‹ μ²­ κΈ°κ°„ 검증: λͺ¨μ§‘ μ‹œμž‘ < λͺ¨μ§‘ 마감 (strict) + if (registrationStartsAt != null && !registrationStartsAt.isBefore(registrationEndsAt)) { throw EventValidationException(EventErrorCode.REGISTRATION_TIME_RANGE_INVALID) } - if (registrationStartsAt != null && - startsAt != null && - registrationStartsAt.isAfter(startsAt) - ) { + // λͺ¨μ§‘ μ‹œμž‘ ≀ 일정 μ‹œμž‘ + if (registrationStartsAt != null && startsAt != null && registrationStartsAt.isAfter(startsAt)) { throw EventValidationException(EventErrorCode.REGISTRATION_STARTS_AFTER_EVENT_START) } - if (registrationEndsAt != null && - startsAt != null && - registrationEndsAt.isAfter(startsAt) - ) { + // λͺ¨μ§‘ 마감 ≀ 일정 μ‹œμž‘ + if (startsAt != null && registrationEndsAt.isAfter(startsAt)) { throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_START) } + // λͺ¨μ§‘ 마감 ≀ 일정 끝 (ν–‰μ‚¬μ‹œμž‘ 없을 λ•Œλ„ 직접 체크) + if (endsAt != null && registrationEndsAt.isAfter(endsAt)) { + throw EventValidationException(EventErrorCode.REGISTRATION_ENDS_AFTER_EVENT_END) + } + } + + private fun isCapacityIncreased( + previousCapacity: Int?, + newCapacity: Int?, + ): Boolean = previousCapacity != null && newCapacity != null && newCapacity > previousCapacity + + private fun isCapacityDecreased( + previousCapacity: Int?, + newCapacity: Int?, + ): Boolean = previousCapacity != null && newCapacity != null && newCapacity < previousCapacity + + private fun afterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + action() + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + action() + } + }, + ) } private fun buildCapabilities( @@ -425,12 +519,12 @@ class EventService( confirmedCount: Int, waitlistEnabled: Boolean, registrationStartsAt: Instant?, - registrationEndsAt: Instant?, + registrationEndsAt: Instant, now: Instant = Instant.now(), ): CapabilitiesInfo { val withinWindow = (registrationStartsAt?.let { !now.isBefore(it) } ?: true) && - (registrationEndsAt?.let { !now.isAfter(it) } ?: true) + !now.isAfter(registrationEndsAt) val isFull = capacity != null && confirmedCount >= capacity diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt index 4fb815e9..213bc67d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/EventRegistrationController.kt @@ -6,11 +6,6 @@ import com.wafflestudio.spring2025.domain.registration.dto.response.CreateRegist import com.wafflestudio.spring2025.domain.registration.dto.response.GetEventRegistrationsResponse import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -22,35 +17,9 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/events/{eventId}/registrations") -@Tag(name = "Registration", description = "이벀트 κΈ°μ€€ μ‹ μ²­ API") class EventRegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "이벀트 μ°Έμ—¬ μ‹ μ²­", - description = - "νŠΉμ • μ΄λ²€νŠΈμ— μ‹ μ²­ν•©λ‹ˆλ‹€. 정원이 λ‚¨μ•„μžˆμœΌλ©΄ CONFIRMED둜 λ“±λ‘λ˜κ³ , 정원이 μ°ΌμœΌλ‚˜ λŒ€κΈ° λͺ…단이 ν—ˆμš©λœ 경우 WAITLISTED둜 λ“±λ‘λ©λ‹ˆλ‹€. " + - "정원이 μ°Όκ³  λŒ€κΈ° λͺ…단이 λΉ„ν™œμ„±ν™”λœ 경우 신청이 κ±°μ ˆλ©λ‹ˆλ‹€.", - ) - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "μ‹ μ²­ 성곡"), - ApiResponse( - responseCode = "400", - description = - "μœ νš¨ν•˜μ§€ μ•Šμ€ μš”μ²­: 3003(Wrong guest name), 3004(Wrong guest email), 2204(Invalid registration window)", - ), - ApiResponse( - responseCode = "403", - description = "κΆŒν•œ μ—†μŒ: 3006(Banned user cannot register)", - ), - ApiResponse(responseCode = "404", description = "이벀트 μ—†μŒ: 2001(Event not found)"), - ApiResponse( - responseCode = "409", - description = "μ‹ μ²­ λΆˆκ°€: 3001(Registration already exists), 3005(Host cannot register), 2301(Event is full)", - ), - ], - ) @PostMapping fun create( @PathVariable eventId: String, @@ -67,17 +36,12 @@ class EventRegistrationController( return ResponseEntity.ok(registration) } - @Operation( - summary = "이벀트 μ‹ μ²­ λͺ©λ‘ 쑰회", - description = "νŠΉμ • μ΄λ²€νŠΈμ— μ†ν•œ μ‹ μ²­ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€. μ‹ μ²­ μƒνƒœ(CONFIRMED/WAITLISTED/BANNED)와 μ‹ μ²­ μ‹œκ°μ„ 포함해 λ°˜ν™˜ν•©λ‹ˆλ‹€.", - ) @GetMapping fun list( @PathVariable eventId: String, @LoggedInUser user: User?, @RequestParam(required = false) status: String?, @RequestParam(required = false) orderBy: String?, - @Parameter(description = "λ‹€μŒ νŽ˜μ΄μ§€ μ‹œμž‘ offset (μ—†μœΌλ©΄ 0λΆ€ν„°)") @RequestParam(required = false) cursor: Int?, ): ResponseEntity = ResponseEntity.ok( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt index 6da5b2c9..71022123 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/MyRegistrationController.kt @@ -5,9 +5,6 @@ import com.wafflestudio.spring2025.domain.auth.LoggedInUser import com.wafflestudio.spring2025.domain.registration.dto.response.GetMyRegistrationsResponse import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -17,17 +14,12 @@ import org.springframework.web.bind.annotation.RestController @AuthRequired @RestController @RequestMapping("/api/registrations") -@Tag(name = "Registration", description = "λ‚΄ μ‹ μ²­ 쑰회 API") class MyRegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "λ‚΄ 이벀트 μ‹ μ²­ λͺ©λ‘ 쑰회", - description = "λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ 이벀트 μ‹ μ²­ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€. μ΅œμ‹  μ‹ μ²­ 순으둜 λ°˜ν™˜λ˜λ©° page/size둜 νŽ˜μ΄μ§•ν•©λ‹ˆλ‹€.", - ) @GetMapping("/me") fun myRegistrations( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int, ): ResponseEntity { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt index 2ffccbe6..28381a59 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/controller/RegistrationController.kt @@ -1,7 +1,6 @@ package com.wafflestudio.spring2025.domain.registration.controller import com.wafflestudio.spring2025.domain.auth.LoggedInUser -import com.wafflestudio.spring2025.domain.registration.dto.request.DeleteRegistrationRequest import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest import com.wafflestudio.spring2025.domain.registration.dto.response.DeleteRegistrationResponse import com.wafflestudio.spring2025.domain.registration.dto.response.GetRegistrationResponse @@ -10,8 +9,6 @@ import com.wafflestudio.spring2025.domain.registration.exception.RegistrationErr import com.wafflestudio.spring2025.domain.registration.exception.RegistrationValidationException import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -23,16 +20,9 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/registrations/{registrationId}") -@Tag(name = "Registration", description = "μ‹ μ²­ 단건 λ¦¬μ†ŒμŠ€ API") class RegistrationController( private val registrationService: RegistrationService, ) { - @Operation( - summary = "μ‹ μ²­ 단건 μƒνƒœ λ³€κ²½", - description = - "μ‹ μ²­ 단건 λ¦¬μ†ŒμŠ€μ˜ μƒνƒœλ₯Ό λ³€κ²½ν•©λ‹ˆλ‹€. " + - "μ‚¬μš©μžκ°€ 본인의 신청을 μ·¨μ†Œν•˜λŠ” κ²½μš°μ™€ κ΄€λ¦¬μžκ°€ κ°•μ œ μ·¨μ†Œ/λ°΄ μ²˜λ¦¬ν•˜λŠ” 경우λ₯Ό 둜직 λ ˆλ²¨μ—μ„œ κ΅¬λΆ„ν•©λ‹ˆλ‹€.", - ) @PatchMapping fun updateStatus( @PathVariable registrationId: String, @@ -45,11 +35,6 @@ class RegistrationController( return ResponseEntity.ok(response) } - @Operation( - summary = "μ‹ μ²­ 단건 정보 μš”μ²­", - description = - "μ‹ μ²­ λ‹¨κ±΄μ˜ 정보λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.", - ) @GetMapping fun getRegistrationInformation( @PathVariable registrationId: String, @@ -62,15 +47,12 @@ class RegistrationController( @DeleteMapping fun delete( @PathVariable registrationId: String, - @RequestBody request: DeleteRegistrationRequest, @LoggedInUser user: User?, ): ResponseEntity { val response = registrationService.delete( registrationPublicId = registrationId, userId = user?.id, - guestName = request.guestName, - guestEmail = request.guestEmail, ) return ResponseEntity.ok(response) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt index e6764f91..33f1410e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationDto.kt @@ -2,23 +2,14 @@ package com.wafflestudio.spring2025.domain.registration.dto.core import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벀트 μ‹ μ²­ 정보") data class RegistrationDto( - @Schema(description = "μ‹ μ²­ 곡개 ID") val registrationPublicId: String, - @Schema(description = "μ‚¬μš©μž ID") val userId: Long?, - @Schema(description = "이벀트 ID") val eventId: Long, - @Schema(description = "λΉ„νšŒμ› 이름") val guestName: String?, - @Schema(description = "λΉ„νšŒμ› 이메일") val guestEmail: String?, - @Schema(description = "μ‹ μ²­ μƒνƒœ") val status: RegistrationStatus, - @Schema(description = "μ‹ μ²­ μΌμ‹œ (epoch milliseconds)") val createdAt: Long, ) { constructor(registration: Registration) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt index 5255eb9e..70c08a66 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/core/RegistrationWithEventDto.kt @@ -2,13 +2,9 @@ package com.wafflestudio.spring2025.domain.registration.dto.core import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.registration.model.Registration -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "λ‚΄ 이벀트 μ‹ μ²­ 정보") data class RegistrationWithEventDto( - @Schema(description = "μ‹ μ²­ 정보") val registration: RegistrationDto, - @Schema(description = "이벀트 μš”μ•½ 정보") val event: EventSummaryDto, ) { constructor(registration: Registration, event: Event) : this( @@ -17,17 +13,11 @@ data class RegistrationWithEventDto( ) } -@Schema(description = "이벀트 μš”μ•½ 정보") data class EventSummaryDto( - @Schema(description = "이벀트 ID") val id: Long, - @Schema(description = "이벀트 제λͺ©") val title: String, - @Schema(description = "μž₯μ†Œ") val location: String?, - @Schema(description = "μ‹œμž‘ μ‹œκ°„ (epoch milliseconds)") val startsAt: Long?, - @Schema(description = "μ’…λ£Œ μ‹œκ°„ (epoch milliseconds)") val endsAt: Long?, ) { constructor(event: Event) : this( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt index baf7052f..820bd1e5 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/CreateRegistrationRequest.kt @@ -1,14 +1,10 @@ package com.wafflestudio.spring2025.domain.registration.dto.request import com.fasterxml.jackson.annotation.JsonAlias -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벀트 μ‹ μ²­ μš”μ²­") data class CreateRegistrationRequest( - @Schema(description = "λΉ„νšŒμ› 이름") @JsonAlias("guest_name") val guestName: String? = null, - @Schema(description = "λΉ„νšŒμ› 이메일") @JsonAlias("guest_email") val guestEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt deleted file mode 100644 index 3f34c5f8..00000000 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/DeleteRegistrationRequest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.wafflestudio.spring2025.domain.registration.dto.request - -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벀트 μ·¨μ†Œ μš”μ²­") -data class DeleteRegistrationRequest( - @Schema(description = "λΉ„νšŒμ› 이름") - val guestName: String? = null, - @Schema(description = "λΉ„νšŒμ› 이메일") - val guestEmail: String? = null, -) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt index f87d15fb..2182cddc 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/request/UpdateRegistrationStatusRequest.kt @@ -1,9 +1,7 @@ package com.wafflestudio.spring2025.domain.registration.dto.request import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벀트 μ‹ μ²­ μƒνƒœ λ³€κ²½ μš”μ²­") data class UpdateRegistrationStatusRequest( val status: RegistrationStatus? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt index eb4ea566..3abe25e8 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/CreateRegistrationResponse.kt @@ -1,9 +1,5 @@ package com.wafflestudio.spring2025.domain.registration.dto.response -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벀트 μ‹ μ²­ 응닡") data class CreateRegistrationResponse( - @Schema(description = "μ‹ μ²­ id") val registrationPublicId: String, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt index 39a7951b..c5837bd2 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetEventRegistrationsResponse.kt @@ -3,36 +3,22 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.user.model.User -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "이벀트 μ‹ μ²­ λͺ©λ‘ 쑰회 응닡") data class GetEventRegistrationsResponse( - @Schema(description = "μ‹ μ²­μž λͺ©λ‘") val participants: List, - @Schema(description = "필터에 따라 λ³€ν•˜λŠ” μ°Έμ—¬μž 수") val totalCount: Int?, - @Schema(description = "λ‹€μŒ νŽ˜μ΄μ§€ μ‹œμž‘ offset (μ—†μœΌλ©΄ null)") val nextCursor: Int?, - @Schema(description = "λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€") val hasNext: Boolean, ) -@Schema(description = "μ‹ μ²­μž 정보") data class EventRegistrationItem( - @Schema(description = "μ‹ μ²­ 곡개 ID") val registrationId: String, - @Schema(description = "이름") val name: String, - @Schema(description = "이메일 (κ΄€λ¦¬μž μš”μ²­ μ‹œ λ…ΈμΆœ)") val email: String?, - @Schema(description = "ν”„λ‘œν•„ 이미지 URL") val profileImage: String?, - @Schema(description = "μ‹ μ²­ μΌμ‹œ") val createdAt: Instant, - @Schema(description = "μ‹ μ²­ μƒνƒœ") val status: RegistrationStatus, - @Schema(description = "λŒ€κΈ° 순번 (statusκ°€ WAITLISTED일 λ•Œλ§Œ μ•„λ‹ˆλ©΄ null)") val waitingNum: Int?, ) { constructor( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt index 74c36966..0aca3e06 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetMyRegistrationsResponse.kt @@ -3,42 +3,30 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.registration.model.Registration import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema import java.time.Instant -@Schema(description = "λ‚΄ 이벀트 μ‹ μ²­ λͺ©λ‘ 응닡") data class GetMyRegistrationsResponse( - @Schema(description = "μ‹ μ²­ λͺ©λ‘") val registrations: List, ) -@Schema(description = "λ‚΄ 이벀트 μ‹ μ²­ μš”μ•½") data class MyRegistrationItem( - @Schema(description = "이벀트 곡개 ID") val publicId: String, - @Schema(description = "이벀트 제λͺ©") val title: String, - @Schema(description = "이벀트 μ‹œμž‘ μ‹œκ°„ (ISO-8601)") val startsAt: Instant?, - @Schema(description = "이벀트 μ’…λ£Œ μ‹œκ°„ (ISO-8601)") val endsAt: Instant?, - @Schema(description = "μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601)") val registrationStartsAt: Instant?, - @Schema(description = "μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601)") val registrationEndsAt: Instant?, - @Schema(description = "정원") val capacity: Int?, - @Schema(description = "ν˜„μž¬ μ‹ μ²­ 수 + λŒ€κΈ° 수") - val registrationCnt: Int, - @Schema(description = "μ‹ μ²­ μƒνƒœ") + val confirmedCount: Int, + val waitlistCount: Int, val status: MyRegistrationStatus, - @Schema(description = "λŒ€κΈ° 순번 (WAITLISTEDκ°€ μ•„λ‹ˆλ©΄ null)") val waitlistedNum: Int?, ) { constructor( registration: Registration, event: Event, - registrationCnt: Int, + confirmedCount: Int, + waitlistCount: Int, waitlistedNum: Int?, ) : this( publicId = event.publicId, @@ -48,7 +36,8 @@ data class MyRegistrationItem( registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, capacity = event.capacity, - registrationCnt = registrationCnt, + confirmedCount = confirmedCount, + waitlistCount = waitlistCount, status = MyRegistrationStatus.from(registration.status), waitlistedNum = waitlistedNum, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt index e03dd30c..d241cd32 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/GetRegistrationResponse.kt @@ -1,9 +1,7 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벀트 μ‹ μ²­ 정보 μš”μ²­ 응닡") data class GetRegistrationResponse( val status: RegistrationStatus, val guestName: String, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt index 5f8f534a..e328c6db 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/PatchRegistrationResponse.kt @@ -1,8 +1,5 @@ package com.wafflestudio.spring2025.domain.registration.dto.response -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "이벀트 μ‹ μ²­ μƒνƒœ λ³€κ²½") data class PatchRegistrationResponse( val patchEmail: String? = null, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt index 5d0d0855..fe52c458 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/dto/response/RegistrationGuestsResponse.kt @@ -1,29 +1,19 @@ package com.wafflestudio.spring2025.domain.registration.dto.response import com.fasterxml.jackson.annotation.JsonProperty -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "이벀트 μ°Έμ—¬μž λͺ…단 응닡") data class RegistrationGuestsResponse( - @Schema(description = "ν™•μ • μ°Έμ—¬μž λͺ©λ‘") val guests: List, @JsonProperty("confirmed_count") - @Schema(description = "ν™•μ • μ°Έμ—¬μž 수") val confirmedCount: Int, @JsonProperty("waiting_count") - @Schema(description = "λŒ€κΈ° μ°Έμ—¬μž 수") val waitingCount: Int, ) { - @Schema(description = "μ°Έμ—¬μž 정보") data class Guest( - @Schema(description = "μ‹ μ²­ 곡개 ID") val registrationPublicId: String, - @Schema(description = "이름") val name: String, - @Schema(description = "이메일 (κ΄€λ¦¬μž μš”μ²­ μ‹œ λ…ΈμΆœ)") val email: String?, @JsonProperty("profile_image") - @Schema(description = "ν”„λ‘œν•„ 이미지 URL") val profileImage: String?, ) } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt index cd978b1e..7f7d9d7a 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/exception/RegistrationErrorCode.kt @@ -74,8 +74,8 @@ enum class RegistrationErrorCode( REGISTRATION_BLOCKED_BANNED( httpStatusCode = HttpStatus.FORBIDDEN, - title = "μ°¨λ‹¨λœ μ‹ μ²­μž…λ‹ˆλ‹€.", - message = "μ°¨λ‹¨λœ 신청은\nλ‹€μ‹œ μ‹ μ²­ν•  수 μ—†μŠ΅λ‹ˆλ‹€.", + title = "μ°¨λ‹¨λœ μ‚¬μš©μžμž…λ‹ˆλ‹€.", + message = "ν•΄λ‹Ή λͺ¨μž„μ˜ μ£Όμ΅œμžμ— μ˜ν•΄\nμ°¨λ‹¨λ˜μ–΄ μ‹ μ²­ν•  수 μ—†μŠ΅λ‹ˆλ‹€.", ), NOT_WITHIN_REGISTRATION_WINDOW( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 81ceeddf..5d6ddac1 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -12,6 +12,8 @@ interface RegistrationRepository : PagingAndSortingRepository { fun findByEventId(eventId: Long): List + fun deleteByEventId(eventId: Long) + fun findByRegistrationPublicId(registrationPublicId: String): Registration? @Query( @@ -39,6 +41,11 @@ interface RegistrationRepository : eventId: Long, ): Registration? + fun findByEventIdAndStatusIn( + eventID: Long, + statuses: Collection, + ): List + fun countByEventId(eventID: Long): Long fun countByEventIdAndStatus( @@ -46,14 +53,45 @@ interface RegistrationRepository : registrationStatus: RegistrationStatus, ): Long + @Query( + """ + SELECT COUNT(*) + FROM registrations + WHERE event_id = :eventId AND status = :status + FOR SHARE + """, + ) + fun countByEventIdAndStatusWithLock( + eventId: Long, + status: RegistrationStatus, + ): Long + fun findByEventIdAndStatusOrderByCreatedAtAsc( eventID: Long, registrationStatus: RegistrationStatus, ): List + fun findByEventIdAndStatusOrderByCreatedAtAsc( + eventID: Long, + registrationStatus: RegistrationStatus, + pageable: Pageable, + ): List + + @Query( + """ + SELECT * FROM registrations + WHERE event_id = :eventId AND status = :status + ORDER BY created_at DESC, id DESC + """, + ) + fun findByEventIdAndStatusOrderByCreatedAtDescIdDesc( + eventId: Long, + status: RegistrationStatus, + ): List + @Query( """ - SELECT event_id AS eventId, COUNT(*) AS totalCount + SELECT event_id, COUNT(*) AS total_count FROM registrations WHERE event_id IN (:eventIds) AND status IN (:statuses) GROUP BY event_id @@ -66,9 +104,9 @@ interface RegistrationRepository : @Query( """ - SELECT registration_public_id AS registrationPublicId, - event_id AS eventId, - ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY created_at ASC, id ASC) AS waitlistNumber + SELECT registration_public_id, + event_id, + ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY created_at ASC, id ASC) AS waitlist_number FROM registrations WHERE event_id IN (:eventIds) AND status = :status """, @@ -83,7 +121,7 @@ interface RegistrationRepository : SELECT r.* FROM registrations r WHERE r.event_id = :eventId - ORDER BY r.created_at DESC, r.registration_public_id DESC + ORDER BY r.created_at ASC, r.registration_public_id ASC LIMIT :limit OFFSET :offset """, ) @@ -98,7 +136,7 @@ interface RegistrationRepository : SELECT r.* FROM registrations r WHERE r.event_id = :eventId AND r.status = :status - ORDER BY r.created_at DESC, r.registration_public_id DESC + ORDER BY r.created_at ASC, r.registration_public_id ASC LIMIT :limit OFFSET :offset """, ) @@ -148,7 +186,7 @@ interface RegistrationRepository : ranked.waitlist_number AS waitlist_number FROM ( SELECT r.registration_public_id, - ROW_NUMBER() OVER (ORDER BY r.created_at ASC, r.registration_public_id ASC) AS waitlist_number + ROW_NUMBER() OVER (ORDER BY r.created_at ASC, r.id ASC) AS waitlist_number FROM registrations r WHERE r.event_id = :eventId AND r.status = :status ) ranked diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index eb02c22d..6aff4dd7 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -28,6 +28,7 @@ import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.springframework.dao.DuplicateKeyException import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronization @@ -47,7 +48,7 @@ RegistrationService( private val userRepository: UserRepository, private val emailService: EmailService, private val imageService: ImageService, -) { +) : WaitlistReconciliationService { private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") private val tokenValidity = Duration.ofHours(24) @@ -71,7 +72,7 @@ RegistrationService( val capacity = lockedEvent.capacity ?: throw IllegalStateException("이벀트의 capacityκ°€ μ„€μ •λ˜μ–΄ μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") val currentConfirmed = registrationRepository - .countByEventIdAndStatus(eventPk, RegistrationStatus.CONFIRMED) + .countByEventIdAndStatusWithLock(eventPk, RegistrationStatus.CONFIRMED) .toInt() val status = @@ -96,6 +97,11 @@ RegistrationService( registrationRepository.findByUserIdAndEventId(userId, eventPk) } + val userIdsToFetch = listOfNotNull(lockedEvent.createdBy, userId).distinct() + val fetchedUsersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! } + val hostEmail = fetchedUsersById[lockedEvent.createdBy]?.email + val user = userId?.let { fetchedUsersById[it] } + val saved = try { if (existingRegistration != null) { @@ -112,8 +118,7 @@ RegistrationService( -> throw RegistrationConflictException(RegistrationErrorCode.REGISTRATION_ALREADY_EXISTS) } } else { - val hostEmail = userRepository.findById(lockedEvent.createdBy).orElse(null)?.email - val registrationEmail = userId?.let { userRepository.findById(it).orElse(null)?.email } ?: guestEmail + val registrationEmail = user?.email ?: guestEmail if (hostEmail != null && hostEmail == registrationEmail) { throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_BLOCKED_HOST) @@ -143,19 +148,11 @@ RegistrationService( throw RegistrationConflictException(RegistrationErrorCode.REGISTRATION_ALREADY_EXISTS) } - val waitlistedNumber: Int? = - if (saved.status == RegistrationStatus.WAITLISTED) { - registrationRepository - .countByEventIdAndStatus(eventPk, RegistrationStatus.WAITLISTED) - .toInt() - } else { - null - } - - val user = - userId?.let { id -> - userRepository.findById(id).orElse(null) - } + val waitlistedCount = + registrationRepository + .countByEventIdAndStatus(eventPk, RegistrationStatus.WAITLISTED) + .toInt() + val waitlistedNumber: Int? = if (saved.status == RegistrationStatus.WAITLISTED) waitlistedCount else null val recipientEmail = user?.email ?: guestEmail @@ -164,10 +161,6 @@ RegistrationService( registrationRepository .countByEventIdAndStatus(eventPk, RegistrationStatus.CONFIRMED) .toInt() - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(eventPk, RegistrationStatus.WAITLISTED) - .toInt() val totalCount = confirmedCount + waitlistedCount val emailData = @@ -205,22 +198,9 @@ RegistrationService( fun delete( registrationPublicId: String, userId: Long?, - guestName: String?, - guestEmail: String?, ): DeleteRegistrationResponse { - if (userId == null) { - if (guestName.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_NAME) - } - if (guestEmail.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) - } - } - deleteInternal( userId = userId, - guestName = guestName, - guestEmail = guestEmail, registrationPublicId = registrationPublicId, ) return DeleteRegistrationResponse() @@ -228,28 +208,31 @@ RegistrationService( private fun deleteInternal( userId: Long?, - guestName: String?, - guestEmail: String?, registrationPublicId: String, ) { - val registration = + // eventIdλ₯Ό μ–»κΈ° μœ„ν•œ 미리보기 쑰회 (락 μ—†μŒ) + val preview = registrationRepository.findByRegistrationPublicId(registrationPublicId) ?: throw RegistrationNotFoundException() + // 이벀트 μ—…λ°μ΄νŠΈμ™€ κ²½ν•© μ‹œ μ·¨μ†Œ κ°€λŠ₯ μ‹œμ  νŒμ •μ„ μΌκ΄€λ˜κ²Œ ν•˜κΈ° μœ„ν•΄ 선락 + eventLockRepository.lockById(preview.eventId) val event = - eventRepository.findById(registration.eventId).orElseThrow { EventNotFoundException() } + eventRepository.findById(preview.eventId).orElseThrow { EventNotFoundException() } + + // 이벀트 락 νšλ“ ν›„ registration ν–‰ 락 + val registration = + registrationRepository.lockByRegistrationPublicId(registrationPublicId) + ?: throw RegistrationNotFoundException() - if (userId != null) { + if (registration.userId != null) { if (registration.userId != userId) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) } - } else { - if (registration.userId != null || - registration.guestName != guestName || - registration.guestEmail != guestEmail - ) { - throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_DELETE_UNAUTHORIZED) - } + } + + if (!isRegistrationEnabled(event)) { + throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) } val wasConfirmed = registration.status == RegistrationStatus.CONFIRMED @@ -290,6 +273,7 @@ RegistrationService( registrationStartsAt = event.registrationStartsAt, registrationEndsAt = event.registrationEndsAt, description = event.description, + publicId = event.publicId, ) afterCommit { @@ -323,11 +307,18 @@ RegistrationService( event.id ?: throw EventNotFoundException() } - val countsByEventId = + val confirmedCountsByEventId = + registrationRepository + .countByEventIdsAndStatuses( + eventIds = eventIds, + statuses = listOf(RegistrationStatus.CONFIRMED), + ).associate { it.eventId to it.totalCount.toInt() } + + val waitlistCountsByEventId = registrationRepository .countByEventIdsAndStatuses( eventIds = eventIds, - statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + statuses = listOf(RegistrationStatus.WAITLISTED), ).associate { it.eventId to it.totalCount.toInt() } val waitlistedByRegistrationId = @@ -348,7 +339,8 @@ RegistrationService( MyRegistrationItem( registration = registration, event = event, - registrationCnt = countsByEventId[registration.eventId] ?: 0, + confirmedCount = confirmedCountsByEventId[registration.eventId] ?: 0, + waitlistCount = waitlistCountsByEventId[registration.eventId] ?: 0, waitlistedNum = waitlistedNumber, ) } @@ -374,12 +366,8 @@ RegistrationService( registrationRepository.lockByRegistrationPublicId(registrationId) ?: throw RegistrationNotFoundException() - if (!isRegistrationEnabled(event)) { - throw RegistrationValidationException(RegistrationErrorCode.NOT_WITHIN_REGISTRATION_WINDOW) - } - val isHost = userId != null && userId == event.createdBy - val isRegistrant = userId != null && registration.userId == userId +// val isRegistrant = userId != null && registration.userId == userId if (status == RegistrationStatus.BANNED && !isHost) { throw RegistrationForbiddenException(RegistrationErrorCode.REGISTRATION_PATCH_UNAUTHORIZED) @@ -567,13 +555,14 @@ RegistrationService( val status = registration.status val waitlistPosition = if (status == RegistrationStatus.WAITLISTED) { - val waitlistedRegs = - registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( - registration.eventId, - RegistrationStatus.WAITLISTED, - ) - val idx = waitlistedRegs.indexOfFirst { it.id == registration.id } - if (idx >= 0) idx + 1 else 0 + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = registration.eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = listOf(registration.registrationPublicId), + ).firstOrNull() + ?.waitlistNumber + ?.toInt() ?: 0 } else { 0 } @@ -604,14 +593,9 @@ RegistrationService( } private fun isRegistrationEnabled(event: Event): Boolean { - val registrationStartsAt = event.registrationStartsAt - val registrationEndsAt = event.registrationEndsAt val now = Instant.now() - - // null μ •μ±…: μ‹œμž‘/끝이 null이면 μ œν•œ μ—†λŠ” κ²ƒμœΌλ‘œ μ·¨κΈ‰ - val afterStart = registrationStartsAt?.let { !now.isBefore(it) } ?: true - val beforeEnd = registrationEndsAt?.let { !now.isAfter(it) } ?: true - + val afterStart = event.registrationStartsAt?.let { !now.isBefore(it) } ?: true + val beforeEnd = !now.isAfter(event.registrationEndsAt) return afterStart && beforeEnd } @@ -669,7 +653,72 @@ RegistrationService( } @Transactional - fun reconcileWaitlist(eventId: Long) { + override fun demoteToWaitlist( + eventId: Long, + newCapacity: Int, + ) { + eventLockRepository.lockById(eventId) + + val event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } + + val confirmedRegs = + registrationRepository.findByEventIdAndStatusOrderByCreatedAtDescIdDesc( + eventId, + RegistrationStatus.CONFIRMED, + ) + + val excessCount = confirmedRegs.size - newCapacity + if (excessCount <= 0) return + + val toDemote = confirmedRegs.take(excessCount) + toDemote.forEach { it.status = RegistrationStatus.WAITLISTED } + registrationRepository.saveAll(toDemote) + + val waitlistedCount = registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.WAITLISTED).toInt() + val totalCount = newCapacity + waitlistedCount + + val demotedPublicIds = toDemote.map { it.registrationPublicId } + val waitlistPositions = + registrationRepository + .findWaitlistPositionsByRegistrationPublicIds( + eventId = eventId, + status = RegistrationStatus.WAITLISTED, + registrationPublicIds = demotedPublicIds, + ).associate { it.registrationPublicId to it.waitlistNumber.toInt() } + + val userIds = toDemote.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + toDemote.mapNotNull { reg -> + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.DemotionEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "μ°Έμ—¬μž", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + newCapacity = newCapacity, + totalCount = totalCount, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + publicId = event.publicId, + registrationPublicId = reg.registrationPublicId, + waitingNum = waitlistPositions[reg.registrationPublicId], + ) + } + + afterCommit { + emailDataList.forEach { emailService.sendDemotionEmail(it) } + } + } + + @Transactional + override fun reconcileWaitlist(eventId: Long) { eventLockRepository.lockById(eventId) val event: Event = eventRepository.findById(eventId).orElseThrow { EventNotFoundException() } @@ -677,35 +726,38 @@ RegistrationService( val capacity = event.capacity ?: throw IllegalStateException("이벀트의 capacityκ°€ μ„€μ •λ˜μ–΄ μžˆμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") val confirmed = - registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.CONFIRMED).toInt() + registrationRepository.countByEventIdAndStatusWithLock(eventId, RegistrationStatus.CONFIRMED).toInt() val available = capacity - confirmed if (available <= 0) return - val waitlistedRegs = + val totalWaitlisted = + registrationRepository.countByEventIdAndStatus(eventId, RegistrationStatus.WAITLISTED).toInt() + + val promoted = registrationRepository.findByEventIdAndStatusOrderByCreatedAtAsc( eventId, RegistrationStatus.WAITLISTED, + Pageable.ofSize(available), ) val waitlistNumbers = - waitlistedRegs.withIndex().associate { indexed -> - indexed.value.registrationPublicId to (indexed.index + 1) + promoted.withIndex().associate { (index, reg) -> + reg.registrationPublicId to (index + 1) } - val promoted = waitlistedRegs.take(available) promoted.forEach { it.status = RegistrationStatus.CONFIRMED } registrationRepository.saveAll(promoted) val confirmedAfter = confirmed + promoted.size - val remainingWaitlisted = waitlistedRegs.size - promoted.size + val remainingWaitlisted = totalWaitlisted - promoted.size val totalCount = confirmedAfter + remainingWaitlisted + val promotedUserIds = promoted.mapNotNull { it.userId }.distinct() + val promotedUsersById = userRepository.findAllById(promotedUserIds).associateBy { it.id!! } + val emailDataList = promoted.mapNotNull { registration -> - val user: User? = - registration.userId?.let { uid -> - userRepository.findById(uid).orElse(null) - } + val user: User? = registration.userId?.let { promotedUsersById[it] } val recipientEmail = user?.email ?: registration.guestEmail val recipientName = user?.name ?: registration.guestName ?: "μ°Έμ—¬μž" diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt new file mode 100644 index 00000000..07110e7c --- /dev/null +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/WaitlistReconciliationService.kt @@ -0,0 +1,10 @@ +package com.wafflestudio.spring2025.domain.registration.service + +interface WaitlistReconciliationService { + fun reconcileWaitlist(eventId: Long) + + fun demoteToWaitlist( + eventId: Long, + newCapacity: Int, + ) +} diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 85c924bd..8f214cf1 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -7,11 +7,6 @@ import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest import com.wafflestudio.spring2025.domain.user.dto.PatchMeResponse import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.service.UserService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -22,27 +17,19 @@ import org.springframework.web.bind.annotation.RestController @AuthRequired @RestController @RequestMapping("/api/users") -@Tag(name = "User", description = "μ‚¬μš©μž API") class UserController( private val userService: UserService, ) { - @Operation(summary = "본인 정보 쑰회", description = "λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€") - @ApiResponses( - value = [ - ApiResponse(responseCode = "200", description = "μ‚¬μš©μž 정보 쑰회 성곡"), - ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰)"), - ], - ) @AuthRequired @GetMapping("/me") fun me( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, ): ResponseEntity = ResponseEntity.ok(userService.me(user)) @AuthRequired @PatchMapping("/me") fun patchMe( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, @RequestBody request: PatchMeRequest, ): ResponseEntity { userService.patchMe( @@ -54,41 +41,4 @@ class UserController( ) return ResponseEntity.ok(userService.me(user)) } - -// @Operation(summary = "ν”„λ‘œν•„ 이미지 μ—…λ‘œλ“œ", description = "λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ ν”„λ‘œν•„ 이미지λ₯Ό μ—…λ‘œλ“œ(ꡐ체)ν•©λ‹ˆλ‹€") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "ν”„λ‘œν•„ 이미지 μ—…λ‘œλ“œ 성곡"), -// ApiResponse(responseCode = "400", description = "잘λͺ»λœ μš”μ²­ (파일 λˆ„λ½/ν˜•μ‹ 였λ₯˜ λ“±)"), -// ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰)"), -// ], -// ) -// @AuthRequired -// @PutMapping( -// "/me/profile-image", -// consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], -// ) -// fun uploadProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// @RequestPart("image") image: MultipartFile, -// ): ResponseEntity { -// userService.updateProfileImage(user, image) -// return ResponseEntity.noContent().build() -// } -// -// @Operation(summary = "ν”„λ‘œν•„ 이미지 μ‚­μ œ", description = "λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ ν”„λ‘œν•„ 이미지λ₯Ό μ‚­μ œ(κΈ°λ³Έ μ΄λ―Έμ§€λ‘œ 볡귀)ν•©λ‹ˆλ‹€") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "ν”„λ‘œν•„ 이미지 μ‚­μ œ 성곡"), -// ApiResponse(responseCode = "401", description = "인증 μ‹€νŒ¨ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰)"), -// ], -// ) -// @AuthRequired -// @DeleteMapping("/me/profile-image") -// fun deleteProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// ): ResponseEntity { -// userService.deleteProfileImage(user) -// return ResponseEntity.noContent().build() -// } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt index 092180d5..1a0f9e05 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/dto/core/UserIdentityDto.kt @@ -1,19 +1,12 @@ package com.wafflestudio.spring2025.domain.user.identity.dto.core import com.wafflestudio.spring2025.domain.user.identity.model.UserIdentity -import io.swagger.v3.oas.annotations.media.Schema -@Schema(description = "μ†Œμ…œ 계정 정보") data class UserIdentityDto( - @Schema(description = "μ†Œμ…œ 계정 ID") val id: Long, - @Schema(description = "μ‚¬μš©μž ID") val userId: Long, - @Schema(description = "μ†Œμ…œ 둜그인 제곡자") val provider: String, - @Schema(description = "제곡자 μ‚¬μš©μž ID") val providerUserId: String, - @Schema(description = "생성 μΌμ‹œ (epoch milliseconds)") val createdAt: Long, ) { constructor(identity: UserIdentity) : this( diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..f8cf5e6f --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=com.wafflestudio.spring2025.config.OciVaultPostProcessorBridge diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml new file mode 100644 index 00000000..920575c1 --- /dev/null +++ b/src/main/resources/application-local.yaml @@ -0,0 +1,7 @@ +spring: + config: + activate: + on-profile: local + data: + redis: + host: localhost \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 29fc2f77..ee7ef521 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,10 +9,10 @@ spring: password: somepassword driver-class-name: com.mysql.cj.jdbc.Driver mail: - host: email-smtp.us-east-1.amazonaws.com + host: smtp.email.ap-chuncheon-1.oci.oraclecloud.com port: 587 - username: ${AWS_SES_SMTP_USERNAME:YOUR_SES_SMTP_USERNAME} - password: ${AWS_SES_SMTP_PASSWORD:YOUR_SES_SMTP_PASSWORD} + username: ${OCI_EMAIL_SMTP_USERNAME:YOUR_OCI_SMTP_USERNAME} + password: ${OCI_EMAIL_SMTP_PASSWORD:YOUR_OCI_SMTP_PASSWORD} properties: mail: smtp: @@ -34,7 +34,7 @@ spring: enabled: true data: redis: - host: localhost + host: moiming-valkey port: 6379 jwt: @@ -42,15 +42,15 @@ jwt: expiration-in-ms: 86400000 email: - from-email: no-reply@moiming.app + from-email: moiming@wafflestudio.com from-name: λͺ¨μ΄λ° oauth: providers: google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: ${GOOGLE_REDIRECT_URI:} logging: level: @@ -68,7 +68,7 @@ management: endpoints: web: exposure: - include: health + include: health,info,prometheus,metrics endpoint: health: show-details: always @@ -87,9 +87,48 @@ aws: allowed-origins: > http://localhost:3000, http://localhost:5173, - https://www.moiming.app, - https://moiming.app, - https://api.moiming.app + https://moiming.wafflestudio.com, + https://dev.moiming.wafflestudio.com, + https://api.moiming.wafflestudio.com + https://api.dev.moiming.wafflestudio.com server: forward-headers-strategy: framework + +springdoc: + api-docs: + enabled: false + swagger-ui: + url: /docs/openapi.yaml + disable-swagger-default-url: true + +--- +spring: + config: + activate: + on-profile: dev + datasource: + url: jdbc:mysql://10.0.30.217:3306/moiming_dev?allowPublicKeyRetrieval=true + username: moiming-dev + password: +cors: + allowed-origins: "" +oci: + vault: + secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqaa3m45icdqcka43cvyzb4kb3ktuatfobwusnsdd4bvzkq + region: ap-chuncheon-1 + +--- +spring: + config: + activate: + on-profile: prod + datasource: + url: jdbc:mysql://10.0.30.217:3306/moiming_prod?allowPublicKeyRetrieval=true + username: moiming-prod +cors: + allowed-origins: "" +oci: + vault: + secret-ids: ocid1.vaultsecret.oc1.ap-chuncheon-1.amaaaaaat2m5lbqameflpkt7abivvrauqwheybzrkqipf7msv3ol6ikhd3aq + region: ap-chuncheon-1 diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html new file mode 100644 index 00000000..4d6fd334 --- /dev/null +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/event-cancelled.html @@ -0,0 +1,102 @@ + + + + + + + + + + + + +
+ + + + + +
+ λͺ¨μ΄λ° +
+ + + + + + +
+
+ + + + +
+ x-mark +
+
+
+ μ•ˆλ…•ν•˜μ„Έμš” {name}λ‹˜
+ 일정이 μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. +
+ +
+
{eventTitle} +
+ + + + + + + + + + +
+ calendar + + μΌμ‹œ {eventDateRange} +
+ map-pin + + μž₯μ†Œ {location} +
+
+ + + + + +
+ + + + + +
+ user + + 문의: + 일정 μ£Όμ΅œμžμ—κ²Œ 연락 λ°”λžλ‹ˆλ‹€ ({hostEmail}) +
+
+ +
+ {description}
+ +
+ + + diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html index 7bd8a682..573ae045 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-banned.html @@ -73,7 +73,7 @@ alt="user" width="20" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}λͺ… + 정원 {capacityDisplay} diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html index 69e8991a..4da10477 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-confirmed.html @@ -37,6 +37,7 @@ +
{eventTitle}
@@ -66,7 +67,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}λͺ… + 정원 {capacityDisplay} @@ -74,6 +75,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
@@ -99,7 +101,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - μ·¨μ†Œν•˜κΈ° diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html index 6c216b92..0b02d08f 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-delete.html @@ -44,6 +44,7 @@
+
{eventTitle}
@@ -74,7 +75,7 @@ alt="user" width="20" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}λͺ… + 정원 {capacityDisplay} @@ -82,6 +83,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html new file mode 100644 index 00000000..541ef4cd --- /dev/null +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-demoted.html @@ -0,0 +1,122 @@ + + + + + + + + +
+ + + +
+ + + + + +
+ λͺ¨μ΄λ° +
+ + + + + + +
+
+ + + + +
+ loader-mark +
+
+
+ μ•ˆλ…•ν•˜μ„Έμš” {name}λ‹˜
+ {waitingNum}번째 λŒ€κΈ°λ‘œ λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€. +
+ + +
+
{eventTitle} +
+ + + + + + + + + + + + + + +
+ calendar + + μΌμ‹œ {eventDateRange} +
+ map-pin + + μž₯μ†Œ {location} +
+ user + + 정원 {capacityDisplay} +
+
+ ›
+
+
+ + + + + +
+ + + + + +
+ clock + + μ‹ μ²­ κΈ°κ°„: + {registrationDateRange} +
+
+ +
+ {description}
+ + + μ·¨μ†Œν•˜κΈ° + +
+ + + diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html index f9c23f1c..8cdc2f3f 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlist-promoted.html @@ -37,6 +37,7 @@ +
{eventTitle}
@@ -66,7 +67,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}λͺ… + 정원 {capacityDisplay} @@ -74,6 +75,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
@@ -99,7 +101,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - μ·¨μ†Œν•˜κΈ° diff --git a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html index 9edf0de0..491e2959 100644 --- a/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html +++ b/src/main/resources/com/wafflestudio/spring2025/common/email/template/registration-waitlisted.html @@ -44,6 +44,7 @@
+
{eventTitle}
@@ -74,7 +75,7 @@ alt="user" width="18" style="display: block; border: 0;"> - 정원 {totalCount}/{capacity}λͺ… + 정원 {capacityDisplay} @@ -82,6 +83,7 @@ style="text-align: right; color: #CCCCCC; font-size: 24px; line-height: 1; font-family: sans-serif; font-weight: 300; margin-top: -10px;"> ›
+
@@ -108,7 +110,7 @@ style="font-size: 16px; line-height: 1.7; color: #333; margin-bottom: 50px; white-space: wrap; word-break: break-all;"> {description} - μ·¨μ†Œν•˜κΈ° diff --git a/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql new file mode 100644 index 00000000..9cdb2d8e --- /dev/null +++ b/src/main/resources/db/migration/V14__make_registration_ends_at_not_null.sql @@ -0,0 +1,3 @@ +DELETE r FROM registrations r INNER JOIN events e ON r.event_id = e.id WHERE e.registration_ends_at IS NULL; +DELETE FROM events WHERE registration_ends_at IS NULL; +ALTER TABLE events MODIFY registration_ends_at DATETIME(6) NOT NULL; diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml new file mode 100644 index 00000000..9f62c069 --- /dev/null +++ b/src/main/resources/static/docs/openapi.yaml @@ -0,0 +1,1257 @@ +openapi: 3.0.1 +info: + title: Moiming - Event Schedule Management System API + description: λͺ¨μž„ 일정 관리 μ‹œμŠ€ν…œ 'λͺ¨μ΄λ°' API λ¬Έμ„œ + version: "1.0" +servers: +- url: http://localhost:8080 + description: Generated server url +tags: +- name: Event + description: 이벀트 관리 API +- name: Registration + description: μ‹ μ²­ API +- name: Auth + description: 인증 API +- name: User + description: μ‚¬μš©μž API +- name: Image + description: 이미지 API +paths: + /api/events/{publicId}: + get: + security: [] + tags: + - Event + summary: 이벀트 상세 쑰회 + description: 이벀트 상세 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€ + parameters: + - name: publicId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/EventDetailResponse" + put: + tags: + - Event + summary: 이벀트 μˆ˜μ • + description: 이벀트λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€ + security: + - bearerAuth: [] + parameters: + - name: publicId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEventRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/UpdateEventResponse" + "401": + description: 인증 ν•„μš” + delete: + tags: + - Event + summary: 이벀트 μ‚­μ œ + description: 이벀트λ₯Ό μ‚­μ œν•©λ‹ˆλ‹€ + security: + - bearerAuth: [] + parameters: + - name: publicId + in: path + required: true + schema: + type: string + responses: + "204": + description: No Content + "401": + description: 인증 ν•„μš” + /api/images: + post: + security: + - bearerAuth: [] + tags: + - Image + summary: 이미지 μ—…λ‘œλ“œ + description: μ„œλ²„ μ €μž₯μ†Œμ— 이미지λ₯Ό μ—…λ‘œλ“œν•©λ‹ˆλ‹€. + parameters: + - name: prefix + in: query + description: 이미지λ₯Ό μ €μž₯ν•  μƒμœ„ 경둜 + required: false + schema: + type: string + requestBody: + content: + multipart/form-data: + schema: + required: + - image + type: object + properties: + image: + type: string + format: binary + responses: + "200": + description: 이미지 μ—…λ‘œλ“œ 성곡 + content: + '*/*': + schema: + $ref: "#/components/schemas/ImageUploadResponse" + "400": + description: 잘λͺ»λœ μš”μ²­ (파일 λˆ„λ½/ν˜•μ‹ 였λ₯˜ λ“±) + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: 인증 μ‹€νŒ¨ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/events: + post: + tags: + - Event + summary: 이벀트 생성 + description: μƒˆλ‘œμš΄ 이벀트λ₯Ό μƒμ„±ν•©λ‹ˆλ‹€ + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEventRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateEventResponse" + "401": + description: 인증 ν•„μš” + /api/events/{eventId}/registrations: + get: + security: + - bearerAuth: [] + tags: + - Registration + summary: 이벀트 μ‹ μ²­ λͺ©λ‘ 쑰회 + description: νŠΉμ • μ΄λ²€νŠΈμ— μ†ν•œ μ‹ μ²­ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€. μ‹ μ²­ μƒνƒœ(CONFIRMED/WAITLISTED/BANNED)와 μ‹ μ²­ + μ‹œκ°μ„ 포함해 λ°˜ν™˜ν•©λ‹ˆλ‹€. + parameters: + - name: eventId + in: path + required: true + schema: + type: string + - name: status + in: query + required: false + schema: + type: string + - name: orderBy + in: query + required: false + schema: + type: string + - name: cursor + in: query + description: λ‹€μŒ νŽ˜μ΄μ§€ μ‹œμž‘ offset (μ—†μœΌλ©΄ 0λΆ€ν„°) + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetEventRegistrationsResponse" + post: + security: [] + tags: + - Registration + summary: 이벀트 μ°Έμ—¬ μ‹ μ²­ + description: "νŠΉμ • μ΄λ²€νŠΈμ— μ‹ μ²­ν•©λ‹ˆλ‹€. 정원이 λ‚¨μ•„μžˆμœΌλ©΄ CONFIRMED둜 λ“±λ‘λ˜κ³ , 정원이 μ°ΌμœΌλ‚˜ λŒ€κΈ° λͺ…단이 ν—ˆμš©λœ κ²½\ + 우 WAITLISTED둜 λ“±λ‘λ©λ‹ˆλ‹€. 정원이 μ°Όκ³  λŒ€κΈ° λͺ…단이 λΉ„ν™œμ„±ν™”λœ 경우 신청이 κ±°μ ˆλ©λ‹ˆλ‹€." + parameters: + - name: eventId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRegistrationRequest" + required: true + responses: + "200": + description: μ‹ μ²­ 성곡 + content: + '*/*': + schema: + $ref: "#/components/schemas/CreateRegistrationResponse" + "409": + description: "μ‹ μ²­ λΆˆκ°€: REGISTRATION_ALREADY_EXISTS, REGISTRATION_BLOCKED_HOST, EVENT_FULL" + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "400": + description: "μœ νš¨ν•˜μ§€ μ•Šμ€ μš”μ²­: REGISTRATION_WRONG_NAME, REGISTRATION_WRONG_EMAIL, NOT_WITHIN_REGISTRATION_WINDOW" + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "403": + description: "κΆŒν•œ μ—†μŒ: REGISTRATION_BLOCKED_BANNED" + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: "이벀트 μ—†μŒ: EVENT_NOT_FOUND" + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/auth/social: + post: + security: [] + tags: + - Auth + summary: μ†Œμ…œ 둜그인 + description: μ†Œμ…œ λ‘œκ·ΈμΈμ„ μš”μ²­ν•©λ‹ˆλ‹€. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SocialLoginRequest" + required: true + responses: + "200": + description: μ†Œμ…œ 둜그인 성곡 + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + "409": + description: 이미 κ°€μž…λœ 이메일 + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: 인증 μ‹€νŒ¨ + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/auth/signup: + post: + security: [] + tags: + - Auth + summary: 이메일 νšŒμ›κ°€μž… + description: μƒˆλ‘œμš΄ μ‚¬μš©μžλ₯Ό λ“±λ‘ν•˜κ³  인증 이메일을 λ°œμ†‘ν•©λ‹ˆλ‹€. 이메일 인증 ν›„ κ°€μž…μ΄ μ™„λ£Œλ©λ‹ˆλ‹€. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + required: true + responses: + "409": + description: 이미 μ‘΄μž¬ν•˜λŠ” email + "204": + description: 인증 이메일 λ°œμ†‘ 성곡 + "400": + description: 잘λͺ»λœ μš”μ²­ (μœ νš¨ν•˜μ§€ μ•Šμ€ email/password/name) + "503": + description: 이메일 μ„œλΉ„μŠ€ μž₯μ•  + /api/auth/logout: + post: + security: + - bearerAuth: [] + tags: + - Auth + summary: λ‘œκ·Έμ•„μ›ƒ + description: ν˜„μž¬ JWT 토큰을 λΈ”λž™λ¦¬μŠ€νŠΈμ— μΆ”κ°€ν•˜μ—¬ λ‘œκ·Έμ•„μ›ƒν•©λ‹ˆλ‹€ + responses: + "204": + description: λ‘œκ·Έμ•„μ›ƒ 성곡 + /api/auth/login: + post: + security: [] + tags: + - Auth + summary: 둜그인 + description: email둜 λ‘œκ·ΈμΈν•˜μ—¬ JWT 토큰을 λ°œκΈ‰λ°›μŠ΅λ‹ˆλ‹€ + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + required: true + responses: + "401": + description: 인증 μ‹€νŒ¨ + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "200": + description: "둜그인 성곡, JWT 토큰 λ°˜ν™˜" + content: + '*/*': + schema: + $ref: "#/components/schemas/LoginResponse" + /api/auth/email-verification/{verificationCode}: + post: + security: [] + tags: + - Auth + summary: 이메일 인증 + description: μ΄λ©”μΌλ‘œ 받은 인증 μ½”λ“œλ₯Ό κ²€μ¦ν•˜κ³  νšŒμ›κ°€μž…μ„ μ™„λ£Œν•©λ‹ˆλ‹€ + parameters: + - name: verificationCode + in: path + required: true + schema: + type: string + responses: + "400": + description: μœ νš¨ν•˜μ§€ μ•Šκ±°λ‚˜ 만료된 인증 μ½”λ“œ + "200": + description: "인증 성곡, μœ μ € 등둝 μ™„λ£Œ" + /api/users/me: + get: + tags: + - User + summary: 본인 정보 쑰회 + description: λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ 정보λ₯Ό μ‘°νšŒν•©λ‹ˆλ‹€ + security: + - bearerAuth: [] + responses: + "401": + description: 인증 μ‹€νŒ¨ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰) + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" + "200": + description: μ‚¬μš©μž 정보 쑰회 성곡 + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + patch: + tags: + - User + summary: 본인 정보 μˆ˜μ • + description: λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ 정보λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€ + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PatchMeRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/UserDto" + "401": + description: 인증 ν•„μš” + /api/registrations/{registrationId}: + get: + security: [] + tags: + - Registration + summary: μ‹ μ²­ 단건 정보 μš”μ²­ + description: μ‹ μ²­ λ‹¨κ±΄μ˜ 정보λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetRegistrationResponse" + delete: + security: [] + tags: + - Registration + summary: μ‹ μ²­ 단건 μ·¨μ†Œ + description: μ‹ μ²­ 단건 λ¦¬μ†ŒμŠ€λ₯Ό μ·¨μ†Œν•©λ‹ˆλ‹€. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/DeleteRegistrationResponse" + patch: + security: + - bearerAuth: [] + tags: + - Registration + summary: μ‹ μ²­ 단건 μƒνƒœ λ³€κ²½ + description: μ‹ μ²­ 단건 λ¦¬μ†ŒμŠ€μ˜ μƒνƒœλ₯Ό λ³€κ²½ν•©λ‹ˆλ‹€. μ‚¬μš©μžκ°€ 본인의 신청을 μ·¨μ†Œν•˜λŠ” κ²½μš°μ™€ κ΄€λ¦¬μžκ°€ κ°•μ œ μ·¨μ†Œ/λ°΄ μ²˜λ¦¬ν•˜λŠ” 경우λ₯Ό + 둜직 λ ˆλ²¨μ—μ„œ κ΅¬λΆ„ν•©λ‹ˆλ‹€. + parameters: + - name: registrationId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRegistrationStatusRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/PatchRegistrationResponse" + /api/registrations/me: + get: + tags: + - Registration + summary: λ‚΄ 이벀트 μ‹ μ²­ λͺ©λ‘ 쑰회 + description: λ‘œκ·ΈμΈν•œ μ‚¬μš©μžμ˜ 이벀트 μ‹ μ²­ λͺ©λ‘μ„ μ‘°νšŒν•©λ‹ˆλ‹€. μ΅œμ‹  μ‹ μ²­ 순으둜 λ°˜ν™˜λ˜λ©° page/size둜 νŽ˜μ΄μ§•ν•©λ‹ˆλ‹€. + security: + - bearerAuth: [] + parameters: + - name: page + in: query + required: false + schema: + type: integer + format: int32 + default: 0 + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 20 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/GetMyRegistrationsResponse" + "401": + description: 인증 ν•„μš” + /api/events/me: + get: + tags: + - Event + summary: λ‚΄κ°€ λ§Œλ“  이벀트 λͺ©λ‘ 쑰회 (λ¬΄ν•œ 슀크둀) + description: 둜그인된 μ‚¬μš©μžκ°€ μƒμ„±ν•œ 이벀트 λͺ©λ‘μ„ λ¬΄ν•œ 슀크둀 방식(cursor)으둜 μ‘°νšŒν•©λ‹ˆλ‹€ + security: + - bearerAuth: [] + parameters: + - name: cursor + in: query + required: false + schema: + type: string + format: date-time + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 5 + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/MyEventsInfiniteResponse" + "401": + description: 인증 ν•„μš” +components: + schemas: + UpdateEventRequest: + type: object + properties: + title: + type: string + description: 이벀트 제λͺ© + nullable: true + example: μ •κΈ° λͺ¨μž„ + description: + type: string + description: 이벀트 μ„€λͺ… + nullable: true + example: μ •κΈ° λͺ¨μž„μž…λ‹ˆλ‹€ + location: + type: string + description: μž₯μ†Œ + nullable: true + example: κ°•μ˜μ‹€ 101 + startsAt: + type: string + description: μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: μ’…λ£Œ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + nullable: true + example: 30 + waitlistEnabled: + type: boolean + description: λŒ€κΈ° λͺ…단 μ‚¬μš© μ—¬λΆ€ + nullable: true + example: true + registrationStartsAt: + type: string + description: μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T17:30:00Z + description: 일정 μˆ˜μ • μš”μ²­ + UpdateEventResponse: + required: + - registrationEndsAt + - title + - capacity + - waitlistEnabled + type: object + properties: + title: + type: string + description: + type: string + nullable: true + location: + type: string + nullable: true + startsAt: + type: string + format: date-time + nullable: true + endsAt: + type: string + format: date-time + nullable: true + capacity: + type: integer + format: int32 + nullable: true + waitlistEnabled: + type: boolean + registrationStartsAt: + type: string + format: date-time + nullable: true + registrationEndsAt: + type: string + format: date-time + ImageUploadResponse: + required: + - key + - url + type: object + properties: + key: + type: string + url: + type: string + CreateEventRequest: + required: + - capacity + - registrationEndsAt + - title + - waitlistEnabled + type: object + properties: + title: + type: string + description: 이벀트 제λͺ© + example: μ •κΈ° λͺ¨μž„ + description: + type: string + description: 이벀트 μ„€λͺ… + nullable: true + example: μ •κΈ° λͺ¨μž„μž…λ‹ˆλ‹€ + location: + type: string + description: μž₯μ†Œ + nullable: true + example: κ°•μ˜μ‹€ 101 + startsAt: + type: string + description: μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: μ’…λ£Œ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + example: 30 + waitlistEnabled: + type: boolean + description: λŒ€κΈ° λͺ…단 μ‚¬μš© μ—¬λΆ€ + example: true + registrationStartsAt: + type: string + description: μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601). null인 경우 μš”μ²­ μ‹œμ μœΌλ‘œ μžλ™ μ„€μ •λ©λ‹ˆλ‹€. + format: date-time + nullable: true + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601) + format: date-time + example: 2026-02-02T17:30:00Z + description: 일정 생성 μš”μ²­ + CreateEventResponse: + required: + - publicId + type: object + properties: + publicId: + type: string + CreateRegistrationRequest: + type: object + properties: + guestName: + type: string + description: λΉ„νšŒμ› 이름 + nullable: true + guestEmail: + type: string + description: λΉ„νšŒμ› 이메일 + nullable: true + description: 이벀트 μ‹ μ²­ μš”μ²­ + CreateRegistrationResponse: + required: + - registrationPublicId + type: object + properties: + registrationPublicId: + type: string + description: μ‹ μ²­ id + description: 이벀트 μ‹ μ²­ 응닡 + SocialLoginRequest: + required: + - code + - provider + type: object + properties: + provider: + type: string + description: μ†Œμ…œλ‘œκ·ΈμΈ 제곡자 + example: GOOGLE + code: + type: string + description: 인가 μ½”λ“œ (authorization code) + description: μ†Œμ…œ 둜그인 μš”μ²­ + LoginResponse: + required: + - token + type: object + properties: + token: + type: string + SignupRequest: + required: + - email + - name + - password + type: object + properties: + email: + type: string + description: μ‚¬μš©μž 이메일 + example: user@example.com + name: + type: string + description: μ‚¬μš©μž 이름 + example: 홍길동 + password: + type: string + description: λΉ„λ°€λ²ˆν˜Έ + example: password123 + profileImage: + type: string + description: ν”„λ‘œν•„ 이미지 URL + nullable: true + example: https://cdn.example.com/profile.png + description: νšŒμ›κ°€μž… μš”μ²­ + LoginRequest: + required: + - email + - password + type: object + properties: + email: + type: string + description: μ‚¬μš©μž 이메일 + example: user@example.com + password: + type: string + description: μ‚¬μš©μž λΉ„λ°€λ²ˆν˜Έ + example: mypassword + description: 둜그인 μš”μ²­ + PatchMeRequest: + type: object + properties: + name: + type: string + nullable: true + email: + type: string + nullable: true + password: + type: string + nullable: true + profileImage: + type: string + nullable: true + UserDto: + required: + - email + - id + - name + type: object + properties: + id: + type: integer + format: int64 + email: + type: string + name: + type: string + profileImage: + type: string + nullable: true + UpdateRegistrationStatusRequest: + type: object + properties: + status: + type: string + nullable: true + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + description: 이벀트 μ‹ μ²­ μƒνƒœ λ³€κ²½ μš”μ²­ + PatchRegistrationResponse: + type: object + properties: + patchEmail: + type: string + nullable: true + description: 이벀트 μ‹ μ²­ μƒνƒœ λ³€κ²½ + GetRegistrationResponse: + required: + - guestName + - registrationPublicId + - reservationEmail + - status + - waitlistPosition + type: object + properties: + status: + type: string + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + guestName: + type: string + waitlistPosition: + type: integer + format: int32 + registrationPublicId: + type: string + reservationEmail: + type: string + description: 이벀트 μ‹ μ²­ 정보 μš”μ²­ 응닡 + GetMyRegistrationsResponse: + required: + - registrations + type: object + properties: + registrations: + type: array + description: μ‹ μ²­ λͺ©λ‘ + items: + $ref: "#/components/schemas/MyRegistrationItem" + description: λ‚΄ 이벀트 μ‹ μ²­ λͺ©λ‘ 응닡 + MyRegistrationItem: + required: + - publicId + - confirmedCount + - waitlistCount + - status + - title + type: object + properties: + publicId: + type: string + description: 이벀트 곡개 ID + title: + type: string + description: 이벀트 제λͺ© + startsAt: + type: string + description: 이벀트 μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + endsAt: + type: string + description: 이벀트 μ’…λ£Œ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + registrationStartsAt: + type: string + description: μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + registrationEndsAt: + type: string + description: μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + capacity: + type: integer + description: 정원 + format: int32 + nullable: true + confirmedCount: + type: integer + description: μ°Έμ—¬ ν™•μ •μž 수 + format: int32 + waitlistCount: + type: integer + description: λŒ€κΈ°μž 수 + format: int32 + status: + type: string + description: μ‹ μ²­ μƒνƒœ + enum: + - CONFIRMED + - WAITLISTED + - BANNED + waitlistedNum: + type: integer + description: λŒ€κΈ° 순번 (WAITLISTEDκ°€ μ•„λ‹ˆλ©΄ null) + format: int32 + nullable: true + description: λ‚΄ 이벀트 μ‹ μ²­ μš”μ•½ + CapabilitiesInfo: + required: + - apply + - cancel + - shareLink + - wait + type: object + properties: + shareLink: + type: boolean + description: 곡유 링크 κ°€λŠ₯ μ—¬λΆ€ + example: false + apply: + type: boolean + description: μ°Έμ—¬ μ‹ μ²­ κ°€λŠ₯ μ—¬λΆ€(ν™•μ • 자리 μ‹ μ²­) + example: false + wait: + type: boolean + description: λŒ€κΈ° μ‹ μ²­ κ°€λŠ₯ μ—¬λΆ€(정원 찼을 λ•Œ) + example: false + cancel: + type: boolean + description: μ‹ μ²­ μ·¨μ†Œ κ°€λŠ₯ μ—¬λΆ€ + example: true + description: κ°€λŠ₯ν•œ λ™μž‘ 블둝 + CreatorInfo: + required: + - email + - name + type: object + properties: + name: + type: string + description: 이름 + example: 홍길동 + email: + type: string + description: 이메일 + example: user@example.com + profileImage: + type: string + description: ν”„λ‘œν•„ 이미지 + nullable: true + example: https://example.com/profile.png + description: μƒμ„±μž 정보 블둝 + EventDetailResponse: + required: + - capabilities + - creator + - event + - guestsPreview + - viewer + type: object + properties: + event: + $ref: "#/components/schemas/EventInfo" + creator: + $ref: "#/components/schemas/CreatorInfo" + viewer: + $ref: "#/components/schemas/ViewerInfo" + capabilities: + $ref: "#/components/schemas/CapabilitiesInfo" + guestsPreview: + type: array + description: μ°Έμ—¬μž 미리보기 λͺ©λ‘ + items: + $ref: "#/components/schemas/GuestPreview" + description: 일정 상세 쑰회 응닡 + EventInfo: + required: + - publicId + - registrationEndsAt + - title + - confirmedCount + - waitlistCount + type: object + properties: + publicId: + type: string + description: 이벀트 publicId + example: dj8sadj10djnkas + title: + type: string + description: 이벀트 제λͺ© + example: 제2회 기획 μ„Έλ―Έλ‚˜ + description: + type: string + description: 이벀트 μ„€λͺ… + nullable: true + example: 일정 상세 μ„€λͺ…... + location: + type: string + description: μž₯μ†Œ + nullable: true + example: μ„œμšΈλŒ€ + startsAt: + type: string + description: μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T18:00:00Z + endsAt: + type: string + description: μ’…λ£Œ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T20:00:00Z + capacity: + type: integer + description: 정원 + format: int32 + nullable: true + example: 10 + confirmedCount: + type: integer + description: μ°Έμ—¬ ν™•μ • μ‹ μ²­μž 수 + format: int32 + example: 8 + waitlistCount: + type: integer + description: λŒ€κΈ°μž 수 + format: int32 + example: 8 + registrationStartsAt: + type: string + description: μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ (ISO-8601) + format: date-time + nullable: true + example: 2026-02-02T17:00:00Z + registrationEndsAt: + type: string + description: μ‹ μ²­ 마감 μ‹œκ°„ (ISO-8601) + format: date-time + example: 2026-02-02T17:00:00Z + description: 이벀트 정보 블둝 + GuestPreview: + required: + - name + type: object + properties: + id: + type: integer + description: μ°Έμ—¬μž ID (λΉ„νšŒμ›μ΄λ©΄ null) + format: int64 + nullable: true + example: 1 + name: + type: string + description: μ°Έμ—¬μž 이름 + example: 홍길동 + profileImage: + type: string + description: ν”„λ‘œν•„ 이미지 URL + nullable: true + example: https://example.com/profile.png + description: μ°Έμ—¬μž 미리보기 정보 + ViewerInfo: + required: + - status + type: object + properties: + status: + type: string + description: 이벀트 쑰회자(Viewer)의 μƒνƒœ + example: WAITLISTED + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + - NONE + name: + type: string + description: viewer 이름 (λΉ„νšŒμ›μ΄λ©΄ null) + nullable: true + example: 홍길동 + waitlistPosition: + type: integer + description: "λŒ€κΈ° 순번 (WAITLISTED일 λ•Œλ§Œ 숫자, μ•„λ‹ˆλ©΄ null)" + format: int32 + nullable: true + example: 3 + registrationPublicId: + type: string + description: "registrationPublicId (게슀트면 κ°’, μ•„λ‹ˆλ©΄ null)" + nullable: true + example: "1" + reservationEmail: + type: string + description: "μ˜ˆμ•½ 이메일(게슀트면 guestEmail, 둜그인 μœ μ €λ©΄ user email λ“± 정책에 따름)" + nullable: true + example: skdfsj@gmaldkl + description: 쑰회자(viewer) 정보 블둝 + EventRegistrationItem: + required: + - createdAt + - name + - registrationId + - status + type: object + properties: + registrationId: + type: string + description: μ‹ μ²­ 곡개 ID + name: + type: string + description: 이름 + email: + type: string + description: 이메일 (κ΄€λ¦¬μž μš”μ²­ μ‹œ λ…ΈμΆœ) + nullable: true + profileImage: + type: string + description: ν”„λ‘œν•„ 이미지 URL + nullable: true + createdAt: + type: string + description: μ‹ μ²­ μΌμ‹œ + format: date-time + status: + type: string + description: μ‹ μ²­ μƒνƒœ + enum: + - HOST + - CONFIRMED + - WAITLISTED + - BANNED + waitingNum: + type: integer + description: λŒ€κΈ° 순번 (statusκ°€ WAITLISTED일 λ•Œλ§Œ μ•„λ‹ˆλ©΄ null) + format: int32 + nullable: true + description: μ‹ μ²­μž 정보 + GetEventRegistrationsResponse: + required: + - hasNext + - participants + type: object + properties: + participants: + type: array + description: μ‹ μ²­μž λͺ©λ‘ + items: + $ref: "#/components/schemas/EventRegistrationItem" + totalCount: + type: integer + description: 필터에 따라 λ³€ν•˜λŠ” μ°Έμ—¬μž 수 + format: int32 + nullable: true + nextCursor: + type: integer + description: λ‹€μŒ νŽ˜μ΄μ§€ μ‹œμž‘ offset (μ—†μœΌλ©΄ null) + format: int32 + nullable: true + hasNext: + type: boolean + description: λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€ + description: 이벀트 μ‹ μ²­ λͺ©λ‘ 쑰회 응닡 + MyEventResponse: + required: + - publicId + - registrationEndsAt + - title + - confirmedCount + - waitlistCount + type: object + properties: + publicId: + type: string + title: + type: string + startsAt: + type: string + format: date-time + nullable: true + endsAt: + type: string + format: date-time + nullable: true + registrationStartsAt: + type: string + format: date-time + nullable: true + registrationEndsAt: + type: string + format: date-time + capacity: + type: integer + format: int32 + nullable: true + confirmedCount: + type: integer + format: int32 + waitlistCount: + type: integer + format: int32 + MyEventsInfiniteResponse: + required: + - events + - hasNext + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MyEventResponse" + nextCursor: + type: string + format: date-time + nullable: true + hasNext: + type: boolean + DeleteRegistrationResponse: + type: object + ErrorResponse: + required: + - code + - message + - title + type: object + properties: + code: + type: string + description: μ—λŸ¬ μ½”λ“œ + title: + type: string + description: μ—λŸ¬ 제λͺ© + message: + type: string + description: μ—λŸ¬ λ©”μ‹œμ§€ + description: μ—λŸ¬ 응닡 + securitySchemes: + bearerAuth: + type: http + description: JWT 토큰을 μž…λ ₯ν•˜μ„Έμš” + scheme: bearer + bearerFormat: JWT diff --git a/src/main/resources/static/docs/swagger-ui.html b/src/main/resources/static/docs/swagger-ui.html new file mode 100644 index 00000000..517735db --- /dev/null +++ b/src/main/resources/static/docs/swagger-ui.html @@ -0,0 +1,36 @@ + + + + + + MoiMing API Docs + + + + +
+ + + + + diff --git a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt index 1d1c9a37..d670c9f3 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/AuthIntegrationTest.kt @@ -1,153 +1,550 @@ package com.wafflestudio.spring2025 import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.auth.dto.LoginRequest import com.wafflestudio.spring2025.domain.auth.dto.SignupRequest +import com.wafflestudio.spring2025.domain.auth.model.SocialProvider +import com.wafflestudio.spring2025.domain.user.identity.model.UserIdentity +import com.wafflestudio.spring2025.domain.user.identity.repository.UserIdentityRepository +import com.wafflestudio.spring2025.domain.user.model.PendingUser +import com.wafflestudio.spring2025.domain.user.repository.PendingUserRepository +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mindrot.jbcrypt.BCrypt +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant +import java.util.UUID @SpringBootTest @ActiveProfiles("test") @Testcontainers @AutoConfigureMockMvc +@Import(TestContainerConfig::class) class AuthIntegrationTest @Autowired constructor( private val mvc: MockMvc, private val mapper: ObjectMapper, private val dataGenerator: DataGenerator, + private val pendingUserRepository: PendingUserRepository, + private val userIdentityRepository: UserIdentityRepository, ) { + // μ‹€μ œ 이메일 λ°œμ†‘ λ°©μ§€: EmailServiceλ₯Ό Mock으둜 λŒ€μ²΄ + @MockitoBean + private lateinit var emailService: EmailService + + // ================================================================= + // POST /api/auth/signup β€” 이메일 νšŒμ›κ°€μž… μš”μ²­ (인증 이메일 λ°œμ†‘ 트리거) + // ================================================================= + @Test - fun `should signup successfully`() { - // νšŒμ›κ°€μž…ν•  수 μžˆλ‹€ - val email = "user1@example.com" - val name = "user1" - val password = "password123" + fun `μœ νš¨ν•œ λ°μ΄ν„°λ‘œ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 204λ₯Ό λ°˜ν™˜ν•˜κ³  인증 이메일을 λ°œμ†‘ν•œλ‹€`() { + val email = "newuser-${UUID.randomUUID()}@example.com" + val request = + SignupRequest( + email = email, + name = "μƒˆμ‚¬μš©μž", + password = "password123", + ) + + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + verify(emailService).sendVerificationEmail(eq(email), any()) + } + + @Test + fun `이메일이 빈 λ¬Έμžμ—΄μ΄λ©΄ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val request = SignupRequest(email = "", name = "ν…ŒμŠ€νŠΈμœ μ €", password = "password123") + + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_EMAIL")) + } + + @Test + fun `이메일 ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμœΌλ©΄ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val request = SignupRequest(email = "not-an-email", name = "ν…ŒμŠ€νŠΈμœ μ €", password = "password123") + + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_EMAIL")) + } + + @Test + fun `이름이 곡백이면 νšŒμ›κ°€μž… μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val request = SignupRequest(email = "user@example.com", name = " ", password = "password123") - val request = SignupRequest(email, name, password) mvc .perform( - post("/api/v1/auth/signup") + post("/api/auth/signup") .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isCreated) + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_NAME")) } @Test - fun `should return 400 error when email is blank during signup`() { - // νšŒμ›κ°€μž… μ‹œ 이메일이 λΉ„μ–΄μžˆμœΌλ©΄ 400 μ—λŸ¬ - val request = SignupRequest("", "user", "password") + fun `λΉ„λ°€λ²ˆν˜Έκ°€ 8자 미만이면 νšŒμ›κ°€μž… μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val request = SignupRequest(email = "user@example.com", name = "ν…ŒμŠ€νŠΈμœ μ €", password = "pass1") + mvc .perform( - post("/api/v1/auth/signup") + post("/api/auth/signup") .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) } @Test - fun `should return 409 error when email already exists during signup`() { - // νšŒμ›κ°€μž… μ‹œ 이메일이 이미 μ‘΄μž¬ν•˜λ©΄ 409 μ—λŸ¬ - val (user, token) = dataGenerator.generateUser() - val request = SignupRequest(user.email, user.name, "password", user.profileImage) + fun `λΉ„λ°€λ²ˆν˜Έμ— μˆ«μžκ°€ μ—†μœΌλ©΄ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val request = SignupRequest(email = "user@example.com", name = "ν…ŒμŠ€νŠΈμœ μ €", password = "onlyletters") + mvc .perform( - post("/api/v1/auth/signup") + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `λΉ„λ°€λ²ˆν˜Έμ— λ¬Έμžκ°€ μ—†μœΌλ©΄ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val request = SignupRequest(email = "user@example.com", name = "ν…ŒμŠ€νŠΈμœ μ €", password = "12345678") + + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `인증 λŒ€κΈ° 쀑인 μ΄λ©”μΌλ‘œ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 409λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val email = "pending-${UUID.randomUUID()}@example.com" + pendingUserRepository.save( + PendingUser( + email = email, + name = "λŒ€κΈ°μœ μ €", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = UUID.randomUUID().toString(), + expiresAt = Instant.now().plusSeconds(3600), + ), + ) + + val request = SignupRequest(email = email, name = "ν…ŒμŠ€νŠΈμœ μ €", password = "password123") + + mvc + .perform( + post("/api/auth/signup") .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EMAIL_VERIFICATION_PENDING")) + } + + @Test + fun `이미 μ΄λ©”μΌλ‘œ κ°€μž…λœ 계정에 νšŒμ›κ°€μž… μš”μ²­ μ‹œ 409λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, _) = dataGenerator.generateUser() + val request = SignupRequest(email = user.email, name = "ν…ŒμŠ€νŠΈμœ μ €", password = "password123") + + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EMAIL_ACCOUNT_ALREADY_EXIST")) + } + + @Test + fun `ꡬ글 μ†Œμ…œλ‘œ κ°€μž…λœ μ΄λ©”μΌλ‘œ νšŒμ›κ°€μž… μš”μ²­ μ‹œ 409λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, _) = dataGenerator.generateUser() + userIdentityRepository.save( + UserIdentity( + userId = user.id!!, + provider = SocialProvider.GOOGLE.name, + providerUserId = "google-${UUID.randomUUID()}", + ), + ) + + val request = SignupRequest(email = user.email, name = "ν…ŒμŠ€νŠΈμœ μ €", password = "password123") + + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("GOOGLE_ACCOUNT_ALREADY_EXIST")) + } + + // ================================================================= + // POST /api/auth/email-verification/{code} β€” 이메일 인증 μ™„λ£Œ + // ================================================================= + + @Test + fun `μœ νš¨ν•œ 인증 μ½”λ“œλ‘œ 이메일 인증 성곡 μ‹œ 200을 λ°˜ν™˜ν•œλ‹€`() { + val verificationCode = UUID.randomUUID().toString() + pendingUserRepository.save( + PendingUser( + email = "verify-${UUID.randomUUID()}@example.com", + name = "μΈμ¦μœ μ €", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = verificationCode, + expiresAt = Instant.now().plusSeconds(3600), + ), + ) + + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) + } + + @Test + fun `μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 인증 μ½”λ“œλ‘œ 이메일 인증 μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val invalidCode = UUID.randomUUID().toString() + + mvc + .perform( + post("/api/auth/email-verification/$invalidCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + } + + @Test + fun `만료된 인증 μ½”λ“œλ‘œ 이메일 인증 μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val expiredCode = UUID.randomUUID().toString() + pendingUserRepository.save( + PendingUser( + email = "expired-${UUID.randomUUID()}@example.com", + name = "λ§Œλ£Œμœ μ €", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = expiredCode, + expiresAt = Instant.now().minusSeconds(3600), // 이미 만료된 μ½”λ“œ + ), + ) + + mvc + .perform( + post("/api/auth/email-verification/$expiredCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) } + // ================================================================= + // POST /api/auth/login β€” 이메일 둜그인 + // ================================================================= + @Test - fun `should login with email successfully`() { - // λ‘œκ·ΈμΈν•  수 μžˆλ‹€ - val password = "myPassword" - val (user, token) = dataGenerator.generateUserWithPassword(password) + fun `μœ νš¨ν•œ 이메일과 λΉ„λ°€λ²ˆν˜Έλ‘œ 둜그인 성곡 μ‹œ 200κ³Ό 토큰을 λ°˜ν™˜ν•œλ‹€`() { + val password = "myPassword1" + val (user, _) = dataGenerator.generateUserWithPassword(password) val request = LoginRequest(email = user.email, password = password) + mvc .perform( - post("/api/v1/auth/login") + post("/api/auth/login") .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) } @Test - fun `should return 401 error when email is incorrect during login`() { - // 둜그인 μ‹œ 이메일이 ν‹€λ Έλ‹€λ©΄ 401 μ—λŸ¬ - val request = LoginRequest(email = "wrong@example.com", password = "something") + fun `μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ΄λ©”μΌλ‘œ 둜그인 μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + val request = LoginRequest(email = "nonexistent@example.com", password = "password123") + mvc .perform( - post("/api/v1/auth/login") + post("/api/auth/login") .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @Test - fun `should retrieve own user information`() { - // 본인 정보λ₯Ό μ‘°νšŒν•  수 μžˆλ‹€ - val (user, token) = dataGenerator.generateUser() + fun `잘λͺ»λœ λΉ„λ°€λ²ˆν˜Έλ‘œ 둜그인 μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + val (user, _) = dataGenerator.generateUserWithPassword("correctPassword1") + val request = LoginRequest(email = user.email, password = "wrongPassword9") + mvc .perform( - get("/api/v1/users/me") - .header("Authorization", "Bearer $token") + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.email").value(user.email)) + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } @Test - fun `should return 401 when retrieving user information with invalid token`() { - // μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μœΌλ‘œ 본인 정보 쑰회 μ‹œ 401 - val (user, token) = dataGenerator.generateUser() + fun `λΉ„λ°€λ²ˆν˜Έ 없이 μƒμ„±λœ μ‚¬μš©μž(μ†Œμ…œ κ°€μž…)둜 이메일 둜그인 μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + // DataGenerator.generateUser()λŠ” passwordHash 없이 User 생성 + val (user, _) = dataGenerator.generateUser() + val request = LoginRequest(email = user.email, password = "anyPassword1") + mvc .perform( - get("/api/v1/users/me") - .header("Authorization", "Bearer wrong") + post("/api/auth/login") + .content(mapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("LOGIN_FAILED")) } + // ================================================================= + // POST /api/auth/logout β€” λ‘œκ·Έμ•„μ›ƒ (JWT λΈ”λž™λ¦¬μŠ€νŠΈ 등둝) + // ================================================================= + @Test - fun `should blacklist jwt`() { - val (user, token) = dataGenerator.generateUser() + fun `μœ νš¨ν•œ ν† ν°μœΌλ‘œ λ‘œκ·Έμ•„μ›ƒ 성곡 μ‹œ 204λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + mvc .perform( - get("/api/v1/users/me") + post("/api/auth/logout") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.email").value(user.email)) + ).andExpect(status().isNoContent) + } + + @Test + fun `Authorization 헀더 없이 λ‘œκ·Έμ•„μ›ƒ μš”μ²­ μ‹œ 204λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + // /api/auth/logout은 @AuthRequiredκ°€ μ—†μœΌλ―€λ‘œ 인증 없이도 204 λ°˜ν™˜ + mvc + .perform( + post("/api/auth/logout") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + } + + // ================================================================= + // GET /api/users/me β€” λ‘œκ·Έμ•„μ›ƒ λΈ”λž™λ¦¬μŠ€νŠΈ 검증 (인증 흐름 관점) + // κΈ°λ³Έ λ‚΄ 정보 쑰회 μ‹œλ‚˜λ¦¬μ˜€λŠ” UserIntegrationTestμ—μ„œ λ‹€λ£Έ + // ================================================================= + + @Test + fun `λ‘œκ·Έμ•„μ›ƒ ν›„ λΈ”λž™λ¦¬μŠ€νŠΈλœ ν† ν°μœΌλ‘œ λ‚΄ 정보 쑰회 μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + // λ‘œκ·Έμ•„μ›ƒμœΌλ‘œ 토큰 λΈ”λž™λ¦¬μŠ€νŠΈ 등둝 mvc .perform( - post("/api/v1/users/logout") - .queryParam("token", token) + post("/api/auth/logout") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isOk) + ).andExpect(status().isNoContent) + + // λΈ”λž™λ¦¬μŠ€νŠΈλœ ν† ν°μœΌλ‘œ λ‚΄ 정보 쑰회 μ‹œ 401 mvc .perform( - post("/api/v1/users/logout") - .queryParam("token", token) + get("/api/users/me") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + // ================================================================= + // End-to-End: νšŒμ›κ°€μž… β†’ 이메일 인증 β†’ 둜그인 β†’ λ‚΄ 정보 쑰회 + // ================================================================= + + @Test + fun `νšŒμ›κ°€μž… μš”μ²­ ν›„ 이메일 인증을 μ™„λ£Œν•˜λ©΄ 둜그인 ν›„ λ‚΄ 정보λ₯Ό μ‘°νšŒν•  수 μžˆλ‹€`() { + val email = "e2e-${UUID.randomUUID()}@example.com" + val name = "E2Eμœ μ €" + val password = "password123" + + // 1. νšŒμ›κ°€μž… μš”μ²­ β†’ 204 No Content (인증 이메일 λ°œμ†‘) mvc .perform( - get("/api/v1/users/me") + post("/api/auth/signup") + .content(mapper.writeValueAsString(SignupRequest(email = email, name = name, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // 2. DBμ—μ„œ PendingUser의 인증 μ½”λ“œ νšλ“ + val verificationCode = pendingUserRepository.findByEmail(email)!!.verificationCode + + // 3. 이메일 인증 μ™„λ£Œ β†’ 200 OK (User 생성) + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) + + // 4. 둜그인 β†’ 200 OK + JWT 토큰 + val loginResult = + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) + .andReturn() + + val token = + mapper + .readTree(loginResult.response.contentAsString) + .get("token") + .asText() + + // 5. λ‚΄ 정보 쑰회 β†’ 200 OK + μ‚¬μš©μž 정보 + mvc + .perform( + get("/api/users/me") .header("Authorization", "Bearer $token") .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isUnauthorized) + ).andExpect(status().isOk) + .andExpect(jsonPath("$.email").value(email)) + .andExpect(jsonPath("$.name").value(name)) + } + + @Test + fun `이메일 인증 μ™„λ£Œ ν›„ λ™μΌν•œ 인증 μ½”λ“œλ‘œ 재인증 μ‹œλ„ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val verificationCode = UUID.randomUUID().toString() + pendingUserRepository.save( + PendingUser( + email = "onetime-${UUID.randomUUID()}@example.com", + name = "일회용인증", + passwordHash = BCrypt.hashpw("password123", BCrypt.gensalt()), + verificationCode = verificationCode, + expiresAt = Instant.now().plusSeconds(3600), + ), + ) + + // 첫 번째 인증 β†’ 성곡 + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isOk) + + // 두 번째 인증 (PendingUser μ‚­μ œλ¨) β†’ μ‹€νŒ¨ + mvc + .perform( + post("/api/auth/email-verification/$verificationCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + } + + // ================================================================= + // 이메일 인증 만료 ν›„ μž¬κ°€μž… μš”μ²­ μ‹œλ‚˜λ¦¬μ˜€ + // EmailVerificationService.createPendingUser: + // 만료된 PendingUserκ°€ μ‘΄μž¬ν•˜λ©΄ μ‚­μ œ ν›„ μƒˆλ‘œμš΄ PendingUser 생성 + // ================================================================= + + @Test + fun `인증 μ½”λ“œκ°€ 만료된 μ΄λ©”μΌλ‘œ μž¬κ°€μž… μš”μ²­ μ‹œ κΈ°μ‘΄ PendingUserλ₯Ό κ΅μ²΄ν•˜κ³  204λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val email = "reregister-${UUID.randomUUID()}@example.com" + val oldCode = UUID.randomUUID().toString() + + // 만료된 PendingUser 사전 생성 + pendingUserRepository.save( + PendingUser( + email = email, + name = "κ΅¬μ‚¬μš©μž", + passwordHash = BCrypt.hashpw("oldPassword1", BCrypt.gensalt()), + verificationCode = oldCode, + expiresAt = Instant.now().minusSeconds(3600), // 이미 만료 + ), + ) + + // 동일 μ΄λ©”μΌλ‘œ μž¬κ°€μž… μš”μ²­ β†’ 만료된 PendingUser μ‚­μ œ ν›„ μƒˆλ‘œ 생성 β†’ 204 + val request = SignupRequest(email = email, name = "μ‹ μ‚¬μš©μž", password = "newPassword1") + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // μƒˆλ‘œμš΄ PendingUserκ°€ μƒμ„±λλŠ”μ§€ 확인 (μ½”λ“œκ°€ ꡐ체됨) + val newPendingUser = pendingUserRepository.findByEmail(email)!! + assertNotEquals(oldCode, newPendingUser.verificationCode) + assertTrue(newPendingUser.expiresAt.isAfter(Instant.now())) + } + + @Test + fun `만료된 인증 μ½”λ“œλ‘œ 인증 μ‹œλ„ ν›„ 동일 μ΄λ©”μΌλ‘œ μž¬κ°€μž…ν•˜λ©΄ μƒˆ μ½”λ“œλ‘œ 인증할 수 μžˆλ‹€`() { + val email = "reauth-${UUID.randomUUID()}@example.com" + val password = "password123" + val expiredCode = UUID.randomUUID().toString() + + // 1. 만료된 PendingUser 사전 생성 + pendingUserRepository.save( + PendingUser( + email = email, + name = "μž¬μΈμ¦μœ μ €", + passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()), + verificationCode = expiredCode, + expiresAt = Instant.now().minusSeconds(3600), + ), + ) + + // 2. 만료된 μ½”λ“œλ‘œ 인증 μ‹œλ„ β†’ 400 + mvc + .perform( + post("/api/auth/email-verification/$expiredCode"), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("INVALID_VERIFICATION_CODE")) + + // 3. 동일 μ΄λ©”μΌλ‘œ μž¬κ°€μž… μš”μ²­ β†’ 204 (만료된 PendingUser μ‚­μ œ ν›„ μ‹ κ·œ 생성) + mvc + .perform( + post("/api/auth/signup") + .content(mapper.writeValueAsString(SignupRequest(email = email, name = "μž¬μΈμ¦μœ μ €", password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNoContent) + + // 4. μƒˆλ‘œμš΄ 인증 μ½”λ“œλ‘œ 이메일 인증 β†’ 200 + val newCode = pendingUserRepository.findByEmail(email)!!.verificationCode + mvc + .perform( + post("/api/auth/email-verification/$newCode"), + ).andExpect(status().isOk) + + // 5. μƒˆλ‘œ κ°€μž…λœ κ³„μ •μœΌλ‘œ 둜그인 β†’ 200 + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = email, password = password))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) } } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt index c55e17af..5e6313aa 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt @@ -40,4 +40,18 @@ class DataGenerator( val token = jwtTokenProvider.createToken(user.id!!) return user to token } + + fun generateUserWithProfileImage(profileImageKey: String): Pair { + val email = "user-${UUID.randomUUID()}@example.com" + val user = + userRepository.save( + User( + email = email, + name = "user", + profileImage = profileImageKey, + ), + ) + val token = jwtTokenProvider.createToken(user.id!!) + return user to token + } } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt new file mode 100644 index 00000000..aa96cba1 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/EventIntegrationTest.kt @@ -0,0 +1,1320 @@ +package com.wafflestudio.spring2025 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.service.EmailService +import com.wafflestudio.spring2025.domain.event.dto.request.CreateEventRequest +import com.wafflestudio.spring2025.domain.event.dto.request.UpdateEventRequest +import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.model.Registration +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@AutoConfigureMockMvc +@Import(TestContainerConfig::class) +class EventIntegrationTest + @Autowired + constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val dataGenerator: DataGenerator, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, + ) { + @MockitoBean + private lateinit var emailService: EmailService + + // ================================================================= + // Helpers + // ================================================================= + + private fun createEventInDb( + createdBy: Long, + title: String = "ν…ŒμŠ€νŠΈ 이벀트", + capacity: Int = 10, + waitlistEnabled: Boolean = false, + startsAt: Instant = Instant.now().plusSeconds(7200), + endsAt: Instant = Instant.now().plusSeconds(10800), + registrationStartsAt: Instant? = null, + registrationEndsAt: Instant = Instant.now().plusSeconds(5400), + ): Event = + eventRepository.save( + Event( + publicId = UUID.randomUUID().toString(), + title = title, + capacity = capacity, + waitlistEnabled = waitlistEnabled, + startsAt = startsAt, + endsAt = endsAt, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, + createdBy = createdBy, + ), + ) + + /** + * μœ νš¨ν•œ CreateEventRequest κΈ°λ³Έκ°’. + * - startsAt=now+7200, endsAt=now+10800 (미래) + * - registrationStartsAt=now+3600 < startsAt(+7200) βœ“ + * - registrationEndsAt=now+5400 < startsAt(+7200), > registrationStartsAt(+3600) βœ“ + */ + private fun validCreateRequest( + title: String = "μ •κΈ° λͺ¨μž„", + capacity: Int = 30, + waitlistEnabled: Boolean = false, + ) = CreateEventRequest( + title = title, + description = "μ •κΈ° λͺ¨μž„μž…λ‹ˆλ‹€", + location = "κ°•μ˜μ‹€ 101", + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + capacity = capacity, + waitlistEnabled = waitlistEnabled, + registrationStartsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + // ================================================================= + // POST /api/events β€” 이벀트 생성 + // ================================================================= + + @Test + fun `μœ νš¨ν•œ λ°μ΄ν„°λ‘œ 이벀트 생성 μ‹œ 200κ³Ό publicIdλ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.publicId").isNotEmpty) + } + + @Test + fun `인증 없이 이벀트 생성 μš”μ²­ μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + mvc + .perform( + post("/api/events") + .content(mapper.writeValueAsString(validCreateRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `제λͺ©μ΄ 곡백인 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest(title = " "))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_TITLE_BLANK")) + } + + @Test + fun `정원이 0인 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest(capacity = 0))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `정원이 음수인 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(validCreateRequest(capacity = -5))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `λͺ¨μž„ μ‹œμž‘ μ‹œκ°„μ΄ μ‹ μ²­ 마감 μ‹œκ°„λ³΄λ‹€ 이λ₯΄λ©΄ 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + // registrationEndsAt(+5400) > startsAt(+3600) μœ„λ°˜ + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) + } + + @Test + fun `λͺ¨μž„ μ‹œμž‘ μ‹œκ°„μ΄ μ’…λ£Œ μ‹œκ°„λ³΄λ‹€ 늦으면 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(3600), // startsAt 보닀 이전 + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_TIME_RANGE_INVALID")) + } + + @Test + fun `μ‹ μ²­ μ‹œμž‘ μ‹œκ°„μ΄ 과거여도 이벀트λ₯Ό 생성할 수 μžˆλ‹€`() { + val (_, token) = dataGenerator.generateUser() + // κ³Όκ±° registrationStartsAt = 이미 λͺ¨μ§‘이 μ‹œμž‘λœ κ²ƒμœΌλ‘œ ν—ˆμš© + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + registrationStartsAt = Instant.now().minusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + } + + @Test + fun `μ‹ μ²­ 마감 μ‹œκ°„μ΄ 과거이면 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + registrationEndsAt = Instant.now().minusSeconds(3600), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_IN_PAST")) + } + + @Test + fun `μ‹ μ²­ 마감 μ‹œκ°„μ΄ μ‹ μ²­ μ‹œμž‘ μ‹œκ°„λ³΄λ‹€ 이λ₯΄λ©΄ 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + registrationStartsAt = Instant.now().plusSeconds(5400), + registrationEndsAt = Instant.now().plusSeconds(3600), // μ‹œμž‘λ³΄λ‹€ 이전 + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_TIME_RANGE_INVALID")) + } + + @Test + fun `μ‹ μ²­ μ‹œμž‘ μ‹œκ°„μ΄ λͺ¨μž„ μ‹œμž‘ μ‹œκ°„λ³΄λ‹€ 늦으면 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + // startsAt=+3600, registrationStartsAt=+5400 β†’ μ‹ μ²­ μ‹œμž‘μ΄ λͺ¨μž„ μ‹œμž‘λ³΄λ‹€ 늦음 + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationStartsAt = Instant.now().plusSeconds(5400), + registrationEndsAt = Instant.now().plusSeconds(7200), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_STARTS_AFTER_EVENT_START")) + } + + @Test + fun `μ‹ μ²­ 마감 μ‹œκ°„μ΄ λͺ¨μž„ μ‹œμž‘ μ‹œκ°„λ³΄λ‹€ 늦으면 이벀트 생성 μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + // startsAt=+3600, registrationEndsAt=+5400 β†’ μ‹ μ²­ 마감이 λͺ¨μž„ μ‹œμž‘λ³΄λ‹€ 늦음 + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) + } + + // ================================================================= + // GET /api/events/{publicId} β€” 이벀트 상세 쑰회 + // ================================================================= + + @Test + fun `μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” publicId둜 이벀트 상세 쑰회 μ‹œ 404λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + mvc + .perform( + get("/api/events/${UUID.randomUUID()}"), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `λΉ„λ‘œκ·ΈμΈ μ‚¬μš©μžκ°€ 이벀트 상세 쑰회 μ‹œ 200κ³Ό μ˜¬λ°”λ₯Έ 응닡 ꡬ쑰λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, title = "곡개 이벀트", capacity = 10) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.event.publicId").value(event.publicId)) + .andExpect(jsonPath("$.event.title").value("곡개 이벀트")) + .andExpect(jsonPath("$.event.capacity").value(10)) + .andExpect(jsonPath("$.event.confirmedCount").value(0)) + .andExpect(jsonPath("$.event.waitlistCount").value(0)) + .andExpect(jsonPath("$.creator.name").value(creator.name)) + .andExpect(jsonPath("$.creator.email").value(creator.email)) + .andExpect(jsonPath("$.viewer.status").value("NONE")) + .andExpect(jsonPath("$.viewer.name").value(null as Any?)) + .andExpect(jsonPath("$.viewer.waitlistPosition").value(null as Any?)) + .andExpect(jsonPath("$.capabilities.shareLink").value(false)) + .andExpect(jsonPath("$.capabilities.apply").value(true)) // 정원 μ—¬μœ  있고 μ‹ μ²­ κΈ°κ°„ μ œν•œ μ—†μŒ + .andExpect(jsonPath("$.capabilities.wait").value(false)) + .andExpect(jsonPath("$.capabilities.cancel").value(false)) + .andExpect(jsonPath("$.guestsPreview").isArray) + .andExpect(jsonPath("$.viewer.registrationPublicId").value(null as Any?)) + .andExpect(jsonPath("$.viewer.reservationEmail").value(null as Any?)) + } + + @Test + fun `λ‘œκ·ΈμΈν•œ 이벀트 μ£Όμ΅œμžκ°€ 상세 쑰회 μ‹œ viewerStatusκ°€ HOST이고 shareLinkκ°€ true이닀`() { + val (host, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("HOST")) + .andExpect(jsonPath("$.viewer.name").value(host.name)) + .andExpect(jsonPath("$.capabilities.shareLink").value(true)) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(false)) + .andExpect(jsonPath("$.capabilities.cancel").value(false)) + } + + @Test + fun `λ‘œκ·ΈμΈν–ˆμ§€λ§Œ μ°Έμ—¬ν•˜μ§€ μ•Šμ€ μ‚¬μš©μžκ°€ 이벀트 쑰회 μ‹œ viewerStatusκ°€ NONE이고 이름이 ν¬ν•¨λœλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val (viewer, viewerToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $viewerToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("NONE")) + .andExpect(jsonPath("$.viewer.name").value(viewer.name)) + } + + @Test + fun `정원이 μ°¬ 이벀트λ₯Ό λΉ„λ‘œκ·ΈμΈ μ‚¬μš©μžκ°€ 쑰회 μ‹œ applyλŠ” false이닀`() { + val (creator, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 1, waitlistEnabled = false) + + registrationRepository.save( + Registration( + userId = creator.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(false)) + } + + @Test + fun `정원이 μ°¬ μ΄λ²€νŠΈμ—μ„œ waitlistEnabled이면 λΉ„λ‘œκ·ΈμΈ μ‚¬μš©μž 쑰회 μ‹œ waitκ°€ true이닀`() { + val (creator, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 1, waitlistEnabled = true) + + registrationRepository.save( + Registration( + userId = creator.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(true)) + } + + @Test + fun `μ‹ μ²­ 기간이 μ§€λ‚œ 이벀트λ₯Ό λΉ„λ‘œκ·ΈμΈ μ‚¬μš©μžκ°€ 쑰회 μ‹œ apply와 wait λͺ¨λ‘ false이닀`() { + val (creator, _) = dataGenerator.generateUser() + // registrationEndsAt이 κ³Όκ±° β†’ withinWindow = false + val event = + eventRepository.save( + Event( + publicId = UUID.randomUUID().toString(), + title = "마감된 이벀트", + capacity = 10, + waitlistEnabled = true, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationStartsAt = Instant.now().minusSeconds(7200), + registrationEndsAt = Instant.now().minusSeconds(3600), // 이미 마감 + createdBy = creator.id!!, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + .andExpect(jsonPath("$.capabilities.wait").value(false)) + } + + @Test + fun `CONFIRMED μ°Έκ°€μžκ°€ 이벀트 쑰회 μ‹œ viewerStatusκ°€ CONFIRMED이고 cancel이 true이닀`() { + val (creator, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $participantToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + .andExpect(jsonPath("$.viewer.registrationPublicId").isNotEmpty) + .andExpect(jsonPath("$.viewer.reservationEmail").value(null as Any?)) + .andExpect(jsonPath("$.capabilities.cancel").value(true)) + .andExpect(jsonPath("$.capabilities.apply").value(false)) + } + + @Test + fun `WAITLISTED μ°Έκ°€μžκ°€ 이벀트 쑰회 μ‹œ viewerStatusκ°€ WAITLISTED이고 waitlistPosition이 ν¬ν•¨λœλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 1, waitlistEnabled = true) + + // 정원 μ±„μš°κΈ° (1λͺ… ν™•μ •) + registrationRepository.save( + Registration( + userId = creator.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + // λŒ€κΈ° 등둝 + registrationRepository.save( + Registration( + userId = waiter.id!!, + eventId = event.id!!, + status = RegistrationStatus.WAITLISTED, + ), + ) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $waiterToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + .andExpect(jsonPath("$.viewer.waitlistPosition").value(1)) + .andExpect(jsonPath("$.viewer.registrationPublicId").isNotEmpty) + .andExpect(jsonPath("$.viewer.reservationEmail").value(null as Any?)) + .andExpect(jsonPath("$.capabilities.cancel").value(true)) + } + + @Test + fun `confirmedCntλŠ” μ°Έμ—¬μž(ν™•μ •) 수λ₯Ό λ°˜μ˜ν•œλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val (user1, _) = dataGenerator.generateUser() + val (user2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 2, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = creator.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user2.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.event.confirmedCount").value(2)) + } + + @Test + fun `waitlistCountλŠ” λŒ€κΈ°μž 수λ₯Ό λ°˜μ˜ν•œλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val (user1, _) = dataGenerator.generateUser() + val (user2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!, capacity = 2, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = creator.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = user2.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.event.waitlistCount").value(1)) + } + + // ================================================================= + // GET /api/events/me β€” λ‚΄κ°€ λ§Œλ“  이벀트 λͺ©λ‘ 쑰회 (λ¬΄ν•œ 슀크둀) + // ================================================================= + + @Test + fun `인증 없이 λ‚΄ 이벀트 λͺ©λ‘ 쑰회 μš”μ²­ μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + mvc + .perform( + get("/api/events/me"), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `μƒμ„±ν•œ μ΄λ²€νŠΈκ°€ 없을 λ•Œ λ‚΄ 이벀트 λͺ©λ‘ 쑰회 μ‹œ 빈 λͺ©λ‘μ„ λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events").isArray) + .andExpect(jsonPath("$.events.length()").value(0)) + .andExpect(jsonPath("$.hasNext").value(false)) + } + + @Test + fun `λ‚΄ 이벀트 λͺ©λ‘ 쑰회 μ‹œ λ‚΄κ°€ μƒμ„±ν•œ 이벀트만 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val (otherUser, _) = dataGenerator.generateUser() + + val myEvent = createEventInDb(createdBy = user.id!!, title = "λ‚΄ 이벀트") + createEventInDb(createdBy = otherUser.id!!, title = "λ‹€λ₯Έ μ‚¬λžŒ 이벀트") + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(1)) + .andExpect(jsonPath("$.events[0].publicId").value(myEvent.publicId)) + .andExpect(jsonPath("$.events[0].title").value("λ‚΄ 이벀트")) + } + + @Test + fun `λ‚΄ 이벀트 λͺ©λ‘ 쑰회 μ‹œ size νŒŒλΌλ―Έν„°λ‘œ νŽ˜μ΄μ§€ 크기λ₯Ό μ œν•œν•  수 μžˆλ‹€`() { + val (user, token) = dataGenerator.generateUser() + repeat(3) { i -> createEventInDb(createdBy = user.id!!, title = "이벀트 $i") } + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(2)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.nextCursor").isNotEmpty) + } + + @Test + fun `λ‚΄ 이벀트 λͺ©λ‘μ— 이벀트 응닡 ν•„λ“œκ°€ μ˜¬λ°”λ₯΄κ²Œ ν¬ν•¨λœλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!, title = "ν•„λ“œ 검증 이벀트", capacity = 20) + + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events[0].publicId").value(event.publicId)) + .andExpect(jsonPath("$.events[0].title").value("ν•„λ“œ 검증 이벀트")) + .andExpect(jsonPath("$.events[0].capacity").value(20)) + .andExpect(jsonPath("$.events[0].confirmedCount").value(0)) + .andExpect(jsonPath("$.events[0].waitlistCount").value(0)) + } + + @Test + fun `cursor 기반 νŽ˜μ΄μ§•μœΌλ‘œ λ‹€μŒ νŽ˜μ΄μ§€λ₯Ό μ‘°νšŒν•  수 μžˆλ‹€`() { + val (user, token) = dataGenerator.generateUser() + // events[0]=이벀트 0(oldest), events[1]=이벀트 1, events[2]=이벀트 2(newest) + val events = + (0 until 3).map { i -> + createEventInDb(createdBy = user.id!!, title = "이벀트 $i") + .also { Thread.sleep(10) } // createdAt μˆœμ„œ 보μž₯ + } + + // 첫 νŽ˜μ΄μ§€ (size=2) β†’ μ΅œμ‹  2개: 이벀트 2, 이벀트 1 + val firstResult = + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(2)) + .andExpect(jsonPath("$.hasNext").value(true)) + .andReturn() + + val firstTree = mapper.readTree(firstResult.response.contentAsString) + val nextCursor = firstTree.get("nextCursor").asText() + val firstPageIds = + (0 until 2).map { + firstTree + .get("events") + .get(it) + .get("publicId") + .asText() + } + + // 1νŽ˜μ΄μ§€λŠ” μ΅œμ‹ μˆœ (이벀트 2 β†’ 이벀트 1) + assertThat(firstPageIds[0]).isEqualTo(events[2].publicId) + assertThat(firstPageIds[1]).isEqualTo(events[1].publicId) + + // 두 번째 νŽ˜μ΄μ§€ (cursor μ‚¬μš©) β†’ λ‚˜λ¨Έμ§€ 1개: 이벀트 0 + val secondResult = + mvc + .perform( + get("/api/events/me") + .header("Authorization", "Bearer $token") + .param("size", "2") + .param("cursor", nextCursor), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.events.length()").value(1)) + .andExpect(jsonPath("$.hasNext").value(false)) + .andReturn() + + val secondTree = mapper.readTree(secondResult.response.contentAsString) + val secondPageIds = + listOf( + secondTree + .get("events") + .get(0) + .get("publicId") + .asText(), + ) + + // 2νŽ˜μ΄μ§€λŠ” κ°€μž₯ 였래된 이벀트 0 + assertThat(secondPageIds[0]).isEqualTo(events[0].publicId) + + // 1νŽ˜μ΄μ§€μ™€ 2νŽ˜μ΄μ§€ κ°„ publicId 쀑볡 μ—†μŒ + assertThat(firstPageIds).doesNotContainAnyElementsOf(secondPageIds) + } + + // ================================================================= + // PUT /api/events/{publicId} β€” 이벀트 μˆ˜μ • + // ================================================================= + + @Test + fun `μ£Όμ΅œμžκ°€ μœ νš¨ν•œ λ°μ΄ν„°λ‘œ 이벀트 μˆ˜μ • μ‹œ 200κ³Ό μˆ˜μ •λœ 정보λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "μˆ˜μ •λœ 제λͺ©", location = "μƒˆ μž₯μ†Œ"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("μˆ˜μ •λœ 제λͺ©")) + .andExpect(jsonPath("$.location").value("μƒˆ μž₯μ†Œ")) + } + + @Test + fun `인증 없이 이벀트 μˆ˜μ • μš”μ²­ μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + val (user, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "μˆ˜μ •"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 이벀트 μˆ˜μ • μš”μ²­ μ‹œ 404λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + put("/api/events/${UUID.randomUUID()}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "μˆ˜μ •"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `이벀트 μ£Όμ΅œμžκ°€ μ•„λ‹Œ μ‚¬μš©μžκ°€ μˆ˜μ • μš”μ²­ μ‹œ 403을 λ°˜ν™˜ν•œλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val (_, otherToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $otherToken") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "μˆ˜μ •"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("NOT_EVENT_ADMIN")) + } + + @Test + fun `이벀트 제λͺ©μ„ 곡백으둜 μˆ˜μ • μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = " "))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_TITLE_BLANK")) + } + + @Test + fun `정원을 0으둜 μˆ˜μ • μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 0))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `정원을 음수둜 μˆ˜μ • μš”μ²­ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = -1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("EVENT_CAPACITY_INVALID")) + } + + @Test + fun `λͺ¨μž„ μ‹œμž‘ μ‹œκ°„μ„ μ‹ μ²­ 마감 μ‹œκ°„λ³΄λ‹€ μ΄μ „μœΌλ‘œ μˆ˜μ •ν•˜λ©΄ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + // κΈ°λ³Έ registrationEndsAt=now+5400 β†’ startsAt=now-3600 으둜 λ³€κ²½ν•˜λ©΄ μ—­μ „ + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(startsAt = Instant.now().minusSeconds(3600)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) + } + + @Test + fun `λͺ¨μž„ μ‹œμž‘ μ‹œκ°„μ„ μ•žλ‹Ήκ²¨ κΈ°μ‘΄ μ‹ μ²­ 마감 μ‹œκ°„κ³Ό μ—­μ „λ˜λ©΄ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + // startsAt=+7200, registrationEndsAt=+5400 β†’ μœ νš¨ν•œ 초기 μƒνƒœ + val event = + createEventInDb( + createdBy = user.id!!, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + // startsAt=+3600 으둜 μ•žλ‹ΉκΈ°λ©΄ registrationEndsAt(+5400) > startsAt(+3600) 이 됨 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(startsAt = Instant.now().plusSeconds(3600)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_ENDS_AFTER_EVENT_START")) + } + + @Test + fun `μˆ˜μ • μ‹œ null ν•„λ“œλŠ” κΈ°μ‘΄ 값을 μœ μ§€ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!, title = "μ›λž˜ 제λͺ©") + + // title만 μˆ˜μ •, description은 null β†’ κΈ°μ‘΄ description(null) μœ μ§€ + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(title = "바뀐 제λͺ©"))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("바뀐 제λͺ©")) + .andExpect(jsonPath("$.waitlistEnabled").value(false)) // κΈ°μ‘΄ κ°’ μœ μ§€ + } + + @Test + fun `ν™•μ • μ°Έκ°€μž μžˆμ–΄λ„ μ‹ μ²­ μ‹œμž‘ μ‹œκ°„μ„ 늦좜 수 μžˆλ‹€`() { + val (host, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = + createEventInDb( + createdBy = host.id!!, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + registrationStartsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + registrationRepository.save( + Registration(userId = participant.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED), + ) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + // +3600 β†’ +4500 으둜 늦좀 β€” κΈ°μ‘΄ μ‹ μ²­μž 영ν–₯ 없이 λͺ¨μ§‘ 쀑단 + .content(mapper.writeValueAsString(UpdateEventRequest(registrationStartsAt = Instant.now().plusSeconds(4500)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + } + + @Test + fun `ν™•μ • μ°Έκ°€μž μžˆμ–΄λ„ μ‹ μ²­ 마감 μ‹œκ°„μ„ 과거둜 μ•žλ‹ΉκΈΈ 수 μžˆλ‹€`() { + val (host, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, registrationEndsAt = Instant.now().plusSeconds(3600)) + + registrationRepository.save( + Registration(userId = participant.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED), + ) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + // λͺ¨μ§‘ λ§ˆκ°μ„ ν˜„μž¬ μ΄μ „μœΌλ‘œ μ•žλ‹ΉκΉ€ β€” λͺ¨μ§‘ 쀑단, κΈ°μ‘΄ μ‹ μ²­μž 영ν–₯ μ—†μŒ + .content(mapper.writeValueAsString(UpdateEventRequest(registrationEndsAt = Instant.now().minusSeconds(60)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + } + + @Test + fun `μ‹ μ²­ μ‹œμž‘ μ‹œκ°„κ³Ό μ‹ μ²­ 마감 μ‹œκ°„μ΄ κ°™μœΌλ©΄ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + val sameTime = Instant.now().plusSeconds(3600) + val request = + CreateEventRequest( + title = "μ •κΈ° λͺ¨μž„", + capacity = 10, + waitlistEnabled = false, + registrationStartsAt = sameTime, + registrationEndsAt = sameTime, + ) + + mvc + .perform( + post("/api/events") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_TIME_RANGE_INVALID")) + } + + @Test + fun `일정 μˆ˜μ • μ‹œ μ‹ μ²­ 마감 μ‹œκ°„μ„ 과거둜 μ•žλ‹ΉκΈΈ 수 μžˆλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!, registrationEndsAt = Instant.now().plusSeconds(3600)) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(UpdateEventRequest(registrationEndsAt = Instant.now().minusSeconds(60)))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + } + + @Test + fun `정원을 ν™•μ • μ°Έκ°€μž μˆ˜λ³΄λ‹€ 쀄이면 초과 μ°Έκ°€μžκ°€ λŒ€κΈ°λ‘œ κ°•λ“±λœλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (p1, p1Token) = dataGenerator.generateUser() + val (p2, p2Token) = dataGenerator.generateUser() + val (p3, p3Token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(10) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(10) + registrationRepository.save(Registration(userId = p3.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 β†’ 2: ν™•μ •μž 3λͺ… 쀑 κ°€μž₯ 졜근 ν™•μ •λœ p3 1λͺ…이 κ°•λ“± + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 2))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(2)) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p1Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p2Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $p3Token")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 μΆ•μ†Œ μ‹œ κ°€μž₯ 졜근 ν™•μ •λœ μ°Έκ°€μžλΆ€ν„° κ°•λ“±λœλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (early, earlyToken) = dataGenerator.generateUser() + val (late, lateToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = early.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + Thread.sleep(20) + registrationRepository.save(Registration(userId = late.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 β†’ 1: 더 늦게 ν™•μ •λœ late만 κ°•λ“± + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $earlyToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $lateToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 μΆ•μ†Œλ‘œ κ°•λ“± μ‹œ κΈ°μ‘΄ λŒ€κΈ°μžλŠ” 영ν–₯ μ—†λ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 5, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + // confirmed=1 == newCapacity=1 β†’ κ°•λ“± μ—†μŒ, κΈ°μ‘΄ λŒ€κΈ°μžλ„ κ·ΈλŒ€λ‘œ + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform(get("/api/events/${event.publicId}").header("Authorization", "Bearer $waiterToken")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원 μΆ•μ†Œλ‘œ κ°•λ“±λœ μ°Έκ°€μžμ—κ²Œ 이메일이 λ°œμ†‘λœλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (p1, _) = dataGenerator.generateUser() + val (p2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 10, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + + // capacity 10 β†’ 1: 1λͺ… κ°•λ“± β†’ sendDemotionEmail 1회 호좜 + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + verify(emailService, times(1)).sendDemotionEmail(any()) + } + + @Test + fun `정원을 ν™•μ • μ°Έκ°€μž μˆ˜μ™€ λ™μΌν•˜κ²Œ 쀄일 수 μžˆλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 5, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + // capacity 5 β†’ 1 (ν™•μ •μž μˆ˜μ™€ 동일) β€” ν—ˆμš©λ¨ + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 1))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(1)) + + // λŒ€κΈ°μžλŠ” μ—¬μ „νžˆ WAITLISTED β€” 정원이 λŠ˜μ§€ μ•Šμ•˜μœΌλ―€λ‘œ μ „ν™˜ μ—†μŒ + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $waiterToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("WAITLISTED")) + } + + @Test + fun `정원을 늘리면 λŒ€κΈ°μžκ°€ ν™•μ • μ°Έκ°€μžλ‘œ μ „ν™˜λœλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (waiter, waiterToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 1, waitlistEnabled = true) + + registrationRepository.save(Registration(userId = host.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = waiter.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(UpdateEventRequest(capacity = 2))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + mvc + .perform( + get("/api/events/${event.publicId}") + .header("Authorization", "Bearer $waiterToken"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewer.status").value("CONFIRMED")) + } + + @Test + fun `ν™•μ • μ°Έκ°€μž 없을 λ•Œ μ‹ μ²­ μ‹œμž‘ μ‹œκ°„κ³Ό 정원을 ν•¨κ»˜ λ³€κ²½ν•  수 μžˆλ‹€`() { + val (host, token) = dataGenerator.generateUser() + val event = + createEventInDb( + createdBy = host.id!!, + capacity = 10, + startsAt = Instant.now().plusSeconds(7200), + endsAt = Instant.now().plusSeconds(10800), + registrationStartsAt = Instant.now().plusSeconds(3600), + registrationEndsAt = Instant.now().plusSeconds(5400), + ) + + val newRegistrationStartsAt = Instant.now().plusSeconds(3900) + + mvc + .perform( + put("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + UpdateEventRequest( + capacity = 5, + registrationStartsAt = newRegistrationStartsAt, + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.capacity").value(5)) + } + + // ================================================================= + // DELETE /api/events/{publicId} β€” 이벀트 μ‚­μ œ + // ================================================================= + + @Test + fun `ν™•μ •λœ μ°Έκ°€μžκ°€ μ—†λŠ” 이벀트λ₯Ό μ£Όμ΅œμžκ°€ μ‚­μ œ μ‹œ 204λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + } + + @Test + fun `이벀트 μ‚­μ œ ν›„ ν•΄λ‹Ή 이벀트 쑰회 μ‹œ 404λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + mvc + .perform( + get("/api/events/${event.publicId}"), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `인증 없이 이벀트 μ‚­μ œ μš”μ²­ μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + val (user, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}"), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 이벀트 μ‚­μ œ μš”μ²­ μ‹œ 404λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + delete("/api/events/${UUID.randomUUID()}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.code").value("EVENT_NOT_FOUND")) + } + + @Test + fun `이벀트 μ£Όμ΅œμžκ°€ μ•„λ‹Œ μ‚¬μš©μžκ°€ μ‚­μ œ μš”μ²­ μ‹œ 403을 λ°˜ν™˜ν•œλ‹€`() { + val (creator, _) = dataGenerator.generateUser() + val (_, otherToken) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = creator.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $otherToken"), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("NOT_EVENT_ADMIN")) + } + + @Test + fun `ν™•μ •λœ μ°Έκ°€μžκ°€ μžˆλŠ” μ΄λ²€νŠΈλ„ μ£Όμ΅œμžκ°€ μ‚­μ œ μ‹œ 204λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + } + + @Test + fun `이벀트 μ‚­μ œ μ‹œ CONFIRMED와 WAITLISTED μ‹ μ²­μžμ—κ²Œ 이메일이 λ°œμ†‘λ˜κ³  BANNEDμ—κ²ŒλŠ” λ°œμ†‘λ˜μ§€ μ•ŠλŠ”λ‹€`() { + val (host, token) = dataGenerator.generateUser() + val (confirmed, _) = dataGenerator.generateUser() + val (waitlisted, _) = dataGenerator.generateUser() + val (banned, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 2) + + registrationRepository.save(Registration(userId = confirmed.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save( + Registration(userId = waitlisted.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED), + ) + registrationRepository.save(Registration(userId = banned.id!!, eventId = event.id!!, status = RegistrationStatus.BANNED)) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + verify(emailService, times(2)).sendEventCancellationEmail(any()) + } + + @Test + fun `이벀트 μ‚­μ œ μ‹œ κ΄€λ ¨ registration도 λͺ¨λ‘ μ‚­μ œλœλ‹€`() { + val (host, token) = dataGenerator.generateUser() + val (p1, _) = dataGenerator.generateUser() + val (p2, _) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = host.id!!, capacity = 1) + + registrationRepository.save(Registration(userId = p1.id!!, eventId = event.id!!, status = RegistrationStatus.CONFIRMED)) + registrationRepository.save(Registration(userId = p2.id!!, eventId = event.id!!, status = RegistrationStatus.WAITLISTED)) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(registrationRepository.findByEventId(event.id!!)).isEmpty() + } + + @Test + fun `μ‹ μ²­μžκ°€ μ—†λŠ” 이벀트 μ‚­μ œ μ‹œ 이메일이 λ°œμ†‘λ˜μ§€ μ•ŠλŠ”λ‹€`() { + val (user, token) = dataGenerator.generateUser() + val event = createEventInDb(createdBy = user.id!!) + + mvc + .perform( + delete("/api/events/${event.publicId}") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + verify(emailService, never()).sendEventCancellationEmail(any()) + } + } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt index 77734df8..f2b1564a 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/RegistrationIntegrationTest.kt @@ -1,38 +1,48 @@ package com.wafflestudio.spring2025 +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper -import com.wafflestudio.spring2025.domain.auth.LoggedInUser +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.domain.event.model.Event import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.dto.request.CreateRegistrationRequest +import com.wafflestudio.spring2025.domain.registration.dto.request.UpdateRegistrationStatusRequest +import com.wafflestudio.spring2025.domain.registration.model.Registration +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository -import com.wafflestudio.spring2025.domain.user.repository.UserRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.core.MethodParameter -import org.springframework.core.annotation.Order +import org.springframework.context.annotation.Import import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit @SpringBootTest @ActiveProfiles("test") @Testcontainers -@AutoConfigureMockMvc(addFilters = false) +@AutoConfigureMockMvc +@Import(TestContainerConfig::class) class RegistrationIntegrationTest @Autowired constructor( @@ -42,41 +52,745 @@ class RegistrationIntegrationTest private val eventRepository: EventRepository, private val registrationRepository: RegistrationRepository, ) { + @MockitoBean + private lateinit var emailService: EmailService + + @Test + fun `둜그인 μ‚¬μš©μžκ°€ λ“±λ‘ν•˜λ©΄ CONFIRMED μƒνƒœλ‘œ μ €μž₯λœλ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Backend Study", capacity = 10, waitlistEnabled = false) + + val result = + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrationPublicId").isNotEmpty) + .andReturn() + + val registrationId = extractRegistrationPublicId(result) + val saved = findRegistration(registrationId) + + assertEquals(participant.id, saved.userId) + assertNull(saved.guestName) + assertNull(saved.guestEmail) + assertEquals(RegistrationStatus.CONFIRMED, saved.status) + } + + @Test + fun `λΉ„λ‘œκ·ΈμΈ μ‚¬μš©μžλ„ guestNameκ³Ό guestEmail둜 등둝할 수 μžˆλ‹€`() { + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Guest Registration") + + val registrationId = registerAsGuest(event.publicId, "Guest User", "guest-1@example.com") + val saved = findRegistration(registrationId) + + assertNull(saved.userId) + assertEquals("Guest User", saved.guestName) + assertEquals("guest-1@example.com", saved.guestEmail) + assertEquals(RegistrationStatus.CONFIRMED, saved.status) + } + + @Test + fun `μ£Όμ΅œμžλŠ” μžμ‹ μ˜ μ΄λ²€νŠΈμ— 등둝할 수 μ—†λ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Host Event") + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $hostToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("REGISTRATION_BLOCKED_HOST")) + } + + @Test + fun `정원이 가득 μ°Όκ³  λŒ€κΈ°μ—΄μ΄ λΉ„ν™œμ„±ν™”λ©΄ 등둝을 ν•  수 μ—†λ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (_, firstRegistrantToken) = dataGenerator.generateUser() + val (_, secondRegistrantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "No Waitlist", capacity = 1, waitlistEnabled = false) + + registerAsUser(event.publicId, firstRegistrantToken) + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $secondRegistrantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("EVENT_FULL")) + } + + @Test + fun `정원이 가득 μ°Όκ³  λŒ€κΈ°μ—΄μ΄ ν™œμ„±ν™”λ©΄ WAITLISTED둜 λ“±λ‘λœλ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (_, confirmedUserToken) = dataGenerator.generateUser() + val (_, waitlistedUserToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Waitlist Enabled", capacity = 1, waitlistEnabled = true) + + val confirmedRegistrationId = registerAsUser(event.publicId, confirmedUserToken) + val waitlistedRegistrationId = registerAsUser(event.publicId, waitlistedUserToken) + + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(confirmedRegistrationId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedRegistrationId).status) + } + + @Test + fun `이벀트 μ‹ μ²­ μ‹œμž‘ μ‹œκ°„ μ΄μ „μ—λŠ” μ‹ μ²­ν•  수 μ—†λ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Registration Not Open Yet", + registrationStartsAt = now.plusSeconds(1800), + registrationEndsAt = now.plusSeconds(7200), + ) + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `이벀트 μ‹ μ²­ 마감 μ‹œκ°„ μ΄ν›„μ—λŠ” μ‹ μ²­ν•  수 μ—†λ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Registration Closed", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $participantToken") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `게슀트 등둝 μ‹œ 이메일 ν˜•μ‹μ΄ 잘λͺ»λ˜λ©΄ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Invalid Guest Email") + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .content( + mapper.writeValueAsString( + CreateRegistrationRequest( + guestName = "Guest User", + guestEmail = "invalid-email-format", + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("REGISTRATION_WRONG_EMAIL")) + } + + @Test + fun `같은 μ΄λ²€νŠΈμ— λ™μΌν•œ 게슀트 μ΄λ©”μΌλ‘œ 쀑볡 등둝할 수 μ—†λ‹€`() { + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Duplicate Guest Email") + + registerAsGuest(event.publicId, "Guest One", "guest-duplicate@example.com") + + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .content( + mapper.writeValueAsString( + CreateRegistrationRequest( + guestName = "Guest Two", + guestEmail = "guest-duplicate@example.com", + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isConflict) + .andExpect(jsonPath("$.code").value("REGISTRATION_ALREADY_EXISTS")) + } + + @Test + fun `본인의 μ°Έκ°€ 신청은 본인만 μ·¨μ†Œν•  수 μžˆλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Host Delete Permission") + val participantRegistrationId = registerAsUser(event.publicId, participantToken) + + mvc + .perform( + delete("/api/registrations/$participantRegistrationId") + .header("Authorization", "Bearer $hostToken") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("REGISTRATION_DELETE_UNAUTHORIZED")) + + mvc + .perform( + delete("/api/registrations/$participantRegistrationId") + .header("Authorization", "Bearer $participantToken") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertNull(registrationRepository.findByRegistrationPublicId(participantRegistrationId)) + } + + @Test + fun `CONFIRMED 등둝을 μ‚­μ œν•˜λ©΄ λŒ€κΈ°μžκ°€ μŠΉκ²©λœλ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (_, confirmedUserToken) = dataGenerator.generateUser() + val (_, waitlistedUserToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Delete Promotion", capacity = 1, waitlistEnabled = true) + + val confirmedRegistrationId = registerAsUser(event.publicId, confirmedUserToken) + val waitlistedRegistrationId = registerAsUser(event.publicId, waitlistedUserToken) + + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(confirmedRegistrationId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedRegistrationId).status) + + mvc + .perform( + delete("/api/registrations/$confirmedRegistrationId") + .header("Authorization", "Bearer $confirmedUserToken") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertNull(registrationRepository.findByRegistrationPublicId(confirmedRegistrationId)) + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(waitlistedRegistrationId).status) + } + + @Test + fun `주졜자만 강퇴할 수 있고 CONFIRMED 강퇴 μ‹œ λŒ€κΈ°μžκ°€ μŠΉκ²©λœλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmedUser, confirmedUserToken) = dataGenerator.generateUser() + val (_, waitlistedUserToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Ban Scenario", capacity = 1, waitlistEnabled = true) + + val confirmedRegistrationId = registerAsUser(event.publicId, confirmedUserToken) + val waitlistedRegistrationId = registerAsUser(event.publicId, waitlistedUserToken) + + mvc + .perform( + patch("/api/registrations/$confirmedRegistrationId") + .header("Authorization", "Bearer $confirmedUserToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("REGISTRATION_PATCH_UNAUTHORIZED")) + + mvc + .perform( + patch("/api/registrations/$confirmedRegistrationId") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.patchEmail").value(confirmedUser.email)) + + assertEquals(RegistrationStatus.BANNED, findRegistration(confirmedRegistrationId).status) + assertEquals(RegistrationStatus.CONFIRMED, findRegistration(waitlistedRegistrationId).status) + } + + @Test + fun `λͺ¨μ§‘ μ‹œμž‘ μ „μ—λŠ” 자발적 μ·¨μ†Œλ₯Ό ν•  수 μ—†λ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Before Registration Opens - Cancel", + registrationStartsAt = now.plusSeconds(3600), + registrationEndsAt = now.plusSeconds(7200), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + delete("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $participantToken") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + @Test - fun `λ‚΄ μ‹ μ²­ λͺ©λ‘μ„ μ΅œμ‹ μˆœκ³Ό νŽ˜μ΄μ§•μœΌλ‘œ μ‘°νšŒν•œλ‹€`() { - val (user, _) = dataGenerator.generateUser() - val event1 = createEvent(createdBy = user.id!!, title = "Event 1") - val event2 = createEvent(createdBy = user.id!!, title = "Event 2") + fun `λͺ¨μ§‘ 마감 ν›„μ—λŠ” 자발적 μ·¨μ†Œλ₯Ό ν•  수 μ—†λ‹€`() { + val (host, _) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "After Registration Closed - Cancel", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) mvc .perform( - post("/api/v1/events/${event1.id}/registrations") - .content("{}") - .header("X-Test-User-Id", user.id.toString()) + delete("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $participantToken") .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("NOT_WITHIN_REGISTRATION_WINDOW")) + } + + @Test + fun `λͺ¨μ§‘ μ‹œμž‘ 전에도 μ£Όμ΅œμžλŠ” BANNED 처리λ₯Ό ν•  수 μžˆλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban Before Registration Opens", + registrationStartsAt = now.plusSeconds(3600), + registrationEndsAt = now.plusSeconds(7200), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) - Thread.sleep(5) + assertEquals(RegistrationStatus.BANNED, findRegistration(registration.registrationPublicId).status) + } + + @Test + fun `λͺ¨μ§‘ 마감 후에도 μ£Όμ΅œμžλŠ” BANNED 처리λ₯Ό ν•  수 μžˆλ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban After Registration Closed", + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val registration = + registrationRepository.save( + Registration( + userId = participant.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + + mvc + .perform( + patch("/api/registrations/${registration.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(registration.registrationPublicId).status) + } + + @Test + fun `λͺ¨μ§‘ 마감 ν›„ BANNED 처리 μ‹œ λŒ€κΈ°μžλŠ” μŠΉκ²©λ˜μ§€ μ•ŠλŠ”λ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (confirmedUser, _) = dataGenerator.generateUser() + val (waitlistedUser, _) = dataGenerator.generateUser() + val now = Instant.now() + val event = + createEvent( + createdBy = host.id!!, + title = "Ban After Closed - No Promotion", + capacity = 1, + waitlistEnabled = true, + registrationStartsAt = now.minusSeconds(7200), + registrationEndsAt = now.minusSeconds(60), + ) + val confirmedReg = + registrationRepository.save( + Registration( + userId = confirmedUser.id!!, + eventId = event.id!!, + status = RegistrationStatus.CONFIRMED, + ), + ) + val waitlistedReg = + registrationRepository.save( + Registration( + userId = waitlistedUser.id!!, + eventId = event.id!!, + status = RegistrationStatus.WAITLISTED, + ), + ) mvc .perform( - post("/api/v1/events/${event2.id}/registrations") - .content("{}") - .header("X-Test-User-Id", user.id.toString()) + patch("/api/registrations/${confirmedReg.registrationPublicId}") + .header("Authorization", "Bearer $hostToken") + .content( + mapper.writeValueAsString( + UpdateRegistrationStatusRequest(status = RegistrationStatus.BANNED), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + assertEquals(RegistrationStatus.BANNED, findRegistration(confirmedReg.registrationPublicId).status) + assertEquals(RegistrationStatus.WAITLISTED, findRegistration(waitlistedReg.registrationPublicId).status) + } + + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `주졜자만 μ°Έμ—¬μž 이메일을 λ³Ό 수 있고 μ°Έμ—¬μžλŠ” λ³Ό 수 μ—†λ‹€`() { + val (host, hostToken) = dataGenerator.generateUser() + val (participant, participantToken) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "Email Visibility", capacity = 10, waitlistEnabled = false) + + registerAsUser(event.publicId, participantToken) + registerAsGuest(event.publicId, "Guest User", "guest@example.com") + + val nonHostResponse = + mvc + .perform( + get("/api/events/${event.publicId}/registrations") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andReturn() + + val nonHostParticipants = extractParticipants(nonHostResponse) + assertTrue(nonHostParticipants.all { it.path("email").isNull }) + + val hostResponse = + mvc + .perform( + get("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $hostToken") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andReturn() + + val hostParticipants = extractParticipants(hostResponse) + assertTrue(hostParticipants.any { it.path("email").asText() == participant.email }) + assertTrue(hostParticipants.any { it.path("email").asText() == "guest@example.com" }) + } + + @Test + fun `λ™μ‹œμ— μ—¬λŸ¬ μ‚¬μš©μžκ°€ 신청해도 정원을 μ΄ˆκ³Όν•˜μ§€ μ•ŠλŠ”λ‹€`() { + val capacity = 4 + val totalUsers = 15 + + val (host, _) = dataGenerator.generateUser() + val event = + createEvent( + createdBy = host.id!!, + title = "λ™μ‹œμ„± ν…ŒμŠ€νŠΈ 이벀트", + capacity = capacity, + waitlistEnabled = true, + ) + + val tokens = (1..totalUsers).map { dataGenerator.generateUser().second } + + val executor = Executors.newFixedThreadPool(totalUsers) + val startLatch = CountDownLatch(1) + val doneLatch = CountDownLatch(totalUsers) + val statusCodes = CopyOnWriteArrayList() + + tokens.forEach { token -> + executor.submit { + try { + startLatch.await() + val result = + mvc + .perform( + post("/api/events/${event.publicId}/registrations") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andReturn() + statusCodes.add(result.response.status) + } finally { + doneLatch.countDown() + } + } + } + + startLatch.countDown() + val completed = doneLatch.await(10, TimeUnit.SECONDS) + executor.shutdown() + + assertTrue(completed, "λͺ¨λ“  μš”μ²­μ΄ νƒ€μž„μ•„μ›ƒ(10초) 내에 μ™„λ£Œλ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€") + assertTrue(statusCodes.all { it == 200 }, "λͺ¨λ“  μš”μ²­μ΄ HTTP 200으둜 성곡해야 ν•©λ‹ˆλ‹€: $statusCodes") + + val confirmed = registrationRepository.countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) + val waitlisted = registrationRepository.countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) + + assertEquals(capacity.toLong(), confirmed, "CONFIRMED μˆ˜κ°€ 정원과 μΌμΉ˜ν•΄μ•Ό ν•©λ‹ˆλ‹€") + assertEquals((totalUsers - capacity).toLong(), waitlisted, "WAITLISTED μˆ˜κ°€ μ •ν™•ν•΄μ•Ό ν•©λ‹ˆλ‹€") + } + + @Test + fun `my registrations endpoint paginates without overlap`() { + val (_, participantToken) = dataGenerator.generateUser() + val registeredEventIds = + (1..25).map { index -> + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "my-registrations-$index") + registerAsUser(event.publicId, participantToken) + event.publicId + } + + val page0 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(10)) + .andReturn() + + val page1 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "1") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(10)) + .andReturn() + + val page2 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "2") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(5)) + .andReturn() + + val page3 = + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "3") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(0)) + .andReturn() + + val page0Ids = extractMyRegistrationEventPublicIds(page0) + val page1Ids = extractMyRegistrationEventPublicIds(page1) + val page2Ids = extractMyRegistrationEventPublicIds(page2) + val page3Ids = extractMyRegistrationEventPublicIds(page3) + + assertTrue(page0Ids.intersect(page1Ids.toSet()).isEmpty()) + assertTrue(page0Ids.intersect(page2Ids.toSet()).isEmpty()) + assertTrue(page1Ids.intersect(page2Ids.toSet()).isEmpty()) + assertTrue(page3Ids.isEmpty()) + + val merged = (page0Ids + page1Ids + page2Ids).toSet() + assertEquals(registeredEventIds.toSet(), merged) + } + + @Test + fun `my registrations endpoint respects size parameter`() { + val (_, participantToken) = dataGenerator.generateUser() + + repeat(7) { index -> + val (host, _) = dataGenerator.generateUser() + val event = createEvent(createdBy = host.id!!, title = "size-check-$index") + registerAsUser(event.publicId, participantToken) + } + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "0") + .param("size", "3") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(3)) + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "1") + .param("size", "3") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(3)) + + mvc + .perform( + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "2") + .param("size", "3") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrations.length()").value(1)) + } + + @Test + fun `my registrations endpoint includes confirmed and waitlist counts`() { + val (host, _) = dataGenerator.generateUser() + val (_, participantToken) = dataGenerator.generateUser() + val (_, anotherParticipantToken) = dataGenerator.generateUser() + + val event = + createEvent( + createdBy = host.id!!, + title = "count-check", + capacity = 1, + waitlistEnabled = true, + ) + + registerAsUser(event.publicId, participantToken) + registerAsUser(event.publicId, anotherParticipantToken) mvc .perform( - get("/api/v1/registrations/me") - .header("X-Test-User-Id", user.id.toString()) - .queryParam("page", "0") - .queryParam("size", "1") + get("/api/registrations/me") + .header("Authorization", "Bearer $participantToken") + .param("page", "0") + .param("size", "10") .contentType(MediaType.APPLICATION_JSON), ).andExpect(status().isOk) .andExpect(jsonPath("$.registrations.length()").value(1)) - .andExpect(jsonPath("$.registrations[0].title").value(event2.title)) - .andExpect(jsonPath("$.registrations[0].publicId").value(event2.publicId)) + .andExpect(jsonPath("$.registrations[0].confirmedCount").value(1)) + .andExpect(jsonPath("$.registrations[0].waitlistCount").value(1)) + .andExpect(jsonPath("$.registrations[0].registrationCnt").doesNotExist()) + } + + private fun registerAsUser( + eventPublicId: String, + token: String, + ): String { + val result = + mvc + .perform( + post("/api/events/$eventPublicId/registrations") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(CreateRegistrationRequest())) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrationPublicId").isNotEmpty) + .andReturn() + + return extractRegistrationPublicId(result) + } + + private fun registerAsGuest( + eventPublicId: String, + guestName: String, + guestEmail: String, + ): String { + val result = + mvc + .perform( + post("/api/events/$eventPublicId/registrations") + .content( + mapper.writeValueAsString( + CreateRegistrationRequest( + guestName = guestName, + guestEmail = guestEmail, + ), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.registrationPublicId").isNotEmpty) + .andReturn() + + return extractRegistrationPublicId(result) + } + + private fun extractRegistrationPublicId(result: MvcResult): String = + mapper.readTree(result.response.contentAsString).path("registrationPublicId").asText() + + private fun extractParticipants(result: MvcResult): List = + mapper + .readTree(result.response.contentAsString) + .path("participants") + .toList() + + private fun extractMyRegistrationEventPublicIds(result: MvcResult): List = + mapper + .readTree(result.response.contentAsString) + .path("registrations") + .map { it.path("publicId").asText() } + + private fun findRegistration(registrationPublicId: String): Registration { + val registration = registrationRepository.findByRegistrationPublicId(registrationPublicId) + assertNotNull(registration) + return registration!! } private fun createEvent( @@ -84,6 +798,8 @@ class RegistrationIntegrationTest title: String, capacity: Int = 10, waitlistEnabled: Boolean = false, + registrationStartsAt: Instant? = null, + registrationEndsAt: Instant = Instant.now().plusSeconds(5400), ): Event = eventRepository.save( Event( @@ -95,35 +811,9 @@ class RegistrationIntegrationTest endsAt = Instant.now().plusSeconds(7200), capacity = capacity, waitlistEnabled = waitlistEnabled, - registrationEndsAt = null, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, createdBy = createdBy, ), ) - - @TestConfiguration - @Order(0) - class TestWebConfig( - private val userRepository: UserRepository, - ) : WebMvcConfigurer { - override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(0, TestLoggedInUserArgumentResolver(userRepository)) - } - } - - private class TestLoggedInUserArgumentResolver( - private val userRepository: UserRepository, - ) : HandlerMethodArgumentResolver { - override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.hasParameterAnnotation(LoggedInUser::class.java) - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory?, - ): Any? { - val userIdHeader = webRequest.getHeader("X-Test-User-Id") ?: return null - val userId = userIdHeader.toLongOrNull() ?: return null - return userRepository.findById(userId).orElse(null) - } - } } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt b/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt new file mode 100644 index 00000000..8b03c934 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/TestContainerConfig.kt @@ -0,0 +1,16 @@ +package com.wafflestudio.spring2025 + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.context.annotation.Bean +import org.testcontainers.containers.GenericContainer +import org.testcontainers.utility.DockerImageName + +@TestConfiguration +class TestContainerConfig { + @Bean + @ServiceConnection(name = "redis") + fun redisContainer(): GenericContainer<*> = + GenericContainer(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379) +} diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt new file mode 100644 index 00000000..dbe38e39 --- /dev/null +++ b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt @@ -0,0 +1,339 @@ +package com.wafflestudio.spring2025 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.image.service.ImageService +import com.wafflestudio.spring2025.domain.auth.dto.LoginRequest +import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.testcontainers.junit.jupiter.Testcontainers +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.NoSuchKeyException + +@SpringBootTest +@ActiveProfiles("test") +@Testcontainers +@AutoConfigureMockMvc +@Import(TestContainerConfig::class) +class UserIntegrationTest + @Autowired + constructor( + private val mvc: MockMvc, + private val mapper: ObjectMapper, + private val dataGenerator: DataGenerator, + ) { + // UserService.validateProfileImage μ—μ„œ 직접 ν˜ΈμΆœν•˜λŠ” S3Client λͺ¨ν‚Ή + @MockitoBean + private lateinit var s3Client: S3Client + + // UserService.me μ—μ„œ presignedGetUrl 호좜 μ‹œ μ‹€μ œ AWS 호좜 λ°©μ§€ + @MockitoBean + private lateinit var imageService: ImageService + + // ================================================================= + // GET /api/users/me β€” λ‚΄ 정보 쑰회 + // ================================================================= + + @Test + fun `μœ νš¨ν•œ ν† ν°μœΌλ‘œ λ‚΄ 정보 쑰회 μ‹œ 200κ³Ό μ‚¬μš©μž 정보λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(user.id)) + .andExpect(jsonPath("$.email").value(user.email)) + .andExpect(jsonPath("$.name").value(user.name)) + .andExpect(jsonPath("$.profileImage").value(null as Any?)) + } + + @Test + fun `Authorization 헀더 없이 λ‚΄ 정보 쑰회 μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + mvc + .perform( + get("/api/users/me") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μœΌλ‘œ λ‚΄ 정보 쑰회 μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer invalid.token.value") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + + @Test + fun `ν”„λ‘œν•„ 이미지가 μžˆλŠ” μ‚¬μš©μžμ˜ λ‚΄ 정보 쑰회 μ‹œ presigned URL을 λ°˜ν™˜ν•œλ‹€`() { + val s3Key = "profile-images/1/photo.jpg" + val presignedUrl = "https://s3.example.com/presigned/photo.jpg" + + // profileImageκ°€ μ„€μ •λœ Userλ₯Ό DB에 직접 생성 + val (user, token) = dataGenerator.generateUserWithProfileImage(s3Key) + + given(imageService.presignedGetUrl(s3Key)).willReturn(presignedUrl) + + mvc + .perform( + get("/api/users/me") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(user.id)) + .andExpect(jsonPath("$.profileImage").value(presignedUrl)) + } + + // ================================================================= + // PATCH /api/users/me β€” λ‚΄ 정보 μˆ˜μ • + // ================================================================= + + @Test + fun `이름을 λ³€κ²½ν•˜λ©΄ 200κ³Ό λ³€κ²½λœ 이름을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + val newName = "λ³€κ²½λœμ΄λ¦„" + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = newName, email = null, password = null, profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(newName)) + } + + @Test + fun `λ³€κ²½ ν•„λ“œ 없이 PATCH μ‹œ κΈ°μ‘΄ 정보 κ·ΈλŒ€λ‘œ 200을 λ°˜ν™˜ν•œλ‹€`() { + val (user, token) = dataGenerator.generateUser() + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content(mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = null))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(user.name)) + .andExpect(jsonPath("$.email").value(user.email)) + } + + @Test + fun `λΉ„λ°€λ²ˆν˜Έλ₯Ό λ³€κ²½ν•˜λ©΄ 이전 λΉ„λ°€λ²ˆν˜ΈλŠ” λ¬΄νš¨ν™”λ˜κ³  μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ λ‘œκ·ΈμΈν•  수 μžˆλ‹€`() { + val oldPassword = "oldPassword1" + val newPassword = "newPassword2" + val (user, token) = dataGenerator.generateUserWithPassword(oldPassword) + + // λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = newPassword, profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + + // 이전 λΉ„λ°€λ²ˆν˜Έλ‘œ 둜그인 β†’ μ‹€νŒ¨ + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = oldPassword))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + + // μƒˆ λΉ„λ°€λ²ˆν˜Έλ‘œ 둜그인 β†’ 성곡 + mvc + .perform( + post("/api/auth/login") + .content(mapper.writeValueAsString(LoginRequest(email = user.email, password = newPassword))) + .contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.token").isNotEmpty) + } + + @Test + fun `S3에 μ‘΄μž¬ν•˜λŠ” ν‚€λ‘œ ν”„λ‘œν•„ 이미지λ₯Ό λ³€κ²½ν•˜λ©΄ 200을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + val validKey = "profile-images/test/valid.jpg" + val presignedUrl = "https://s3.example.com/presigned/valid.jpg" + + // headObject λŠ” 기본적으둜 μ˜ˆμ™Έ 없이 λ°˜ν™˜ (mock κΈ°λ³Έκ°’) β†’ μœ νš¨ν•œ ν‚€λ‘œ κ°„μ£Ό + given(imageService.presignedGetUrl(validKey)).willReturn(presignedUrl) + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = null, profileImage = validKey)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.profileImage").value(presignedUrl)) + } + + @Test + fun `이름과 λΉ„λ°€λ²ˆν˜Έλ₯Ό λ™μ‹œμ— λ³€κ²½ν•˜λ©΄ 200κ³Ό λ³€κ²½λœ 정보λ₯Ό λ°˜ν™˜ν•œλ‹€`() { + val oldPassword = "oldPassword1" + val newPassword = "newPassword2" + val newName = "μƒˆμ΄λ¦„" + val (user, token) = dataGenerator.generateUserWithPassword(oldPassword) + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = newName, email = null, password = newPassword, profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value(newName)) + .andExpect(jsonPath("$.email").value(user.email)) + } + + @Test + fun `이메일 λ³€κ²½ μ‹œλ„ μ‹œ 403을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = "newemail@example.com", password = null, profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("EMAIL_CHANGE_FORBIDDEN")) + } + + @Test + fun `이름을 곡백 λ¬Έμžμ—΄λ‘œ λ³€κ²½ μ‹œλ„ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = " ", email = null, password = null, profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_NAME")) + } + + @Test + fun `λΉ„λ°€λ²ˆν˜Έλ₯Ό 8자 미만으둜 λ³€κ²½ μ‹œλ„ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString(PatchMeRequest(name = null, email = null, password = "abc1", profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `λΉ„λ°€λ²ˆν˜Έμ— μˆ«μžκ°€ μ—†μœΌλ©΄ λ³€κ²½ μ‹œλ„ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = "onlyletters", profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `λΉ„λ°€λ²ˆν˜Έμ— λ¬Έμžκ°€ μ—†μœΌλ©΄ λ³€κ²½ μ‹œλ„ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = "12345678", profileImage = null), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("BAD_PASSWORD")) + } + + @Test + fun `S3에 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” ν‚€λ‘œ ν”„λ‘œν•„ 이미지 λ³€κ²½ μ‹œλ„ μ‹œ 400을 λ°˜ν™˜ν•œλ‹€`() { + val (_, token) = dataGenerator.generateUser() + + given(s3Client.headObject(any(HeadObjectRequest::class.java))) + .willThrow( + NoSuchKeyException + .builder() + .message("Not found") + .statusCode(404) + .build(), + ) + + mvc + .perform( + patch("/api/users/me") + .header("Authorization", "Bearer $token") + .content( + mapper.writeValueAsString( + PatchMeRequest(name = null, email = null, password = null, profileImage = "nonexistent/image.jpg"), + ), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.code").value("PROFILE_IMAGE_NOT_FOUND")) + } + + @Test + fun `토큰 없이 λ‚΄ 정보 μˆ˜μ • μ‹œλ„ μ‹œ 401을 λ°˜ν™˜ν•œλ‹€`() { + mvc + .perform( + patch("/api/users/me") + .content( + mapper.writeValueAsString(PatchMeRequest(name = "μƒˆμ΄λ¦„", email = null, password = null, profileImage = null)), + ).contentType(MediaType.APPLICATION_JSON), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) + } + } diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index f5effe00..fcec3309 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -8,3 +8,10 @@ spring.data.redis.port=6379 jwt.secret=test-secret-key-for-jwt-token-generation-minimum-32-chars jwt.expiration-in-ms=86400000 + +oauth.providers.google.client-id=test-google-client-id +oauth.providers.google.client-secret=test-google-client-secret +oauth.providers.google.redirect-uri=http://localhost:8080/auth/google/callback + +aws.s3.bucket=test-bucket +aws.s3.region=ap-northeast-2