From 17abe264bd9e725a479c9c8682256c59f39c0580 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Fri, 28 Aug 2026 11:59:46 +0900 Subject: [PATCH 01/60] =?UTF-8?q?docs(template):=20PR=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..47c5ed8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## ๐Ÿ› ๏ธ ์ฃผ์š” ๋ณ€๊ฒฝ ์‚ฌํ•ญ +> ์ด๋ฒˆ PR์—์„œ ์ž‘์—…ํ•œ ๋‚ด์šฉ์„ ๊ฐ„๋žตํžˆ ์š”์•ฝํ•ด์ฃผ์„ธ์š”. (๊ธฐ๋Šฅ ์ถ”๊ฐ€, ๋ฒ„๊ทธ ์ˆ˜์ •, ๋ฆฌํŒฉํ† ๋ง ๋“ฑ) +- + + +## โœจ ์ž‘์—… ๋‚ด์šฉ +> ์ฝ”๋“œ์—์„œ ๋ณ€๊ฒฝ๋œ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. +- + + +## โš ๏ธ ์ฃผ์˜ ์‚ฌํ•ญ ๋ฐ ํŠน์ด์‚ฌํ•ญ +> ๋ฆฌ๋ทฐ์–ด๊ฐ€ ํŠน๋ณ„ํžˆ ์ฐธ๊ณ ํ•ด์•ผ ํ•  ๋ถ€๋ถ„์ด๋‚˜ ๊ณ ๋ฏผํ–ˆ๋˜ ์ ์ด ์žˆ๋‹ค๋ฉด ์ ์–ด์ฃผ์„ธ์š”. +- [ ] (์˜ˆ: DB ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ํ•„์š”, ํŠน์ • API ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ ์—ฌ๋ถ€ ๋“ฑ) + + +## ๐Ÿ’ก ๊ด€๋ จ ์ด์Šˆ (Optional) +> ์—ฐ๊ด€๋œ ์ด์Šˆ ๋ฒˆํ˜ธ๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. (์˜ˆ: Close #123) +- + + +## ๐Ÿงช ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ (Optional) +> ๊ธฐ๋Šฅ์ด ์ •์ƒ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•œ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ(ํฌ์ŠคํŠธ๋งจ, ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋“ฑ)๋ฅผ ์ฒจ๋ถ€ํ•˜๊ฑฐ๋‚˜ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”. +- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ํ†ต๊ณผ +- [ ] ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ํ†ต๊ณผ \ No newline at end of file From e8d2d44a836d27b786d9cd4f1ccca9c3b0bbc461 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Fri, 28 Aug 2026 12:05:50 +0900 Subject: [PATCH 02/60] =?UTF-8?q?chore(infra):=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 +++++ .gitignore | 5 +++ build.gradle | 20 +++++++++++ compose.yml | 36 +++++++++++++++++++ .../resources/application-local.properties | 6 ++++ src/main/resources/application.properties | 10 ++++++ 6 files changed, 86 insertions(+) create mode 100644 .env.example create mode 100644 compose.yml create mode 100644 src/main/resources/application-local.properties diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0d8a345 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DB_HOST= +DB_PORT= +DB_NAME= +DB_USERNAME= +DB_PASSWORD= +MYSQL_ROOT_PASSWORD= +REDIS_HOST= +REDIS_PORT= +JWT_SECRET= \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..a7cb09d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ out/ ### VS Code ### .vscode/ + +### ENV ### +.env +.env.* +!.env.example \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9a77bb0..b6a25ab 100644 --- a/build.gradle +++ b/build.gradle @@ -21,10 +21,18 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.testcontainers:testcontainers-mysql' + testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' @@ -33,3 +41,15 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.register('dbUp', Exec) { + group = 'development' + description = '๋กœ์ปฌ DB ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.' + commandLine 'docker', 'compose', '--env-file', '.env.local', 'up', '-d' +} + +tasks.register('dbDown', Exec) { + group = 'development' + description = '๋กœ์ปฌ DB ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.' + commandLine 'docker', 'compose', '--env-file', '.env.local', 'down' +} \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4978f23 --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +name: dropit + +services: + db: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + ports: + - "127.0.0.1:${DB_PORT}:3306" + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: [ "CMD-SHELL", 'mysqladmin ping -h localhost -u root --password="$$MYSQL_ROOT_PASSWORD"' ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + redis: + image: redis:7.4-alpine + ports: + - "127.0.0.1:${REDIS_PORT}:6379" + volumes: + - redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + mysql-data: + redis-data: \ No newline at end of file diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 0000000..e758cb8 --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,6 @@ +spring.config.import=optional:file:.env.local[.properties] + +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +logging.level.com.dropit=DEBUG \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 731ca66..0849ac0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,11 @@ spring.application.name=dropit-server + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.open-in-view=false + +spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT} + From aa80afa6fcccc0ec1ae9655aa2cc4442e5ceea97 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Fri, 28 Aug 2026 12:36:34 +0900 Subject: [PATCH 03/60] =?UTF-8?q?docs(template):=20Issue=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug.md | 29 +++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/chore.md | 21 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 2 ++ .github/ISSUE_TEMPLATE/feature.md | 21 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/refactor.md | 25 +++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/chore.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.md create mode 100644 .github/ISSUE_TEMPLATE/refactor.md diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..5dfe70a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,29 @@ +--- +name: Bug +about: ๋ฒ„๊ทธ ๋˜๋Š” ์˜ˆ์ƒํ•˜์ง€ ๋ชปํ•œ ๋™์ž‘์„ ์ˆ˜์ •ํ•˜๊ธฐ ์œ„ํ•œ ์ด์Šˆ์ž…๋‹ˆ๋‹ค. +title: "[Bug] " +labels: "bug" +assignees: "" +--- + +## ๋ฌธ์ œ ์ƒํ™ฉ + +๋ฐœ์ƒํ•œ ๋ฌธ์ œ๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ์žฌํ˜„ ๋ฐฉ๋ฒ• + +1. +2. +3. + +## ๊ธฐ๋Œ€ ๋™์ž‘ + +์ •์ƒ์ ์œผ๋กœ ๊ธฐ๋Œ€ํ–ˆ๋˜ ๋™์ž‘์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ์‹ค์ œ ๋™์ž‘ + +์‹ค์ œ๋กœ ๋ฐœ์ƒํ•œ ๋™์ž‘์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ์ฐธ๊ณ  ์‚ฌํ•ญ + +์—๋Ÿฌ ๋กœ๊ทธ, ์Šคํฌ๋ฆฐ์ƒท ๋“ฑ ๋ฌธ์ œ ํŒŒ์•…์— ๋„์›€์ด ๋˜๋Š” ๋‚ด์šฉ์ด ์žˆ๋‹ค๋ฉด ์ฒจ๋ถ€ํ•ด์ฃผ์„ธ์š”. diff --git a/.github/ISSUE_TEMPLATE/chore.md b/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..b9e9b29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,21 @@ +--- +name: Chore +about: ํ™˜๊ฒฝ ์„ค์ •, ์ธํ”„๋ผ, ๋ฌธ์„œ ๋“ฑ์˜ ์ž‘์—…์„ ์œ„ํ•œ ์ด์Šˆ์ž…๋‹ˆ๋‹ค. +title: "[Chore] " +labels: "chore" +assignees: "" +--- + +## ์ž‘์—… ๋‚ด์šฉ + +ํ™˜๊ฒฝ ์„ค์ •, ์ธํ”„๋ผ, ๋ฌธ์„œ ๋“ฑ์˜ ์ž‘์—… ๋‚ด์šฉ์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ์„ธ๋ถ€ ์ž‘์—… + +- [ ] ์ž‘์—… 1 +- [ ] ์ž‘์—… 2 +- [ ] ์ž‘์—… 3 + +## ์ฐธ๊ณ  ์‚ฌํ•ญ + +์ถ”๊ฐ€์ ์œผ๋กœ ๊ณต์œ ํ•  ๋‚ด์šฉ์ด ์žˆ๋‹ค๋ฉด ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8005e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..019347a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,21 @@ +--- +name: Feature +about: ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ๊ตฌํ˜„์„ ์œ„ํ•œ ์ด์Šˆ์ž…๋‹ˆ๋‹ค. +title: "[Feature] " +labels: "feature" +assignees: "" +--- + +## ์ž‘์—… ๋‚ด์šฉ + +๊ตฌํ˜„ํ•  ๊ธฐ๋Šฅ ๋˜๋Š” ์ž‘์—… ๋‚ด์šฉ์„ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ์„ธ๋ถ€ ์ž‘์—… + +- [ ] ์ž‘์—… 1 +- [ ] ์ž‘์—… 2 +- [ ] ์ž‘์—… 3 + +## ์ฐธ๊ณ  ์‚ฌํ•ญ + +๊ด€๋ จ ๋ฌธ์„œ, ์ด๋ฏธ์ง€, ๋…ผ์˜ ๋‚ด์šฉ ๋“ฑ์ด ์žˆ๋‹ค๋ฉด ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. diff --git a/.github/ISSUE_TEMPLATE/refactor.md b/.github/ISSUE_TEMPLATE/refactor.md new file mode 100644 index 0000000..1e09512 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.md @@ -0,0 +1,25 @@ +--- +name: Refactor +about: ๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์—†์ด ์ฝ”๋“œ ๋˜๋Š” ๊ตฌ์กฐ๋ฅผ ๊ฐœ์„ ํ•˜๊ธฐ ์œ„ํ•œ ์ด์Šˆ์ž…๋‹ˆ๋‹ค. +title: "[Refactor] " +labels: "refactor" +assignees: "" +--- + +## ๋ฆฌํŒฉํ† ๋ง ๋Œ€์ƒ + +๋ฆฌํŒฉํ† ๋งํ•  ์ฝ”๋“œ ๋˜๋Š” ๊ตฌ์กฐ๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ๊ฐœ์„  ์ด์œ  + +ํ˜„์žฌ ๊ตฌ์กฐ์˜ ๋ฌธ์ œ์  ๋˜๋Š” ๊ฐœ์„ ํ•˜๋ ค๋Š” ์ด์œ ๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. + +## ์„ธ๋ถ€ ์ž‘์—… + +- [ ] ์ž‘์—… 1 +- [ ] ์ž‘์—… 2 +- [ ] ์ž‘์—… 3 + +## ์ฐธ๊ณ  ์‚ฌํ•ญ + +์ถ”๊ฐ€์ ์œผ๋กœ ๊ณต์œ ํ•  ๋‚ด์šฉ์ด ์žˆ๋‹ค๋ฉด ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”. From 6e3c2a913ed698e2eebf725092d2e21152f099ec Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Fri, 28 Aug 2026 12:46:54 +0900 Subject: [PATCH 04/60] =?UTF-8?q?feat(domain):=20=ED=95=B5=EC=8B=AC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20entity=20=EB=B0=8F=20repository?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gustavo Ruiz <4112699+gustavors88@users.noreply.github.com> Co-authored-by: Hyiyek <180898440+Hyiyek@users.noreply.github.com> Co-authored-by: jungu8745 <232289884+jungu8745@users.noreply.github.com> --- .../java/com/dropit/drop/entity/Drop.java | 64 +++++++++++++++++++ .../com/dropit/drop/entity/DropStatus.java | 8 +++ .../drop/repository/DropRepository.java | 7 ++ .../com/dropit/global/entity/BaseEntity.java | 18 ++++++ .../java/com/dropit/order/entity/Order.java | 43 +++++++++++++ .../order/repository/OrderRepository.java | 7 ++ .../com/dropit/product/entity/Product.java | 45 +++++++++++++ .../product/repository/ProductRepository.java | 7 ++ .../sellerprofile/entity/SellerProfile.java | 49 ++++++++++++++ .../repository/SellerProfileRepository.java | 7 ++ .../java/com/dropit/user/entity/User.java | 38 +++++++++++ .../java/com/dropit/user/entity/UserRole.java | 7 ++ .../user/repository/UserRepository.java | 7 ++ 13 files changed, 307 insertions(+) create mode 100644 src/main/java/com/dropit/drop/entity/Drop.java create mode 100644 src/main/java/com/dropit/drop/entity/DropStatus.java create mode 100644 src/main/java/com/dropit/drop/repository/DropRepository.java create mode 100644 src/main/java/com/dropit/global/entity/BaseEntity.java create mode 100644 src/main/java/com/dropit/order/entity/Order.java create mode 100644 src/main/java/com/dropit/order/repository/OrderRepository.java create mode 100644 src/main/java/com/dropit/product/entity/Product.java create mode 100644 src/main/java/com/dropit/product/repository/ProductRepository.java create mode 100644 src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java create mode 100644 src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java create mode 100644 src/main/java/com/dropit/user/entity/User.java create mode 100644 src/main/java/com/dropit/user/entity/UserRole.java create mode 100644 src/main/java/com/dropit/user/repository/UserRepository.java diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java new file mode 100644 index 0000000..3ef3ff2 --- /dev/null +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -0,0 +1,64 @@ +package com.dropit.drop.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.product.entity.Product; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "drops") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Drop extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(name = "initial_quantity", nullable = false) + private int initialQuantity; + + @Column(name = "remaining_quantity", nullable = false) + private int remainingQuantity; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DropStatus status = DropStatus.READY; + + @Column(name = "discount_amount", nullable = false) + private int discountAmount; + + @Column(name = "open_at", nullable = false) + private LocalDateTime openAt; + + @Column(name = "close_at", nullable = false) + private LocalDateTime closeAt; + + @Column(name = "purchase_limit", nullable = false) + private int purchaseLimit; + + public Drop( + Product product, + int initialQuantity, + int discountAmount, + LocalDateTime openAt, + LocalDateTime closeAt, + int purchaseLimit + ) { + this.product = product; + this.initialQuantity = initialQuantity; + this.remainingQuantity = initialQuantity; + this.discountAmount = discountAmount; + this.openAt = openAt; + this.closeAt = closeAt; + this.purchaseLimit = purchaseLimit; + } +} diff --git a/src/main/java/com/dropit/drop/entity/DropStatus.java b/src/main/java/com/dropit/drop/entity/DropStatus.java new file mode 100644 index 0000000..cb5b022 --- /dev/null +++ b/src/main/java/com/dropit/drop/entity/DropStatus.java @@ -0,0 +1,8 @@ +package com.dropit.drop.entity; + +public enum DropStatus { + READY, + OPEN, + SOLDOUT, + CLOSED +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepository.java b/src/main/java/com/dropit/drop/repository/DropRepository.java new file mode 100644 index 0000000..3181873 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropRepository.java @@ -0,0 +1,7 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.entity.Drop; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DropRepository extends JpaRepository { +} diff --git a/src/main/java/com/dropit/global/entity/BaseEntity.java b/src/main/java/com/dropit/global/entity/BaseEntity.java new file mode 100644 index 0000000..7512cfe --- /dev/null +++ b/src/main/java/com/dropit/global/entity/BaseEntity.java @@ -0,0 +1,18 @@ +package com.dropit.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +public abstract class BaseEntity { + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dropit/order/entity/Order.java b/src/main/java/com/dropit/order/entity/Order.java new file mode 100644 index 0000000..5fae724 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/Order.java @@ -0,0 +1,43 @@ +package com.dropit.order.entity; + +import com.dropit.drop.entity.Drop; +import com.dropit.global.entity.BaseEntity; +import com.dropit.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@Entity +@Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "drop_id", nullable = false) + private Drop drop; + + @Column(nullable = false, precision = 15, scale = 2) + private BigDecimal price; + + @Column(name = "order_quantity", nullable = false) + private int orderQuantity; + + public Order(User user, Drop drop, BigDecimal price, int orderQuantity) { + this.user = user; + this.drop = drop; + this.price = price; + this.orderQuantity = orderQuantity; + } +} diff --git a/src/main/java/com/dropit/order/repository/OrderRepository.java b/src/main/java/com/dropit/order/repository/OrderRepository.java new file mode 100644 index 0000000..324f183 --- /dev/null +++ b/src/main/java/com/dropit/order/repository/OrderRepository.java @@ -0,0 +1,7 @@ +package com.dropit.order.repository; + +import com.dropit.order.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { +} diff --git a/src/main/java/com/dropit/product/entity/Product.java b/src/main/java/com/dropit/product/entity/Product.java new file mode 100644 index 0000000..ff27a13 --- /dev/null +++ b/src/main/java/com/dropit/product/entity/Product.java @@ -0,0 +1,45 @@ +package com.dropit.product.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@Entity +@Table(name = "products") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "seller_id", nullable = false) + private User seller; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, precision = 15, scale = 2) + private BigDecimal price; + + @Column(columnDefinition = "text") + private String description; + + @Column(name = "image_url", length = 2048) + private String imageUrl; + + public Product(User seller, String name, BigDecimal price, String description, String imageUrl) { + this.seller = seller; + this.name = name; + this.price = price; + this.description = description; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/dropit/product/repository/ProductRepository.java b/src/main/java/com/dropit/product/repository/ProductRepository.java new file mode 100644 index 0000000..f5430f3 --- /dev/null +++ b/src/main/java/com/dropit/product/repository/ProductRepository.java @@ -0,0 +1,7 @@ +package com.dropit.product.repository; + +import com.dropit.product.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { +} diff --git a/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java b/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java new file mode 100644 index 0000000..01dd780 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java @@ -0,0 +1,49 @@ +package com.dropit.sellerprofile.entity; + +import com.dropit.global.entity.BaseEntity; +import com.dropit.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "seller_profiles") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SellerProfile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(columnDefinition = "text") + private String description; + + @Column(name = "image_url", length = 2048) + private String imageUrl; + + @Column(name = "instagram_url", length = 2048) + private String instagramUrl; + + @Column(name = "youtube_url", length = 2048) + private String youtubeUrl; + + public SellerProfile( + User user, + String description, + String imageUrl, + String instagramUrl, + String youtubeUrl + ) { + this.user = user; + this.description = description; + this.imageUrl = imageUrl; + this.instagramUrl = instagramUrl; + this.youtubeUrl = youtubeUrl; + } +} diff --git a/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java b/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java new file mode 100644 index 0000000..828b277 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java @@ -0,0 +1,7 @@ +package com.dropit.sellerprofile.repository; + +import com.dropit.sellerprofile.entity.SellerProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SellerProfileRepository extends JpaRepository { +} diff --git a/src/main/java/com/dropit/user/entity/User.java b/src/main/java/com/dropit/user/entity/User.java new file mode 100644 index 0000000..42045f9 --- /dev/null +++ b/src/main/java/com/dropit/user/entity/User.java @@ -0,0 +1,38 @@ +package com.dropit.user.entity; + +import com.dropit.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 100) + private String username; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserRole role; + + public User(String email, String password, String username, UserRole role) { + this.email = email; + this.password = password; + this.username = username; + this.role = role; + } +} diff --git a/src/main/java/com/dropit/user/entity/UserRole.java b/src/main/java/com/dropit/user/entity/UserRole.java new file mode 100644 index 0000000..ab326b0 --- /dev/null +++ b/src/main/java/com/dropit/user/entity/UserRole.java @@ -0,0 +1,7 @@ +package com.dropit.user.entity; + +public enum UserRole { + SELLER, + USER, + ADMIN +} diff --git a/src/main/java/com/dropit/user/repository/UserRepository.java b/src/main/java/com/dropit/user/repository/UserRepository.java new file mode 100644 index 0000000..a9235c1 --- /dev/null +++ b/src/main/java/com/dropit/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.dropit.user.repository; + +import com.dropit.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} From 363091b6e7debf53d84197b021e1e7a873171315 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 10:32:39 +0900 Subject: [PATCH 05/60] =?UTF-8?q?fix:=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9E=90=EB=8F=99=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dropit/global/entity/BaseEntity.java | 14 +++++++ .../dropit/global/entity/BaseEntityTest.java | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/test/java/com/dropit/global/entity/BaseEntityTest.java diff --git a/src/main/java/com/dropit/global/entity/BaseEntity.java b/src/main/java/com/dropit/global/entity/BaseEntity.java index 7512cfe..52a4eef 100644 --- a/src/main/java/com/dropit/global/entity/BaseEntity.java +++ b/src/main/java/com/dropit/global/entity/BaseEntity.java @@ -2,6 +2,8 @@ import jakarta.persistence.Column; import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import lombok.Getter; import java.time.LocalDateTime; @@ -15,4 +17,16 @@ public abstract class BaseEntity { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/test/java/com/dropit/global/entity/BaseEntityTest.java b/src/test/java/com/dropit/global/entity/BaseEntityTest.java new file mode 100644 index 0000000..ce14cb2 --- /dev/null +++ b/src/test/java/com/dropit/global/entity/BaseEntityTest.java @@ -0,0 +1,40 @@ +package com.dropit.global.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class BaseEntityTest { + + @Test + @DisplayName("์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ์‹œ ์ƒ์„ฑ ์‹œ๊ฐ๊ณผ ์ˆ˜์ • ์‹œ๊ฐ์„ ํ•จ๊ป˜ ๊ธฐ๋กํ•œ๋‹ค") + void recordCreatedAndUpdatedTime() { + TestEntity entity = new TestEntity(); + + entity.onCreate(); + + assertNotNull(entity.getCreatedAt()); + assertEquals(entity.getCreatedAt(), entity.getUpdatedAt()); + } + + @Test + @DisplayName("์—”ํ‹ฐํ‹ฐ ์ˆ˜์ • ์‹œ ์ƒ์„ฑ ์‹œ๊ฐ์€ ์œ ์ง€ํ•˜๊ณ  ์ˆ˜์ • ์‹œ๊ฐ๋งŒ ๊ฐฑ์‹ ํ•œ๋‹ค") + void updateOnlyUpdatedTime() { + TestEntity entity = new TestEntity(); + entity.onCreate(); + LocalDateTime createdAt = entity.getCreatedAt(); + + entity.onUpdate(); + + assertEquals(createdAt, entity.getCreatedAt()); + assertFalse(entity.getUpdatedAt().isBefore(createdAt)); + } + + private static class TestEntity extends BaseEntity { + } +} From 48abfa5c775d23cccb6590e775f018aa381381fa Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 10:33:36 +0900 Subject: [PATCH 06/60] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ProductCreateRequest.java | 33 +++++ .../product/dto/response/ProductResponse.java | 57 ++++++++ .../com/dropit/product/entity/Product.java | 22 ++- .../product/service/ProductService.java | 42 ++++++ .../dto/request/ProductCreateRequestTest.java | 96 +++++++++++++ .../dropit/product/entity/ProductTest.java | 70 +++++++++ .../product/service/ProductServiceTest.java | 134 ++++++++++++++++++ 7 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java create mode 100644 src/main/java/com/dropit/product/dto/response/ProductResponse.java create mode 100644 src/main/java/com/dropit/product/service/ProductService.java create mode 100644 src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java create mode 100644 src/test/java/com/dropit/product/entity/ProductTest.java create mode 100644 src/test/java/com/dropit/product/service/ProductServiceTest.java diff --git a/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java b/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java new file mode 100644 index 0000000..cd395fe --- /dev/null +++ b/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java @@ -0,0 +1,33 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@NoArgsConstructor +public class ProductCreateRequest { + + @NotBlank(message = "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "์ƒํ’ˆ๋ช…์€ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + private String name; + + @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + private BigDecimal price; + + private String description; + + public ProductCreateRequest(String name, BigDecimal price, String description) { + this.name = name; + this.price = price; + this.description = description; + } +} diff --git a/src/main/java/com/dropit/product/dto/response/ProductResponse.java b/src/main/java/com/dropit/product/dto/response/ProductResponse.java new file mode 100644 index 0000000..6db3f09 --- /dev/null +++ b/src/main/java/com/dropit/product/dto/response/ProductResponse.java @@ -0,0 +1,57 @@ +package com.dropit.product.dto.response; + +import com.dropit.product.entity.Product; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +public class ProductResponse { + + private final Long id; + private final Long sellerId; + private final String sellerName; + private final String name; + private final BigDecimal price; + private final String description; + private final String imageUrl; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public ProductResponse( + Long id, + Long sellerId, + String sellerName, + String name, + BigDecimal price, + String description, + String imageUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + this.id = id; + this.sellerId = sellerId; + this.sellerName = sellerName; + this.name = name; + this.price = price; + this.description = description; + this.imageUrl = imageUrl; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getSeller().getId(), + product.getSeller().getUsername(), + product.getName(), + product.getPrice(), + product.getDescription(), + product.getImageUrl(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/dropit/product/entity/Product.java b/src/main/java/com/dropit/product/entity/Product.java index ff27a13..413232a 100644 --- a/src/main/java/com/dropit/product/entity/Product.java +++ b/src/main/java/com/dropit/product/entity/Product.java @@ -2,7 +2,15 @@ import com.dropit.global.entity.BaseEntity; import com.dropit.user.entity.User; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -23,7 +31,7 @@ public class Product extends BaseEntity { @JoinColumn(name = "seller_id", nullable = false) private User seller; - @Column(nullable = false) + @Column(nullable = false, length = 100) private String name; @Column(nullable = false, precision = 15, scale = 2) @@ -42,4 +50,14 @@ public Product(User seller, String name, BigDecimal price, String description, S this.description = description; this.imageUrl = imageUrl; } + + public void updateInfo(String name, BigDecimal price, String description) { + this.name = name; + this.price = price; + this.description = description; + } + + public void changeImage(String imageUrl) { + this.imageUrl = imageUrl; + } } diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java new file mode 100644 index 0000000..d2fbe62 --- /dev/null +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -0,0 +1,42 @@ +package com.dropit.product.service; + +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final UserRepository userRepository; + + @Transactional + public ProductResponse createProduct(Long sellerId, ProductCreateRequest request) { + User seller = userRepository.findById(sellerId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + + if (seller.getRole() != UserRole.SELLER) { + throw new IllegalStateException("ํŒ๋งค์ž๋งŒ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + + Product product = new Product( + seller, + request.getName(), + request.getPrice(), + request.getDescription(), + null + ); + + Product savedProduct = productRepository.save(product); + + return ProductResponse.from(savedProduct); + } +} diff --git a/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java b/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java new file mode 100644 index 0000000..24b6eef --- /dev/null +++ b/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java @@ -0,0 +1,96 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProductCreateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ์ƒํ’ˆ ๋“ฑ๋ก ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptValidRequest() { + ProductCreateRequest request = new ProductCreateRequest( + "Limited Hoodie", + new BigDecimal("59000.00"), + "Limited edition hoodie" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("์ƒํ’ˆ๋ช…์€ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†๋‹ค") + void rejectBlankName() { + ProductCreateRequest request = new ProductCreateRequest( + " ", + new BigDecimal("59000.00"), + null + ); + + assertViolationCount(request, "name", 1); + } + + @Test + @DisplayName("์ƒํ’ˆ๋ช…์€ 100์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + void rejectTooLongName() { + ProductCreateRequest request = new ProductCreateRequest( + "a".repeat(101), + new BigDecimal("59000.00"), + null + ); + + assertViolationCount(request, "name", 1); + } + + @Test + @DisplayName("๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ด๋ฉฐ 0๋ณด๋‹ค ์ปค์•ผ ํ•œ๋‹ค") + void rejectMissingOrNonPositivePrice() { + ProductCreateRequest missingPrice = new ProductCreateRequest("Product", null, null); + ProductCreateRequest zeroPrice = new ProductCreateRequest("Product", BigDecimal.ZERO, null); + + assertViolationCount(missingPrice, "price", 1); + assertViolationCount(zeroPrice, "price", 1); + } + + @Test + @DisplayName("๊ฐ€๊ฒฉ์€ DB ์ปฌ๋Ÿผ ๋ฒ”์œ„์ธ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๋ฅผ ๋„˜์„ ์ˆ˜ ์—†๋‹ค") + void rejectPriceOutsideColumnRange() { + ProductCreateRequest tooManyIntegerDigits = new ProductCreateRequest( + "Product", + new BigDecimal("12345678901234.00"), + null + ); + ProductCreateRequest tooManyFractionDigits = new ProductCreateRequest( + "Product", + new BigDecimal("1000.001"), + null + ); + + assertViolationCount(tooManyIntegerDigits, "price", 1); + assertViolationCount(tooManyFractionDigits, "price", 1); + } + + private void assertViolationCount( + ProductCreateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/product/entity/ProductTest.java b/src/test/java/com/dropit/product/entity/ProductTest.java new file mode 100644 index 0000000..30c3f15 --- /dev/null +++ b/src/test/java/com/dropit/product/entity/ProductTest.java @@ -0,0 +1,70 @@ +package com.dropit.product.entity; + +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ProductTest { + + private final User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + + @Test + @DisplayName("์ƒํ’ˆ์„ ์ƒ์„ฑํ•˜๋ฉด ์ž…๋ ฅํ•œ ์ •๋ณด๊ฐ€ ์ €์žฅ๋œ๋‹ค") + void createProduct() { + Product product = new Product( + seller, + "Limited Hoodie", + new BigDecimal("59000"), + "Limited edition hoodie", + "products/limited-hoodie.webp" + ); + + assertEquals(seller, product.getSeller()); + assertEquals("Limited Hoodie", product.getName()); + assertEquals(new BigDecimal("59000"), product.getPrice()); + assertEquals("Limited edition hoodie", product.getDescription()); + assertEquals("products/limited-hoodie.webp", product.getImageUrl()); + } + + @Test + @DisplayName("์ƒํ’ˆ์˜ ๊ธฐ๋ณธ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void updateInfo() { + Product product = createProductWithoutImage(); + + product.updateInfo("Updated Product", new BigDecimal("15000"), "Updated description"); + + assertEquals("Updated Product", product.getName()); + assertEquals(new BigDecimal("15000"), product.getPrice()); + assertEquals("Updated description", product.getDescription()); + } + + @Test + @DisplayName("๋Œ€ํ‘œ ์ด๋ฏธ์ง€๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void changeImage() { + Product product = createProductWithoutImage(); + + product.changeImage("products/new-image.webp"); + + assertEquals("products/new-image.webp", product.getImageUrl()); + } + + private Product createProductWithoutImage() { + return new Product( + seller, + "Product", + new BigDecimal("10000"), + "Description", + null + ); + } +} diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java new file mode 100644 index 0000000..8368469 --- /dev/null +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -0,0 +1,134 @@ +package com.dropit.product.service; + +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ProductService productService; + + @Test + @DisplayName("ํŒ๋งค์ž๋Š” ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋‹ค") + void createProduct() { + Long sellerId = 1L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + ProductCreateRequest request = new ProductCreateRequest( + "Limited Hoodie", + new BigDecimal("59000.00"), + "Limited edition hoodie" + ); + + when(userRepository.findById(sellerId)).thenReturn(Optional.of(seller)); + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { + Product product = invocation.getArgument(0); + ReflectionTestUtils.setField(product, "id", 100L); + ReflectionTestUtils.setField(product, "createdAt", LocalDateTime.of(2026, 8, 31, 10, 0)); + ReflectionTestUtils.setField(product, "updatedAt", LocalDateTime.of(2026, 8, 31, 10, 0)); + return product; + }); + + ProductResponse response = productService.createProduct(sellerId, request); + + assertEquals(100L, response.getId()); + assertEquals(sellerId, response.getSellerId()); + assertEquals("username", response.getSellerName()); + assertEquals("Limited Hoodie", response.getName()); + assertEquals(new BigDecimal("59000.00"), response.getPrice()); + assertEquals("Limited edition hoodie", response.getDescription()); + assertNull(response.getImageUrl()); + assertEquals(LocalDateTime.of(2026, 8, 31, 10, 0), response.getCreatedAt()); + assertEquals(LocalDateTime.of(2026, 8, 31, 10, 0), response.getUpdatedAt()); + + ArgumentCaptor productCaptor = ArgumentCaptor.forClass(Product.class); + verify(productRepository).save(productCaptor.capture()); + + Product savedProduct = productCaptor.getValue(); + assertEquals(seller, savedProduct.getSeller()); + assertEquals("Limited Hoodie", savedProduct.getName()); + assertEquals(new BigDecimal("59000.00"), savedProduct.getPrice()); + assertEquals("Limited edition hoodie", savedProduct.getDescription()); + assertNull(savedProduct.getImageUrl()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž๋Š” ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋‹ค") + void rejectMissingUser() { + Long sellerId = 1L; + ProductCreateRequest request = createRequest(); + when(userRepository.findById(sellerId)).thenReturn(Optional.empty()); + + assertThrows( + IllegalArgumentException.class, + () -> productService.createProduct(sellerId, request) + ); + + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋‹ค") + void rejectNonSeller() { + Long userId = 1L; + User user = createUser(UserRole.USER); + ProductCreateRequest request = createRequest(); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThrows( + IllegalStateException.class, + () -> productService.createProduct(userId, request) + ); + + verify(productRepository, never()).save(any(Product.class)); + } + + private ProductCreateRequest createRequest() { + return new ProductCreateRequest( + "Product", + new BigDecimal("10000.00"), + "Description" + ); + } + + private User createUser(UserRole role) { + return new User( + "user@example.com", + "encoded-password", + "username", + role + ); + } +} From 406f48584022a3e791ffd1a14fad6cdb88cf81e5 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 10:36:38 +0900 Subject: [PATCH 07/60] =?UTF-8?q?refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20=EB=B3=80=ED=99=98=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/dto/response/ProductResponse.java | 62 ++++++------------- .../product/service/ProductService.java | 2 +- 2 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/dropit/product/dto/response/ProductResponse.java b/src/main/java/com/dropit/product/dto/response/ProductResponse.java index 6db3f09..09d4966 100644 --- a/src/main/java/com/dropit/product/dto/response/ProductResponse.java +++ b/src/main/java/com/dropit/product/dto/response/ProductResponse.java @@ -9,49 +9,25 @@ @Getter public class ProductResponse { - private final Long id; - private final Long sellerId; - private final String sellerName; - private final String name; - private final BigDecimal price; - private final String description; - private final String imageUrl; - private final LocalDateTime createdAt; - private final LocalDateTime updatedAt; + private Long id; + private Long sellerId; + private String sellerName; + private String name; + private BigDecimal price; + private String description; + private String imageUrl; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; - public ProductResponse( - Long id, - Long sellerId, - String sellerName, - String name, - BigDecimal price, - String description, - String imageUrl, - LocalDateTime createdAt, - LocalDateTime updatedAt - ) { - this.id = id; - this.sellerId = sellerId; - this.sellerName = sellerName; - this.name = name; - this.price = price; - this.description = description; - this.imageUrl = imageUrl; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - } - - public static ProductResponse from(Product product) { - return new ProductResponse( - product.getId(), - product.getSeller().getId(), - product.getSeller().getUsername(), - product.getName(), - product.getPrice(), - product.getDescription(), - product.getImageUrl(), - product.getCreatedAt(), - product.getUpdatedAt() - ); + public ProductResponse(Product product) { + this.id = product.getId(); + this.sellerId = product.getSeller().getId(); + this.sellerName = product.getSeller().getUsername(); + this.name = product.getName(); + this.price = product.getPrice(); + this.description = product.getDescription(); + this.imageUrl = product.getImageUrl(); + this.createdAt = product.getCreatedAt(); + this.updatedAt = product.getUpdatedAt(); } } diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index d2fbe62..8e79658 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -37,6 +37,6 @@ public ProductResponse createProduct(Long sellerId, ProductCreateRequest request Product savedProduct = productRepository.save(product); - return ProductResponse.from(savedProduct); + return new ProductResponse(savedProduct); } } From 187dcb6fe59d73e0864763cc650e094d4ef204e7 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 10:52:33 +0900 Subject: [PATCH 08/60] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/controller/ProductController.java | 32 +++++ .../product/service/ProductService.java | 5 +- .../controller/ProductControllerTest.java | 133 ++++++++++++++++++ .../product/service/ProductServiceTest.java | 21 +-- 4 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/dropit/product/controller/ProductController.java create mode 100644 src/test/java/com/dropit/product/controller/ProductControllerTest.java diff --git a/src/main/java/com/dropit/product/controller/ProductController.java b/src/main/java/com/dropit/product/controller/ProductController.java new file mode 100644 index 0000000..f3ed556 --- /dev/null +++ b/src/main/java/com/dropit/product/controller/ProductController.java @@ -0,0 +1,32 @@ +package com.dropit.product.controller; + +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.service.ProductService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; + +@RestController +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @PostMapping("/products") + public ResponseEntity create( + @RequestParam Long sellerId, + @Valid @RequestBody ProductCreateRequest request + ) { + Long productId = productService.create(sellerId, request); + + return ResponseEntity + .created(URI.create("/products/" + productId)) + .body(productId); + } +} diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index 8e79658..96ad3ed 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -1,7 +1,6 @@ package com.dropit.product.service; import com.dropit.product.dto.request.ProductCreateRequest; -import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; import com.dropit.product.repository.ProductRepository; import com.dropit.user.entity.User; @@ -19,7 +18,7 @@ public class ProductService { private final UserRepository userRepository; @Transactional - public ProductResponse createProduct(Long sellerId, ProductCreateRequest request) { + public Long create(Long sellerId, ProductCreateRequest request) { User seller = userRepository.findById(sellerId) .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); @@ -37,6 +36,6 @@ public ProductResponse createProduct(Long sellerId, ProductCreateRequest request Product savedProduct = productRepository.save(product); - return new ProductResponse(savedProduct); + return savedProduct.getId(); } } diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java new file mode 100644 index 0000000..129526f --- /dev/null +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -0,0 +1,133 @@ +package com.dropit.product.controller; + +import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.service.ProductService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ProductControllerTest { + + private MockMvc mockMvc; + private ProductService productService; + + @BeforeEach + void setUp() { + productService = mock(ProductService.class); + ProductController productController = new ProductController(productService); + mockMvc = MockMvcBuilders.standaloneSetup(productController).build(); + } + + @Test + @DisplayName("์ƒํ’ˆ์„ ๋“ฑ๋กํ•˜๋ฉด 201 ์ƒํƒœ์™€ ์ƒ์„ฑ๋œ ์ƒํ’ˆ ID๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void create() throws Exception { + Long sellerId = 1L; + when(productService.create(eq(sellerId), any(ProductCreateRequest.class))) + .thenReturn(100L); + + mockMvc.perform(post("/products") + .param("sellerId", sellerId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Limited Hoodie", + "price": 59000.00, + "description": "Limited edition hoodie" + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/products/100")) + .andExpect(content().string("100")); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ProductCreateRequest.class); + verify(productService).create(eq(sellerId), requestCaptor.capture()); + + ProductCreateRequest request = requestCaptor.getValue(); + assertEquals("Limited Hoodie", request.getName()); + assertEquals(new BigDecimal("59000.00"), request.getPrice()); + assertEquals("Limited edition hoodie", request.getDescription()); + } + + @Test + @DisplayName("ํŒ๋งค์ž ID๊ฐ€ ์—†์œผ๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectMissingSellerId() throws Exception { + mockMvc.perform(post("/products") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "price": 10000.00, + "description": "Description" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("์ƒํ’ˆ๋ช…์ด ๊ณต๋ฐฑ์ด๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectBlankName() throws Exception { + mockMvc.perform(post("/products") + .param("sellerId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": " ", + "price": 59000.00, + "description": "Description" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("์ƒํ’ˆ ๊ฐ€๊ฒฉ์ด 0์ด๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectZeroPrice() throws Exception { + mockMvc.perform(post("/products") + .param("sellerId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "price": 0, + "description": "Description" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("์š”์ฒญ ๋ณธ๋ฌธ์ด ์—†์œผ๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectMissingBody() throws Exception { + mockMvc.perform(post("/products") + .param("sellerId", "1") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } +} diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index 8368469..1a72626 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -1,7 +1,6 @@ package com.dropit.product.service; import com.dropit.product.dto.request.ProductCreateRequest; -import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; import com.dropit.product.repository.ProductRepository; import com.dropit.user.entity.User; @@ -17,7 +16,6 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -45,7 +43,6 @@ class ProductServiceTest { void createProduct() { Long sellerId = 1L; User seller = createUser(UserRole.SELLER); - ReflectionTestUtils.setField(seller, "id", sellerId); ProductCreateRequest request = new ProductCreateRequest( "Limited Hoodie", new BigDecimal("59000.00"), @@ -56,22 +53,12 @@ void createProduct() { when(productRepository.save(any(Product.class))).thenAnswer(invocation -> { Product product = invocation.getArgument(0); ReflectionTestUtils.setField(product, "id", 100L); - ReflectionTestUtils.setField(product, "createdAt", LocalDateTime.of(2026, 8, 31, 10, 0)); - ReflectionTestUtils.setField(product, "updatedAt", LocalDateTime.of(2026, 8, 31, 10, 0)); return product; }); - ProductResponse response = productService.createProduct(sellerId, request); + Long productId = productService.create(sellerId, request); - assertEquals(100L, response.getId()); - assertEquals(sellerId, response.getSellerId()); - assertEquals("username", response.getSellerName()); - assertEquals("Limited Hoodie", response.getName()); - assertEquals(new BigDecimal("59000.00"), response.getPrice()); - assertEquals("Limited edition hoodie", response.getDescription()); - assertNull(response.getImageUrl()); - assertEquals(LocalDateTime.of(2026, 8, 31, 10, 0), response.getCreatedAt()); - assertEquals(LocalDateTime.of(2026, 8, 31, 10, 0), response.getUpdatedAt()); + assertEquals(100L, productId); ArgumentCaptor productCaptor = ArgumentCaptor.forClass(Product.class); verify(productRepository).save(productCaptor.capture()); @@ -93,7 +80,7 @@ void rejectMissingUser() { assertThrows( IllegalArgumentException.class, - () -> productService.createProduct(sellerId, request) + () -> productService.create(sellerId, request) ); verify(productRepository, never()).save(any(Product.class)); @@ -109,7 +96,7 @@ void rejectNonSeller() { assertThrows( IllegalStateException.class, - () -> productService.createProduct(userId, request) + () -> productService.create(userId, request) ); verify(productRepository, never()).save(any(Product.class)); From f9510897b1640dc19677abed8325a49083fa03b9 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 11:22:17 +0900 Subject: [PATCH 09/60] =?UTF-8?q?feat(global):=20JPA=20Auditing=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/dropit/DropitServerApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/dropit/DropitServerApplication.java b/src/main/java/com/dropit/DropitServerApplication.java index 1aed907..d617bf6 100644 --- a/src/main/java/com/dropit/DropitServerApplication.java +++ b/src/main/java/com/dropit/DropitServerApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class DropitServerApplication { From 5a7e8f9f07795d60dfc0d0e19670b58b9169f00a Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 11:24:19 +0900 Subject: [PATCH 10/60] =?UTF-8?q?feat(global):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/CommonErrorCode.java | 18 ++++++++ .../dropit/global/exception/ErrorCode.java | 14 +++++++ .../global/exception/ErrorResponse.java | 19 +++++++++ .../exception/GlobalExceptionHandler.java | 42 +++++++++++++++++++ .../global/exception/ServiceException.java | 14 +++++++ 5 files changed, 107 insertions(+) create mode 100644 src/main/java/com/dropit/global/exception/CommonErrorCode.java create mode 100644 src/main/java/com/dropit/global/exception/ErrorCode.java create mode 100644 src/main/java/com/dropit/global/exception/ErrorResponse.java create mode 100644 src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/dropit/global/exception/ServiceException.java diff --git a/src/main/java/com/dropit/global/exception/CommonErrorCode.java b/src/main/java/com/dropit/global/exception/CommonErrorCode.java new file mode 100644 index 0000000..dfa5739 --- /dev/null +++ b/src/main/java/com/dropit/global/exception/CommonErrorCode.java @@ -0,0 +1,18 @@ +package com.dropit.global.exception; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + + INVALID_INPUT(HttpStatus.BAD_REQUEST, "์ž…๋ ฅ ๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "์š”์ฒญ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + + private final HttpStatus status; + private final String message; + + @Override public HttpStatus status() { return status; } + @Override public String message() { return message; } +} diff --git a/src/main/java/com/dropit/global/exception/ErrorCode.java b/src/main/java/com/dropit/global/exception/ErrorCode.java new file mode 100644 index 0000000..6954613 --- /dev/null +++ b/src/main/java/com/dropit/global/exception/ErrorCode.java @@ -0,0 +1,14 @@ +package com.dropit.global.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + HttpStatus status(); + + String message(); + + default String code() { + return ((Enum) this).name(); + } +} diff --git a/src/main/java/com/dropit/global/exception/ErrorResponse.java b/src/main/java/com/dropit/global/exception/ErrorResponse.java new file mode 100644 index 0000000..a3ea434 --- /dev/null +++ b/src/main/java/com/dropit/global/exception/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.dropit.global.exception; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Map; + +public record ErrorResponse( + String code, + String message, + @JsonInclude(JsonInclude.Include.NON_EMPTY) Map errors +) { + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse(errorCode.code(), errorCode.message(), null); + } + + public static ErrorResponse from(ErrorCode errorCode, Map errors) { + return new ErrorResponse(errorCode.code(), errorCode.message(), errors); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java b/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1b48e0d --- /dev/null +++ b/src/main/java/com/dropit/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package com.dropit.global.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ServiceException.class) + public ResponseEntity handleServiceException(ServiceException e) { + ErrorCode errorCode = e.getErrorCode(); + + return ResponseEntity + .status(errorCode.status()) + .body(ErrorResponse.from(errorCode)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + // Bean Validation ์‹คํŒจ ์‹œ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ˆ˜์ •ํ•  ํ•„๋“œ๋ฅผ ์•Œ ์ˆ˜ ์žˆ๋„๋ก ํ•„๋“œ๋ณ„ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + Map errors = new LinkedHashMap<>(); + e.getBindingResult().getFieldErrors().forEach(error -> + errors.putIfAbsent(error.getField(), error.getDefaultMessage())); + + return ResponseEntity + .badRequest() + .body(ErrorResponse.from(CommonErrorCode.INVALID_INPUT, errors)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException() { + return ResponseEntity + .badRequest() + .body(ErrorResponse.from(CommonErrorCode.INVALID_REQUEST)); + } +} diff --git a/src/main/java/com/dropit/global/exception/ServiceException.java b/src/main/java/com/dropit/global/exception/ServiceException.java new file mode 100644 index 0000000..3974e1e --- /dev/null +++ b/src/main/java/com/dropit/global/exception/ServiceException.java @@ -0,0 +1,14 @@ +package com.dropit.global.exception; + +import lombok.Getter; + +@Getter +public class ServiceException extends RuntimeException { + + private final ErrorCode errorCode; + + public ServiceException(ErrorCode errorCode) { + super(errorCode.message()); + this.errorCode = errorCode; + } +} \ No newline at end of file From 8fa27b28eacd6254f2a88b4ce3e9b583fee82c9e Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 11:43:47 +0900 Subject: [PATCH 11/60] =?UTF-8?q?fix(entity):=20BaseEntity=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=95=84=EB=93=9C=EB=A5=BC=20JPA=20Auditing?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/dropit/global/entity/BaseEntity.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dropit/global/entity/BaseEntity.java b/src/main/java/com/dropit/global/entity/BaseEntity.java index 7512cfe..52ff7d9 100644 --- a/src/main/java/com/dropit/global/entity/BaseEntity.java +++ b/src/main/java/com/dropit/global/entity/BaseEntity.java @@ -1,18 +1,24 @@ package com.dropit.global.entity; import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Getter @MappedSuperclass +@EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { - @Column(name = "created_at", nullable = false, updatable = false) + @CreatedDate + @Column(updatable = false) private LocalDateTime createdAt; - @Column(name = "updated_at", nullable = false) + @LastModifiedDate private LocalDateTime updatedAt; } From 8899b2083e4c52c0fd19bcdab0556c026ba4a2af Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 13:24:09 +0900 Subject: [PATCH 12/60] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=88=98=EC=A0=95=20=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/controller/ProductController.java | 42 ++++ .../dto/request/ProductUpdateRequest.java | 34 +++ .../product/service/ProductService.java | 55 +++++ .../controller/ProductControllerTest.java | 130 ++++++++++ .../dto/request/ProductUpdateRequestTest.java | 78 ++++++ .../product/service/ProductServiceTest.java | 228 ++++++++++++++++++ 6 files changed, 567 insertions(+) create mode 100644 src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java create mode 100644 src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java diff --git a/src/main/java/com/dropit/product/controller/ProductController.java b/src/main/java/com/dropit/product/controller/ProductController.java index f3ed556..494cf2f 100644 --- a/src/main/java/com/dropit/product/controller/ProductController.java +++ b/src/main/java/com/dropit/product/controller/ProductController.java @@ -1,16 +1,23 @@ package com.dropit.product.controller; import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.service.ProductService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.net.URI; +import java.util.List; @RestController @RequiredArgsConstructor @@ -29,4 +36,39 @@ public ResponseEntity create( .created(URI.create("/products/" + productId)) .body(productId); } + + @GetMapping("/products/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductResponse response = productService.getProduct(productId); + + return ResponseEntity.ok(response); + } + + @GetMapping("/products") + public ResponseEntity> getProducts() { + List response = productService.getProducts(); + + return ResponseEntity.ok(response); + } + + @PutMapping("/products/{productId}") + public ResponseEntity update( + @RequestParam Long sellerId, + @PathVariable Long productId, + @Valid @RequestBody ProductUpdateRequest request + ) { + ProductResponse response = productService.update(sellerId, productId, request); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/products/{productId}") + public ResponseEntity delete( + @RequestParam Long sellerId, + @PathVariable Long productId + ) { + productService.delete(sellerId, productId); + + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java b/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java new file mode 100644 index 0000000..66be30b --- /dev/null +++ b/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java @@ -0,0 +1,34 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@NoArgsConstructor +public class ProductUpdateRequest { + + @NotBlank(message = "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(max = 100, message = "์ƒํ’ˆ๋ช…์€ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + private String name; + + @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + private BigDecimal price; + + @Size(max = 3000, message = "์ƒํ’ˆ ์„ค๋ช…์€ 3000์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + private String description; + + public ProductUpdateRequest(String name, BigDecimal price, String description) { + this.name = name; + this.price = price; + this.description = description; + } +} diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index 96ad3ed..ed8f81c 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -1,6 +1,8 @@ package com.dropit.product.service; import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; import com.dropit.product.repository.ProductRepository; import com.dropit.user.entity.User; @@ -10,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class ProductService { @@ -38,4 +42,55 @@ public Long create(Long sellerId, ProductCreateRequest request) { return savedProduct.getId(); } + + @Transactional(readOnly = true) + public ProductResponse getProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + + return new ProductResponse(product); + } + + @Transactional(readOnly = true) + public List getProducts() { + List products = productRepository.findAll(); + + return products.stream() + .map(product -> new ProductResponse(product)) + .toList(); + } + + @Transactional + public ProductResponse update( + Long sellerId, + Long productId, + ProductUpdateRequest request + ) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + + if (!product.getSeller().getId().equals(sellerId)) { + throw new IllegalStateException("์ƒํ’ˆ ์†Œ์œ ์ž๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + + product.updateInfo( + request.getName(), + request.getPrice(), + request.getDescription() + ); + + return new ProductResponse(product); + } + + @Transactional + public void delete(Long sellerId, Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + + if (!product.getSeller().getId().equals(sellerId)) { + throw new IllegalStateException("์ƒํ’ˆ ์†Œ์œ ์ž๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + } + + productRepository.delete(product); + } } diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index 129526f..7efc4ff 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -1,7 +1,12 @@ package com.dropit.product.controller; import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; +import com.dropit.product.entity.Product; import com.dropit.product.service.ProductService; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,8 +14,10 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -20,8 +27,12 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class ProductControllerTest { @@ -130,4 +141,123 @@ void rejectMissingBody() throws Exception { verifyNoInteractions(productService); } + + @Test + @DisplayName("์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋ฉด 200 ์ƒํƒœ์™€ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getProducts() throws Exception { + User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product product = new Product( + seller, + "Limited Hoodie", + new BigDecimal("59000.00"), + "Limited edition hoodie", + null + ); + ReflectionTestUtils.setField(product, "id", 100L); + + when(productService.getProducts()) + .thenReturn(List.of(new ProductResponse(product))); + + mockMvc.perform(get("/products")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(100L)) + .andExpect(jsonPath("$[0].sellerId").value(1L)) + .andExpect(jsonPath("$[0].sellerName").value("seller")) + .andExpect(jsonPath("$[0].name").value("Limited Hoodie")) + .andExpect(jsonPath("$[0].price").value(59000.00)) + .andExpect(jsonPath("$[0].description").value("Limited edition hoodie")); + + verify(productService).getProducts(); + } + + @Test + @DisplayName("์ƒํ’ˆ์„ ์ˆ˜์ •ํ•˜๋ฉด 200 ์ƒํƒœ์™€ ์ˆ˜์ •๋œ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void updateProduct() throws Exception { + Long sellerId = 1L; + Long productId = 100L; + User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Updated Product", + new BigDecimal("49000.00"), + "Updated description", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + + when(productService.update(eq(sellerId), eq(productId), any(ProductUpdateRequest.class))) + .thenReturn(new ProductResponse(product)); + + mockMvc.perform(put("/products/{productId}", productId) + .param("sellerId", sellerId.toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Updated Product", + "price": 49000.00, + "description": "Updated description" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(productId)) + .andExpect(jsonPath("$.sellerId").value(sellerId)) + .andExpect(jsonPath("$.name").value("Updated Product")) + .andExpect(jsonPath("$.price").value(49000.00)) + .andExpect(jsonPath("$.description").value("Updated description")); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(ProductUpdateRequest.class); + verify(productService).update(eq(sellerId), eq(productId), requestCaptor.capture()); + + ProductUpdateRequest request = requestCaptor.getValue(); + assertEquals("Updated Product", request.getName()); + assertEquals(new BigDecimal("49000.00"), request.getPrice()); + assertEquals("Updated description", request.getDescription()); + } + + @Test + @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์š”์ฒญ์˜ ์ƒํ’ˆ๋ช…์ด ๊ณต๋ฐฑ์ด๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectUpdatingWithBlankName() throws Exception { + mockMvc.perform(put("/products/{productId}", 100L) + .param("sellerId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": " ", + "price": 49000.00, + "description": "Updated description" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(productService); + } + + @Test + @DisplayName("์ƒํ’ˆ์„ ์‚ญ์ œํ•˜๋ฉด 204 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void deleteProduct() throws Exception { + Long sellerId = 1L; + Long productId = 100L; + + mockMvc.perform(delete("/products/{productId}", productId) + .param("sellerId", sellerId.toString())) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + verify(productService).delete(sellerId, productId); + } } diff --git a/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java b/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java new file mode 100644 index 0000000..6f2b6ba --- /dev/null +++ b/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java @@ -0,0 +1,78 @@ +package com.dropit.product.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProductUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ์ƒํ’ˆ ์ˆ˜์ • ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptValidRequest() { + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + new BigDecimal("49000.00"), + "Updated description" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์‹œ ์ƒํ’ˆ๋ช…์€ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†๋‹ค") + void rejectBlankName() { + ProductUpdateRequest request = new ProductUpdateRequest( + " ", + new BigDecimal("49000.00"), + null + ); + + assertViolationCount(request, "name", 1); + } + + @Test + @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์‹œ ๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ด๋ฉฐ 0๋ณด๋‹ค ์ปค์•ผ ํ•œ๋‹ค") + void rejectMissingOrNonPositivePrice() { + ProductUpdateRequest missingPrice = new ProductUpdateRequest("Product", null, null); + ProductUpdateRequest zeroPrice = new ProductUpdateRequest("Product", BigDecimal.ZERO, null); + + assertViolationCount(missingPrice, "price", 1); + assertViolationCount(zeroPrice, "price", 1); + } + + @Test + @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์‹œ ์ƒํ’ˆ ์„ค๋ช…์€ 3000์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + void rejectTooLongDescription() { + ProductUpdateRequest request = new ProductUpdateRequest( + "Product", + new BigDecimal("49000.00"), + "a".repeat(3001) + ); + + assertViolationCount(request, "description", 1); + } + + private void assertViolationCount( + ProductUpdateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index 1a72626..19b54c0 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -1,6 +1,8 @@ package com.dropit.product.service; import com.dropit.product.dto.request.ProductCreateRequest; +import com.dropit.product.dto.request.ProductUpdateRequest; +import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; import com.dropit.product.repository.ProductRepository; import com.dropit.user.entity.User; @@ -16,6 +18,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -102,6 +105,231 @@ void rejectNonSeller() { verify(productRepository, never()).save(any(Product.class)); } + @Test + @DisplayName("์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•œ๋‹ค") + void getProduct() { + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product product = new Product( + seller, + "Limited Hoodie", + new BigDecimal("59000.00"), + "Limited edition hoodie", + "products/limited-hoodie.webp" + ); + ReflectionTestUtils.setField(product, "id", productId); + + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + ProductResponse response = productService.getProduct(productId); + + assertEquals(productId, response.getId()); + assertEquals(1L, response.getSellerId()); + assertEquals("username", response.getSellerName()); + assertEquals("Limited Hoodie", response.getName()); + assertEquals(new BigDecimal("59000.00"), response.getPrice()); + assertEquals("Limited edition hoodie", response.getDescription()); + assertEquals("products/limited-hoodie.webp", response.getImageUrl()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์€ ์กฐํšŒํ•  ์ˆ˜ ์—†๋‹ค") + void rejectMissingProduct() { + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + assertThrows( + IllegalArgumentException.class, + () -> productService.getProduct(productId) + ); + } + + @Test + @DisplayName("์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") + void getProducts() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product firstProduct = new Product( + seller, + "First Product", + new BigDecimal("10000.00"), + "First description", + null + ); + Product secondProduct = new Product( + seller, + "Second Product", + new BigDecimal("20000.00"), + "Second description", + null + ); + ReflectionTestUtils.setField(firstProduct, "id", 1L); + ReflectionTestUtils.setField(secondProduct, "id", 2L); + + when(productRepository.findAll()).thenReturn(List.of(firstProduct, secondProduct)); + + List response = productService.getProducts(); + + assertEquals(2, response.size()); + assertEquals("First Product", response.get(0).getName()); + assertEquals("Second Product", response.get(1).getName()); + verify(productRepository).findAll(); + } + + @Test + @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๋Š” ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void updateProduct() { + Long sellerId = 1L; + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Original Product", + new BigDecimal("30000.00"), + "Original description", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + new BigDecimal("49000.00"), + "Updated description" + ); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + ProductResponse response = productService.update(sellerId, productId, request); + + assertEquals("Updated Product", product.getName()); + assertEquals(new BigDecimal("49000.00"), product.getPrice()); + assertEquals("Updated description", product.getDescription()); + assertEquals("Updated Product", response.getName()); + assertEquals(new BigDecimal("49000.00"), response.getPrice()); + verify(productRepository).findById(productId); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์€ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋‹ค") + void rejectUpdatingMissingProduct() { + Long productId = 999L; + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + new BigDecimal("49000.00"), + null + ); + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + assertThrows( + IllegalArgumentException.class, + () -> productService.update(1L, productId, request) + ); + } + + @Test + @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์ƒํ’ˆ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋‹ค") + void rejectUpdatingAnotherSellersProduct() { + Long ownerId = 1L; + Long otherSellerId = 2L; + Long productId = 100L; + User owner = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Product product = new Product( + owner, + "Original Product", + new BigDecimal("30000.00"), + "Original description", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + + ProductUpdateRequest request = new ProductUpdateRequest( + "Updated Product", + new BigDecimal("49000.00"), + "Updated description" + ); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + assertThrows( + IllegalStateException.class, + () -> productService.update(otherSellerId, productId, request) + ); + + assertEquals("Original Product", product.getName()); + assertEquals(new BigDecimal("30000.00"), product.getPrice()); + assertEquals("Original description", product.getDescription()); + } + + @Test + @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๋Š” ์ƒํ’ˆ์„ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void deleteProduct() { + Long sellerId = 1L; + Long productId = 100L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Product", + new BigDecimal("30000.00"), + null, + null + ); + ReflectionTestUtils.setField(product, "id", productId); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + productService.delete(sellerId, productId); + + verify(productRepository).delete(product); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค") + void rejectDeletingMissingProduct() { + Long productId = 999L; + when(productRepository.findById(productId)).thenReturn(Optional.empty()); + + assertThrows( + IllegalArgumentException.class, + () -> productService.delete(1L, productId) + ); + + verify(productRepository, never()).delete(any(Product.class)); + } + + @Test + @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์ƒํ’ˆ์„ ์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค") + void rejectDeletingAnotherSellersProduct() { + Long ownerId = 1L; + Long otherSellerId = 2L; + Long productId = 100L; + User owner = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(owner, "id", ownerId); + + Product product = new Product( + owner, + "Product", + new BigDecimal("30000.00"), + null, + null + ); + ReflectionTestUtils.setField(product, "id", productId); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + assertThrows( + IllegalStateException.class, + () -> productService.delete(otherSellerId, productId) + ); + + verify(productRepository, never()).delete(any(Product.class)); + } + private ProductCreateRequest createRequest() { return new ProductCreateRequest( "Product", From f0d1b6f3cab2116c233d05ca652f2a208b1f6fd6 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 13:54:15 +0900 Subject: [PATCH 13/60] =?UTF-8?q?fix=20:=20BaseEntity=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dropit/global/entity/BaseEntity.java | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/main/java/com/dropit/global/entity/BaseEntity.java b/src/main/java/com/dropit/global/entity/BaseEntity.java index a7535dc..52ff7d9 100644 --- a/src/main/java/com/dropit/global/entity/BaseEntity.java +++ b/src/main/java/com/dropit/global/entity/BaseEntity.java @@ -3,8 +3,6 @@ import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -23,16 +21,4 @@ public abstract class BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; - - @PrePersist - protected void onCreate() { - LocalDateTime now = LocalDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } } From 8339aa0d9172d2af789ed24d33dc6e9cb2b8a6cd Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 14:06:14 +0900 Subject: [PATCH 14/60] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/exception/ProductErrorCode.java | 27 ++++++ .../product/service/ProductService.java | 16 ++-- .../controller/ProductControllerTest.java | 83 ++++++++++++++++++- .../product/service/ProductServiceTest.java | 37 +++++---- 4 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/dropit/product/exception/ProductErrorCode.java diff --git a/src/main/java/com/dropit/product/exception/ProductErrorCode.java b/src/main/java/com/dropit/product/exception/ProductErrorCode.java new file mode 100644 index 0000000..ca63a44 --- /dev/null +++ b/src/main/java/com/dropit/product/exception/ProductErrorCode.java @@ -0,0 +1,27 @@ +package com.dropit.product.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum ProductErrorCode implements ErrorCode { + + SELLER_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž์ž…๋‹ˆ๋‹ค."), + SELLER_ROLE_REQUIRED(HttpStatus.FORBIDDEN, "ํŒ๋งค์ž๋งŒ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."), + PRODUCT_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "์ƒํ’ˆ ์†Œ์œ ์ž๋งŒ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index ed8f81c..7a8a2ef 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -1,9 +1,11 @@ package com.dropit.product.service; +import com.dropit.global.exception.ServiceException; import com.dropit.product.dto.request.ProductCreateRequest; import com.dropit.product.dto.request.ProductUpdateRequest; import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; import com.dropit.product.repository.ProductRepository; import com.dropit.user.entity.User; import com.dropit.user.entity.UserRole; @@ -24,10 +26,10 @@ public class ProductService { @Transactional public Long create(Long sellerId, ProductCreateRequest request) { User seller = userRepository.findById(sellerId) - .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + .orElseThrow(() -> new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); if (seller.getRole() != UserRole.SELLER) { - throw new IllegalStateException("ํŒ๋งค์ž๋งŒ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + throw new ServiceException(ProductErrorCode.SELLER_ROLE_REQUIRED); } Product product = new Product( @@ -46,7 +48,7 @@ public Long create(Long sellerId, ProductCreateRequest request) { @Transactional(readOnly = true) public ProductResponse getProduct(Long productId) { Product product = productRepository.findById(productId) - .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); return new ProductResponse(product); } @@ -67,10 +69,10 @@ public ProductResponse update( ProductUpdateRequest request ) { Product product = productRepository.findById(productId) - .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); if (!product.getSeller().getId().equals(sellerId)) { - throw new IllegalStateException("์ƒํ’ˆ ์†Œ์œ ์ž๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); } product.updateInfo( @@ -85,10 +87,10 @@ public ProductResponse update( @Transactional public void delete(Long sellerId, Long productId) { Product product = productRepository.findById(productId) - .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); if (!product.getSeller().getId().equals(sellerId)) { - throw new IllegalStateException("์ƒํ’ˆ ์†Œ์œ ์ž๋งŒ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); } productRepository.delete(product); diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index 7efc4ff..4c1151c 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -1,9 +1,12 @@ package com.dropit.product.controller; +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.exception.ServiceException; import com.dropit.product.dto.request.ProductCreateRequest; import com.dropit.product.dto.request.ProductUpdateRequest; import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; import com.dropit.product.service.ProductService; import com.dropit.user.entity.User; import com.dropit.user.entity.UserRole; @@ -44,7 +47,10 @@ class ProductControllerTest { void setUp() { productService = mock(ProductService.class); ProductController productController = new ProductController(productService); - mockMvc = MockMvcBuilders.standaloneSetup(productController).build(); + mockMvc = MockMvcBuilders + .standaloneSetup(productController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); } @Test @@ -142,6 +148,60 @@ void rejectMissingBody() throws Exception { verifyNoInteractions(productService); } + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž๋กœ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•˜๋ฉด 404 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectMissingSeller() throws Exception { + when(productService.create(eq(999L), any(ProductCreateRequest.class))) + .thenThrow(new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); + + mockMvc.perform(post("/products") + .param("sellerId", "999") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "price": 10000.00, + "description": "Description" + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("SELLER_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž์ž…๋‹ˆ๋‹ค.")); + } + + @Test + @DisplayName("ํŒ๋งค์ž ์—ญํ• ์ด ์•„๋‹ˆ๋ฉด ์ƒํ’ˆ ๋“ฑ๋ก ์‹œ 403 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectNonSeller() throws Exception { + when(productService.create(eq(1L), any(ProductCreateRequest.class))) + .thenThrow(new ServiceException(ProductErrorCode.SELLER_ROLE_REQUIRED)); + + mockMvc.perform(post("/products") + .param("sellerId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Product", + "price": 10000.00, + "description": "Description" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("SELLER_ROLE_REQUIRED")) + .andExpect(jsonPath("$.message").value("ํŒ๋งค์ž๋งŒ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์„ ์กฐํšŒํ•˜๋ฉด 404 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectMissingProduct() throws Exception { + when(productService.getProduct(999L)) + .thenThrow(new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + mockMvc.perform(get("/products/{productId}", 999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PRODUCT_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค.")); + } + @Test @DisplayName("์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•˜๋ฉด 200 ์ƒํƒœ์™€ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void getProducts() throws Exception { @@ -247,6 +307,27 @@ void rejectUpdatingWithBlankName() throws Exception { verifyNoInteractions(productService); } + @Test + @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์ˆ˜์ • ์‹œ 403 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectUpdatingAnotherSellersProduct() throws Exception { + when(productService.update(eq(2L), eq(100L), any(ProductUpdateRequest.class))) + .thenThrow(new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED)); + + mockMvc.perform(put("/products/{productId}", 100L) + .param("sellerId", "2") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Updated Product", + "price": 49000.00, + "description": "Updated description" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("PRODUCT_OWNER_REQUIRED")) + .andExpect(jsonPath("$.message").value("์ƒํ’ˆ ์†Œ์œ ์ž๋งŒ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")); + } + @Test @DisplayName("์ƒํ’ˆ์„ ์‚ญ์ œํ•˜๋ฉด 204 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void deleteProduct() throws Exception { diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index 19b54c0..86cf409 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -1,9 +1,11 @@ package com.dropit.product.service; +import com.dropit.global.exception.ServiceException; import com.dropit.product.dto.request.ProductCreateRequest; import com.dropit.product.dto.request.ProductUpdateRequest; import com.dropit.product.dto.response.ProductResponse; import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; import com.dropit.product.repository.ProductRepository; import com.dropit.user.entity.User; import com.dropit.user.entity.UserRole; @@ -11,6 +13,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.function.Executable; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -81,8 +84,8 @@ void rejectMissingUser() { ProductCreateRequest request = createRequest(); when(userRepository.findById(sellerId)).thenReturn(Optional.empty()); - assertThrows( - IllegalArgumentException.class, + assertServiceException( + ProductErrorCode.SELLER_NOT_FOUND, () -> productService.create(sellerId, request) ); @@ -97,8 +100,8 @@ void rejectNonSeller() { ProductCreateRequest request = createRequest(); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - assertThrows( - IllegalStateException.class, + assertServiceException( + ProductErrorCode.SELLER_ROLE_REQUIRED, () -> productService.create(userId, request) ); @@ -140,8 +143,8 @@ void rejectMissingProduct() { Long productId = 999L; when(productRepository.findById(productId)).thenReturn(Optional.empty()); - assertThrows( - IllegalArgumentException.class, + assertServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND, () -> productService.getProduct(productId) ); } @@ -225,8 +228,8 @@ void rejectUpdatingMissingProduct() { ); when(productRepository.findById(productId)).thenReturn(Optional.empty()); - assertThrows( - IllegalArgumentException.class, + assertServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND, () -> productService.update(1L, productId, request) ); } @@ -256,8 +259,8 @@ void rejectUpdatingAnotherSellersProduct() { ); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - assertThrows( - IllegalStateException.class, + assertServiceException( + ProductErrorCode.PRODUCT_OWNER_REQUIRED, () -> productService.update(otherSellerId, productId, request) ); @@ -295,8 +298,8 @@ void rejectDeletingMissingProduct() { Long productId = 999L; when(productRepository.findById(productId)).thenReturn(Optional.empty()); - assertThrows( - IllegalArgumentException.class, + assertServiceException( + ProductErrorCode.PRODUCT_NOT_FOUND, () -> productService.delete(1L, productId) ); @@ -322,8 +325,8 @@ void rejectDeletingAnotherSellersProduct() { ReflectionTestUtils.setField(product, "id", productId); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - assertThrows( - IllegalStateException.class, + assertServiceException( + ProductErrorCode.PRODUCT_OWNER_REQUIRED, () -> productService.delete(otherSellerId, productId) ); @@ -338,6 +341,12 @@ private ProductCreateRequest createRequest() { ); } + private void assertServiceException(ProductErrorCode errorCode, Executable executable) { + ServiceException exception = assertThrows(ServiceException.class, executable); + + assertEquals(errorCode, exception.getErrorCode()); + } + private User createUser(UserRole role) { return new User( "user@example.com", From 6760b775fd21b530698f53e8c35edf8973c474f3 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 31 Aug 2026 14:10:19 +0900 Subject: [PATCH 15/60] =?UTF-8?q?fix=20:=20BaseEntitytest=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dropit/global/entity/BaseEntityTest.java | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 src/test/java/com/dropit/global/entity/BaseEntityTest.java diff --git a/src/test/java/com/dropit/global/entity/BaseEntityTest.java b/src/test/java/com/dropit/global/entity/BaseEntityTest.java deleted file mode 100644 index ce14cb2..0000000 --- a/src/test/java/com/dropit/global/entity/BaseEntityTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.dropit.global.entity; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -class BaseEntityTest { - - @Test - @DisplayName("์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ์‹œ ์ƒ์„ฑ ์‹œ๊ฐ๊ณผ ์ˆ˜์ • ์‹œ๊ฐ์„ ํ•จ๊ป˜ ๊ธฐ๋กํ•œ๋‹ค") - void recordCreatedAndUpdatedTime() { - TestEntity entity = new TestEntity(); - - entity.onCreate(); - - assertNotNull(entity.getCreatedAt()); - assertEquals(entity.getCreatedAt(), entity.getUpdatedAt()); - } - - @Test - @DisplayName("์—”ํ‹ฐํ‹ฐ ์ˆ˜์ • ์‹œ ์ƒ์„ฑ ์‹œ๊ฐ์€ ์œ ์ง€ํ•˜๊ณ  ์ˆ˜์ • ์‹œ๊ฐ๋งŒ ๊ฐฑ์‹ ํ•œ๋‹ค") - void updateOnlyUpdatedTime() { - TestEntity entity = new TestEntity(); - entity.onCreate(); - LocalDateTime createdAt = entity.getCreatedAt(); - - entity.onUpdate(); - - assertEquals(createdAt, entity.getCreatedAt()); - assertFalse(entity.getUpdatedAt().isBefore(createdAt)); - } - - private static class TestEntity extends BaseEntity { - } -} From d74e13c1018d5d3b590a5c0c3c6a8ca3a53b3207 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Mon, 31 Aug 2026 16:40:14 +0900 Subject: [PATCH 16/60] feature(User): Changed User entity to have a unique username --- src/main/java/com/dropit/user/entity/User.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/dropit/user/entity/User.java b/src/main/java/com/dropit/user/entity/User.java index 42045f9..50537d1 100644 --- a/src/main/java/com/dropit/user/entity/User.java +++ b/src/main/java/com/dropit/user/entity/User.java @@ -22,7 +22,7 @@ public class User extends BaseEntity { @Column(nullable = false) private String password; - @Column(nullable = false, length = 100) + @Column(nullable = false, unique = true, length = 100) private String username; @Enumerated(EnumType.STRING) From cd3129f50ae807d5def4effb0b7fbaf0dbaf7044 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 16:48:26 +0900 Subject: [PATCH 17/60] =?UTF-8?q?feat(drop):=20=EB=93=9C=EB=9E=8D=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropController.java | 33 ++++++++++++++ .../drop/dto/request/DropCreateRequest.java | 32 ++++++++++++++ .../com/dropit/drop/service/DropService.java | 44 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/main/java/com/dropit/drop/controller/DropController.java create mode 100644 src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java create mode 100644 src/main/java/com/dropit/drop/service/DropService.java diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java new file mode 100644 index 0000000..1abec0b --- /dev/null +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -0,0 +1,33 @@ +package com.dropit.drop.controller; + +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.dto.response.DropStockResponse; +import com.dropit.drop.service.DropService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class DropController { + + private final DropService dropService; + + @PostMapping("/drops") + public ResponseEntity create( + @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @Valid @RequestBody DropCreateRequest request + ) { + Long dropId = dropService.save(userId, request); + + return ResponseEntity + .created(URI.create("/drops/" + dropId)) + .body(dropId); + } +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java new file mode 100644 index 0000000..c0abe2a --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java @@ -0,0 +1,32 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +import java.time.LocalDateTime; + +public record DropCreateRequest( + @NotNull(message = "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Long productId, + + @Positive(message = "์ดˆ๊ธฐ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + int initialQuantity, + + @Min(value = 0, message = "ํ• ์ธ์œจ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Max(value = 99, message = "ํ• ์ธ์œจ์€ 99 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + int discountRate, + + @NotNull(message = "๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @PositiveOrZero(message = "๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ 0๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer purchaseLimit, + + @NotNull(message = "ํŒ๋งค ์‹œ์ž‘ ์‹œ๊ฐ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + LocalDateTime openAt, + + @NotNull(message = "ํŒ๋งค ์ข…๋ฃŒ ์‹œ๊ฐ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + LocalDateTime closeAt +) { +} diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java new file mode 100644 index 0000000..07328cb --- /dev/null +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -0,0 +1,44 @@ +package com.dropit.drop.service; + +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.dto.response.DropStockResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class DropService { + + private final DropRepository dropRepository; + private final ProductRepository productRepository; + + @Transactional + public Long save(Long userId, DropCreateRequest request) { + Product product = productRepository.findById(request.productId()) + .orElseThrow(() -> new ServiceException(ProductErrorCode.PRODUCT_NOT_FOUND)); + + if (!Objects.equals(product.getSeller().getId(), userId)) { + throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); + } + + Drop drop = new Drop(product, request.initialQuantity(), request.discountRate(), request.purchaseLimit(), request.openAt(), request.closeAt()); + + Drop savedDrop = dropRepository.save(drop); + + return savedDrop.getId(); + } +} From 8d7606fd2cb224ed170be87e5289c7940497ed30 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Mon, 31 Aug 2026 16:48:35 +0900 Subject: [PATCH 18/60] feat(users): add User request and response DTOs --- .../dto/request/PasswordUpdateRequest.java | 15 +++++++++++ .../dto/request/UserRoleUpdateRequest.java | 12 +++++++++ .../user/dto/request/UserUpdateRequest.java | 13 +++++++++ .../user/dto/response/UserResponse.java | 27 +++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java create mode 100644 src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java create mode 100644 src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java create mode 100644 src/main/java/com/dropit/user/dto/response/UserResponse.java diff --git a/src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java b/src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java new file mode 100644 index 0000000..2803c5c --- /dev/null +++ b/src/main/java/com/dropit/user/dto/request/PasswordUpdateRequest.java @@ -0,0 +1,15 @@ +package com.dropit.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PasswordUpdateRequest( + + @NotBlank(message = "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String currentPassword, + + @NotBlank(message = "์ƒˆ ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(min = 8, max = 255, message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String newPassword +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java b/src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java new file mode 100644 index 0000000..1d0de4a --- /dev/null +++ b/src/main/java/com/dropit/user/dto/request/UserRoleUpdateRequest.java @@ -0,0 +1,12 @@ +package com.dropit.user.dto.request; + +import com.dropit.user.entity.UserRole; +import jakarta.validation.constraints.NotNull; + +public record UserRoleUpdateRequest( + + @NotNull(message = "์‚ฌ์šฉ์ž ์—ญํ• ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + UserRole role + +) { +} diff --git a/src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java b/src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java new file mode 100644 index 0000000..94b95fc --- /dev/null +++ b/src/main/java/com/dropit/user/dto/request/UserUpdateRequest.java @@ -0,0 +1,13 @@ +package com.dropit.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record UserUpdateRequest ( + @Email(message = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." ) + String email, + @Size(min = 1, max = 100, message = "์‚ฌ์šฉ์ž ์ด๋ฆ„์€ 1์ž ์ด์ƒ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String username +){ + +} diff --git a/src/main/java/com/dropit/user/dto/response/UserResponse.java b/src/main/java/com/dropit/user/dto/response/UserResponse.java new file mode 100644 index 0000000..0afc8fe --- /dev/null +++ b/src/main/java/com/dropit/user/dto/response/UserResponse.java @@ -0,0 +1,27 @@ +package com.dropit.user.dto.response; + +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; + +import java.time.LocalDateTime; + +public record UserResponse( + Long id, + String email, + String username, + UserRole role, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + public static UserResponse from(User user) { + return new UserResponse( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getRole(), + user.getCreatedAt(), + user.getUpdatedAt() + ); + } +} \ No newline at end of file From d9f813f703956fcb9e3bdbe85e599ca520b289e6 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Mon, 31 Aug 2026 16:48:47 +0900 Subject: [PATCH 19/60] feat(users): add User Controller --- .../user/controller/UserController.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/java/com/dropit/user/controller/UserController.java diff --git a/src/main/java/com/dropit/user/controller/UserController.java b/src/main/java/com/dropit/user/controller/UserController.java new file mode 100644 index 0000000..c569c1a --- /dev/null +++ b/src/main/java/com/dropit/user/controller/UserController.java @@ -0,0 +1,56 @@ +package com.dropit.user.controller; + +import com.dropit.user.dto.request.PasswordUpdateRequest; +import com.dropit.user.dto.request.UserUpdateRequest; +import com.dropit.user.dto.response.UserResponse; +import com.dropit.user.entity.User; +import com.dropit.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class UserController { + // Temporary endpoints, Authentication, Authorization, JWT need to be added + private final UserService userService; + + @GetMapping("/users/me") + public UserResponse getMyInfo( + @RequestHeader("X-User-Id") Long userId + ) { + User user = userService.findUser(userId); + + return UserResponse.from(user); + } + + @PatchMapping("/users/me") + public UserResponse updateMyProfile( + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody UserUpdateRequest request + ) { + User user = userService.updateProfile(userId, request); + + return UserResponse.from(user); + } + + @PatchMapping("/users/me/password") + public ResponseEntity updateMyPassword( + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody PasswordUpdateRequest request + ) { + userService.updatePassword(userId, request); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/users/me") + public ResponseEntity deleteMyAccount( + @RequestHeader("X-User-Id") Long userId + ) { + userService.deleteUser(userId); + + return ResponseEntity.noContent().build(); + } + +} From d93f37c3156fade61ff2559c8b88c73880046ccf Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Mon, 31 Aug 2026 16:48:54 +0900 Subject: [PATCH 20/60] feat(users): add User Service --- .../com/dropit/user/service/UserService.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/main/java/com/dropit/user/service/UserService.java diff --git a/src/main/java/com/dropit/user/service/UserService.java b/src/main/java/com/dropit/user/service/UserService.java new file mode 100644 index 0000000..43896e3 --- /dev/null +++ b/src/main/java/com/dropit/user/service/UserService.java @@ -0,0 +1,92 @@ +package com.dropit.user.service; + +import com.dropit.user.dto.request.PasswordUpdateRequest; +import com.dropit.user.dto.request.UserUpdateRequest; +import com.dropit.user.entity.User; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "User not found." + )); + } + + @Transactional + public User updateProfile( + Long userId, + UserUpdateRequest request + ) { + User user = findUser(userId); + + if (request.email() != null + && !Objects.equals(user.getEmail(), request.email())) { + + if (userRepository.existsByEmail(request.email())) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Email already exists." + ); + } + + user.updateEmail(request.email()); + } + + if (request.username() != null + && !Objects.equals(user.getUsername(), request.username())) { + + if (userRepository.existsByUsername(request.username())) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Username already exists." + ); + } + + user.updateUsername(request.username()); + } + + return user; + } + + @Transactional + public void updatePassword( + Long userId, + PasswordUpdateRequest request + ) { + User user = findUser(userId); + + if (!Objects.equals( + user.getPassword(), + request.currentPassword() + )) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Current password is incorrect." + ); + } + // dirty checking + user.updatePassword(request.newPassword()); + } + + @Transactional + public void deleteUser(Long userId) { + User user = findUser(userId); + + userRepository.delete(user); + } +} \ No newline at end of file From 7036a63068830be38312b6cf590a52cefe354dc7 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Mon, 31 Aug 2026 16:49:45 +0900 Subject: [PATCH 21/60] feat(users): add to UserRepository methods existsByEmail and existsByUsername --- .../java/com/dropit/user/repository/UserRepository.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dropit/user/repository/UserRepository.java b/src/main/java/com/dropit/user/repository/UserRepository.java index a9235c1..91d1c7c 100644 --- a/src/main/java/com/dropit/user/repository/UserRepository.java +++ b/src/main/java/com/dropit/user/repository/UserRepository.java @@ -4,4 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { -} + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); +} \ No newline at end of file From 910942b365985996a00fbeec561da83b814b591e Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 17:11:44 +0900 Subject: [PATCH 22/60] =?UTF-8?q?feat(drop):=20=EB=93=9C=EB=9E=8D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84,=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropController.java | 12 ++++ .../drop/dto/response/DropResponse.java | 57 +++++++++++++++++++ .../java/com/dropit/drop/entity/Drop.java | 35 +++++++----- .../dropit/drop/exception/DropErrorCode.java | 17 ++++++ .../com/dropit/drop/service/DropService.java | 15 +++++ 5 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/dropit/drop/dto/response/DropResponse.java create mode 100644 src/main/java/com/dropit/drop/exception/DropErrorCode.java diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index 1abec0b..30bd7e9 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -30,4 +30,16 @@ public ResponseEntity create( .created(URI.create("/drops/" + dropId)) .body(dropId); } + + @GetMapping("/drops") + public ResponseEntity> getAll() { + return ResponseEntity.ok(dropService.getAll()); + } + + @GetMapping("/drops/{dropId}") + public ResponseEntity getOne( + @PathVariable Long dropId + ) { + return ResponseEntity.ok(dropService.getOne(dropId)); + } } diff --git a/src/main/java/com/dropit/drop/dto/response/DropResponse.java b/src/main/java/com/dropit/drop/dto/response/DropResponse.java new file mode 100644 index 0000000..668b727 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/response/DropResponse.java @@ -0,0 +1,57 @@ +package com.dropit.drop.dto.response; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.entity.DropStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record DropResponse( + Long id, + Long sellerId, + String sellerName, + + Long productId, + String productName, + String imageUrl, + + BigDecimal productPrice, + int discountRate, + + int initialQuantity, + int remainingQuantity, + int soldQuantity, + + int purchaseLimit, + + LocalDateTime openAt, + LocalDateTime closeAt, + + DropStatus status +) { + public static DropResponse from(Drop drop) { + return new DropResponse( + drop.getId(), + drop.getProduct().getSeller().getId(), + drop.getProduct().getSeller().getUsername(), + + drop.getProduct().getId(), + drop.getProduct().getName(), + drop.getProduct().getImageUrl(), + + drop.getProduct().getPrice(), + drop.getDiscountRate(), + + drop.getInitialQuantity(), + drop.getRemainingQuantity(), + drop.getInitialQuantity() - drop.getRemainingQuantity(), + + drop.getPurchaseLimit(), + + drop.getOpenAt(), + drop.getCloseAt(), + + drop.currentStatus(LocalDateTime.now()) + ); + } +} diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index 3ef3ff2..9824ff2 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -1,6 +1,9 @@ package com.dropit.drop.entity; +import com.dropit.drop.exception.DropErrorCode; import com.dropit.global.entity.BaseEntity; +import com.dropit.global.exception.CommonErrorCode; +import com.dropit.global.exception.ServiceException; import com.dropit.product.entity.Product; import jakarta.persistence.*; import lombok.AccessLevel; @@ -8,6 +11,7 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Objects; @Getter @Entity @@ -29,12 +33,11 @@ public class Drop extends BaseEntity { @Column(name = "remaining_quantity", nullable = false) private int remainingQuantity; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private DropStatus status = DropStatus.READY; + @Column(name = "discount_rate", nullable = false) + private int discountRate; // 0์ด๋ฉด ํ• ์ธ ์—†์Œ - @Column(name = "discount_amount", nullable = false) - private int discountAmount; + @Column(name = "purchase_limit", nullable = false) + private int purchaseLimit; // 0์ด๋ฉด 1์ธ๋‹น ๊ตฌ๋งค ์ œํ•œ ์—†์Œ @Column(name = "open_at", nullable = false) private LocalDateTime openAt; @@ -42,23 +45,29 @@ public class Drop extends BaseEntity { @Column(name = "close_at", nullable = false) private LocalDateTime closeAt; - @Column(name = "purchase_limit", nullable = false) - private int purchaseLimit; - public Drop( Product product, int initialQuantity, - int discountAmount, + int discountRate, + int purchaseLimit, LocalDateTime openAt, - LocalDateTime closeAt, - int purchaseLimit + LocalDateTime closeAt ) { this.product = product; this.initialQuantity = initialQuantity; this.remainingQuantity = initialQuantity; - this.discountAmount = discountAmount; + this.discountRate = discountRate; + this.purchaseLimit = purchaseLimit; this.openAt = openAt; this.closeAt = closeAt; - this.purchaseLimit = purchaseLimit; + } + + public DropStatus currentStatus(LocalDateTime now) { + if (!now.isBefore(closeAt)) return DropStatus.CLOSED; + if (now.isBefore(openAt)) return DropStatus.READY; + if (remainingQuantity == 0) return DropStatus.SOLDOUT; + + // openAt <= now < closeAt + return DropStatus.OPEN; } } diff --git a/src/main/java/com/dropit/drop/exception/DropErrorCode.java b/src/main/java/com/dropit/drop/exception/DropErrorCode.java new file mode 100644 index 0000000..e7c66a9 --- /dev/null +++ b/src/main/java/com/dropit/drop/exception/DropErrorCode.java @@ -0,0 +1,17 @@ +package com.dropit.drop.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum DropErrorCode implements ErrorCode { + + DROP_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋“œ๋ž์ž…๋‹ˆ๋‹ค."), + + private final HttpStatus status; + private final String message; + + @Override public HttpStatus status() { return status; } + @Override public String message() { return message; } +} diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index 07328cb..5879901 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -41,4 +41,19 @@ public Long save(Long userId, DropCreateRequest request) { return savedDrop.getId(); } + + @Transactional(readOnly = true) + public List getAll() { + List drops = dropRepository.findAll(); + + return drops.stream().map(DropResponse::from).toList(); + } + + @Transactional(readOnly = true) + public DropResponse getOne(Long dropId) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + return DropResponse.from(drop); + } } From f38a76857f7c32b2727ce6ba8a691782c1e73f14 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 22:02:44 +0900 Subject: [PATCH 23/60] =?UTF-8?q?feat(drop):=20=EB=93=9C=EB=9E=8D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropController.java | 9 +++++ .../drop/dto/request/DropUpdateRequest.java | 25 ++++++++++++ .../java/com/dropit/drop/entity/Drop.java | 38 ++++++++++++++++++- .../dropit/drop/exception/DropErrorCode.java | 3 ++ .../com/dropit/drop/service/DropService.java | 17 ++++++++- 5 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index 30bd7e9..89b6639 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -42,4 +42,13 @@ public ResponseEntity getOne( ) { return ResponseEntity.ok(dropService.getOne(dropId)); } + + @PatchMapping("/drops/{dropId}") + public ResponseEntity update( + @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @PathVariable Long dropId, + @Valid @RequestBody DropUpdateRequest request + ) { + return ResponseEntity.ok(dropService.update(userId, dropId, request)); + } } diff --git a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java new file mode 100644 index 0000000..adb091b --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java @@ -0,0 +1,25 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +import java.time.LocalDateTime; + +public record DropUpdateRequest( + @Positive(message = "์ดˆ๊ธฐ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer initialQuantity, + + @Min(value = 0, message = "ํ• ์ธ์œจ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Max(value = 99, message = "ํ• ์ธ์œจ์€ 99 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer discountRate, + + @PositiveOrZero(message = "๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ 0๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + Integer purchaseLimit, + + LocalDateTime openAt, + + LocalDateTime closeAt +) { +} diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index 9824ff2..95ed5f4 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -2,7 +2,6 @@ import com.dropit.drop.exception.DropErrorCode; import com.dropit.global.entity.BaseEntity; -import com.dropit.global.exception.CommonErrorCode; import com.dropit.global.exception.ServiceException; import com.dropit.product.entity.Product; import jakarta.persistence.*; @@ -11,7 +10,6 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; -import java.util.Objects; @Getter @Entity @@ -53,6 +51,8 @@ public Drop( LocalDateTime openAt, LocalDateTime closeAt ) { + validatePeriod(openAt, closeAt); + this.product = product; this.initialQuantity = initialQuantity; this.remainingQuantity = initialQuantity; @@ -70,4 +70,38 @@ public DropStatus currentStatus(LocalDateTime now) { // openAt <= now < closeAt return DropStatus.OPEN; } + + public void update( + Integer initialQuantity, + Integer discountRate, + Integer purchaseLimit, + LocalDateTime openAt, + LocalDateTime closeAt + ) { + int updatedInitialQuantity = initialQuantity != null ? initialQuantity : this.initialQuantity; + LocalDateTime updatedOpenAt = openAt != null ? openAt : this.openAt; + LocalDateTime updatedCloseAt = closeAt != null ? closeAt : this.closeAt; + validatePeriod(updatedOpenAt, updatedCloseAt); + + if (this.remainingQuantity == this.initialQuantity) { + this.remainingQuantity = updatedInitialQuantity; + } + this.initialQuantity = updatedInitialQuantity; + this.discountRate = discountRate != null ? discountRate : this.discountRate; + this.purchaseLimit = purchaseLimit != null ? purchaseLimit : this.purchaseLimit; + this.openAt = updatedOpenAt; + this.closeAt = updatedCloseAt; + } + + private static void validatePeriod(LocalDateTime openAt, LocalDateTime closeAt) { + if (!openAt.isBefore(closeAt)) { + throw new ServiceException(DropErrorCode.INVALID_DROP_PERIOD); + } + } + + public void ensureEditable(LocalDateTime now) { + if (!now.isBefore(openAt)) { + throw new ServiceException(DropErrorCode.DROP_ALREADY_STARTED); + } + } } diff --git a/src/main/java/com/dropit/drop/exception/DropErrorCode.java b/src/main/java/com/dropit/drop/exception/DropErrorCode.java index e7c66a9..cf47c1f 100644 --- a/src/main/java/com/dropit/drop/exception/DropErrorCode.java +++ b/src/main/java/com/dropit/drop/exception/DropErrorCode.java @@ -8,6 +8,9 @@ public enum DropErrorCode implements ErrorCode { DROP_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋“œ๋ž์ž…๋‹ˆ๋‹ค."), + DROP_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "ํ•ด๋‹น ๋“œ๋ž์€ ํŒ๋งค์ž๋งŒ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + INVALID_DROP_PERIOD(HttpStatus.BAD_REQUEST, "ํŒ๋งค ์ข…๋ฃŒ ์‹œ๊ฐ์€ ์‹œ์ž‘ ์‹œ๊ฐ๋ณด๋‹ค ๋Šฆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), + DROP_ALREADY_STARTED(HttpStatus.CONFLICT, "์ด๋ฏธ ํŒ๋งค๊ฐ€ ์‹œ์ž‘๋œ ๋“œ๋ž์ž…๋‹ˆ๋‹ค."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index 5879901..0b6e31c 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -3,7 +3,6 @@ import com.dropit.drop.dto.request.DropCreateRequest; import com.dropit.drop.dto.request.DropUpdateRequest; import com.dropit.drop.dto.response.DropResponse; -import com.dropit.drop.dto.response.DropStockResponse; import com.dropit.drop.entity.Drop; import com.dropit.drop.exception.DropErrorCode; import com.dropit.drop.repository.DropRepository; @@ -56,4 +55,20 @@ public DropResponse getOne(Long dropId) { return DropResponse.from(drop); } + + @Transactional + public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + + drop.ensureEditable(LocalDateTime.now()); + + drop.update(request.initialQuantity(), request.discountRate(), request.purchaseLimit(), request.openAt(), request.closeAt()); + + return DropResponse.from(drop); + } } From 90cd39d72dab3542db008f2fabadff5bacab5ace Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 31 Aug 2026 22:07:30 +0900 Subject: [PATCH 24/60] =?UTF-8?q?feat(drop):=20=EB=93=9C=EB=9E=8D=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dropit/drop/controller/DropController.java | 11 ++++++++++- .../com/dropit/drop/service/DropService.java | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index 89b6639..d1f4f98 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -3,7 +3,6 @@ import com.dropit.drop.dto.request.DropCreateRequest; import com.dropit.drop.dto.request.DropUpdateRequest; import com.dropit.drop.dto.response.DropResponse; -import com.dropit.drop.dto.response.DropStockResponse; import com.dropit.drop.service.DropService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -51,4 +50,14 @@ public ResponseEntity update( ) { return ResponseEntity.ok(dropService.update(userId, dropId, request)); } + + @DeleteMapping("/drops/{dropId}") + public ResponseEntity delete( + @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @PathVariable Long dropId + ) { + dropService.delete(userId, dropId); + + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index 0b6e31c..b67554e 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -64,11 +64,25 @@ public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); } - + drop.ensureEditable(LocalDateTime.now()); drop.update(request.initialQuantity(), request.discountRate(), request.purchaseLimit(), request.openAt(), request.closeAt()); return DropResponse.from(drop); } + + @Transactional + public void delete(Long sellerId, Long dropId) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + + drop.ensureEditable(LocalDateTime.now()); + + dropRepository.delete(drop); + } } From a47645fb8aa899294ac3292fa04f34166803bf44 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Tue, 1 Sep 2026 10:02:40 +0900 Subject: [PATCH 25/60] fix(users): add to the User entity update methods for its fields (updateEmail, updateUsername, updatePassword, updateRole). --- src/main/java/com/dropit/user/entity/User.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/dropit/user/entity/User.java b/src/main/java/com/dropit/user/entity/User.java index 50537d1..4825094 100644 --- a/src/main/java/com/dropit/user/entity/User.java +++ b/src/main/java/com/dropit/user/entity/User.java @@ -35,4 +35,20 @@ public User(String email, String password, String username, UserRole role) { this.username = username; this.role = role; } + + public void updateEmail(String email) { + this.email = email; + } + + public void updateUsername(String username) { + this.username = username; + } + + public void updatePassword(String password) { + this.password = password; + } + + public void updateRole(UserRole role) { + this.role = role; + } } From 8e147a3281b333ef67f0a5c62fc103345d091e15 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Tue, 1 Sep 2026 10:26:06 +0900 Subject: [PATCH 26/60] feat(users): add Error codes to be utilized by UserService (USER_NOT_FOUND, EMAIL_ALREADY_EXISTS, USERNAME_ALREADY_EXISTS, INVALID_CURRENT_PASSWORD) --- .../dropit/user/exception/UserErrorCode.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/com/dropit/user/exception/UserErrorCode.java diff --git a/src/main/java/com/dropit/user/exception/UserErrorCode.java b/src/main/java/com/dropit/user/exception/UserErrorCode.java new file mode 100644 index 0000000..5f634bb --- /dev/null +++ b/src/main/java/com/dropit/user/exception/UserErrorCode.java @@ -0,0 +1,42 @@ +package com.dropit.user.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + USER_NOT_FOUND( + HttpStatus.NOT_FOUND, + "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค." + ), + + EMAIL_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค." + ), + + USERNAME_ALREADY_EXISTS( + HttpStatus.CONFLICT, + "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ์‚ฌ์šฉ์ž๋ช…์ž…๋‹ˆ๋‹ค." + ), + + INVALID_CURRENT_PASSWORD( + HttpStatus.BAD_REQUEST, + "ํ˜„์žฌ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} \ No newline at end of file From bf400c5dca00d1100572e2a86735ab880166a745 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Tue, 1 Sep 2026 10:30:40 +0900 Subject: [PATCH 27/60] feat(users): modified UserService ServiceExceptions specified in the UserErrorCode enum. --- .../com/dropit/user/service/UserService.java | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/dropit/user/service/UserService.java b/src/main/java/com/dropit/user/service/UserService.java index 43896e3..5f2059c 100644 --- a/src/main/java/com/dropit/user/service/UserService.java +++ b/src/main/java/com/dropit/user/service/UserService.java @@ -1,14 +1,14 @@ package com.dropit.user.service; +import com.dropit.global.exception.ServiceException; import com.dropit.user.dto.request.PasswordUpdateRequest; import com.dropit.user.dto.request.UserUpdateRequest; import com.dropit.user.entity.User; +import com.dropit.user.exception.UserErrorCode; import com.dropit.user.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; import java.util.Objects; @@ -21,10 +21,9 @@ public class UserService { @Transactional(readOnly = true) public User findUser(Long userId) { return userRepository.findById(userId) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, - "User not found." - )); + .orElseThrow(() -> + new ServiceException(UserErrorCode.USER_NOT_FOUND) + ); } @Transactional @@ -38,9 +37,8 @@ public User updateProfile( && !Objects.equals(user.getEmail(), request.email())) { if (userRepository.existsByEmail(request.email())) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, - "Email already exists." + throw new ServiceException( + UserErrorCode.EMAIL_ALREADY_EXISTS ); } @@ -51,9 +49,8 @@ public User updateProfile( && !Objects.equals(user.getUsername(), request.username())) { if (userRepository.existsByUsername(request.username())) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, - "Username already exists." + throw new ServiceException( + UserErrorCode.USERNAME_ALREADY_EXISTS ); } @@ -74,12 +71,11 @@ public void updatePassword( user.getPassword(), request.currentPassword() )) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Current password is incorrect." + throw new ServiceException( + UserErrorCode.INVALID_CURRENT_PASSWORD ); } - // dirty checking + user.updatePassword(request.newPassword()); } From 62672e5437d4510b453ce78a5fcc5f3f4ae620c7 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 12:18:25 +0900 Subject: [PATCH 28/60] =?UTF-8?q?refactor(drop):=20=EC=83=81=ED=92=88=20pr?= =?UTF-8?q?ice=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=93=9C=EB=9E=8D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/dto/request/DropCreateRequest.java | 7 ++++++ .../drop/dto/request/DropUpdateRequest.java | 6 +++++ .../drop/dto/response/DropResponse.java | 4 ++-- .../java/com/dropit/drop/entity/Drop.java | 8 +++++++ .../com/dropit/drop/service/DropService.java | 19 +++++++++++++-- .../dto/request/ProductCreateRequest.java | 13 +---------- .../dto/request/ProductUpdateRequest.java | 13 +---------- .../product/dto/response/ProductResponse.java | 23 ++++++++++--------- .../com/dropit/product/entity/Product.java | 11 ++------- .../product/service/ProductService.java | 2 -- 10 files changed, 56 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java index c0abe2a..caceabb 100644 --- a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java +++ b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java @@ -1,17 +1,24 @@ package com.dropit.drop.dto.request; import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Digits; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import java.math.BigDecimal; import java.time.LocalDateTime; public record DropCreateRequest( @NotNull(message = "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") Long productId, + @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + BigDecimal price, + @Positive(message = "์ดˆ๊ธฐ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") int initialQuantity, diff --git a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java index adb091b..96ab200 100644 --- a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java +++ b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java @@ -1,13 +1,19 @@ package com.dropit.drop.dto.request; import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Digits; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import java.math.BigDecimal; import java.time.LocalDateTime; public record DropUpdateRequest( + @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + BigDecimal price, + @Positive(message = "์ดˆ๊ธฐ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") Integer initialQuantity, diff --git a/src/main/java/com/dropit/drop/dto/response/DropResponse.java b/src/main/java/com/dropit/drop/dto/response/DropResponse.java index 668b727..79d4385 100644 --- a/src/main/java/com/dropit/drop/dto/response/DropResponse.java +++ b/src/main/java/com/dropit/drop/dto/response/DropResponse.java @@ -15,7 +15,7 @@ public record DropResponse( String productName, String imageUrl, - BigDecimal productPrice, + BigDecimal price, int discountRate, int initialQuantity, @@ -39,7 +39,7 @@ public static DropResponse from(Drop drop) { drop.getProduct().getName(), drop.getProduct().getImageUrl(), - drop.getProduct().getPrice(), + drop.getPrice(), drop.getDiscountRate(), drop.getInitialQuantity(), diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index 95ed5f4..da2ab4d 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -9,6 +9,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.math.BigDecimal; import java.time.LocalDateTime; @Getter @@ -25,6 +26,9 @@ public class Drop extends BaseEntity { @JoinColumn(name = "product_id", nullable = false) private Product product; + @Column(nullable = false, precision = 15, scale = 2) + private BigDecimal price; + @Column(name = "initial_quantity", nullable = false) private int initialQuantity; @@ -45,6 +49,7 @@ public class Drop extends BaseEntity { public Drop( Product product, + BigDecimal price, int initialQuantity, int discountRate, int purchaseLimit, @@ -54,6 +59,7 @@ public Drop( validatePeriod(openAt, closeAt); this.product = product; + this.price = price; this.initialQuantity = initialQuantity; this.remainingQuantity = initialQuantity; this.discountRate = discountRate; @@ -72,6 +78,7 @@ public DropStatus currentStatus(LocalDateTime now) { } public void update( + BigDecimal price, Integer initialQuantity, Integer discountRate, Integer purchaseLimit, @@ -87,6 +94,7 @@ public void update( this.remainingQuantity = updatedInitialQuantity; } this.initialQuantity = updatedInitialQuantity; + this.price = price != null ? price : this.price; this.discountRate = discountRate != null ? discountRate : this.discountRate; this.purchaseLimit = purchaseLimit != null ? purchaseLimit : this.purchaseLimit; this.openAt = updatedOpenAt; diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index b67554e..70ac777 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -34,7 +34,15 @@ public Long save(Long userId, DropCreateRequest request) { throw new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED); } - Drop drop = new Drop(product, request.initialQuantity(), request.discountRate(), request.purchaseLimit(), request.openAt(), request.closeAt()); + Drop drop = new Drop( + product, + request.price(), + request.initialQuantity(), + request.discountRate(), + request.purchaseLimit(), + request.openAt(), + request.closeAt() + ); Drop savedDrop = dropRepository.save(drop); @@ -67,7 +75,14 @@ public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request drop.ensureEditable(LocalDateTime.now()); - drop.update(request.initialQuantity(), request.discountRate(), request.purchaseLimit(), request.openAt(), request.closeAt()); + drop.update( + request.price(), + request.initialQuantity(), + request.discountRate(), + request.purchaseLimit(), + request.openAt(), + request.closeAt() + ); return DropResponse.from(drop); } diff --git a/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java b/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java index cd395fe..ef9df66 100644 --- a/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java +++ b/src/main/java/com/dropit/product/dto/request/ProductCreateRequest.java @@ -1,15 +1,10 @@ package com.dropit.product.dto.request; -import jakarta.validation.constraints.Digits; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; -import java.math.BigDecimal; - @Getter @NoArgsConstructor public class ProductCreateRequest { @@ -18,16 +13,10 @@ public class ProductCreateRequest { @Size(max = 100, message = "์ƒํ’ˆ๋ช…์€ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") private String name; - @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") - @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") - @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") - private BigDecimal price; - private String description; - public ProductCreateRequest(String name, BigDecimal price, String description) { + public ProductCreateRequest(String name, String description) { this.name = name; - this.price = price; this.description = description; } } diff --git a/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java b/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java index 66be30b..6d4f0c3 100644 --- a/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java +++ b/src/main/java/com/dropit/product/dto/request/ProductUpdateRequest.java @@ -1,15 +1,10 @@ package com.dropit.product.dto.request; -import jakarta.validation.constraints.Digits; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; -import java.math.BigDecimal; - @Getter @NoArgsConstructor public class ProductUpdateRequest { @@ -18,17 +13,11 @@ public class ProductUpdateRequest { @Size(max = 100, message = "์ƒํ’ˆ๋ช…์€ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") private String name; - @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") - @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") - @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") - private BigDecimal price; - @Size(max = 3000, message = "์ƒํ’ˆ ์„ค๋ช…์€ 3000์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") private String description; - public ProductUpdateRequest(String name, BigDecimal price, String description) { + public ProductUpdateRequest(String name, String description) { this.name = name; - this.price = price; this.description = description; } } diff --git a/src/main/java/com/dropit/product/dto/response/ProductResponse.java b/src/main/java/com/dropit/product/dto/response/ProductResponse.java index 09d4966..e949706 100644 --- a/src/main/java/com/dropit/product/dto/response/ProductResponse.java +++ b/src/main/java/com/dropit/product/dto/response/ProductResponse.java @@ -3,30 +3,31 @@ import com.dropit.product.entity.Product; import lombok.Getter; -import java.math.BigDecimal; import java.time.LocalDateTime; @Getter public class ProductResponse { - private Long id; - private Long sellerId; - private String sellerName; - private String name; - private BigDecimal price; - private String description; - private String imageUrl; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; + private final Long id; + private final Long sellerId; + private final String sellerName; + + private final String name; + private final String description; + private final String imageUrl; + + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; public ProductResponse(Product product) { this.id = product.getId(); this.sellerId = product.getSeller().getId(); this.sellerName = product.getSeller().getUsername(); + this.name = product.getName(); - this.price = product.getPrice(); this.description = product.getDescription(); this.imageUrl = product.getImageUrl(); + this.createdAt = product.getCreatedAt(); this.updatedAt = product.getUpdatedAt(); } diff --git a/src/main/java/com/dropit/product/entity/Product.java b/src/main/java/com/dropit/product/entity/Product.java index 413232a..3d3312f 100644 --- a/src/main/java/com/dropit/product/entity/Product.java +++ b/src/main/java/com/dropit/product/entity/Product.java @@ -15,8 +15,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.math.BigDecimal; - @Getter @Entity @Table(name = "products") @@ -34,26 +32,21 @@ public class Product extends BaseEntity { @Column(nullable = false, length = 100) private String name; - @Column(nullable = false, precision = 15, scale = 2) - private BigDecimal price; - @Column(columnDefinition = "text") private String description; @Column(name = "image_url", length = 2048) private String imageUrl; - public Product(User seller, String name, BigDecimal price, String description, String imageUrl) { + public Product(User seller, String name, String description, String imageUrl) { this.seller = seller; this.name = name; - this.price = price; this.description = description; this.imageUrl = imageUrl; } - public void updateInfo(String name, BigDecimal price, String description) { + public void updateInfo(String name, String description) { this.name = name; - this.price = price; this.description = description; } diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index 7a8a2ef..e783e5c 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -35,7 +35,6 @@ public Long create(Long sellerId, ProductCreateRequest request) { Product product = new Product( seller, request.getName(), - request.getPrice(), request.getDescription(), null ); @@ -77,7 +76,6 @@ public ProductResponse update( product.updateInfo( request.getName(), - request.getPrice(), request.getDescription() ); From 7d09feca3cf3bcca8c14f79db5c01c1310fa1e3b Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 12:23:45 +0900 Subject: [PATCH 29/60] =?UTF-8?q?refactor(drop):=20=EA=B0=80=EA=B2=A9?= =?UTF-8?q?=EC=9D=84=20=EC=9B=90=ED=99=94=20=EC=A0=95=EC=88=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dropit/drop/dto/request/DropCreateRequest.java | 2 +- .../java/com/dropit/drop/dto/request/DropUpdateRequest.java | 2 +- src/main/java/com/dropit/drop/entity/Drop.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java index caceabb..90d9b05 100644 --- a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java +++ b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java @@ -16,7 +16,7 @@ public record DropCreateRequest( @NotNull(message = "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") - @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + @Digits(integer = 13, fraction = 0, message = "๊ฐ€๊ฒฉ์€ ์› ๋‹จ์œ„ ์ •์ˆ˜๋กœ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") BigDecimal price, @Positive(message = "์ดˆ๊ธฐ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") diff --git a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java index 96ab200..71448e4 100644 --- a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java +++ b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java @@ -11,7 +11,7 @@ public record DropUpdateRequest( @Positive(message = "๊ฐ€๊ฒฉ์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค.") - @Digits(integer = 13, fraction = 2, message = "๊ฐ€๊ฒฉ์€ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๊นŒ์ง€ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + @Digits(integer = 13, fraction = 0, message = "๊ฐ€๊ฒฉ์€ ์› ๋‹จ์œ„ ์ •์ˆ˜๋กœ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") BigDecimal price, @Positive(message = "์ดˆ๊ธฐ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index da2ab4d..b625ec8 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -26,7 +26,7 @@ public class Drop extends BaseEntity { @JoinColumn(name = "product_id", nullable = false) private Product product; - @Column(nullable = false, precision = 15, scale = 2) + @Column(nullable = false, precision = 13, scale = 0) private BigDecimal price; @Column(name = "initial_quantity", nullable = false) From e1e4863dd472526d355a8930861a9cd689a7639a Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 12:32:22 +0900 Subject: [PATCH 30/60] =?UTF-8?q?feat(drop):=20=EB=93=9C=EB=9E=8D=20visibl?= =?UTF-8?q?e=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=EC=97=AC=EB=B6=80=20=EA=B4=80=EB=A6=AC=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropController.java | 10 ++++++++ .../request/DropVisibilityUpdateRequest.java | 9 +++++++ .../drop/dto/response/DropResponse.java | 4 ++++ .../java/com/dropit/drop/entity/Drop.java | 7 ++++++ .../com/dropit/drop/service/DropService.java | 24 ++++++++++++++++++- 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index d1f4f98..d925d73 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -2,6 +2,7 @@ import com.dropit.drop.dto.request.DropCreateRequest; import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; import com.dropit.drop.service.DropService; import jakarta.validation.Valid; @@ -51,6 +52,15 @@ public ResponseEntity update( return ResponseEntity.ok(dropService.update(userId, dropId, request)); } + @PatchMapping("/drops/{dropId}/visibility") + public ResponseEntity changeVisibility( + @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @PathVariable Long dropId, + @Valid @RequestBody DropVisibilityUpdateRequest request + ) { + return ResponseEntity.ok(dropService.changeVisibility(userId, dropId, request)); + } + @DeleteMapping("/drops/{dropId}") public ResponseEntity delete( @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ diff --git a/src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java new file mode 100644 index 0000000..bc655a0 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequest.java @@ -0,0 +1,9 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record DropVisibilityUpdateRequest( + @NotNull(message = "๊ณต๊ฐœ ์—ฌ๋ถ€๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Boolean visible +) { +} diff --git a/src/main/java/com/dropit/drop/dto/response/DropResponse.java b/src/main/java/com/dropit/drop/dto/response/DropResponse.java index 79d4385..04a9833 100644 --- a/src/main/java/com/dropit/drop/dto/response/DropResponse.java +++ b/src/main/java/com/dropit/drop/dto/response/DropResponse.java @@ -24,6 +24,8 @@ public record DropResponse( int purchaseLimit, + boolean visible, + LocalDateTime openAt, LocalDateTime closeAt, @@ -48,6 +50,8 @@ public static DropResponse from(Drop drop) { drop.getPurchaseLimit(), + drop.isVisible(), + drop.getOpenAt(), drop.getCloseAt(), diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index b625ec8..df5d03c 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -41,6 +41,9 @@ public class Drop extends BaseEntity { @Column(name = "purchase_limit", nullable = false) private int purchaseLimit; // 0์ด๋ฉด 1์ธ๋‹น ๊ตฌ๋งค ์ œํ•œ ์—†์Œ + @Column(nullable = false) + private boolean visible = false; + @Column(name = "open_at", nullable = false) private LocalDateTime openAt; @@ -112,4 +115,8 @@ public void ensureEditable(LocalDateTime now) { throw new ServiceException(DropErrorCode.DROP_ALREADY_STARTED); } } + + public void changeVisibility(boolean visible) { + this.visible = visible; + } } diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index 70ac777..4c263c0 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -2,6 +2,7 @@ import com.dropit.drop.dto.request.DropCreateRequest; import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; import com.dropit.drop.entity.Drop; import com.dropit.drop.exception.DropErrorCode; @@ -53,7 +54,10 @@ public Long save(Long userId, DropCreateRequest request) { public List getAll() { List drops = dropRepository.findAll(); - return drops.stream().map(DropResponse::from).toList(); + return drops.stream() + .filter(Drop::isVisible) + .map(DropResponse::from) + .toList(); } @Transactional(readOnly = true) @@ -61,6 +65,10 @@ public DropResponse getOne(Long dropId) { Drop drop = dropRepository.findById(dropId) .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + if (!drop.isVisible()) { + throw new ServiceException(DropErrorCode.DROP_NOT_FOUND); + } + return DropResponse.from(drop); } @@ -87,6 +95,20 @@ public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request return DropResponse.from(drop); } + @Transactional + public DropResponse changeVisibility(Long sellerId, Long dropId, DropVisibilityUpdateRequest request) { + Drop drop = dropRepository.findById(dropId) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + + if (!Objects.equals(drop.getProduct().getSeller().getId(), sellerId)) { + throw new ServiceException(DropErrorCode.DROP_OWNER_REQUIRED); + } + + drop.changeVisibility(request.visible()); + + return DropResponse.from(drop); + } + @Transactional public void delete(Long sellerId, Long dropId) { Drop drop = dropRepository.findById(dropId) From 3ab22556e4c28b930064849b48c0afda4be3ba8d Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 12:45:00 +0900 Subject: [PATCH 31/60] =?UTF-8?q?test(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20price=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ProductControllerTest.java | 35 ++----------------- .../dto/request/ProductCreateRequestTest.java | 32 ----------------- .../dto/request/ProductUpdateRequestTest.java | 14 -------- .../dropit/product/entity/ProductTest.java | 8 +---- .../product/service/ProductServiceTest.java | 18 ---------- 5 files changed, 3 insertions(+), 104 deletions(-) diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index 4c1151c..1d14442 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -19,7 +19,6 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.util.ReflectionTestUtils; -import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -66,7 +65,6 @@ void create() throws Exception { .content(""" { "name": "Limited Hoodie", - "price": 59000.00, "description": "Limited edition hoodie" } """)) @@ -80,7 +78,6 @@ void create() throws Exception { ProductCreateRequest request = requestCaptor.getValue(); assertEquals("Limited Hoodie", request.getName()); - assertEquals(new BigDecimal("59000.00"), request.getPrice()); assertEquals("Limited edition hoodie", request.getDescription()); } @@ -92,7 +89,6 @@ void rejectMissingSellerId() throws Exception { .content(""" { "name": "Product", - "price": 10000.00, "description": "Description" } """)) @@ -110,25 +106,6 @@ void rejectBlankName() throws Exception { .content(""" { "name": " ", - "price": 59000.00, - "description": "Description" - } - """)) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(productService); - } - - @Test - @DisplayName("์ƒํ’ˆ ๊ฐ€๊ฒฉ์ด 0์ด๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - void rejectZeroPrice() throws Exception { - mockMvc.perform(post("/products") - .param("sellerId", "1") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "name": "Product", - "price": 0, "description": "Description" } """)) @@ -160,7 +137,6 @@ void rejectMissingSeller() throws Exception { .content(""" { "name": "Product", - "price": 10000.00, "description": "Description" } """)) @@ -181,7 +157,6 @@ void rejectNonSeller() throws Exception { .content(""" { "name": "Product", - "price": 10000.00, "description": "Description" } """)) @@ -216,7 +191,6 @@ void getProducts() throws Exception { Product product = new Product( seller, "Limited Hoodie", - new BigDecimal("59000.00"), "Limited edition hoodie", null ); @@ -231,7 +205,7 @@ void getProducts() throws Exception { .andExpect(jsonPath("$[0].sellerId").value(1L)) .andExpect(jsonPath("$[0].sellerName").value("seller")) .andExpect(jsonPath("$[0].name").value("Limited Hoodie")) - .andExpect(jsonPath("$[0].price").value(59000.00)) + .andExpect(jsonPath("$[0].price").doesNotExist()) .andExpect(jsonPath("$[0].description").value("Limited edition hoodie")); verify(productService).getProducts(); @@ -253,7 +227,6 @@ void updateProduct() throws Exception { Product product = new Product( seller, "Updated Product", - new BigDecimal("49000.00"), "Updated description", null ); @@ -268,7 +241,6 @@ void updateProduct() throws Exception { .content(""" { "name": "Updated Product", - "price": 49000.00, "description": "Updated description" } """)) @@ -276,7 +248,7 @@ void updateProduct() throws Exception { .andExpect(jsonPath("$.id").value(productId)) .andExpect(jsonPath("$.sellerId").value(sellerId)) .andExpect(jsonPath("$.name").value("Updated Product")) - .andExpect(jsonPath("$.price").value(49000.00)) + .andExpect(jsonPath("$.price").doesNotExist()) .andExpect(jsonPath("$.description").value("Updated description")); ArgumentCaptor requestCaptor = @@ -285,7 +257,6 @@ void updateProduct() throws Exception { ProductUpdateRequest request = requestCaptor.getValue(); assertEquals("Updated Product", request.getName()); - assertEquals(new BigDecimal("49000.00"), request.getPrice()); assertEquals("Updated description", request.getDescription()); } @@ -298,7 +269,6 @@ void rejectUpdatingWithBlankName() throws Exception { .content(""" { "name": " ", - "price": 49000.00, "description": "Updated description" } """)) @@ -319,7 +289,6 @@ void rejectUpdatingAnotherSellersProduct() throws Exception { .content(""" { "name": "Updated Product", - "price": 49000.00, "description": "Updated description" } """)) diff --git a/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java b/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java index 24b6eef..6d0323e 100644 --- a/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java +++ b/src/test/java/com/dropit/product/dto/request/ProductCreateRequestTest.java @@ -6,7 +6,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -21,7 +20,6 @@ class ProductCreateRequestTest { void acceptValidRequest() { ProductCreateRequest request = new ProductCreateRequest( "Limited Hoodie", - new BigDecimal("59000.00"), "Limited edition hoodie" ); @@ -33,7 +31,6 @@ void acceptValidRequest() { void rejectBlankName() { ProductCreateRequest request = new ProductCreateRequest( " ", - new BigDecimal("59000.00"), null ); @@ -45,41 +42,12 @@ void rejectBlankName() { void rejectTooLongName() { ProductCreateRequest request = new ProductCreateRequest( "a".repeat(101), - new BigDecimal("59000.00"), null ); assertViolationCount(request, "name", 1); } - @Test - @DisplayName("๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ด๋ฉฐ 0๋ณด๋‹ค ์ปค์•ผ ํ•œ๋‹ค") - void rejectMissingOrNonPositivePrice() { - ProductCreateRequest missingPrice = new ProductCreateRequest("Product", null, null); - ProductCreateRequest zeroPrice = new ProductCreateRequest("Product", BigDecimal.ZERO, null); - - assertViolationCount(missingPrice, "price", 1); - assertViolationCount(zeroPrice, "price", 1); - } - - @Test - @DisplayName("๊ฐ€๊ฒฉ์€ DB ์ปฌ๋Ÿผ ๋ฒ”์œ„์ธ ์ •์ˆ˜ 13์ž๋ฆฌ์™€ ์†Œ์ˆ˜ 2์ž๋ฆฌ๋ฅผ ๋„˜์„ ์ˆ˜ ์—†๋‹ค") - void rejectPriceOutsideColumnRange() { - ProductCreateRequest tooManyIntegerDigits = new ProductCreateRequest( - "Product", - new BigDecimal("12345678901234.00"), - null - ); - ProductCreateRequest tooManyFractionDigits = new ProductCreateRequest( - "Product", - new BigDecimal("1000.001"), - null - ); - - assertViolationCount(tooManyIntegerDigits, "price", 1); - assertViolationCount(tooManyFractionDigits, "price", 1); - } - private void assertViolationCount( ProductCreateRequest request, String propertyName, diff --git a/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java b/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java index 6f2b6ba..9d015fa 100644 --- a/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java +++ b/src/test/java/com/dropit/product/dto/request/ProductUpdateRequestTest.java @@ -6,7 +6,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -21,7 +20,6 @@ class ProductUpdateRequestTest { void acceptValidRequest() { ProductUpdateRequest request = new ProductUpdateRequest( "Updated Product", - new BigDecimal("49000.00"), "Updated description" ); @@ -33,29 +31,17 @@ void acceptValidRequest() { void rejectBlankName() { ProductUpdateRequest request = new ProductUpdateRequest( " ", - new BigDecimal("49000.00"), null ); assertViolationCount(request, "name", 1); } - @Test - @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์‹œ ๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ด๋ฉฐ 0๋ณด๋‹ค ์ปค์•ผ ํ•œ๋‹ค") - void rejectMissingOrNonPositivePrice() { - ProductUpdateRequest missingPrice = new ProductUpdateRequest("Product", null, null); - ProductUpdateRequest zeroPrice = new ProductUpdateRequest("Product", BigDecimal.ZERO, null); - - assertViolationCount(missingPrice, "price", 1); - assertViolationCount(zeroPrice, "price", 1); - } - @Test @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์‹œ ์ƒํ’ˆ ์„ค๋ช…์€ 3000์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") void rejectTooLongDescription() { ProductUpdateRequest request = new ProductUpdateRequest( "Product", - new BigDecimal("49000.00"), "a".repeat(3001) ); diff --git a/src/test/java/com/dropit/product/entity/ProductTest.java b/src/test/java/com/dropit/product/entity/ProductTest.java index 30c3f15..09eeae2 100644 --- a/src/test/java/com/dropit/product/entity/ProductTest.java +++ b/src/test/java/com/dropit/product/entity/ProductTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.math.BigDecimal; - import static org.junit.jupiter.api.Assertions.assertEquals; class ProductTest { @@ -24,14 +22,12 @@ void createProduct() { Product product = new Product( seller, "Limited Hoodie", - new BigDecimal("59000"), "Limited edition hoodie", "products/limited-hoodie.webp" ); assertEquals(seller, product.getSeller()); assertEquals("Limited Hoodie", product.getName()); - assertEquals(new BigDecimal("59000"), product.getPrice()); assertEquals("Limited edition hoodie", product.getDescription()); assertEquals("products/limited-hoodie.webp", product.getImageUrl()); } @@ -41,10 +37,9 @@ void createProduct() { void updateInfo() { Product product = createProductWithoutImage(); - product.updateInfo("Updated Product", new BigDecimal("15000"), "Updated description"); + product.updateInfo("Updated Product", "Updated description"); assertEquals("Updated Product", product.getName()); - assertEquals(new BigDecimal("15000"), product.getPrice()); assertEquals("Updated description", product.getDescription()); } @@ -62,7 +57,6 @@ private Product createProductWithoutImage() { return new Product( seller, "Product", - new BigDecimal("10000"), "Description", null ); diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index 86cf409..8f34ab4 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -20,7 +20,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; -import java.math.BigDecimal; import java.util.List; import java.util.Optional; @@ -51,7 +50,6 @@ void createProduct() { User seller = createUser(UserRole.SELLER); ProductCreateRequest request = new ProductCreateRequest( "Limited Hoodie", - new BigDecimal("59000.00"), "Limited edition hoodie" ); @@ -72,7 +70,6 @@ void createProduct() { Product savedProduct = productCaptor.getValue(); assertEquals(seller, savedProduct.getSeller()); assertEquals("Limited Hoodie", savedProduct.getName()); - assertEquals(new BigDecimal("59000.00"), savedProduct.getPrice()); assertEquals("Limited edition hoodie", savedProduct.getDescription()); assertNull(savedProduct.getImageUrl()); } @@ -118,7 +115,6 @@ void getProduct() { Product product = new Product( seller, "Limited Hoodie", - new BigDecimal("59000.00"), "Limited edition hoodie", "products/limited-hoodie.webp" ); @@ -132,7 +128,6 @@ void getProduct() { assertEquals(1L, response.getSellerId()); assertEquals("username", response.getSellerName()); assertEquals("Limited Hoodie", response.getName()); - assertEquals(new BigDecimal("59000.00"), response.getPrice()); assertEquals("Limited edition hoodie", response.getDescription()); assertEquals("products/limited-hoodie.webp", response.getImageUrl()); } @@ -158,14 +153,12 @@ void getProducts() { Product firstProduct = new Product( seller, "First Product", - new BigDecimal("10000.00"), "First description", null ); Product secondProduct = new Product( seller, "Second Product", - new BigDecimal("20000.00"), "Second description", null ); @@ -193,7 +186,6 @@ void updateProduct() { Product product = new Product( seller, "Original Product", - new BigDecimal("30000.00"), "Original description", null ); @@ -201,7 +193,6 @@ void updateProduct() { ProductUpdateRequest request = new ProductUpdateRequest( "Updated Product", - new BigDecimal("49000.00"), "Updated description" ); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); @@ -209,10 +200,8 @@ void updateProduct() { ProductResponse response = productService.update(sellerId, productId, request); assertEquals("Updated Product", product.getName()); - assertEquals(new BigDecimal("49000.00"), product.getPrice()); assertEquals("Updated description", product.getDescription()); assertEquals("Updated Product", response.getName()); - assertEquals(new BigDecimal("49000.00"), response.getPrice()); verify(productRepository).findById(productId); verify(productRepository, never()).save(any(Product.class)); } @@ -223,7 +212,6 @@ void rejectUpdatingMissingProduct() { Long productId = 999L; ProductUpdateRequest request = new ProductUpdateRequest( "Updated Product", - new BigDecimal("49000.00"), null ); when(productRepository.findById(productId)).thenReturn(Optional.empty()); @@ -246,7 +234,6 @@ void rejectUpdatingAnotherSellersProduct() { Product product = new Product( owner, "Original Product", - new BigDecimal("30000.00"), "Original description", null ); @@ -254,7 +241,6 @@ void rejectUpdatingAnotherSellersProduct() { ProductUpdateRequest request = new ProductUpdateRequest( "Updated Product", - new BigDecimal("49000.00"), "Updated description" ); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); @@ -265,7 +251,6 @@ void rejectUpdatingAnotherSellersProduct() { ); assertEquals("Original Product", product.getName()); - assertEquals(new BigDecimal("30000.00"), product.getPrice()); assertEquals("Original description", product.getDescription()); } @@ -280,7 +265,6 @@ void deleteProduct() { Product product = new Product( seller, "Product", - new BigDecimal("30000.00"), null, null ); @@ -318,7 +302,6 @@ void rejectDeletingAnotherSellersProduct() { Product product = new Product( owner, "Product", - new BigDecimal("30000.00"), null, null ); @@ -336,7 +319,6 @@ void rejectDeletingAnotherSellersProduct() { private ProductCreateRequest createRequest() { return new ProductCreateRequest( "Product", - new BigDecimal("10000.00"), "Description" ); } From fd28dcf0f58aef4d1680d1d62790415366bc1298 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 12:51:03 +0900 Subject: [PATCH 32/60] =?UTF-8?q?test(drop):=20=EB=93=9C=EB=9E=8D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropControllerTest.java | 190 +++++++++++ .../dto/request/DropCreateRequestTest.java | 101 ++++++ .../dto/request/DropUpdateRequestTest.java | 53 +++ .../DropVisibilityUpdateRequestTest.java | 38 +++ .../java/com/dropit/drop/entity/DropTest.java | 73 +++++ .../dropit/drop/service/DropServiceTest.java | 307 ++++++++++++++++++ 6 files changed, 762 insertions(+) create mode 100644 src/test/java/com/dropit/drop/controller/DropControllerTest.java create mode 100644 src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java create mode 100644 src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java create mode 100644 src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java create mode 100644 src/test/java/com/dropit/drop/entity/DropTest.java create mode 100644 src/test/java/com/dropit/drop/service/DropServiceTest.java diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java new file mode 100644 index 0000000..8d130cf --- /dev/null +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -0,0 +1,190 @@ +package com.dropit.drop.controller; + +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.DropStatus; +import com.dropit.drop.service.DropService; +import com.dropit.global.exception.GlobalExceptionHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class DropControllerTest { + + @Mock + private DropService dropService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders + .standaloneSetup(new DropController(dropService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setValidator(validator) + .build(); + } + + @Test + @DisplayName("์›ํ™” ๋‹จ์œ„ ๊ฐ€๊ฒฉ์œผ๋กœ ๋“œ๋ž ์ƒ์„ฑ ์š”์ฒญ ์‹œ 201 ์ƒํƒœ์™€ ๋“œ๋ž ID๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void createDrop() throws Exception { + when(dropService.save(eq(1L), any())).thenReturn(100L); + + mockMvc.perform(post("/drops") + .queryParam("userId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "productId": 10, + "price": 59000, + "initialQuantity": 100, + "discountRate": 20, + "purchaseLimit": 2, + "openAt": "2026-09-01T12:00:00", + "closeAt": "2026-09-02T12:00:00" + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/drops/100")) + .andExpect(jsonPath("$").value(100)); + } + + @Test + @DisplayName("์œ ํšจํ•˜์ง€ ์•Š์€ ๋“œ๋ž ์ƒ์„ฑ ์š”์ฒญ์€ 400 ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectInvalidCreateRequest() throws Exception { + mockMvc.perform(post("/drops") + .queryParam("userId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "productId": 10, + "price": 0, + "initialQuantity": 0, + "discountRate": 100, + "openAt": "2026-09-01T12:00:00", + "closeAt": "2026-09-02T12:00:00" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT")) + .andExpect(jsonPath("$.errors.initialQuantity").exists()) + .andExpect(jsonPath("$.errors.price").exists()) + .andExpect(jsonPath("$.errors.discountRate").exists()) + .andExpect(jsonPath("$.errors.purchaseLimit").exists()); + } + + @Test + @DisplayName("๋“œ๋ž ๋ชฉ๋ก๊ณผ ์ƒ์„ธ ์กฐํšŒ ์š”์ฒญ ์‹œ 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getAllAndOneDrop() throws Exception { + DropResponse response = response(); + when(dropService.getAll()).thenReturn(List.of(response)); + when(dropService.getOne(100L)).thenReturn(response); + + mockMvc.perform(get("/drops")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(100)); + mockMvc.perform(get("/drops/100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.productId").value(1)); + } + + @Test + @DisplayName("๋“œ๋ž ์ˆ˜์ • ์š”์ฒญ ์‹œ 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void updateDrop() throws Exception { + when(dropService.update(eq(1L), eq(100L), any())).thenReturn(response()); + + mockMvc.perform(patch("/drops/100") + .queryParam("userId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "price": 49000, + "initialQuantity": 100, + "discountRate": 30, + "purchaseLimit": 2, + "openAt": "2026-09-01T12:00:00", + "closeAt": "2026-09-02T12:00:00" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100)); + } + + @Test + @DisplayName("๋“œ๋ž ๊ณต๊ฐœ ์—ฌ๋ถ€ ๋ณ€๊ฒฝ ์š”์ฒญ ์‹œ 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void changeVisibility() throws Exception { + when(dropService.changeVisibility(eq(1L), eq(100L), any())).thenReturn(response()); + + mockMvc.perform(patch("/drops/100/visibility") + .queryParam("userId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "visible": true + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.visible").value(true)); + } + + @Test + @DisplayName("๋“œ๋ž ์‚ญ์ œ ์š”์ฒญ ์‹œ 204 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void deleteDrop() throws Exception { + mockMvc.perform(delete("/drops/100").queryParam("userId", "1")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("๊ณต๊ฐœ ์—ฌ๋ถ€๊ฐ€ ์—†๋Š” ๋ณ€๊ฒฝ ์š”์ฒญ์€ 400 ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectVisibilityRequestWithoutVisible() throws Exception { + mockMvc.perform(patch("/drops/100/visibility") + .queryParam("userId", "1") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT")) + .andExpect(jsonPath("$.errors.visible").exists()); + } + + private DropResponse response() { + LocalDateTime openAt = LocalDateTime.of(2026, 9, 1, 12, 0); + return new DropResponse( + 100L, + 10L, + "seller", + 1L, + "Limited Hoodie", + null, + new BigDecimal("59000"), + 20, + 100, + 100, + 0, + 2, + true, + openAt, + openAt.plusDays(1), + DropStatus.READY + ); + } +} diff --git a/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java new file mode 100644 index 0000000..1c33aaa --- /dev/null +++ b/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java @@ -0,0 +1,101 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropCreateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ์›ํ™” ๋‹จ์œ„ ๋“œ๋ž ์ƒ์„ฑ ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptValidRequest() { + DropCreateRequest request = createRequest(10, 20, 2); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์ด 0์ธ ์ƒ์„ฑ ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptZeroPurchaseLimitAsUnlimited() { + DropCreateRequest request = createRequest(10, 20, 0); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("์ž˜๋ชป๋œ ์ˆ˜๋Ÿ‰, ํ• ์ธ์œจ, ๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") + void rejectInvalidQuantityDiscountRateAndPurchaseLimit() { + DropCreateRequest request = createRequest(0, 100, -1); + + Set> violations = validator.validate(request); + + assertEquals(1, count(violations, "initialQuantity")); + assertEquals(1, count(violations, "discountRate")); + assertEquals(1, count(violations, "purchaseLimit")); + } + + @Test + @DisplayName("ํ•„์ˆ˜ ์ƒํ’ˆ, ๊ฐ€๊ฒฉ, ํŒ๋งค ๊ธฐ๊ฐ„ ์ •๋ณด๊ฐ€ ์—†์œผ๋ฉด ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") + void rejectMissingProductAndPeriod() { + DropCreateRequest request = new DropCreateRequest(null, null, 10, 20, null, null, null); + + Set> violations = validator.validate(request); + + assertEquals(1, count(violations, "productId")); + assertEquals(1, count(violations, "price")); + assertEquals(1, count(violations, "purchaseLimit")); + assertEquals(1, count(violations, "openAt")); + assertEquals(1, count(violations, "closeAt")); + } + + @Test + @DisplayName("์›ํ™” ๊ฐ€๊ฒฉ์— ์†Œ์ˆ˜์ ์ด ํฌํ•จ๋˜๋ฉด ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") + void rejectPriceWithFractionalWon() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + DropCreateRequest request = new DropCreateRequest( + 1L, + new BigDecimal("59000.50"), + 10, + 20, + 2, + openAt, + openAt.plusDays(1) + ); + + assertViolationCount(request, "price", 1); + } + + private DropCreateRequest createRequest(int quantity, int discountRate, Integer purchaseLimit) { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + return new DropCreateRequest( + 1L, + new BigDecimal("59000"), + quantity, + discountRate, + purchaseLimit, + openAt, + openAt.plusDays(1) + ); + } + + private long count(Set> violations, String property) { + return violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(property)) + .count(); + } + + private void assertViolationCount(DropCreateRequest request, String property, long expectedCount) { + assertEquals(expectedCount, count(validator.validate(request), property)); + } +} diff --git a/src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java new file mode 100644 index 0000000..a27598e --- /dev/null +++ b/src/test/java/com/dropit/drop/dto/request/DropUpdateRequestTest.java @@ -0,0 +1,53 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("์›ํ™” ๋‹จ์œ„ ๊ฐ€๊ฒฉ์„ ํฌํ•จํ•œ ๋“œ๋ž ์ˆ˜์ • ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptValidPrice() { + DropUpdateRequest request = new DropUpdateRequest( + new BigDecimal("49000"), + null, + null, + null, + null, + null + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("๋“œ๋ž ์ˆ˜์ • ๊ฐ€๊ฒฉ์— ์†Œ์ˆ˜์ ์ด ํฌํ•จ๋˜๋ฉด ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") + void rejectPriceWithFractionalWon() { + DropUpdateRequest request = new DropUpdateRequest( + new BigDecimal("49000.50"), + null, + null, + null, + null, + null + ); + + Set> violations = validator.validate(request); + + long priceViolationCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals("price")) + .count(); + assertEquals(1, priceViolationCount); + } +} diff --git a/src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java new file mode 100644 index 0000000..df28695 --- /dev/null +++ b/src/test/java/com/dropit/drop/dto/request/DropVisibilityUpdateRequestTest.java @@ -0,0 +1,38 @@ +package com.dropit.drop.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropVisibilityUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("๊ณต๊ฐœ ์—ฌ๋ถ€๊ฐ€ ํฌํ•จ๋œ ๋ณ€๊ฒฝ ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptVisibility() { + DropVisibilityUpdateRequest request = new DropVisibilityUpdateRequest(true); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("๊ณต๊ฐœ ์—ฌ๋ถ€๊ฐ€ ์—†์œผ๋ฉด ๋ณ€๊ฒฝ ์š”์ฒญ์€ ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") + void rejectMissingVisibility() { + DropVisibilityUpdateRequest request = new DropVisibilityUpdateRequest(null); + + Set> violations = validator.validate(request); + + long visibleViolationCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals("visible")) + .count(); + assertEquals(1, visibleViolationCount); + } +} diff --git a/src/test/java/com/dropit/drop/entity/DropTest.java b/src/test/java/com/dropit/drop/entity/DropTest.java new file mode 100644 index 0000000..991a943 --- /dev/null +++ b/src/test/java/com/dropit/drop/entity/DropTest.java @@ -0,0 +1,73 @@ +package com.dropit.drop.entity; + +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class DropTest { + + private final Product product = mock(Product.class); + + @Test + @DisplayName("๋“œ๋ž ์ƒํƒœ๋Š” ํŒ๋งค ๊ธฐ๊ฐ„๊ณผ ์žฌ๊ณ ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐํ•œ๋‹ค") + void calculateCurrentStatusFromPeriodAndStock() { + LocalDateTime openAt = LocalDateTime.of(2026, 9, 1, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt.plusHours(1)); + + assertEquals(DropStatus.READY, drop.currentStatus(openAt.minusSeconds(1))); + assertEquals(DropStatus.OPEN, drop.currentStatus(openAt)); + assertEquals(DropStatus.CLOSED, drop.currentStatus(openAt.plusHours(1))); + } + + @Test + @DisplayName("๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด ๋ฌด์ œํ•œ์œผ๋กœ ์ €์žฅ๋œ๋‹ค") + void acceptZeroPurchaseLimitAsUnlimited() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); + + assertEquals(0, drop.getPurchaseLimit()); + } + + @Test + @DisplayName("๋“œ๋ž์€ ์ƒ์„ฑ ์‹œ ๊ธฐ๋ณธ์ ์œผ๋กœ ๋น„๊ณต๊ฐœ ์ƒํƒœ์ด๋‹ค") + void defaultVisibilityIsFalse() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); + + assertEquals(false, drop.isVisible()); + } + + @Test + @DisplayName("๋“œ๋ž ๊ณต๊ฐœ ์—ฌ๋ถ€๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void changeVisibility() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, openAt, openAt.plusDays(1)); + + drop.changeVisibility(true); + + assertEquals(true, drop.isVisible()); + } + + @Test + @DisplayName("ํŒ๋งค ์‹œ์ž‘ ์‹œ๊ฐ๊ณผ ์ข…๋ฃŒ ์‹œ๊ฐ์ด ๊ฐ™์œผ๋ฉด ๋“œ๋ž์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void rejectInvalidDropPeriodWithDropErrorCode() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt) + ); + + assertEquals(DropErrorCode.INVALID_DROP_PERIOD, exception.getErrorCode()); + } +} diff --git a/src/test/java/com/dropit/drop/service/DropServiceTest.java b/src/test/java/com/dropit/drop/service/DropServiceTest.java new file mode 100644 index 0000000..fa6d97a --- /dev/null +++ b/src/test/java/com/dropit/drop/service/DropServiceTest.java @@ -0,0 +1,307 @@ +package com.dropit.drop.service; + +import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropUpdateRequest; +import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; +import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.product.entity.Product; +import com.dropit.product.exception.ProductErrorCode; +import com.dropit.product.repository.ProductRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DropServiceTest { + + @Mock + private DropRepository dropRepository; + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private DropService dropService; + + @Test + @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๋Š” ๋“œ๋ž์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค") + void saveDropOwnedBySeller() { + Product product = saveProduct(1L, 10L); + DropCreateRequest request = saveRequest(); + when(productRepository.findById(10L)).thenReturn(Optional.of(product)); + when(dropRepository.save(any(Drop.class))).thenAnswer(invocation -> { + Drop drop = invocation.getArgument(0); + ReflectionTestUtils.setField(drop, "id", 100L); + return drop; + }); + + Long dropId = dropService.save(1L, request); + + assertEquals(100L, dropId); + ArgumentCaptor captor = ArgumentCaptor.forClass(Drop.class); + verify(dropRepository).save(captor.capture()); + Drop savedDrop = captor.getValue(); + assertEquals(product, savedDrop.getProduct()); + assertEquals(new BigDecimal("59000"), savedDrop.getPrice()); + assertEquals(10, savedDrop.getInitialQuantity()); + assertEquals(20, savedDrop.getDiscountRate()); + assertEquals(2, savedDrop.getPurchaseLimit()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์œผ๋กœ ๋“œ๋ž์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void rejectMissingProduct() { + when(productRepository.findById(10L)).thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.save(1L, saveRequest()) + ); + + assertEquals(ProductErrorCode.PRODUCT_NOT_FOUND, exception.getErrorCode()); + verify(dropRepository, never()).save(any()); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ํŒ๋งค์ž์˜ ์ƒํ’ˆ์œผ๋กœ ๋“œ๋ž์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†๋‹ค") + void rejectCreatingDropForAnotherSellersProduct() { + Product product = saveProduct(1L, 10L); + when(productRepository.findById(10L)).thenReturn(Optional.of(product)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.save(2L, saveRequest()) + ); + + assertEquals(ProductErrorCode.PRODUCT_OWNER_REQUIRED, exception.getErrorCode()); + verify(dropRepository, never()).save(any()); + } + + @Test + @DisplayName("๊ณต๊ฐœ๋œ ๋“œ๋ž ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void getOneDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + DropResponse response = dropService.getOne(100L); + + assertEquals(100L, response.id()); + assertEquals(10L, response.productId()); + } + + @Test + @DisplayName("๊ณต๊ฐœ๋œ ๋“œ๋ž ๋ชฉ๋ก์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void getAllDrops() { + Drop first = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + Drop second = saveDrop(1L, 11L, 101L, LocalDateTime.now().plusDays(2)); + when(dropRepository.findAll()).thenReturn(List.of(first, second)); + + List responses = dropService.getAll(); + + assertEquals(2, responses.size()); + assertEquals(100L, responses.get(0).id()); + assertEquals(101L, responses.get(1).id()); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ๋“œ๋ž์€ ๊ณต๊ฐœ ๋ชฉ๋ก์—์„œ ์ œ์™ธ๋œ๋‹ค") + void excludeHiddenDropsFromPublicList() { + Drop visibleDrop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + Product product = saveProduct(1L, 11L); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + ); + ReflectionTestUtils.setField(hiddenDrop, "id", 101L); + when(dropRepository.findAll()).thenReturn(List.of(visibleDrop, hiddenDrop)); + + List responses = dropService.getAll(); + + assertEquals(1, responses.size()); + assertEquals(100L, responses.get(0).id()); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ๋“œ๋ž ์ƒ์„ธ ์กฐํšŒ๋Š” ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋“œ๋ž์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค") + void rejectHiddenDropFromPublicDetail() { + Product product = saveProduct(1L, 10L); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + ); + when(dropRepository.findById(100L)).thenReturn(Optional.of(hiddenDrop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.getOne(100L) + ); + + assertEquals(DropErrorCode.DROP_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("ํŒ๋งค ์‹œ์ž‘ ์ „ ์†Œ์œ ์ž๋Š” ๋“œ๋ž ๊ฐ€๊ฒฉ๊ณผ ํŒ๋งค ์กฐ๊ฑด์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void updateOwnedDropBeforeOpen() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(2)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + DropUpdateRequest request = new DropUpdateRequest( + new BigDecimal("49000"), + 20, + 30, + 3, + LocalDateTime.now().plusDays(3), + LocalDateTime.now().plusDays(4) + ); + + DropResponse response = dropService.update(1L, 100L, request); + + assertEquals(20, response.initialQuantity()); + assertEquals(new BigDecimal("49000"), response.price()); + assertEquals(20, response.remainingQuantity()); + assertEquals(30, response.discountRate()); + assertEquals(3, response.purchaseLimit()); + verify(dropRepository, never()).save(any()); + } + + @Test + @DisplayName("๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ 0์œผ๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ๋ฌด์ œํ•œ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void updatePurchaseLimitToUnlimited() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(2)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + DropUpdateRequest request = new DropUpdateRequest( + null, + 10, + 20, + 0, + LocalDateTime.now().plusDays(2), + LocalDateTime.now().plusDays(3) + ); + + DropResponse response = dropService.update(1L, 100L, request); + + assertEquals(0, response.purchaseLimit()); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ํŒ๋งค์ž๋Š” ๋“œ๋ž์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋‹ค") + void rejectUpdatingAnotherSellersDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.update(2L, 100L, new DropUpdateRequest( + null, + 10, + 30, + null, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + )) + ); + + assertEquals(DropErrorCode.DROP_OWNER_REQUIRED, exception.getErrorCode()); + } + + @Test + @DisplayName("๋“œ๋ž ์†Œ์œ ์ž๋Š” ๊ณต๊ฐœ ์—ฌ๋ถ€๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void changeVisibilityOwnedBySeller() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + DropResponse response = dropService.changeVisibility( + 1L, + 100L, + new DropVisibilityUpdateRequest(false) + ); + + assertEquals(false, response.visible()); + } + + @Test + @DisplayName("๋‹ค๋ฅธ ํŒ๋งค์ž๋Š” ๋“œ๋ž ๊ณต๊ฐœ ์—ฌ๋ถ€๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†๋‹ค") + void rejectChangingVisibilityOfAnotherSellersDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.changeVisibility(2L, 100L, new DropVisibilityUpdateRequest(false)) + ); + + assertEquals(DropErrorCode.DROP_OWNER_REQUIRED, exception.getErrorCode()); + assertEquals(true, drop.isVisible()); + } + + @Test + @DisplayName("ํŒ๋งค๊ฐ€ ์‹œ์ž‘๋œ ๋“œ๋ž์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†๋‹ค") + void rejectDeletingStartedDrop() { + Drop drop = saveDrop(1L, 10L, 100L, LocalDateTime.now().minusMinutes(1)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> dropService.delete(1L, 100L) + ); + + assertEquals(DropErrorCode.DROP_ALREADY_STARTED, exception.getErrorCode()); + verify(dropRepository, never()).delete(any()); + } + + private DropCreateRequest saveRequest() { + LocalDateTime openAt = LocalDateTime.now().plusDays(1); + return new DropCreateRequest(10L, new BigDecimal("59000"), 10, 20, 2, openAt, openAt.plusDays(1)); + } + + private Drop saveDrop(Long sellerId, Long productId, Long dropId, LocalDateTime openAt) { + Product product = saveProduct(sellerId, productId); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 2, openAt, openAt.plusDays(1)); + drop.changeVisibility(true); + ReflectionTestUtils.setField(drop, "id", dropId); + return drop; + } + + private Product saveProduct(Long sellerId, Long productId) { + User seller = new User("seller@example.com", "password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + Product product = new Product( + seller, + "Limited Hoodie", + "Limited edition hoodie", + null + ); + ReflectionTestUtils.setField(product, "id", productId); + return product; + } +} From 8b9bfbd1909165bcd48b023acac94e25db02a60c Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 13:16:52 +0900 Subject: [PATCH 33/60] =?UTF-8?q?fix(drop):=20=ED=95=A0=EC=9D=B8=EC=9C=A8?= =?UTF-8?q?=20100=EA=B9=8C=EC=A7=80=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dropit/drop/dto/request/DropCreateRequest.java | 9 ++------- .../com/dropit/drop/dto/request/DropUpdateRequest.java | 8 ++------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java index 90d9b05..127df32 100644 --- a/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java +++ b/src/main/java/com/dropit/drop/dto/request/DropCreateRequest.java @@ -1,11 +1,6 @@ package com.dropit.drop.dto.request; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Digits; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -23,7 +18,7 @@ public record DropCreateRequest( int initialQuantity, @Min(value = 0, message = "ํ• ์ธ์œจ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") - @Max(value = 99, message = "ํ• ์ธ์œจ์€ 99 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Max(value = 100, message = "ํ• ์ธ์œจ์€ 100 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") int discountRate, @NotNull(message = "๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") diff --git a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java index 71448e4..ad56931 100644 --- a/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java +++ b/src/main/java/com/dropit/drop/dto/request/DropUpdateRequest.java @@ -1,10 +1,6 @@ package com.dropit.drop.dto.request; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Digits; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -18,7 +14,7 @@ public record DropUpdateRequest( Integer initialQuantity, @Min(value = 0, message = "ํ• ์ธ์œจ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") - @Max(value = 99, message = "ํ• ์ธ์œจ์€ 99 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + @Max(value = 100, message = "ํ• ์ธ์œจ์€ 100 ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") Integer discountRate, @PositiveOrZero(message = "๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ 0๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") From ff34d73f720fa94d784e025f6a59390158ad6e26 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Tue, 1 Sep 2026 13:20:01 +0900 Subject: [PATCH 34/60] =?UTF-8?q?test(drop):=20=ED=95=A0=EC=9D=B8=EC=9C=A8?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dropit/drop/controller/DropControllerTest.java | 2 +- .../java/com/dropit/drop/dto/request/DropCreateRequestTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java index 8d130cf..be6fa53 100644 --- a/src/test/java/com/dropit/drop/controller/DropControllerTest.java +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -80,7 +80,7 @@ void rejectInvalidCreateRequest() throws Exception { "productId": 10, "price": 0, "initialQuantity": 0, - "discountRate": 100, + "discountRate": 101, "openAt": "2026-09-01T12:00:00", "closeAt": "2026-09-02T12:00:00" } diff --git a/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java b/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java index 1c33aaa..e1a286e 100644 --- a/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java +++ b/src/test/java/com/dropit/drop/dto/request/DropCreateRequestTest.java @@ -36,7 +36,7 @@ void acceptZeroPurchaseLimitAsUnlimited() { @Test @DisplayName("์ž˜๋ชป๋œ ์ˆ˜๋Ÿ‰, ํ• ์ธ์œจ, ๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์€ ๊ฒ€์ฆ์— ์‹คํŒจํ•œ๋‹ค") void rejectInvalidQuantityDiscountRateAndPurchaseLimit() { - DropCreateRequest request = createRequest(0, 100, -1); + DropCreateRequest request = createRequest(0, 101, -1); Set> violations = validator.validate(request); From 63222e739a5c01b35e0a58e4a61d29ae98fda6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EA=B5=AC?= Date: Wed, 2 Sep 2026 07:34:45 +0900 Subject: [PATCH 35/60] =?UTF-8?q?feat:=20SellerProfile=20CRUD=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .../controller/SellerProfileController.java | 68 +++++++++++++ .../request/SellerProfileCreateRequest.java | 18 ++++ .../request/SellerProfileUpdateRequest.java | 18 ++++ .../dto/response/SellerProfileResponse.java | 24 +++++ .../sellerprofile/entity/SellerProfile.java | 12 +++ .../exception/SellerProfileErrorCode.java | 26 +++++ .../repository/SellerProfileRepository.java | 5 + .../service/SellerProfileService.java | 96 +++++++++++++++++++ 9 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java create mode 100644 src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java create mode 100644 src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java create mode 100644 src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java create mode 100644 src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java create mode 100644 src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java diff --git a/.gitignore b/.gitignore index a7cb09d..511b728 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ out/ ### ENV ### .env .env.* -!.env.example \ No newline at end of file +!.env.example.DS_Store diff --git a/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java new file mode 100644 index 0000000..7595622 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java @@ -0,0 +1,68 @@ +package com.dropit.sellerprofile.controller; + +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.service.SellerProfileService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/seller-profiles") +public class SellerProfileController { + + private final SellerProfileService sellerProfileService; + + @PostMapping + public ResponseEntity create( + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody SellerProfileCreateRequest request + ) { + SellerProfileResponse response = sellerProfileService.create(userId, request); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/me") + public ResponseEntity getMine( + @RequestHeader("X-User-Id") Long userId + ) { + return ResponseEntity.ok(sellerProfileService.getMine(userId)); + } + + @GetMapping("/{sellerProfileId}") + public ResponseEntity getById( + @PathVariable Long sellerProfileId + ) { + return ResponseEntity.ok(sellerProfileService.getById(sellerProfileId)); + } + + @PatchMapping("/me") + public ResponseEntity update( + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody SellerProfileUpdateRequest request + ) { + return ResponseEntity.ok(sellerProfileService.update(userId, request)); + } + + @DeleteMapping("/me") + public ResponseEntity delete( + @RequestHeader("X-User-Id") Long userId + ) { + sellerProfileService.delete(userId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java new file mode 100644 index 0000000..007a532 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequest.java @@ -0,0 +1,18 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.constraints.Size; + +public record SellerProfileCreateRequest( + @Size(max = 2000, message = "ํŒ๋งค์ž ์†Œ๊ฐœ๋Š” 2000์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String description, + + @Size(max = 2048, message = "์ด๋ฏธ์ง€ URL์€ 2048์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String imageUrl, + + @Size(max = 2048, message = "์ธ์Šคํƒ€๊ทธ๋žจ URL์€ 2048์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String instagramUrl, + + @Size(max = 2048, message = "์œ ํŠœ๋ธŒ URL์€ 2048์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String youtubeUrl +) { +} diff --git a/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java new file mode 100644 index 0000000..ff3feb5 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequest.java @@ -0,0 +1,18 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.constraints.Size; + +public record SellerProfileUpdateRequest( + @Size(max = 2000, message = "ํŒ๋งค์ž ์†Œ๊ฐœ๋Š” 2000์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String description, + + @Size(max = 2048, message = "์ด๋ฏธ์ง€ URL์€ 2048์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String imageUrl, + + @Size(max = 2048, message = "์ธ์Šคํƒ€๊ทธ๋žจ URL์€ 2048์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String instagramUrl, + + @Size(max = 2048, message = "์œ ํŠœ๋ธŒ URL์€ 2048์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String youtubeUrl +) { +} diff --git a/src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java b/src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java new file mode 100644 index 0000000..23f68ff --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/dto/response/SellerProfileResponse.java @@ -0,0 +1,24 @@ +package com.dropit.sellerprofile.dto.response; + +import com.dropit.sellerprofile.entity.SellerProfile; + +public record SellerProfileResponse( + Long id, + Long userId, + String description, + String imageUrl, + String instagramUrl, + String youtubeUrl +) { + + public static SellerProfileResponse from(SellerProfile sellerProfile) { + return new SellerProfileResponse( + sellerProfile.getId(), + sellerProfile.getUser().getId(), + sellerProfile.getDescription(), + sellerProfile.getImageUrl(), + sellerProfile.getInstagramUrl(), + sellerProfile.getYoutubeUrl() + ); + } +} diff --git a/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java b/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java index 01dd780..ba0da3c 100644 --- a/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java +++ b/src/main/java/com/dropit/sellerprofile/entity/SellerProfile.java @@ -46,4 +46,16 @@ public SellerProfile( this.instagramUrl = instagramUrl; this.youtubeUrl = youtubeUrl; } + + public void update( + String description, + String imageUrl, + String instagramUrl, + String youtubeUrl + ) { + this.description = description; + this.imageUrl = imageUrl; + this.instagramUrl = instagramUrl; + this.youtubeUrl = youtubeUrl; + } } diff --git a/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java b/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java new file mode 100644 index 0000000..3289513 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java @@ -0,0 +1,26 @@ +package com.dropit.sellerprofile.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum SellerProfileErrorCode implements ErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + SELLER_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž ํ”„๋กœํ•„์ž…๋‹ˆ๋‹ค."), + SELLER_PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "์ด๋ฏธ ํŒ๋งค์ž ํ”„๋กœํ•„์ด ์กด์žฌํ•ฉ๋‹ˆ๋‹ค."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} diff --git a/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java b/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java index 828b277..815db35 100644 --- a/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java +++ b/src/main/java/com/dropit/sellerprofile/repository/SellerProfileRepository.java @@ -1,7 +1,12 @@ package com.dropit.sellerprofile.repository; import com.dropit.sellerprofile.entity.SellerProfile; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface SellerProfileRepository extends JpaRepository { + + Optional findByUser_Id(Long userId); + + boolean existsByUser_Id(Long userId); } diff --git a/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java new file mode 100644 index 0000000..0d48e65 --- /dev/null +++ b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java @@ -0,0 +1,96 @@ +package com.dropit.sellerprofile.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.entity.SellerProfile; +import com.dropit.sellerprofile.exception.SellerProfileErrorCode; +import com.dropit.sellerprofile.repository.SellerProfileRepository; +import com.dropit.user.entity.User; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SellerProfileService { + + private final SellerProfileRepository sellerProfileRepository; + private final UserRepository userRepository; + + @Transactional + public SellerProfileResponse create(Long userId, SellerProfileCreateRequest request) { + User user = findUser(userId); + + if (sellerProfileRepository.existsByUser_Id(userId)) { + throw new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS + ); + } + + SellerProfile sellerProfile = new SellerProfile( + user, + request.description(), + request.imageUrl(), + request.instagramUrl(), + request.youtubeUrl() + ); + + return SellerProfileResponse.from( + sellerProfileRepository.save(sellerProfile) + ); + } + + public SellerProfileResponse getMine(Long userId) { + return SellerProfileResponse.from(findByUserId(userId)); + } + + public SellerProfileResponse getById(Long sellerProfileId) { + SellerProfile sellerProfile = sellerProfileRepository.findById(sellerProfileId) + .orElseThrow(() -> new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_NOT_FOUND + )); + + return SellerProfileResponse.from(sellerProfile); + } + + @Transactional + public SellerProfileResponse update( + Long userId, + SellerProfileUpdateRequest request + ) { + SellerProfile sellerProfile = findByUserId(userId); + + sellerProfile.update( + request.description(), + request.imageUrl(), + request.instagramUrl(), + request.youtubeUrl() + ); + + return SellerProfileResponse.from(sellerProfile); + } + + @Transactional + public void delete(Long userId) { + SellerProfile sellerProfile = findByUserId(userId); + sellerProfileRepository.delete(sellerProfile); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ServiceException( + SellerProfileErrorCode.USER_NOT_FOUND + )); + } + + private SellerProfile findByUserId(Long userId) { + return sellerProfileRepository.findByUser_Id(userId) + .orElseThrow(() -> new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_NOT_FOUND + )); + } +} From 539cabafee6524b987785b3e66748585eb399455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EA=B5=AC?= Date: Wed, 2 Sep 2026 07:40:06 +0900 Subject: [PATCH 36/60] =?UTF-8?q?feat:=20SellerProfile=20=ED=8C=90?= =?UTF-8?q?=EB=A7=A4=EC=9E=90=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sellerprofile/exception/SellerProfileErrorCode.java | 1 + .../dropit/sellerprofile/service/SellerProfileService.java | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java b/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java index 3289513..ba88f7c 100644 --- a/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java +++ b/src/main/java/com/dropit/sellerprofile/exception/SellerProfileErrorCode.java @@ -8,6 +8,7 @@ public enum SellerProfileErrorCode implements ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + SELLER_ROLE_REQUIRED(HttpStatus.FORBIDDEN, "ํŒ๋งค์ž๋งŒ ํŒ๋งค์ž ํ”„๋กœํ•„์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), SELLER_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž ํ”„๋กœํ•„์ž…๋‹ˆ๋‹ค."), SELLER_PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "์ด๋ฏธ ํŒ๋งค์ž ํ”„๋กœํ•„์ด ์กด์žฌํ•ฉ๋‹ˆ๋‹ค."); diff --git a/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java index 0d48e65..a789b81 100644 --- a/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java +++ b/src/main/java/com/dropit/sellerprofile/service/SellerProfileService.java @@ -8,6 +8,7 @@ import com.dropit.sellerprofile.exception.SellerProfileErrorCode; import com.dropit.sellerprofile.repository.SellerProfileRepository; import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; import com.dropit.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -25,6 +26,12 @@ public class SellerProfileService { public SellerProfileResponse create(Long userId, SellerProfileCreateRequest request) { User user = findUser(userId); + if (user.getRole() != UserRole.SELLER) { + throw new ServiceException( + SellerProfileErrorCode.SELLER_ROLE_REQUIRED + ); + } + if (sellerProfileRepository.existsByUser_Id(userId)) { throw new ServiceException( SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS From d61facdfed360c8d374110642e9c940dbddf5e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EA=B5=AC?= Date: Wed, 2 Sep 2026 07:46:27 +0900 Subject: [PATCH 37/60] =?UTF-8?q?test:=20SellerProfile=20CRUD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SellerProfileControllerTest.java | 176 ++++++++++++++++ .../SellerProfileCreateRequestTest.java | 64 ++++++ .../SellerProfileUpdateRequestTest.java | 64 ++++++ .../entity/SellerProfileTest.java | 56 +++++ .../service/SellerProfileServiceTest.java | 191 ++++++++++++++++++ 5 files changed, 551 insertions(+) create mode 100644 src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java create mode 100644 src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java create mode 100644 src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java create mode 100644 src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java create mode 100644 src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java diff --git a/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java new file mode 100644 index 0000000..bde1c6d --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java @@ -0,0 +1,176 @@ +package com.dropit.sellerprofile.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.exception.ServiceException; +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.exception.SellerProfileErrorCode; +import com.dropit.sellerprofile.service.SellerProfileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class SellerProfileControllerTest { + + private MockMvc mockMvc; + private SellerProfileService sellerProfileService; + + @BeforeEach + void setUp() { + sellerProfileService = mock(SellerProfileService.class); + SellerProfileController controller = new SellerProfileController(sellerProfileService); + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ๋“ฑ๋กํ•˜๋ฉด 201 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void create() throws Exception { + SellerProfileResponse response = + new SellerProfileResponse(100L, 1L, "ํŒ๋งค์ž ์†Œ๊ฐœ", null, null, null); + + when(sellerProfileService.create(eq(1L), any(SellerProfileCreateRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/seller-profiles") + .header("X-User-Id", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "ํŒ๋งค์ž ์†Œ๊ฐœ" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(100L)) + .andExpect(jsonPath("$.userId").value(1L)) + .andExpect(jsonPath("$.description").value("ํŒ๋งค์ž ์†Œ๊ฐœ")); + } + + @Test + @DisplayName("์‚ฌ์šฉ์ž ID ํ—ค๋”๊ฐ€ ์—†์œผ๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectMissingUserId() throws Exception { + mockMvc.perform(post("/seller-profiles") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "ํŒ๋งค์ž ์†Œ๊ฐœ" + } + """)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(sellerProfileService); + } + + @Test + @DisplayName("์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ํ”„๋กœํ•„ ๋“ฑ๋ก ์‹œ 403 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectNonSeller() throws Exception { + when(sellerProfileService.create(eq(1L), any(SellerProfileCreateRequest.class))) + .thenThrow(new ServiceException(SellerProfileErrorCode.SELLER_ROLE_REQUIRED)); + + mockMvc.perform(post("/seller-profiles") + .header("X-User-Id", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "ํŒ๋งค์ž ์†Œ๊ฐœ" + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("SELLER_ROLE_REQUIRED")); + } + + @Test + @DisplayName("์ค‘๋ณต ํŒ๋งค์ž ํ”„๋กœํ•„ ๋“ฑ๋ก ์‹œ 409 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void rejectDuplicateProfile() throws Exception { + when(sellerProfileService.create(eq(1L), any(SellerProfileCreateRequest.class))) + .thenThrow(new ServiceException( + SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS)); + + mockMvc.perform(post("/seller-profiles") + .header("X-User-Id", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "ํŒ๋งค์ž ์†Œ๊ฐœ" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("SELLER_PROFILE_ALREADY_EXISTS")); + } + + @Test + @DisplayName("๋‚ด ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์กฐํšŒํ•˜๋ฉด 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getMine() throws Exception { + when(sellerProfileService.getMine(1L)) + .thenReturn(new SellerProfileResponse( + 100L, 1L, "ํŒ๋งค์ž ์†Œ๊ฐœ", null, null, null)); + + mockMvc.perform(get("/seller-profiles/me") + .header("X-User-Id", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100L)); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„ ID๋กœ ๊ณต๊ฐœ ํ”„๋กœํ•„์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void getById() throws Exception { + when(sellerProfileService.getById(100L)) + .thenReturn(new SellerProfileResponse( + 100L, 1L, "ํŒ๋งค์ž ์†Œ๊ฐœ", null, null, null)); + + mockMvc.perform(get("/seller-profiles/{sellerProfileId}", 100L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(100L)); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์ˆ˜์ •ํ•˜๋ฉด 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void update() throws Exception { + when(sellerProfileService.update(eq(1L), any(SellerProfileUpdateRequest.class))) + .thenReturn(new SellerProfileResponse( + 100L, 1L, "์ˆ˜์ •๋œ ์†Œ๊ฐœ", null, null, null)); + + mockMvc.perform(patch("/seller-profiles/me") + .header("X-User-Id", "1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "description": "์ˆ˜์ •๋œ ์†Œ๊ฐœ" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.description").value("์ˆ˜์ •๋œ ์†Œ๊ฐœ")); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์‚ญ์ œํ•˜๋ฉด 204 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void deleteProfile() throws Exception { + mockMvc.perform(delete("/seller-profiles/me") + .header("X-User-Id", "1")) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + + verify(sellerProfileService).delete(1L); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java new file mode 100644 index 0000000..07c9113 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileCreateRequestTest.java @@ -0,0 +1,64 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SellerProfileCreateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํŒ๋งค์ž ํ”„๋กœํ•„ ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptValidRequest() { + SellerProfileCreateRequest request = new SellerProfileCreateRequest( + "ํŒ๋งค์ž ์†Œ๊ฐœ", + "https://example.com/profile.png", + "https://instagram.com/example", + "https://youtube.com/@example" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("์†Œ๊ฐœ๋Š” 2000์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + void rejectTooLongDescription() { + SellerProfileCreateRequest request = new SellerProfileCreateRequest( + "a".repeat(2001), null, null, null + ); + + assertViolationCount(request, "description", 1); + } + + @Test + @DisplayName("URL์€ 2048์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + void rejectTooLongUrl() { + SellerProfileCreateRequest request = new SellerProfileCreateRequest( + null, "a".repeat(2049), null, null + ); + + assertViolationCount(request, "imageUrl", 1); + } + + private void assertViolationCount( + SellerProfileCreateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java new file mode 100644 index 0000000..c312925 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/dto/request/SellerProfileUpdateRequestTest.java @@ -0,0 +1,64 @@ +package com.dropit.sellerprofile.dto.request; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SellerProfileUpdateRequestTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("์˜ฌ๋ฐ”๋ฅธ ํŒ๋งค์ž ํ”„๋กœํ•„ ์š”์ฒญ์€ ๊ฒ€์ฆ์„ ํ†ต๊ณผํ•œ๋‹ค") + void acceptValidRequest() { + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + "ํŒ๋งค์ž ์†Œ๊ฐœ", + "https://example.com/profile.png", + "https://instagram.com/example", + "https://youtube.com/@example" + ); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + @DisplayName("์†Œ๊ฐœ๋Š” 2000์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + void rejectTooLongDescription() { + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + "a".repeat(2001), null, null, null + ); + + assertViolationCount(request, "description", 1); + } + + @Test + @DisplayName("URL์€ 2048์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†๋‹ค") + void rejectTooLongUrl() { + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + null, "a".repeat(2049), null, null + ); + + assertViolationCount(request, "imageUrl", 1); + } + + private void assertViolationCount( + SellerProfileUpdateRequest request, + String propertyName, + long expectedCount + ) { + Set> violations = validator.validate(request); + + long actualCount = violations.stream() + .filter(violation -> violation.getPropertyPath().toString().equals(propertyName)) + .count(); + + assertEquals(expectedCount, actualCount); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java b/src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java new file mode 100644 index 0000000..74b8027 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/entity/SellerProfileTest.java @@ -0,0 +1,56 @@ +package com.dropit.sellerprofile.entity; + +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SellerProfileTest { + + private final User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์ƒ์„ฑํ•˜๋ฉด ์ž…๋ ฅํ•œ ์ •๋ณด๊ฐ€ ์ €์žฅ๋œ๋‹ค") + void createSellerProfile() { + SellerProfile sellerProfile = new SellerProfile( + seller, + "ํŒ๋งค์ž ์†Œ๊ฐœ", + "https://example.com/profile.png", + "https://instagram.com/example", + "https://youtube.com/@example" + ); + + assertEquals(seller, sellerProfile.getUser()); + assertEquals("ํŒ๋งค์ž ์†Œ๊ฐœ", sellerProfile.getDescription()); + assertEquals("https://example.com/profile.png", sellerProfile.getImageUrl()); + assertEquals("https://instagram.com/example", sellerProfile.getInstagramUrl()); + assertEquals("https://youtube.com/@example", sellerProfile.getYoutubeUrl()); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void updateSellerProfile() { + SellerProfile sellerProfile = new SellerProfile( + seller, "๊ธฐ์กด ์†Œ๊ฐœ", null, null, null + ); + + sellerProfile.update( + "์ˆ˜์ •๋œ ์†Œ๊ฐœ", + "https://example.com/updated.png", + "https://instagram.com/updated", + "https://youtube.com/@updated" + ); + + assertEquals("์ˆ˜์ •๋œ ์†Œ๊ฐœ", sellerProfile.getDescription()); + assertEquals("https://example.com/updated.png", sellerProfile.getImageUrl()); + assertEquals("https://instagram.com/updated", sellerProfile.getInstagramUrl()); + assertEquals("https://youtube.com/@updated", sellerProfile.getYoutubeUrl()); + } +} diff --git a/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java b/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java new file mode 100644 index 0000000..4671913 --- /dev/null +++ b/src/test/java/com/dropit/sellerprofile/service/SellerProfileServiceTest.java @@ -0,0 +1,191 @@ +package com.dropit.sellerprofile.service; + +import com.dropit.global.exception.ServiceException; +import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; +import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; +import com.dropit.sellerprofile.dto.response.SellerProfileResponse; +import com.dropit.sellerprofile.entity.SellerProfile; +import com.dropit.sellerprofile.exception.SellerProfileErrorCode; +import com.dropit.sellerprofile.repository.SellerProfileRepository; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.function.Executable; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SellerProfileServiceTest { + + @Mock SellerProfileRepository sellerProfileRepository; + @Mock UserRepository userRepository; + @InjectMocks SellerProfileService sellerProfileService; + + @Test + @DisplayName("ํŒ๋งค์ž๋Š” ํŒ๋งค์ž ํ”„๋กœํ•„์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ๋‹ค") + void createSellerProfile() { + Long userId = 1L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", userId); + SellerProfileCreateRequest request = createRequest(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(seller)); + when(sellerProfileRepository.existsByUser_Id(userId)).thenReturn(false); + when(sellerProfileRepository.save(any(SellerProfile.class))).thenAnswer(invocation -> { + SellerProfile profile = invocation.getArgument(0); + ReflectionTestUtils.setField(profile, "id", 100L); + return profile; + }); + + SellerProfileResponse response = sellerProfileService.create(userId, request); + + assertEquals(100L, response.id()); + assertEquals(userId, response.userId()); + assertEquals("ํŒ๋งค์ž ์†Œ๊ฐœ", response.description()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž๋Š” ํŒ๋งค์ž ํ”„๋กœํ•„์„ ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋‹ค") + void rejectMissingUser() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertServiceException( + SellerProfileErrorCode.USER_NOT_FOUND, + () -> sellerProfileService.create(1L, createRequest()) + ); + + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ํŒ๋งค์ž ํ”„๋กœํ•„์„ ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋‹ค") + void rejectNonSeller() { + User user = createUser(UserRole.USER); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertServiceException( + SellerProfileErrorCode.SELLER_ROLE_REQUIRED, + () -> sellerProfileService.create(1L, createRequest()) + ); + + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์€ ์ค‘๋ณต ๋“ฑ๋กํ•  ์ˆ˜ ์—†๋‹ค") + void rejectDuplicateProfile() { + User seller = createUser(UserRole.SELLER); + when(userRepository.findById(1L)).thenReturn(Optional.of(seller)); + when(sellerProfileRepository.existsByUser_Id(1L)).thenReturn(true); + + assertServiceException( + SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS, + () -> sellerProfileService.create(1L, createRequest()) + ); + + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("๋‚ด ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void getMine() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = createProfile(seller); + ReflectionTestUtils.setField(profile, "id", 100L); + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.of(profile)); + + SellerProfileResponse response = sellerProfileService.getMine(1L); + + assertEquals(100L, response.id()); + assertEquals(1L, response.userId()); + } + + @Test + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž ํ”„๋กœํ•„์€ ์กฐํšŒํ•  ์ˆ˜ ์—†๋‹ค") + void rejectMissingProfile() { + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.empty()); + + assertServiceException( + SellerProfileErrorCode.SELLER_PROFILE_NOT_FOUND, + () -> sellerProfileService.getMine(1L) + ); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„ ID๋กœ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค") + void getById() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = createProfile(seller); + ReflectionTestUtils.setField(profile, "id", 100L); + when(sellerProfileRepository.findById(100L)).thenReturn(Optional.of(profile)); + + SellerProfileResponse response = sellerProfileService.getById(100L); + + assertEquals(100L, response.id()); + assertEquals(1L, response.userId()); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") + void updateSellerProfile() { + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", 1L); + SellerProfile profile = createProfile(seller); + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.of(profile)); + + SellerProfileUpdateRequest request = new SellerProfileUpdateRequest( + "์ˆ˜์ •๋œ ์†Œ๊ฐœ", "updated-image", "updated-instagram", "updated-youtube" + ); + + SellerProfileResponse response = sellerProfileService.update(1L, request); + + assertEquals("์ˆ˜์ •๋œ ์†Œ๊ฐœ", profile.getDescription()); + assertEquals("์ˆ˜์ •๋œ ์†Œ๊ฐœ", response.description()); + verify(sellerProfileRepository, never()).save(any()); + } + + @Test + @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋‹ค") + void deleteSellerProfile() { + User seller = createUser(UserRole.SELLER); + SellerProfile profile = createProfile(seller); + when(sellerProfileRepository.findByUser_Id(1L)).thenReturn(Optional.of(profile)); + + sellerProfileService.delete(1L); + + verify(sellerProfileRepository).delete(profile); + } + + private SellerProfileCreateRequest createRequest() { + return new SellerProfileCreateRequest("ํŒ๋งค์ž ์†Œ๊ฐœ", null, null, null); + } + + private SellerProfile createProfile(User user) { + return new SellerProfile(user, "ํŒ๋งค์ž ์†Œ๊ฐœ", null, null, null); + } + + private User createUser(UserRole role) { + return new User("seller@example.com", "encoded-password", "seller", role); + } + + private void assertServiceException(SellerProfileErrorCode errorCode, Executable executable) { + ServiceException exception = assertThrows(ServiceException.class, executable); + assertEquals(errorCode, exception.getErrorCode()); + } +} From 3fed4dc3021771f6a2dfa8362f6dd5b028d0a5a5 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Wed, 2 Sep 2026 10:14:06 +0900 Subject: [PATCH 38/60] =?UTF-8?q?feat(product):=20=ED=8C=90=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=EB=B3=84=20=EC=83=81=ED=92=88=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++++- .../product/controller/ProductController.java | 24 ++++++++++++++--- .../product/repository/ProductRepository.java | 4 +++ .../product/service/ProductService.java | 26 ++++++++++++++----- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index a7cb09d..6dd1501 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,8 @@ out/ ### ENV ### .env .env.* -!.env.example \ No newline at end of file +!.env.example + +### Performance test reports ### +performance/reports/*.html +/์ƒํ’ˆ-๋ถ€ํ•˜ํ…Œ์ŠคํŠธ-๊ฒฐ๊ณผ.zip diff --git a/src/main/java/com/dropit/product/controller/ProductController.java b/src/main/java/com/dropit/product/controller/ProductController.java index 494cf2f..6d13c2c 100644 --- a/src/main/java/com/dropit/product/controller/ProductController.java +++ b/src/main/java/com/dropit/product/controller/ProductController.java @@ -6,6 +6,10 @@ import com.dropit.product.service.ProductService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -17,8 +21,6 @@ import org.springframework.web.bind.annotation.RestController; import java.net.URI; -import java.util.List; - @RestController @RequiredArgsConstructor public class ProductController { @@ -45,8 +47,22 @@ public ResponseEntity getProduct(@PathVariable Long productId) } @GetMapping("/products") - public ResponseEntity> getProducts() { - List response = productService.getProducts(); + public ResponseEntity> getProducts( + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page response = productService.getProducts(pageable); + + return ResponseEntity.ok(response); + } + + @GetMapping("/sellers/{sellerId}/products") + public ResponseEntity> getProductsBySeller( + @PathVariable Long sellerId, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page response = productService.getProductsBySeller(sellerId, pageable); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/dropit/product/repository/ProductRepository.java b/src/main/java/com/dropit/product/repository/ProductRepository.java index f5430f3..5b13567 100644 --- a/src/main/java/com/dropit/product/repository/ProductRepository.java +++ b/src/main/java/com/dropit/product/repository/ProductRepository.java @@ -1,7 +1,11 @@ package com.dropit.product.repository; import com.dropit.product.entity.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ProductRepository extends JpaRepository { + + Page findAllBySellerId(Long sellerId, Pageable pageable); } diff --git a/src/main/java/com/dropit/product/service/ProductService.java b/src/main/java/com/dropit/product/service/ProductService.java index e783e5c..659ee8b 100644 --- a/src/main/java/com/dropit/product/service/ProductService.java +++ b/src/main/java/com/dropit/product/service/ProductService.java @@ -11,11 +11,11 @@ import com.dropit.user.entity.UserRole; import com.dropit.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor public class ProductService { @@ -53,12 +53,24 @@ public ProductResponse getProduct(Long productId) { } @Transactional(readOnly = true) - public List getProducts() { - List products = productRepository.findAll(); + public Page getProducts(Pageable pageable) { + Page products = productRepository.findAll(pageable); + + return products.map(product -> new ProductResponse(product)); + } + + @Transactional(readOnly = true) + public Page getProductsBySeller(Long sellerId, Pageable pageable) { + User seller = userRepository.findById(sellerId) + .orElseThrow(() -> new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); + + if (seller.getRole() != UserRole.SELLER) { + throw new ServiceException(ProductErrorCode.SELLER_NOT_FOUND); + } + + Page products = productRepository.findAllBySellerId(sellerId, pageable); - return products.stream() - .map(product -> new ProductResponse(product)) - .toList(); + return products.map(product -> new ProductResponse(product)); } @Transactional From 568a3e96ee3f27737b65209ce594f359e780cd4d Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Wed, 2 Sep 2026 11:07:55 +0900 Subject: [PATCH 39/60] =?UTF-8?q?feat(drop)=20=ED=8C=90=EB=A7=A4=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EA=B3=B5=EA=B0=9C=20=EB=93=9C=EB=A1=AD=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EB=B9=84=EA=B3=B5=EA=B0=9C=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropController.java | 38 +++++++++++++++++++ .../drop/repository/DropRepository.java | 12 ++++++ .../com/dropit/drop/service/DropService.java | 28 ++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index d925d73..24f95a9 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -7,6 +7,10 @@ import com.dropit.drop.service.DropService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -43,6 +47,40 @@ public ResponseEntity getOne( return ResponseEntity.ok(dropService.getOne(dropId)); } + @GetMapping("/creators/{sellerId}/drops") + public ResponseEntity> getPublicDropsBySeller( + @PathVariable Long sellerId, + @PageableDefault( + size = 20, + sort = "createdAt", + direction = Sort.Direction.DESC + ) Pageable pageable + ) { + Page response = dropService.getPublicDropsBySeller( + sellerId, + pageable + ); + + return ResponseEntity.ok(response); + } + + @GetMapping("/sellers/{sellerId}/drops") + public ResponseEntity> getDropsForSellerManagement( + @PathVariable Long sellerId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ID๋กœ ๊ต์ฒด + @PageableDefault( + size = 20, + sort = "createdAt", + direction = Sort.Direction.DESC + ) Pageable pageable + ) { + Page response = dropService.getDropsForSellerManagement( + sellerId, + pageable + ); + + return ResponseEntity.ok(response); + } + @PatchMapping("/drops/{dropId}") public ResponseEntity update( @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ diff --git a/src/main/java/com/dropit/drop/repository/DropRepository.java b/src/main/java/com/dropit/drop/repository/DropRepository.java index 3181873..79eb40a 100644 --- a/src/main/java/com/dropit/drop/repository/DropRepository.java +++ b/src/main/java/com/dropit/drop/repository/DropRepository.java @@ -1,7 +1,19 @@ package com.dropit.drop.repository; import com.dropit.drop.entity.Drop; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface DropRepository extends JpaRepository { + + Page findAllByProductSellerIdAndVisibleTrue( + Long sellerId, + Pageable pageable + ); + + Page findAllByProductSellerId( + Long sellerId, + Pageable pageable + ); } diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index 4c263c0..cd9668d 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -12,6 +12,8 @@ import com.dropit.product.exception.ProductErrorCode; import com.dropit.product.repository.ProductRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -72,6 +74,32 @@ public DropResponse getOne(Long dropId) { return DropResponse.from(drop); } + @Transactional(readOnly = true) + public Page getPublicDropsBySeller( + Long sellerId, + Pageable pageable + ) { + Page drops = dropRepository.findAllByProductSellerIdAndVisibleTrue( + sellerId, + pageable + ); + + return drops.map(DropResponse::from); + } + + @Transactional(readOnly = true) + public Page getDropsForSellerManagement( + Long sellerId, + Pageable pageable + ) { + Page drops = dropRepository.findAllByProductSellerId( + sellerId, + pageable + ); + + return drops.map(DropResponse::from); + } + @Transactional public DropResponse update(Long sellerId, Long dropId, DropUpdateRequest request) { Drop drop = dropRepository.findById(dropId) From cf2da5ef8db767cbb9771730d029aee1941fa13b Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Wed, 2 Sep 2026 11:55:17 +0900 Subject: [PATCH 40/60] =?UTF-8?q?feat(drop):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EB=93=9C=EB=A1=AD=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dropit/drop/controller/DropController.java | 13 ++++++++++--- .../com/dropit/drop/repository/DropRepository.java | 2 ++ .../java/com/dropit/drop/service/DropService.java | 10 +++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index 24f95a9..7de33ba 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -15,7 +15,6 @@ import org.springframework.web.bind.annotation.*; import java.net.URI; -import java.util.List; @RestController @RequiredArgsConstructor @@ -36,8 +35,16 @@ public ResponseEntity create( } @GetMapping("/drops") - public ResponseEntity> getAll() { - return ResponseEntity.ok(dropService.getAll()); + public ResponseEntity> getAll( + @PageableDefault( + size = 20, + sort = "createdAt", + direction = Sort.Direction.DESC + ) Pageable pageable + ) { + Page response = dropService.getAll(pageable); + + return ResponseEntity.ok(response); } @GetMapping("/drops/{dropId}") diff --git a/src/main/java/com/dropit/drop/repository/DropRepository.java b/src/main/java/com/dropit/drop/repository/DropRepository.java index 79eb40a..6447130 100644 --- a/src/main/java/com/dropit/drop/repository/DropRepository.java +++ b/src/main/java/com/dropit/drop/repository/DropRepository.java @@ -7,6 +7,8 @@ public interface DropRepository extends JpaRepository { + Page findAllByVisibleTrue(Pageable pageable); + Page findAllByProductSellerIdAndVisibleTrue( Long sellerId, Pageable pageable diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index cd9668d..78ab2ed 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -18,7 +18,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.List; import java.util.Objects; @Service @@ -53,13 +52,10 @@ public Long save(Long userId, DropCreateRequest request) { } @Transactional(readOnly = true) - public List getAll() { - List drops = dropRepository.findAll(); + public Page getAll(Pageable pageable) { + Page drops = dropRepository.findAllByVisibleTrue(pageable); - return drops.stream() - .filter(Drop::isVisible) - .map(DropResponse::from) - .toList(); + return drops.map(DropResponse::from); } @Transactional(readOnly = true) From 695d23c6710e1f14678b980a428370c33088b271 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Wed, 2 Sep 2026 16:32:59 +0900 Subject: [PATCH 41/60] =?UTF-8?q?feat(order):=20OrderItem=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dropit/order/entity/Order.java | 19 +--- .../com/dropit/order/entity/OrderItem.java | 71 ++++++++++++++ .../order/repository/OrderItemRepository.java | 11 +++ .../dropit/order/entity/OrderItemTest.java | 95 +++++++++++++++++++ 4 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/dropit/order/entity/OrderItem.java create mode 100644 src/main/java/com/dropit/order/repository/OrderItemRepository.java create mode 100644 src/test/java/com/dropit/order/entity/OrderItemTest.java diff --git a/src/main/java/com/dropit/order/entity/Order.java b/src/main/java/com/dropit/order/entity/Order.java index 5fae724..c6b0b7d 100644 --- a/src/main/java/com/dropit/order/entity/Order.java +++ b/src/main/java/com/dropit/order/entity/Order.java @@ -1,6 +1,5 @@ package com.dropit.order.entity; -import com.dropit.drop.entity.Drop; import com.dropit.global.entity.BaseEntity; import com.dropit.user.entity.User; import jakarta.persistence.*; @@ -24,20 +23,12 @@ public class Order extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "drop_id", nullable = false) - private Drop drop; - - @Column(nullable = false, precision = 15, scale = 2) - private BigDecimal price; - - @Column(name = "order_quantity", nullable = false) - private int orderQuantity; + // ๋ชจ๋“  ์ฃผ๋ฌธ ํ•ญ๋ชฉ์˜ ๊ธˆ์•ก์„ ํ•ฉํ•œ ์ „์ฒด ๊ฒฐ์ œ ๊ธˆ์•ก + @Column(name = "total_price", nullable = false, precision = 13, scale = 0) + private BigDecimal totalPrice; - public Order(User user, Drop drop, BigDecimal price, int orderQuantity) { + public Order(User user, BigDecimal totalPrice) { this.user = user; - this.drop = drop; - this.price = price; - this.orderQuantity = orderQuantity; + this.totalPrice = totalPrice; } } diff --git a/src/main/java/com/dropit/order/entity/OrderItem.java b/src/main/java/com/dropit/order/entity/OrderItem.java new file mode 100644 index 0000000..6458631 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/OrderItem.java @@ -0,0 +1,71 @@ +package com.dropit.order.entity; + +import com.dropit.drop.entity.Drop; +import com.dropit.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Getter +@Entity +@Table(name = "order_items") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItem extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + // ์–ด๋–ค ๋“œ๋ž ํŒ๋งค์—์„œ ๊ตฌ๋งคํ–ˆ๋Š”์ง€ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•œ ์›๋ณธ ์ฐธ์กฐ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "drop_id", nullable = false) + private Drop drop; + + // ์ฃผ๋ฌธ ๋‹น์‹œ ์ƒํ’ˆ๋ช… ์Šค๋ƒ…์ƒท + @Column(name = "product_name", nullable = false, length = 100) + private String productName; + + // ์ฃผ๋ฌธ ๋‹น์‹œ ์ •๊ฐ€ ์Šค๋ƒ…์ƒท + @Column(name = "unit_price", nullable = false, precision = 13, scale = 0) + private BigDecimal unitPrice; + + // ์ฃผ๋ฌธ ๋‹น์‹œ ์ ์šฉ๋œ ํ• ์ธ์œจ ์Šค๋ƒ…์ƒท + @Column(name = "discount_rate", nullable = false) + private int discountRate; + + // ๊ฐ™์€ ์ƒํ’ˆ+์˜ต์…˜ ์กฐํ•ฉ์œผ๋กœ ๊ตฌ๋งคํ•œ ์ˆ˜๋Ÿ‰ + @Column(nullable = false) + private int quantity; + + // ์ด ์ฃผ๋ฌธ ํ•ญ๋ชฉ์˜ ์ตœ์ข… ๊ฒฐ์ œ ๊ธˆ์•ก: unitPrice ร— (1 - discountRate ร— 0.01) ร— quantity + @Column(name = "item_total_price", nullable = false, precision = 13, scale = 0) + private BigDecimal itemTotalPrice; + + public OrderItem( + Order order, + Drop drop, + String productName, + BigDecimal unitPrice, + int discountRate, + int quantity + ) { + this.order = order; + this.drop = drop; + this.productName = productName; + this.unitPrice = unitPrice; + this.discountRate = discountRate; + this.quantity = quantity; + this.itemTotalPrice = unitPrice + .multiply(BigDecimal.ONE.subtract(BigDecimal.valueOf(discountRate).movePointLeft(2))) + .multiply(BigDecimal.valueOf(quantity)) + .setScale(0, RoundingMode.DOWN); + } +} diff --git a/src/main/java/com/dropit/order/repository/OrderItemRepository.java b/src/main/java/com/dropit/order/repository/OrderItemRepository.java new file mode 100644 index 0000000..84fd8af --- /dev/null +++ b/src/main/java/com/dropit/order/repository/OrderItemRepository.java @@ -0,0 +1,11 @@ +package com.dropit.order.repository; + +import com.dropit.order.entity.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemRepository extends JpaRepository { + + List findAllByOrder_Id(Long orderId); +} diff --git a/src/test/java/com/dropit/order/entity/OrderItemTest.java b/src/test/java/com/dropit/order/entity/OrderItemTest.java new file mode 100644 index 0000000..e0e87a4 --- /dev/null +++ b/src/test/java/com/dropit/order/entity/OrderItemTest.java @@ -0,0 +1,95 @@ +package com.dropit.order.entity; + +import com.dropit.drop.entity.Drop; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class OrderItemTest { + + @Test + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ •๊ฐ€์™€ ํ• ์ธ์œจ์„ ์Šค๋ƒ…์ƒท์œผ๋กœ ์ €์žฅํ•˜๊ณ  ํ• ์ธ ์ ์šฉ ๊ธˆ์•ก์„ ๊ณ„์‚ฐํ•œ๋‹ค") + void createOrderItemWithPriceSnapshotsAndDiscountedTotal() { + User buyer = new User("buyer@example.com", "encoded-password", "buyer", UserRole.USER); + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + Drop drop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 0, + LocalDateTime.of(2026, 9, 3, 12, 0), + LocalDateTime.of(2026, 9, 3, 13, 0) + ); + Order order = new Order(buyer, new BigDecimal("94400")); + + OrderItem orderItem = new OrderItem( + order, + drop, + product.getName(), + drop.getPrice(), + drop.getDiscountRate(), + 2 + ); + + assertAll( + () -> assertEquals(order, orderItem.getOrder()), + () -> assertEquals(drop, orderItem.getDrop()), + () -> assertEquals("Limited Hoodie", orderItem.getProductName()), + () -> assertEquals(new BigDecimal("59000"), orderItem.getUnitPrice()), + () -> assertEquals(20, orderItem.getDiscountRate()), + () -> assertEquals(2, orderItem.getQuantity()), + () -> assertEquals(new BigDecimal("94400"), orderItem.getItemTotalPrice()) + ); + } + + @Test + @DisplayName("ํ• ์ธ์œจ์ด 0์ด๋ฉด ์ •๊ฐ€์™€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๊ธˆ์•ก์„ ๊ณ„์‚ฐํ•œ๋‹ค") + void calculateItemTotalPriceWithoutDiscount() { + OrderItem orderItem = createOrderItem(new BigDecimal("59000"), 0, 2); + + assertEquals(new BigDecimal("118000"), orderItem.getItemTotalPrice()); + } + + @Test + @DisplayName("ํ• ์ธ ์ ์šฉ ๊ธˆ์•ก์— ์› ๋‹จ์œ„ ๋ฏธ๋งŒ์ด ์ƒ๊ธฐ๋ฉด ๋ฒ„๋ฆผ ์ฒ˜๋ฆฌํ•œ๋‹ค") + void roundDownFractionalWonInItemTotalPrice() { + OrderItem orderItem = createOrderItem(new BigDecimal("9999"), 10, 1); + + assertEquals(new BigDecimal("8999"), orderItem.getItemTotalPrice()); + } + + private OrderItem createOrderItem(BigDecimal price, int discountRate, int quantity) { + User buyer = new User("buyer@example.com", "encoded-password", "buyer", UserRole.USER); + User seller = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + Drop drop = new Drop( + product, + price, + 10, + discountRate, + 0, + LocalDateTime.of(2026, 9, 3, 12, 0), + LocalDateTime.of(2026, 9, 3, 13, 0) + ); + Order order = new Order(buyer, BigDecimal.ZERO); + + return new OrderItem( + order, + drop, + product.getName(), + drop.getPrice(), + drop.getDiscountRate(), + quantity + ); + } +} From 88a806d1c5c9cea81ba00cf0e197fe9009f74922 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Wed, 2 Sep 2026 17:08:39 +0900 Subject: [PATCH 42/60] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EC=9E=AC=EA=B3=A0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dropit/drop/entity/Drop.java | 15 ++++++ .../dropit/drop/exception/DropErrorCode.java | 2 + .../java/com/dropit/order/entity/Order.java | 15 ++++++ .../com/dropit/order/entity/OrderItem.java | 10 +++- .../com/dropit/order/entity/OrderStatus.java | 6 +++ .../order/exception/OrderErrorCode.java | 19 ++++++++ .../java/com/dropit/drop/entity/DropTest.java | 44 +++++++++++++++++ .../com/dropit/order/entity/OrderTest.java | 48 +++++++++++++++++++ 8 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dropit/order/entity/OrderStatus.java create mode 100644 src/main/java/com/dropit/order/exception/OrderErrorCode.java create mode 100644 src/test/java/com/dropit/order/entity/OrderTest.java diff --git a/src/main/java/com/dropit/drop/entity/Drop.java b/src/main/java/com/dropit/drop/entity/Drop.java index df5d03c..35d05cf 100644 --- a/src/main/java/com/dropit/drop/entity/Drop.java +++ b/src/main/java/com/dropit/drop/entity/Drop.java @@ -119,4 +119,19 @@ public void ensureEditable(LocalDateTime now) { public void changeVisibility(boolean visible) { this.visible = visible; } + + public void decreaseStock(int quantity, LocalDateTime now) { + if (!visible || currentStatus(now) != DropStatus.OPEN) { + throw new ServiceException(DropErrorCode.DROP_NOT_OPEN); + } + if (remainingQuantity < quantity) { + throw new ServiceException(DropErrorCode.INSUFFICIENT_STOCK); + } + + this.remainingQuantity -= quantity; + } + + public void restoreStock(int quantity) { + this.remainingQuantity += quantity; + } } diff --git a/src/main/java/com/dropit/drop/exception/DropErrorCode.java b/src/main/java/com/dropit/drop/exception/DropErrorCode.java index cf47c1f..f0f4e45 100644 --- a/src/main/java/com/dropit/drop/exception/DropErrorCode.java +++ b/src/main/java/com/dropit/drop/exception/DropErrorCode.java @@ -9,6 +9,8 @@ public enum DropErrorCode implements ErrorCode { DROP_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋“œ๋ž์ž…๋‹ˆ๋‹ค."), DROP_OWNER_REQUIRED(HttpStatus.FORBIDDEN, "ํ•ด๋‹น ๋“œ๋ž์€ ํŒ๋งค์ž๋งŒ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + DROP_NOT_OPEN(HttpStatus.CONFLICT, "ํ˜„์žฌ ์ฃผ๋ฌธํ•  ์ˆ˜ ์—†๋Š” ๋“œ๋ž์ž…๋‹ˆ๋‹ค."), + INSUFFICIENT_STOCK(HttpStatus.CONFLICT, "๋“œ๋ž ์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."), INVALID_DROP_PERIOD(HttpStatus.BAD_REQUEST, "ํŒ๋งค ์ข…๋ฃŒ ์‹œ๊ฐ์€ ์‹œ์ž‘ ์‹œ๊ฐ๋ณด๋‹ค ๋Šฆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), DROP_ALREADY_STARTED(HttpStatus.CONFLICT, "์ด๋ฏธ ํŒ๋งค๊ฐ€ ์‹œ์ž‘๋œ ๋“œ๋ž์ž…๋‹ˆ๋‹ค."); diff --git a/src/main/java/com/dropit/order/entity/Order.java b/src/main/java/com/dropit/order/entity/Order.java index c6b0b7d..5a82926 100644 --- a/src/main/java/com/dropit/order/entity/Order.java +++ b/src/main/java/com/dropit/order/entity/Order.java @@ -1,6 +1,8 @@ package com.dropit.order.entity; import com.dropit.global.entity.BaseEntity; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.exception.OrderErrorCode; import com.dropit.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; @@ -27,8 +29,21 @@ public class Order extends BaseEntity { @Column(name = "total_price", nullable = false, precision = 13, scale = 0) private BigDecimal totalPrice; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + public Order(User user, BigDecimal totalPrice) { this.user = user; this.totalPrice = totalPrice; + this.status = OrderStatus.ORDERED; + } + + public void cancel() { + if (status == OrderStatus.CANCELED) { + throw new ServiceException(OrderErrorCode.ORDER_ALREADY_CANCELED); + } + + this.status = OrderStatus.CANCELED; } } diff --git a/src/main/java/com/dropit/order/entity/OrderItem.java b/src/main/java/com/dropit/order/entity/OrderItem.java index 6458631..66858df 100644 --- a/src/main/java/com/dropit/order/entity/OrderItem.java +++ b/src/main/java/com/dropit/order/entity/OrderItem.java @@ -63,7 +63,15 @@ public OrderItem( this.unitPrice = unitPrice; this.discountRate = discountRate; this.quantity = quantity; - this.itemTotalPrice = unitPrice + this.itemTotalPrice = calculateItemTotalPrice(unitPrice, discountRate, quantity); + } + + public static BigDecimal calculateItemTotalPrice( + BigDecimal unitPrice, + int discountRate, + int quantity + ) { + return unitPrice .multiply(BigDecimal.ONE.subtract(BigDecimal.valueOf(discountRate).movePointLeft(2))) .multiply(BigDecimal.valueOf(quantity)) .setScale(0, RoundingMode.DOWN); diff --git a/src/main/java/com/dropit/order/entity/OrderStatus.java b/src/main/java/com/dropit/order/entity/OrderStatus.java new file mode 100644 index 0000000..1e3b254 --- /dev/null +++ b/src/main/java/com/dropit/order/entity/OrderStatus.java @@ -0,0 +1,6 @@ +package com.dropit.order.entity; + +public enum OrderStatus { + ORDERED, + CANCELED +} diff --git a/src/main/java/com/dropit/order/exception/OrderErrorCode.java b/src/main/java/com/dropit/order/exception/OrderErrorCode.java new file mode 100644 index 0000000..db8aad9 --- /dev/null +++ b/src/main/java/com/dropit/order/exception/OrderErrorCode.java @@ -0,0 +1,19 @@ +package com.dropit.order.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), + ORDER_ALREADY_CANCELED(HttpStatus.CONFLICT, "์ด๋ฏธ ์ทจ์†Œ๋œ ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค."), + PURCHASE_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "๋“œ๋ž์˜ 1์ธ๋‹น ๊ตฌ๋งค ์ œํ•œ ์ˆ˜๋Ÿ‰์„ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); + + private final HttpStatus status; + private final String message; + + @Override public HttpStatus status() { return status; } + @Override public String message() { return message; } +} diff --git a/src/test/java/com/dropit/drop/entity/DropTest.java b/src/test/java/com/dropit/drop/entity/DropTest.java index 991a943..082f76a 100644 --- a/src/test/java/com/dropit/drop/entity/DropTest.java +++ b/src/test/java/com/dropit/drop/entity/DropTest.java @@ -70,4 +70,48 @@ void rejectInvalidDropPeriodWithDropErrorCode() { assertEquals(DropErrorCode.INVALID_DROP_PERIOD, exception.getErrorCode()); } + + @Test + @DisplayName("ํŒ๋งค ์ค‘์ธ ๊ณต๊ฐœ ๋“œ๋ž์˜ ์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•˜๊ณ  ์ทจ์†Œ ์‹œ ๋ณต๊ตฌํ•  ์ˆ˜ ์žˆ๋‹ค") + void decreaseAndRestoreStock() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + + drop.decreaseStock(2, now); + assertEquals(8, drop.getRemainingQuantity()); + + drop.restoreStock(2); + assertEquals(10, drop.getRemainingQuantity()); + } + + @Test + @DisplayName("๋“œ๋ž ์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์„ ์ฃผ๋ฌธํ•  ์ˆ˜ ์—†๋‹ค") + void rejectQuantityExceedingStock() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 1, 20, 0, now.minusHours(1), now.plusHours(1)); + drop.changeVisibility(true); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.decreaseStock(2, now) + ); + + assertEquals(DropErrorCode.INSUFFICIENT_STOCK, exception.getErrorCode()); + assertEquals(1, drop.getRemainingQuantity()); + } + + @Test + @DisplayName("๋น„๊ณต๊ฐœ ๋“œ๋ž์€ ํŒ๋งค ๊ธฐ๊ฐ„์ด์–ด๋„ ์ฃผ๋ฌธํ•  ์ˆ˜ ์—†๋‹ค") + void rejectHiddenDropPurchase() { + LocalDateTime now = LocalDateTime.of(2026, 9, 2, 12, 0); + Drop drop = new Drop(product, new BigDecimal("59000"), 10, 20, 0, now.minusHours(1), now.plusHours(1)); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> drop.decreaseStock(1, now) + ); + + assertEquals(DropErrorCode.DROP_NOT_OPEN, exception.getErrorCode()); + } } diff --git a/src/test/java/com/dropit/order/entity/OrderTest.java b/src/test/java/com/dropit/order/entity/OrderTest.java new file mode 100644 index 0000000..6440d7c --- /dev/null +++ b/src/test/java/com/dropit/order/entity/OrderTest.java @@ -0,0 +1,48 @@ +package com.dropit.order.entity; + +import com.dropit.global.exception.ServiceException; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +class OrderTest { + + private final User user = mock(User.class); + + @Test + @DisplayName("์ฃผ๋ฌธ์€ ์ฃผ๋ฌธ ์™„๋ฃŒ ์ƒํƒœ๋กœ ์ƒ์„ฑ๋œ๋‹ค") + void createOrderAsOrdered() { + Order order = new Order(user, new BigDecimal("94400")); + + assertEquals(OrderStatus.ORDERED, order.getStatus()); + assertEquals(new BigDecimal("94400"), order.getTotalPrice()); + } + + @Test + @DisplayName("์ฃผ๋ฌธ์„ ์ทจ์†Œ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค") + void cancelOrder() { + Order order = new Order(user, new BigDecimal("94400")); + + order.cancel(); + + assertEquals(OrderStatus.CANCELED, order.getStatus()); + } + + @Test + @DisplayName("์ด๋ฏธ ์ทจ์†Œํ•œ ์ฃผ๋ฌธ์€ ๋‹ค์‹œ ์ทจ์†Œํ•  ์ˆ˜ ์—†๋‹ค") + void rejectAlreadyCanceledOrder() { + Order order = new Order(user, new BigDecimal("94400")); + order.cancel(); + + ServiceException exception = assertThrows(ServiceException.class, order::cancel); + + assertEquals(OrderErrorCode.ORDER_ALREADY_CANCELED, exception.getErrorCode()); + } +} From a0ce1898babab85e1bdfdc94a05780fffacc283c Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Wed, 2 Sep 2026 17:25:38 +0900 Subject: [PATCH 43/60] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=A1=B0=ED=9A=8C,=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/controller/OrderController.java | 62 ++++++ .../order/dto/request/OrderCreateRequest.java | 14 ++ .../dto/request/OrderItemCreateRequest.java | 13 ++ .../order/dto/response/OrderItemResponse.java | 27 +++ .../order/dto/response/OrderResponse.java | 27 +++ .../dto/response/OrderSummaryResponse.java | 23 +++ .../order/repository/OrderItemRepository.java | 18 +- .../order/repository/OrderRepository.java | 7 + .../dropit/order/service/OrderService.java | 114 +++++++++++ .../order/controller/OrderControllerTest.java | 136 ++++++++++++ .../order/service/OrderServiceTest.java | 193 ++++++++++++++++++ 11 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dropit/order/controller/OrderController.java create mode 100644 src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java create mode 100644 src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java create mode 100644 src/main/java/com/dropit/order/dto/response/OrderItemResponse.java create mode 100644 src/main/java/com/dropit/order/dto/response/OrderResponse.java create mode 100644 src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java create mode 100644 src/main/java/com/dropit/order/service/OrderService.java create mode 100644 src/test/java/com/dropit/order/controller/OrderControllerTest.java create mode 100644 src/test/java/com/dropit/order/service/OrderServiceTest.java diff --git a/src/main/java/com/dropit/order/controller/OrderController.java b/src/main/java/com/dropit/order/controller/OrderController.java new file mode 100644 index 0000000..33e885e --- /dev/null +++ b/src/main/java/com/dropit/order/controller/OrderController.java @@ -0,0 +1,62 @@ +package com.dropit.order.controller; + +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.service.OrderService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + + @PostMapping("/orders") + public ResponseEntity create( + @RequestHeader("X-User-Id") Long userId, // TODO: ์ธ์ฆ ์ ์šฉ ํ›„ SecurityContext์—์„œ ์ถ”์ถœ + @Valid @RequestBody OrderCreateRequest request + ) { + OrderResponse response = orderService.create(userId, request); + + return ResponseEntity + .created(URI.create("/orders/" + response.id())) + .body(response); + } + + @GetMapping("/orders/me") + public ResponseEntity> getMyOrders( + @RequestHeader("X-User-Id") Long userId // TODO: ์ธ์ฆ ์ ์šฉ ํ›„ SecurityContext์—์„œ ์ถ”์ถœ + ) { + return ResponseEntity.ok(orderService.getMyOrders(userId)); + } + + @GetMapping("/orders/{orderId}") + public ResponseEntity getMyOrder( + @RequestHeader("X-User-Id") Long userId, // TODO: ์ธ์ฆ ์ ์šฉ ํ›„ SecurityContext์—์„œ ์ถ”์ถœ + @PathVariable Long orderId + ) { + return ResponseEntity.ok(orderService.getMyOrder(userId, orderId)); + } + + @PostMapping("/orders/{orderId}/cancel") + public ResponseEntity cancel( + @RequestHeader("X-User-Id") Long userId, // TODO: ์ธ์ฆ ์ ์šฉ ํ›„ SecurityContext์—์„œ ์ถ”์ถœ + @PathVariable Long orderId + ) { + orderService.cancel(userId, orderId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java b/src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java new file mode 100644 index 0000000..0e9f761 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/request/OrderCreateRequest.java @@ -0,0 +1,14 @@ +package com.dropit.order.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record OrderCreateRequest( + @NotNull(message = "์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(min = 1, max = 1, message = "ํ˜„์žฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ 1๊ฐœ๋งŒ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + List<@Valid OrderItemCreateRequest> items +) { +} diff --git a/src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java b/src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java new file mode 100644 index 0000000..bbdcbb2 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/request/OrderItemCreateRequest.java @@ -0,0 +1,13 @@ +package com.dropit.order.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record OrderItemCreateRequest( + @NotNull(message = "๋“œ๋ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + Long dropId, + + @Positive(message = "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + int quantity +) { +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderItemResponse.java b/src/main/java/com/dropit/order/dto/response/OrderItemResponse.java new file mode 100644 index 0000000..b5137c5 --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderItemResponse.java @@ -0,0 +1,27 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.OrderItem; + +import java.math.BigDecimal; + +public record OrderItemResponse( + Long id, + Long dropId, + String productName, + BigDecimal unitPrice, + int discountRate, + int quantity, + BigDecimal itemTotalPrice +) { + public static OrderItemResponse from(OrderItem orderItem) { + return new OrderItemResponse( + orderItem.getId(), + orderItem.getDrop().getId(), + orderItem.getProductName(), + orderItem.getUnitPrice(), + orderItem.getDiscountRate(), + orderItem.getQuantity(), + orderItem.getItemTotalPrice() + ); + } +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderResponse.java b/src/main/java/com/dropit/order/dto/response/OrderResponse.java new file mode 100644 index 0000000..4b9c0cd --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderResponse.java @@ -0,0 +1,27 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record OrderResponse( + Long id, + OrderStatus status, + BigDecimal totalPrice, + LocalDateTime createdAt, + List items +) { + public static OrderResponse from(Order order, List orderItems) { + return new OrderResponse( + order.getId(), + order.getStatus(), + order.getTotalPrice(), + order.getCreatedAt(), + orderItems.stream().map(OrderItemResponse::from).toList() + ); + } +} diff --git a/src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java b/src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java new file mode 100644 index 0000000..a6caafe --- /dev/null +++ b/src/main/java/com/dropit/order/dto/response/OrderSummaryResponse.java @@ -0,0 +1,23 @@ +package com.dropit.order.dto.response; + +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record OrderSummaryResponse( + Long id, + OrderStatus status, + BigDecimal totalPrice, + LocalDateTime createdAt +) { + public static OrderSummaryResponse from(Order order) { + return new OrderSummaryResponse( + order.getId(), + order.getStatus(), + order.getTotalPrice(), + order.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/dropit/order/repository/OrderItemRepository.java b/src/main/java/com/dropit/order/repository/OrderItemRepository.java index 84fd8af..19b7442 100644 --- a/src/main/java/com/dropit/order/repository/OrderItemRepository.java +++ b/src/main/java/com/dropit/order/repository/OrderItemRepository.java @@ -1,11 +1,27 @@ package com.dropit.order.repository; import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface OrderItemRepository extends JpaRepository { - List findAllByOrder_Id(Long orderId); + List findAllByOrder_IdOrderByIdAsc(Long orderId); + + @Query(""" + select coalesce(sum(oi.quantity), 0) + from OrderItem oi + where oi.order.user.id = :userId + and oi.drop.id = :dropId + and oi.order.status = :status + """) + long sumQuantityByUserAndDropAndStatus( + @Param("userId") Long userId, + @Param("dropId") Long dropId, + @Param("status") OrderStatus status + ); } diff --git a/src/main/java/com/dropit/order/repository/OrderRepository.java b/src/main/java/com/dropit/order/repository/OrderRepository.java index 324f183..52b3dfd 100644 --- a/src/main/java/com/dropit/order/repository/OrderRepository.java +++ b/src/main/java/com/dropit/order/repository/OrderRepository.java @@ -3,5 +3,12 @@ import com.dropit.order.entity.Order; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + public interface OrderRepository extends JpaRepository { + + List findAllByUser_IdOrderByCreatedAtDesc(Long userId); + + Optional findByIdAndUser_Id(Long orderId, Long userId); } diff --git a/src/main/java/com/dropit/order/service/OrderService.java b/src/main/java/com/dropit/order/service/OrderService.java new file mode 100644 index 0000000..9e17ef2 --- /dev/null +++ b/src/main/java/com/dropit/order/service/OrderService.java @@ -0,0 +1,114 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.exception.DropErrorCode; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.request.OrderItemCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.user.entity.User; +import com.dropit.user.exception.UserErrorCode; +import com.dropit.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final UserRepository userRepository; + private final DropRepository dropRepository; + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderResponse create(Long userId, OrderCreateRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ServiceException(UserErrorCode.USER_NOT_FOUND)); + + OrderItemCreateRequest itemRequest = request.items().getFirst(); + Drop drop = dropRepository.findById(itemRequest.dropId()) + .orElseThrow(() -> new ServiceException(DropErrorCode.DROP_NOT_FOUND)); + LocalDateTime now = LocalDateTime.now(); + validatePurchaseLimit(userId, drop, itemRequest.quantity()); + drop.decreaseStock(itemRequest.quantity(), now); + BigDecimal totalPrice = OrderItem.calculateItemTotalPrice( + drop.getPrice(), + drop.getDiscountRate(), + itemRequest.quantity() + ); + + Order order = orderRepository.save(new Order(user, totalPrice)); + List orderItems = List.of(createOrderItem(order, drop, itemRequest.quantity())); + List savedOrderItems = orderItemRepository.saveAll(orderItems); + + return OrderResponse.from(order, savedOrderItems); + } + + @Transactional(readOnly = true) + public List getMyOrders(Long userId) { + return orderRepository.findAllByUser_IdOrderByCreatedAtDesc(userId).stream() + .map(OrderSummaryResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderResponse getMyOrder(Long userId, Long orderId) { + Order order = orderRepository.findByIdAndUser_Id(orderId, userId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_NOT_FOUND)); + List orderItems = orderItemRepository.findAllByOrder_IdOrderByIdAsc(orderId); + + return OrderResponse.from(order, orderItems); + } + + @Transactional + public void cancel(Long userId, Long orderId) { + Order order = orderRepository.findByIdAndUser_Id(orderId, userId) + .orElseThrow(() -> new ServiceException(OrderErrorCode.ORDER_NOT_FOUND)); + List orderItems = orderItemRepository.findAllByOrder_IdOrderByIdAsc(orderId); + + order.cancel(); + for (OrderItem orderItem : orderItems) { + orderItem.getDrop().restoreStock(orderItem.getQuantity()); + } + } + + private void validatePurchaseLimit(Long userId, Drop drop, int quantity) { + if (drop.getPurchaseLimit() == 0) { + return; + } + + long purchasedQuantity = orderItemRepository.sumQuantityByUserAndDropAndStatus( + userId, + drop.getId(), + OrderStatus.ORDERED + ); + if (purchasedQuantity + quantity > drop.getPurchaseLimit()) { + throw new ServiceException(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED); + } + } + + private OrderItem createOrderItem(Order order, Drop drop, int quantity) { + return new OrderItem( + order, + drop, + drop.getProduct().getName(), + drop.getPrice(), + drop.getDiscountRate(), + quantity + ); + } +} diff --git a/src/test/java/com/dropit/order/controller/OrderControllerTest.java b/src/test/java/com/dropit/order/controller/OrderControllerTest.java new file mode 100644 index 0000000..69b9020 --- /dev/null +++ b/src/test/java/com/dropit/order/controller/OrderControllerTest.java @@ -0,0 +1,136 @@ +package com.dropit.order.controller; + +import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.service.OrderService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class OrderControllerTest { + + private MockMvc mockMvc; + private OrderService orderService; + + @BeforeEach + void setUp() { + orderService = mock(OrderService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new OrderController(orderService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("๋ฐฐ์—ด ํ˜•ํƒœ์˜ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•œ๋‹ค") + void createOrder() throws Exception { + OrderResponse response = new OrderResponse( + 1000L, + OrderStatus.ORDERED, + new BigDecimal("94400"), + null, + List.of() + ); + when(orderService.create(eq(1L), any(OrderCreateRequest.class))).thenReturn(response); + + mockMvc.perform(post("/orders") + .header("X-User-Id", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "items": [ + {"dropId": 100, "quantity": 2} + ] + } + """)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/orders/1000")) + .andExpect(jsonPath("$.id").value(1000L)) + .andExpect(jsonPath("$.status").value("ORDERED")) + .andExpect(jsonPath("$.totalPrice").value(94400)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrderCreateRequest.class); + verify(orderService).create(eq(1L), captor.capture()); + assertEquals(1, captor.getValue().items().size()); + assertEquals(100L, captor.getValue().items().getFirst().dropId()); + assertEquals(2, captor.getValue().items().getFirst().quantity()); + } + + @Test + @DisplayName("ํ˜„์žฌ MVP์—์„œ๋Š” ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ๋‘ ๊ฐœ ์ด์ƒ ์š”์ฒญํ•  ์ˆ˜ ์—†๋‹ค") + void rejectMultipleOrderItems() throws Exception { + mockMvc.perform(post("/orders") + .header("X-User-Id", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "items": [ + {"dropId": 100, "quantity": 1}, + {"dropId": 101, "quantity": 1} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_INPUT")); + + verifyNoInteractions(orderService); + } + + @Test + @DisplayName("๋‚ด ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์กฐํšŒํ•œ๋‹ค") + void getMyOrders() throws Exception { + when(orderService.getMyOrders(1L)).thenReturn(List.of( + new OrderSummaryResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null) + )); + + mockMvc.perform(get("/orders/me").header("X-User-Id", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1000L)) + .andExpect(jsonPath("$[0].status").value("ORDERED")); + } + + @Test + @DisplayName("๋‚ด ์ฃผ๋ฌธ ์ƒ์„ธ๋ฅผ ์กฐํšŒํ•œ๋‹ค") + void getMyOrder() throws Exception { + when(orderService.getMyOrder(1L, 1000L)).thenReturn( + new OrderResponse(1000L, OrderStatus.ORDERED, new BigDecimal("94400"), null, List.of()) + ); + + mockMvc.perform(get("/orders/{orderId}", 1000L).header("X-User-Id", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1000L)) + .andExpect(jsonPath("$.totalPrice").value(94400)); + } + + @Test + @DisplayName("๋‚ด ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•œ๋‹ค") + void cancelOrder() throws Exception { + mockMvc.perform(post("/orders/{orderId}/cancel", 1000L).header("X-User-Id", 1L)) + .andExpect(status().isNoContent()); + + verify(orderService).cancel(1L, 1000L); + } +} diff --git a/src/test/java/com/dropit/order/service/OrderServiceTest.java b/src/test/java/com/dropit/order/service/OrderServiceTest.java new file mode 100644 index 0000000..5c8fd89 --- /dev/null +++ b/src/test/java/com/dropit/order/service/OrderServiceTest.java @@ -0,0 +1,193 @@ +package com.dropit.order.service; + +import com.dropit.drop.entity.Drop; +import com.dropit.drop.repository.DropRepository; +import com.dropit.global.exception.ServiceException; +import com.dropit.order.dto.request.OrderCreateRequest; +import com.dropit.order.dto.request.OrderItemCreateRequest; +import com.dropit.order.dto.response.OrderResponse; +import com.dropit.order.dto.response.OrderSummaryResponse; +import com.dropit.order.entity.Order; +import com.dropit.order.entity.OrderItem; +import com.dropit.order.entity.OrderStatus; +import com.dropit.order.exception.OrderErrorCode; +import com.dropit.order.repository.OrderItemRepository; +import com.dropit.order.repository.OrderRepository; +import com.dropit.product.entity.Product; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock private UserRepository userRepository; + @Mock private DropRepository dropRepository; + @Mock private OrderRepository orderRepository; + @Mock private OrderItemRepository orderItemRepository; + @InjectMocks private OrderService orderService; + + @Test + @DisplayName("์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•˜๋ฉด ์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•˜๊ณ  ์ฃผ๋ฌธ ์‹œ์  ์ •๋ณด๋ฅผ ์ €์žฅํ•œ๋‹ค") + void createOrder() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + OrderCreateRequest request = new OrderCreateRequest(List.of(new OrderItemCreateRequest(100L, 2))); + + when(userRepository.findById(1L)).thenReturn(Optional.of(buyer)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + when(orderItemRepository.sumQuantityByUserAndDropAndStatus(1L, 100L, OrderStatus.ORDERED)) + .thenReturn(0L); + when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> { + Order order = invocation.getArgument(0); + ReflectionTestUtils.setField(order, "id", 1000L); + return order; + }); + when(orderItemRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + OrderResponse response = orderService.create(1L, request); + + assertEquals(1000L, response.id()); + assertEquals(OrderStatus.ORDERED, response.status()); + assertEquals(new BigDecimal("94400"), response.totalPrice()); + assertEquals(8, drop.getRemainingQuantity()); + assertEquals(1, response.items().size()); + assertEquals("Limited Hoodie", response.items().getFirst().productName()); + assertEquals(new BigDecimal("59000"), response.items().getFirst().unitPrice()); + assertEquals(20, response.items().getFirst().discountRate()); + assertEquals(new BigDecimal("94400"), response.items().getFirst().itemTotalPrice()); + } + + @Test + @DisplayName("๊ธฐ์กด ๊ตฌ๋งค ์ˆ˜๋Ÿ‰๊ณผ ์ƒˆ ์ˆ˜๋Ÿ‰์˜ ํ•ฉ์ด ๊ตฌ๋งค ์ œํ•œ์„ ๋„˜์œผ๋ฉด ์ฃผ๋ฌธํ•  ์ˆ˜ ์—†๋‹ค") + void rejectPurchaseLimitExceeded() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + OrderCreateRequest request = new OrderCreateRequest(List.of(new OrderItemCreateRequest(100L, 2))); + + when(userRepository.findById(1L)).thenReturn(Optional.of(buyer)); + when(dropRepository.findById(100L)).thenReturn(Optional.of(drop)); + when(orderItemRepository.sumQuantityByUserAndDropAndStatus(1L, 100L, OrderStatus.ORDERED)) + .thenReturn(1L); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> orderService.create(1L, request) + ); + + assertEquals(OrderErrorCode.PURCHASE_LIMIT_EXCEEDED, exception.getErrorCode()); + assertEquals(10, drop.getRemainingQuantity()); + verify(orderRepository, never()).save(any()); + } + + @Test + @DisplayName("๋‚ด ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์ตœ์‹ ์ˆœ ์กฐํšŒ ๊ฒฐ๊ณผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getMyOrders() { + User buyer = createUser(1L, UserRole.USER); + Order order = new Order(buyer, new BigDecimal("94400")); + ReflectionTestUtils.setField(order, "id", 1000L); + when(orderRepository.findAllByUser_IdOrderByCreatedAtDesc(1L)).thenReturn(List.of(order)); + + List responses = orderService.getMyOrders(1L); + + assertEquals(1, responses.size()); + assertEquals(1000L, responses.getFirst().id()); + assertEquals(new BigDecimal("94400"), responses.getFirst().totalPrice()); + } + + @Test + @DisplayName("๋‚ด ์ฃผ๋ฌธ ์ƒ์„ธ๋Š” ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ํ•จ๊ป˜ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getMyOrder() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + Order order = new Order(buyer, new BigDecimal("94400")); + ReflectionTestUtils.setField(order, "id", 1000L); + OrderItem orderItem = new OrderItem(order, drop, "Limited Hoodie", new BigDecimal("59000"), 20, 2); + + when(orderRepository.findByIdAndUser_Id(1000L, 1L)).thenReturn(Optional.of(order)); + when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of(orderItem)); + + OrderResponse response = orderService.getMyOrder(1L, 1000L); + + assertEquals(1000L, response.id()); + assertEquals(1, response.items().size()); + assertEquals(100L, response.items().getFirst().dropId()); + } + + @Test + @DisplayName("๋ณธ์ธ ์ฃผ๋ฌธ์ด ์•„๋‹ˆ๋ฉด ์ฃผ๋ฌธ ์ƒ์„ธ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์—†๋‹ค") + void rejectAnotherUsersOrder() { + when(orderRepository.findByIdAndUser_Id(1000L, 2L)).thenReturn(Optional.empty()); + + ServiceException exception = assertThrows( + ServiceException.class, + () -> orderService.getMyOrder(2L, 1000L) + ); + + assertEquals(OrderErrorCode.ORDER_NOT_FOUND, exception.getErrorCode()); + verify(orderItemRepository, never()).findAllByOrder_IdOrderByIdAsc(any()); + } + + @Test + @DisplayName("์ฃผ๋ฌธ์„ ์ทจ์†Œํ•˜๋ฉด ์ฃผ๋ฌธ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๊ณ  ์žฌ๊ณ ๋ฅผ ๋ณต๊ตฌํ•œ๋‹ค") + void cancelOrder() { + User buyer = createUser(1L, UserRole.USER); + Drop drop = createOpenDrop(10L, 100L, 10, 20, 2); + drop.decreaseStock(2, LocalDateTime.now()); + Order order = new Order(buyer, new BigDecimal("94400")); + ReflectionTestUtils.setField(order, "id", 1000L); + OrderItem orderItem = new OrderItem(order, drop, "Limited Hoodie", new BigDecimal("59000"), 20, 2); + + when(orderRepository.findByIdAndUser_Id(1000L, 1L)).thenReturn(Optional.of(order)); + when(orderItemRepository.findAllByOrder_IdOrderByIdAsc(1000L)).thenReturn(List.of(orderItem)); + + orderService.cancel(1L, 1000L); + + assertEquals(OrderStatus.CANCELED, order.getStatus()); + assertEquals(10, drop.getRemainingQuantity()); + } + + private User createUser(Long id, UserRole role) { + User user = new User("user@example.com", "password", "user", role); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Drop createOpenDrop(Long sellerId, Long dropId, int stock, int discountRate, int purchaseLimit) { + User seller = createUser(sellerId, UserRole.SELLER); + Product product = new Product(seller, "Limited Hoodie", "description", null); + ReflectionTestUtils.setField(product, "id", 10L); + Drop drop = new Drop( + product, + new BigDecimal("59000"), + stock, + discountRate, + purchaseLimit, + LocalDateTime.now().minusHours(1), + LocalDateTime.now().plusHours(1) + ); + ReflectionTestUtils.setField(drop, "id", dropId); + drop.changeVisibility(true); + return drop; + } +} From 052e453991b9e84bc1e72ed6ca72dee8f3470cc9 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Thu, 3 Sep 2026 12:32:39 +0900 Subject: [PATCH 44/60] =?UTF-8?q?feat(drop):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A0=AC=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?DSL=EB=A1=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 ++++- .../drop/controller/DropController.java | 10 +++---- .../drop/repository/DropRepository.java | 2 +- .../com/dropit/drop/service/DropService.java | 8 +++-- .../drop/controller/DropControllerTest.java | 18 ++++++++++-- .../dropit/drop/service/DropServiceTest.java | 29 +++++++++++++------ .../controller/ProductControllerTest.java | 29 ++++++++++++------- .../product/service/ProductServiceTest.java | 18 ++++++++---- 8 files changed, 85 insertions(+), 37 deletions(-) diff --git a/build.gradle b/build.gradle index b6a25ab..c58811f 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,8 @@ group = 'com.dropit' version = '0.0.1-SNAPSHOT' description = 'dropit-server' +def querydslVersion = '7.5' + java { toolchain { languageVersion = JavaLanguageVersion.of(21) @@ -23,10 +25,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa" + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' @@ -52,4 +58,4 @@ tasks.register('dbDown', Exec) { group = 'development' description = '๋กœ์ปฌ DB ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.' commandLine 'docker', 'compose', '--env-file', '.env.local', 'down' -} \ No newline at end of file +} diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index 7de33ba..c2f9fd0 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -1,6 +1,7 @@ package com.dropit.drop.controller; import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.dto.request.DropUpdateRequest; import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; @@ -36,13 +37,10 @@ public ResponseEntity create( @GetMapping("/drops") public ResponseEntity> getAll( - @PageableDefault( - size = 20, - sort = "createdAt", - direction = Sort.Direction.DESC - ) Pageable pageable + @ModelAttribute DropSearchCondition condition, + @PageableDefault(size = 20) Pageable pageable ) { - Page response = dropService.getAll(pageable); + Page response = dropService.getAll(condition, pageable); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/dropit/drop/repository/DropRepository.java b/src/main/java/com/dropit/drop/repository/DropRepository.java index 6447130..828f9d2 100644 --- a/src/main/java/com/dropit/drop/repository/DropRepository.java +++ b/src/main/java/com/dropit/drop/repository/DropRepository.java @@ -5,7 +5,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -public interface DropRepository extends JpaRepository { +public interface DropRepository extends JpaRepository, DropRepositoryCustom { Page findAllByVisibleTrue(Pageable pageable); diff --git a/src/main/java/com/dropit/drop/service/DropService.java b/src/main/java/com/dropit/drop/service/DropService.java index 78ab2ed..fc4f494 100644 --- a/src/main/java/com/dropit/drop/service/DropService.java +++ b/src/main/java/com/dropit/drop/service/DropService.java @@ -1,6 +1,7 @@ package com.dropit.drop.service; import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.dto.request.DropUpdateRequest; import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; @@ -52,8 +53,11 @@ public Long save(Long userId, DropCreateRequest request) { } @Transactional(readOnly = true) - public Page getAll(Pageable pageable) { - Page drops = dropRepository.findAllByVisibleTrue(pageable); + public Page getAll( + DropSearchCondition condition, + Pageable pageable + ) { + Page drops = dropRepository.searchPublicDrops(condition, pageable); return drops.map(DropResponse::from); } diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java index be6fa53..b92702f 100644 --- a/src/test/java/com/dropit/drop/controller/DropControllerTest.java +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -1,6 +1,7 @@ package com.dropit.drop.controller; import com.dropit.drop.dto.response.DropResponse; +import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.entity.DropStatus; import com.dropit.drop.service.DropService; import com.dropit.global.exception.GlobalExceptionHandler; @@ -10,10 +11,14 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -41,6 +46,7 @@ void setUp() { mockMvc = MockMvcBuilders .standaloneSetup(new DropController(dropService)) .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) .setValidator(validator) .build(); } @@ -97,12 +103,20 @@ void rejectInvalidCreateRequest() throws Exception { @DisplayName("๋“œ๋ž ๋ชฉ๋ก๊ณผ ์ƒ์„ธ ์กฐํšŒ ์š”์ฒญ ์‹œ 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void getAllAndOneDrop() throws Exception { DropResponse response = response(); - when(dropService.getAll()).thenReturn(List.of(response)); + when(dropService.getAll( + any(DropSearchCondition.class), + any(Pageable.class) + )) + .thenReturn(new PageImpl<>( + List.of(response), + PageRequest.of(0, 20), + 1 + )); when(dropService.getOne(100L)).thenReturn(response); mockMvc.perform(get("/drops")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(100)); + .andExpect(jsonPath("$.content[0].id").value(100)); mockMvc.perform(get("/drops/100")) .andExpect(status().isOk()) .andExpect(jsonPath("$.productId").value(1)); diff --git a/src/test/java/com/dropit/drop/service/DropServiceTest.java b/src/test/java/com/dropit/drop/service/DropServiceTest.java index fa6d97a..821e623 100644 --- a/src/test/java/com/dropit/drop/service/DropServiceTest.java +++ b/src/test/java/com/dropit/drop/service/DropServiceTest.java @@ -1,6 +1,7 @@ package com.dropit.drop.service; import com.dropit.drop.dto.request.DropCreateRequest; +import com.dropit.drop.dto.request.DropSearchCondition; import com.dropit.drop.dto.request.DropUpdateRequest; import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; @@ -20,6 +21,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; @@ -115,13 +120,16 @@ void getOneDrop() { void getAllDrops() { Drop first = saveDrop(1L, 10L, 100L, LocalDateTime.now().plusDays(1)); Drop second = saveDrop(1L, 11L, 101L, LocalDateTime.now().plusDays(2)); - when(dropRepository.findAll()).thenReturn(List.of(first, second)); + DropSearchCondition condition = new DropSearchCondition(null, null, null); + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.searchPublicDrops(condition, pageable)) + .thenReturn(new PageImpl<>(List.of(first, second), pageable, 2)); - List responses = dropService.getAll(); + Page responses = dropService.getAll(condition, pageable); - assertEquals(2, responses.size()); - assertEquals(100L, responses.get(0).id()); - assertEquals(101L, responses.get(1).id()); + assertEquals(2, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + assertEquals(101L, responses.getContent().get(1).id()); } @Test @@ -139,12 +147,15 @@ void excludeHiddenDropsFromPublicList() { LocalDateTime.now().plusDays(2) ); ReflectionTestUtils.setField(hiddenDrop, "id", 101L); - when(dropRepository.findAll()).thenReturn(List.of(visibleDrop, hiddenDrop)); + DropSearchCondition condition = new DropSearchCondition(null, null, null); + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.searchPublicDrops(condition, pageable)) + .thenReturn(new PageImpl<>(List.of(visibleDrop), pageable, 1)); - List responses = dropService.getAll(); + Page responses = dropService.getAll(condition, pageable); - assertEquals(1, responses.size()); - assertEquals(100L, responses.get(0).id()); + assertEquals(1, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); } @Test diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index 1d14442..abde122 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -14,10 +14,14 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import java.util.List; @@ -49,6 +53,7 @@ void setUp() { mockMvc = MockMvcBuilders .standaloneSetup(productController) .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) .build(); } @@ -196,19 +201,23 @@ void getProducts() throws Exception { ); ReflectionTestUtils.setField(product, "id", 100L); - when(productService.getProducts()) - .thenReturn(List.of(new ProductResponse(product))); + when(productService.getProducts(any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(new ProductResponse(product)), + PageRequest.of(0, 20), + 1 + )); mockMvc.perform(get("/products")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(100L)) - .andExpect(jsonPath("$[0].sellerId").value(1L)) - .andExpect(jsonPath("$[0].sellerName").value("seller")) - .andExpect(jsonPath("$[0].name").value("Limited Hoodie")) - .andExpect(jsonPath("$[0].price").doesNotExist()) - .andExpect(jsonPath("$[0].description").value("Limited edition hoodie")); - - verify(productService).getProducts(); + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.content[0].sellerId").value(1L)) + .andExpect(jsonPath("$.content[0].sellerName").value("seller")) + .andExpect(jsonPath("$.content[0].name").value("Limited Hoodie")) + .andExpect(jsonPath("$.content[0].price").doesNotExist()) + .andExpect(jsonPath("$.content[0].description").value("Limited edition hoodie")); + + verify(productService).getProducts(any(Pageable.class)); } @Test diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index 8f34ab4..ee006f2 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -18,6 +18,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -165,14 +169,16 @@ void getProducts() { ReflectionTestUtils.setField(firstProduct, "id", 1L); ReflectionTestUtils.setField(secondProduct, "id", 2L); - when(productRepository.findAll()).thenReturn(List.of(firstProduct, secondProduct)); + Pageable pageable = PageRequest.of(0, 20); + when(productRepository.findAll(pageable)) + .thenReturn(new PageImpl<>(List.of(firstProduct, secondProduct), pageable, 2)); - List response = productService.getProducts(); + Page response = productService.getProducts(pageable); - assertEquals(2, response.size()); - assertEquals("First Product", response.get(0).getName()); - assertEquals("Second Product", response.get(1).getName()); - verify(productRepository).findAll(); + assertEquals(2, response.getTotalElements()); + assertEquals("First Product", response.getContent().get(0).getName()); + assertEquals("Second Product", response.getContent().get(1).getName()); + verify(productRepository).findAll(pageable); } @Test From 7132824a6f0f1a054df6447a05fd9be1c5fe1262 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Thu, 3 Sep 2026 12:46:21 +0900 Subject: [PATCH 45/60] =?UTF-8?q?feat(drop):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A0=AC=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?DSL=EB=A1=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/dto/request/DropSearchCondition.java | 10 ++ .../dropit/drop/dto/request/DropSortType.java | 8 ++ .../drop/repository/DropRepositoryCustom.java | 14 ++ .../repository/DropRepositoryCustomImpl.java | 120 ++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java create mode 100644 src/main/java/com/dropit/drop/dto/request/DropSortType.java create mode 100644 src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java create mode 100644 src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java diff --git a/src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java b/src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java new file mode 100644 index 0000000..a0cd2e9 --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropSearchCondition.java @@ -0,0 +1,10 @@ +package com.dropit.drop.dto.request; + +import com.dropit.drop.entity.DropStatus; + +public record DropSearchCondition( + String keyword, + DropStatus status, + DropSortType sortType +) { +} diff --git a/src/main/java/com/dropit/drop/dto/request/DropSortType.java b/src/main/java/com/dropit/drop/dto/request/DropSortType.java new file mode 100644 index 0000000..72beb5f --- /dev/null +++ b/src/main/java/com/dropit/drop/dto/request/DropSortType.java @@ -0,0 +1,8 @@ +package com.dropit.drop.dto.request; + +public enum DropSortType { + + LATEST, + PRICE_ASC, + CLOSING_SOON +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java b/src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java new file mode 100644 index 0000000..52744a3 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.entity.Drop; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface DropRepositoryCustom { + + Page searchPublicDrops( + DropSearchCondition condition, + Pageable pageable + ); +} diff --git a/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java b/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java new file mode 100644 index 0000000..a410b50 --- /dev/null +++ b/src/main/java/com/dropit/drop/repository/DropRepositoryCustomImpl.java @@ -0,0 +1,120 @@ +package com.dropit.drop.repository; + +import com.dropit.drop.dto.request.DropSearchCondition; +import com.dropit.drop.dto.request.DropSortType; +import com.dropit.drop.entity.Drop; +import com.dropit.drop.entity.DropStatus; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.dropit.drop.entity.QDrop.drop; +import static com.dropit.product.entity.QProduct.product; +import static com.dropit.user.entity.QUser.user; + +@RequiredArgsConstructor +public class DropRepositoryCustomImpl implements DropRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchPublicDrops( + DropSearchCondition condition, + Pageable pageable + ) { + LocalDateTime now = LocalDateTime.now(); + + List content = queryFactory + .selectFrom(drop) + .join(drop.product, product).fetchJoin() + .join(product.seller, user).fetchJoin() + .where( + drop.visible.isTrue(), + keywordContains(condition.keyword()), + statusEquals(condition.status(), now) + ) + .orderBy(orderSpecifiers(condition.sortType())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(drop.count()) + .from(drop) + .join(drop.product, product) + .join(product.seller, user) + .where( + drop.visible.isTrue(), + keywordContains(condition.keyword()), + statusEquals(condition.status(), now) + ) + .fetchOne(); + + return new PageImpl<>( + content, + pageable, + total != null ? total : 0L + ); + } + + private BooleanExpression keywordContains(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + + return product.name.containsIgnoreCase(keyword) + .or(user.username.containsIgnoreCase(keyword)); + } + + private BooleanExpression statusEquals( + DropStatus status, + LocalDateTime now + ) { + if (status == null) { + return null; + } + + return switch (status) { + case READY -> drop.openAt.gt(now); + case OPEN -> drop.openAt.loe(now) + .and(drop.closeAt.gt(now)) + .and(drop.remainingQuantity.gt(0)); + case SOLDOUT -> drop.openAt.loe(now) + .and(drop.closeAt.gt(now)) + .and(drop.remainingQuantity.eq(0)); + case CLOSED -> drop.closeAt.loe(now); + }; + } + + private OrderSpecifier[] orderSpecifiers(DropSortType sortType) { + if (sortType == null) { + return latestOrder(); + } + + return switch (sortType) { + case LATEST -> latestOrder(); + case PRICE_ASC -> new OrderSpecifier[]{ + drop.price.asc(), + drop.id.desc() + }; + case CLOSING_SOON -> new OrderSpecifier[]{ + drop.closeAt.asc(), + drop.id.desc() + }; + }; + } + + private OrderSpecifier[] latestOrder() { + return new OrderSpecifier[]{ + drop.createdAt.desc(), + drop.id.desc() + }; + } +} From 49fd54ba8cf1be0112c094f4df0f342aa2da62c7 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 13:41:01 +0900 Subject: [PATCH 46/60] chore(gradle): Add Spring Security and JWT dependencies --- build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index b6a25ab..b60a7e8 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' From e302138835e46efc7bd0c2d12a803bda2d842816 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 14:34:17 +0900 Subject: [PATCH 47/60] feat(jwt): JwtProperties configuration --- .../global/security/jwt/JwtProperties.java | 17 +++++++++++++++++ src/main/resources/application.properties | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 src/main/java/com/dropit/global/security/jwt/JwtProperties.java diff --git a/src/main/java/com/dropit/global/security/jwt/JwtProperties.java b/src/main/java/com/dropit/global/security/jwt/JwtProperties.java new file mode 100644 index 0000000..7a25a8d --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtProperties.java @@ -0,0 +1,17 @@ +package com.dropit.global.security.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String secret, + Expire expire +) { + public record Expire( + Duration access, + Duration refresh + ) { + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0849ac0..45863b2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,6 @@ spring.datasource.password=${DB_PASSWORD} spring.data.redis.host=${REDIS_HOST} spring.data.redis.port=${REDIS_PORT} +jwt.secret=${JWT_SECRET} +jwt.expire.access=30m +jwt.expire.refresh=14d From 92d9fa28a320c9da52ac368a01fc454c28dd2b90 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 22:37:31 +0900 Subject: [PATCH 48/60] feat(jwt): Added JWT token issuance and verification features" --- .../com/dropit/DropitServerApplication.java | 6 +- .../global/security/jwt/JwtErrorCode.java | 37 +++++++ .../global/security/jwt/JwtTokenPair.java | 7 ++ .../dropit/global/security/jwt/JwtUtil.java | 98 +++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java create mode 100644 src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java create mode 100644 src/main/java/com/dropit/global/security/jwt/JwtUtil.java diff --git a/src/main/java/com/dropit/DropitServerApplication.java b/src/main/java/com/dropit/DropitServerApplication.java index d617bf6..b10bd26 100644 --- a/src/main/java/com/dropit/DropitServerApplication.java +++ b/src/main/java/com/dropit/DropitServerApplication.java @@ -1,11 +1,15 @@ package com.dropit; +import com.dropit.global.security.jwt.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing -@SpringBootApplication +@EnableConfigurationProperties(JwtProperties.class) +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) public class DropitServerApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java b/src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java new file mode 100644 index 0000000..b68adb2 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtErrorCode.java @@ -0,0 +1,37 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ErrorCode; +import com.dropit.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum JwtErrorCode implements ErrorCode { + + JWT_BEARER_TOKEN_REQUIRED(HttpStatus.UNAUTHORIZED, "Authorization ํ—ค๋”์— Bearer ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"), + JWT_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"), + JWT_TOKEN_SIGNATURE_ERROR(HttpStatus.UNAUTHORIZED, "ํ† ํฐ ์„œ๋ช…์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค"), + JWT_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "ํ† ํฐ์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค"), + JWT_TOKEN_UNSUPPORTED_ERROR(HttpStatus.UNAUTHORIZED, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ† ํฐ์ž…๋‹ˆ๋‹ค"), + JWT_UNKNOWN_EXCEPTION(HttpStatus.UNAUTHORIZED, "JWT ์ฒ˜๋ฆฌ ์ค‘ ์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"), + JWT_INVALID_TOKEN_TYPE(HttpStatus.UNAUTHORIZED, "์š”์ฒญ์— ๋งž๋Š” ํ† ํฐ ํƒ€์ž…์ด ์•„๋‹™๋‹ˆ๋‹ค"), + JWT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } + + public ServiceException toException() { + return new ServiceException(this); + } +} + diff --git a/src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java b/src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java new file mode 100644 index 0000000..368c4b7 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtTokenPair.java @@ -0,0 +1,7 @@ +package com.dropit.global.security.jwt; + +public record JwtTokenPair( + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/security/jwt/JwtUtil.java b/src/main/java/com/dropit/global/security/jwt/JwtUtil.java new file mode 100644 index 0000000..0f3d109 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtUtil.java @@ -0,0 +1,98 @@ +package com.dropit.global.security.jwt; + +import com.dropit.user.entity.User; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.Duration; +import java.util.Date; +import java.util.UUID; + +@Component +public class JwtUtil { + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + public JwtUtil(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public static final String TOKEN_TYPE_ACCESS = "access"; + public static final String TOKEN_TYPE_REFRESH = "refresh"; + + public static final String TOKEN_TYPE_CLAIM = "type"; + public static final String USER_ROLE_CLAIM = "role"; + + public JwtTokenPair issueTokenPair(User user) { + String accessToken = createToken(user, TOKEN_TYPE_ACCESS, jwtProperties.expire().access(), true); + String refreshToken = createToken(user, TOKEN_TYPE_REFRESH, jwtProperties.expire().refresh(), false); + + return new JwtTokenPair(accessToken, refreshToken); + } + + private String createToken(User user, String tokenType, Duration tokenExpired, boolean includeRole) { + long now = System.currentTimeMillis(); + + JwtBuilder builder = Jwts.builder() + .subject(user.getId().toString()) // sub + .id(UUID.randomUUID().toString()) // jti + .claim(TOKEN_TYPE_CLAIM, tokenType) + .issuedAt(new Date(now)) // iat + .expiration(new Date(now + tokenExpired.toMillis())); // exp + + if (includeRole) { + builder.claim(USER_ROLE_CLAIM, user.getRole().name()); + } + + return builder + .signWith(secretKey) + .compact(); + } + + public Claims getAccessTokenClaims(String token) { + return getTokenClaims(token, TOKEN_TYPE_ACCESS); + } + + public Claims getRefreshTokenClaims(String token) { + return getTokenClaims(token, TOKEN_TYPE_REFRESH); + } + + private Claims getTokenClaims(String token, String expectedTokenType) { + Claims claims = getClaims(token); + String actualTokenType = claims.get(TOKEN_TYPE_CLAIM, String.class); + + if (!expectedTokenType.equals(actualTokenType)) { + throw JwtErrorCode.JWT_INVALID_TOKEN_TYPE.toException(); + } + + return claims; + } + + private Claims getClaims(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException exception) { + throw JwtErrorCode.JWT_TOKEN_EXPIRED.toException(); + } catch (SignatureException exception) { + throw JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR.toException(); + } catch (MalformedJwtException | IllegalArgumentException exception) { + throw JwtErrorCode.JWT_TOKEN_ERROR.toException(); + } catch (UnsupportedJwtException exception) { + throw JwtErrorCode.JWT_TOKEN_UNSUPPORTED_ERROR.toException(); + } catch (JwtException exception) { + throw JwtErrorCode.JWT_UNKNOWN_EXCEPTION.toException(); + } + } +} From a453c59eeb203f26001242154e8107befe9e49d1 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 22:38:39 +0900 Subject: [PATCH 49/60] feat(security): Add JWT authentication object and authority conversion --- .../JwtAuthenticationToken.java | 47 +++++++++++++++++++ .../global/security/principal/AuthUser.java | 10 ++++ .../java/com/dropit/user/entity/UserRole.java | 6 ++- 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java create mode 100644 src/main/java/com/dropit/global/security/principal/AuthUser.java diff --git a/src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java b/src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java new file mode 100644 index 0000000..9264afc --- /dev/null +++ b/src/main/java/com/dropit/global/security/authentication/JwtAuthenticationToken.java @@ -0,0 +1,47 @@ +package com.dropit.global.security.authentication; + +import com.dropit.global.security.principal.AuthUser; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public final class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final AuthUser principal; + + public JwtAuthenticationToken( + AuthUser principal, + Collection authorities + ) { + super(authorities); + this.principal = principal; + super.setAuthenticated(true); + } + + @Override + public AuthUser getPrincipal() { + return principal; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public String getName() { + return principal.getName(); + } + + @Override + public void setAuthenticated(boolean authenticated) { + if (authenticated) { + throw new IllegalArgumentException( + "์ธ์ฆ ์™„๋ฃŒ ์ƒํƒœ๋Š” JwtAuthenticationToken ์ƒ์„ฑ์ž๋กœ๋งŒ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." + ); + } + super.setAuthenticated(false); + } +} + diff --git a/src/main/java/com/dropit/global/security/principal/AuthUser.java b/src/main/java/com/dropit/global/security/principal/AuthUser.java new file mode 100644 index 0000000..fda34b2 --- /dev/null +++ b/src/main/java/com/dropit/global/security/principal/AuthUser.java @@ -0,0 +1,10 @@ +package com.dropit.global.security.principal; + +import org.springframework.security.core.AuthenticatedPrincipal; + +public record AuthUser(Long id) implements AuthenticatedPrincipal { + @Override + public String getName() { + return id.toString(); + } +} diff --git a/src/main/java/com/dropit/user/entity/UserRole.java b/src/main/java/com/dropit/user/entity/UserRole.java index ab326b0..879beda 100644 --- a/src/main/java/com/dropit/user/entity/UserRole.java +++ b/src/main/java/com/dropit/user/entity/UserRole.java @@ -3,5 +3,9 @@ public enum UserRole { SELLER, USER, - ADMIN + ADMIN; + + public String authority() { + return "ROLE_" + name(); + } } From a44091e7d936cc76cf2084fe412c9db40e32a681 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 22:43:47 +0900 Subject: [PATCH 50/60] feat(security): Add JWT authentication filter and exception response handling --- .../global/security/SecurityConfig.java | 69 ++++++++++++++ .../security/SecurityErrorResponseSender.java | 31 +++++++ .../dropit/global/security/jwt/JwtFilter.java | 89 +++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/main/java/com/dropit/global/security/SecurityConfig.java create mode 100644 src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java create mode 100644 src/main/java/com/dropit/global/security/jwt/JwtFilter.java diff --git a/src/main/java/com/dropit/global/security/SecurityConfig.java b/src/main/java/com/dropit/global/security/SecurityConfig.java new file mode 100644 index 0000000..dd152d9 --- /dev/null +++ b/src/main/java/com/dropit/global/security/SecurityConfig.java @@ -0,0 +1,69 @@ +package com.dropit.global.security; + +import com.dropit.global.security.jwt.JwtErrorCode; +import com.dropit.global.security.jwt.JwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtFilter jwtFilter; + private final SecurityErrorResponseSender errorResponseSender; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) // ์ฟ ํ‚ค๊ฐ€ ์•„๋‹Œ Authorization ํ—ค๋”๋กœ JWT๋ฅผ ์ „๋‹ฌํ•˜๋ฏ€๋กœ CSRF ๋ณดํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .requestCache(AbstractHttpConfigurer::disable) // ์ธ์ฆ ์ „ ์š”์ฒญ์„ ์„ธ์…˜์— ์ €์žฅํ•œ ๋’ค ๋ณต์›ํ•˜๋Š” ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ํ๋ฆ„์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + .formLogin(AbstractHttpConfigurer::disable) // ์„œ๋ฒ„๊ฐ€ ๋กœ๊ทธ์ธ HTML ํผ์„ ์ œ๊ณตํ•˜๋Š” ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + .httpBasic(AbstractHttpConfigurer::disable) // ๋ธŒ๋ผ์šฐ์ €์˜ HTTP Basic ์ธ์ฆ ํŒ์—… ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ + .logout(AbstractHttpConfigurer::disable) // ์„œ๋ฒ„ ์„ธ์…˜์„ ๋ฌดํšจํ™”ํ•˜๋Š” ๋กœ๊ทธ์•„์›ƒ ๋ฐฉ์‹์ด ์•„๋‹Œ JWT ํ๊ธฐ ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•จ + .rememberMe(AbstractHttpConfigurer::disable) // ์ž๋™ ๋กœ๊ทธ์ธ์„ ์œ„ํ•œ Remember-Me ์ฟ ํ‚ค๋ฅผ ๋ฐœ๊ธ‰ํ•˜์ง€ ์•Š์Œ + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, cause) -> + errorResponseSender.send(response, JwtErrorCode.JWT_BEARER_TOKEN_REQUIRED)) + .accessDeniedHandler((request, response, cause) -> + errorResponseSender.send(response, JwtErrorCode.JWT_ACCESS_DENIED)) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/auth/reissue").permitAll() + .anyRequest().authenticated() + ) + // JWT ์ธ์ฆ์ด ์ต๋ช… ์ธ์ฆ๋ณด๋‹ค ๋จผ์ € ์ฒ˜๋ฆฌ๋˜๋„๋ก ์ˆœ์„œ ์ง€์ • + .addFilterBefore(jwtFilter, AnonymousAuthenticationFilter.class) + .build(); + } + + // JwtFilter๊ฐ€ Spring Security ์ฒด์ธ์—์„œ๋งŒ ์‹คํ–‰๋˜๋„๋ก ์ž๋™ ๋“ฑ๋ก์„ ๋ง‰๋Š”๋‹ค. (์„œ๋ธ”๋ฆฟ ์ปจํ…Œ์ด๋„ˆ ์ค‘๋ณต ๋“ฑ๋ก ๋ฐฉ์ง€) + @Bean + public FilterRegistrationBean jwtFilterRegistration(JwtFilter filter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } +} + diff --git a/src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java b/src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java new file mode 100644 index 0000000..04cd804 --- /dev/null +++ b/src/main/java/com/dropit/global/security/SecurityErrorResponseSender.java @@ -0,0 +1,31 @@ +package com.dropit.global.security; + +import com.dropit.global.exception.ErrorCode; +import com.dropit.global.exception.ErrorResponse; +import com.dropit.global.exception.ServiceException; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class SecurityErrorResponseSender { + + private final ObjectMapper objectMapper; + + public void send(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.status().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(ErrorResponse.from(errorCode))); + } + + public void send(HttpServletResponse response, ServiceException exception) throws IOException { + send(response, exception.getErrorCode()); + } +} diff --git a/src/main/java/com/dropit/global/security/jwt/JwtFilter.java b/src/main/java/com/dropit/global/security/jwt/JwtFilter.java new file mode 100644 index 0000000..c0a0296 --- /dev/null +++ b/src/main/java/com/dropit/global/security/jwt/JwtFilter.java @@ -0,0 +1,89 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.SecurityErrorResponseSender; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import com.dropit.user.entity.UserRole; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final SecurityErrorResponseSender errorResponseSender; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader("Authorization"); + + // Bearer ํ† ํฐ์ด ์—†๋Š” ์š”์ฒญ์˜ ํ—ˆ์šฉ ์—ฌ๋ถ€๋Š” SecurityConfig๊ฐ€ ํŒ๋‹จํ•œ๋‹ค. + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.substring("Bearer ".length()); + + try { + authenticate(token, request); + } catch (ServiceException exception) { + errorResponseSender.send(response, exception); + return; + } + + filterChain.doFilter(request, response); + } + + private void authenticate(String token, HttpServletRequest request) { + Claims claims = jwtUtil.getAccessTokenClaims(token); + Long userId = parseUserId(claims.getSubject()); + UserRole role = parseRole(claims.get(JwtUtil.USER_ROLE_CLAIM, String.class)); + + JwtAuthenticationToken authentication = new JwtAuthenticationToken( + new AuthUser(userId), // principal + List.of(new SimpleGrantedAuthority(role.authority())) // authorities + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + } + + private Long parseUserId(String subject) { + try { + return Long.valueOf(subject); + } catch (NumberFormatException exception) { + throw JwtErrorCode.JWT_TOKEN_ERROR.toException(); + } + } + + private UserRole parseRole(String roleClaim) { + try { + return UserRole.valueOf(roleClaim); + } catch (IllegalArgumentException | NullPointerException exception) { + throw JwtErrorCode.JWT_TOKEN_ERROR.toException(); + } + } +} + From 132c315baecc47f3725fda7956407ab74397e9a9 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 22:44:20 +0900 Subject: [PATCH 51/60] test(security): Add tests for JWT issuance and authentication filter --- .../global/security/jwt/JwtFilterTest.java | 80 +++++++++++++++++++ .../global/security/jwt/JwtUtilTest.java | 76 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java create mode 100644 src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java diff --git a/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java b/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java new file mode 100644 index 0000000..291728e --- /dev/null +++ b/src/test/java/com/dropit/global/security/jwt/JwtFilterTest.java @@ -0,0 +1,80 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.SecurityErrorResponseSender; +import com.dropit.global.security.principal.AuthUser; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JwtFilterTest { + + private final JwtUtil jwtUtil = mock(JwtUtil.class); + private final SecurityErrorResponseSender errorResponseSender = mock(SecurityErrorResponseSender.class); + private final JwtFilter jwtFilter = new JwtFilter(jwtUtil, errorResponseSender); + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void requestWithoutBearerTokenContinuesWithoutAuthentication() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + jwtFilter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void validAccessTokenCreatesAuthenticatedSecurityContext() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + Claims claims = mock(Claims.class); + when(claims.getSubject()).thenReturn("42"); + when(claims.get(JwtUtil.USER_ROLE_CLAIM, String.class)).thenReturn("SELLER"); + when(jwtUtil.getAccessTokenClaims("valid-token")).thenReturn(claims); + + jwtFilter.doFilter(request, response, filterChain); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication.isAuthenticated()).isTrue(); + assertThat(authentication.getPrincipal()).isEqualTo(new AuthUser(42L)); + assertThat(authentication.getAuthorities()) + .extracting("authority") + .containsExactly("ROLE_SELLER"); + verify(filterChain).doFilter(request, response); + } + + @Test + void invalidTokenWritesErrorAndStopsFilterChain() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + ServiceException exception = new ServiceException(JwtErrorCode.JWT_TOKEN_ERROR); + when(jwtUtil.getAccessTokenClaims("invalid-token")).thenThrow(exception); + + jwtFilter.doFilter(request, response, filterChain); + + verify(errorResponseSender).send(response, exception); + verify(filterChain, never()).doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java b/src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java new file mode 100644 index 0000000..28daacd --- /dev/null +++ b/src/test/java/com/dropit/global/security/jwt/JwtUtilTest.java @@ -0,0 +1,76 @@ +package com.dropit.global.security.jwt; + +import com.dropit.global.exception.ServiceException; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtUtilTest { + + private static final String SECRET = Base64.getEncoder().encodeToString( + "01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8) + ); + + private JwtUtil jwtUtil; + private User user; + + @BeforeEach + void setUp() { + JwtProperties properties = new JwtProperties( + SECRET, + new JwtProperties.Expire(Duration.ofMinutes(30), Duration.ofDays(14)) + ); + jwtUtil = new JwtUtil(properties); + + user = new User("seller@example.com", "encoded-password", "seller", UserRole.SELLER); + ReflectionTestUtils.setField(user, "id", 42L); + } + + @Test + void issueTokenPairCreatesAccessTokenWithUserIdAndRole() { + JwtTokenPair tokenPair = jwtUtil.issueTokenPair(user); + + Claims claims = jwtUtil.getAccessTokenClaims(tokenPair.accessToken()); + + assertThat(claims.getSubject()).isEqualTo("42"); + assertThat(claims.get(JwtUtil.TOKEN_TYPE_CLAIM, String.class)) + .isEqualTo(JwtUtil.TOKEN_TYPE_ACCESS); + assertThat(claims.get(JwtUtil.USER_ROLE_CLAIM, String.class)) + .isEqualTo(UserRole.SELLER.name()); + assertThat(claims.getId()).isNotBlank(); + assertThat(claims.getIssuedAt()).isBefore(claims.getExpiration()); + } + + @Test + void issueTokenPairCreatesRefreshTokenWithoutRole() { + JwtTokenPair tokenPair = jwtUtil.issueTokenPair(user); + + Claims claims = jwtUtil.getRefreshTokenClaims(tokenPair.refreshToken()); + + assertThat(claims.getSubject()).isEqualTo("42"); + assertThat(claims.get(JwtUtil.TOKEN_TYPE_CLAIM, String.class)) + .isEqualTo(JwtUtil.TOKEN_TYPE_REFRESH); + assertThat(claims.get(JwtUtil.USER_ROLE_CLAIM)).isNull(); + } + + @Test + void refreshTokenCannotBeUsedAsAccessToken() { + JwtTokenPair tokenPair = jwtUtil.issueTokenPair(user); + + assertThatThrownBy(() -> jwtUtil.getAccessTokenClaims(tokenPair.refreshToken())) + .isInstanceOfSatisfying(ServiceException.class, exception -> + assertThat(exception.getErrorCode()) + .isEqualTo(JwtErrorCode.JWT_INVALID_TOKEN_TYPE) + ); + } +} From 266cce3d5a3d7b15d4d34666a85733e1d825b95a Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Thu, 3 Sep 2026 23:04:12 +0900 Subject: [PATCH 52/60] chore(grade): Add Spring security test dependency. --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index b60a7e8..121c023 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-security-test' testImplementation 'org.testcontainers:testcontainers-mysql' testCompileOnly 'org.projectlombok:lombok' From b82c4f11d2ac85fca57492343c9c1daa4cd940aa Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Fri, 4 Sep 2026 08:59:37 +0900 Subject: [PATCH 53/60] =?UTF-8?q?feat(drop):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A0=AC=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?DSL=EB=A1=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dropit/global/config/QuerydslConfig.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/dropit/global/config/QuerydslConfig.java diff --git a/src/main/java/com/dropit/global/config/QuerydslConfig.java b/src/main/java/com/dropit/global/config/QuerydslConfig.java new file mode 100644 index 0000000..660d3a3 --- /dev/null +++ b/src/main/java/com/dropit/global/config/QuerydslConfig.java @@ -0,0 +1,15 @@ +package com.dropit.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} From 0426b60ba57eac6bf3cc640764fbe2b24e63f756 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Sun, 6 Sep 2026 03:05:46 +0900 Subject: [PATCH 54/60] feat(user): add soft deletion support for users --- .../entity/BaseSoftDeletableEntity.java | 23 +++++++++++++++++++ .../java/com/dropit/user/entity/User.java | 4 ++-- .../user/repository/UserRepository.java | 6 +++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java diff --git a/src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java b/src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java new file mode 100644 index 0000000..38d2697 --- /dev/null +++ b/src/main/java/com/dropit/global/entity/BaseSoftDeletableEntity.java @@ -0,0 +1,23 @@ +package com.dropit.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +public abstract class BaseSoftDeletableEntity extends BaseEntity { + + @Column + private LocalDateTime deletedAt; + + public void markDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return deletedAt != null; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/entity/User.java b/src/main/java/com/dropit/user/entity/User.java index 4825094..5ad4c98 100644 --- a/src/main/java/com/dropit/user/entity/User.java +++ b/src/main/java/com/dropit/user/entity/User.java @@ -1,6 +1,6 @@ package com.dropit.user.entity; -import com.dropit.global.entity.BaseEntity; +import com.dropit.global.entity.BaseSoftDeletableEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -10,7 +10,7 @@ @Entity @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User extends BaseEntity { +public class User extends BaseSoftDeletableEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/dropit/user/repository/UserRepository.java b/src/main/java/com/dropit/user/repository/UserRepository.java index 91d1c7c..dff813f 100644 --- a/src/main/java/com/dropit/user/repository/UserRepository.java +++ b/src/main/java/com/dropit/user/repository/UserRepository.java @@ -3,8 +3,14 @@ import com.dropit.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + Optional findByEmailAndDeletedAtIsNull(String email); + + Optional findByIdAndDeletedAtIsNull(Long id); + boolean existsByEmail(String email); boolean existsByUsername(String username); From 1a5fd5b9b02593e0531b9508e9e1da3ec22a22b3 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Sun, 6 Sep 2026 03:06:13 +0900 Subject: [PATCH 55/60] feat(auth): add signup login and token reissue APIs --- .../auth/controller/AuthController.java | 49 ++++++++ .../dropit/auth/dto/request/LoginRequest.java | 15 +++ .../auth/dto/request/ReissueRequest.java | 10 ++ .../auth/dto/request/SignupRequest.java | 21 ++++ .../auth/dto/response/TokenResponse.java | 16 +++ .../dropit/auth/exception/AuthErrorCode.java | 32 +++++ .../com/dropit/auth/service/AuthService.java | 115 ++++++++++++++++++ 7 files changed, 258 insertions(+) create mode 100644 src/main/java/com/dropit/auth/controller/AuthController.java create mode 100644 src/main/java/com/dropit/auth/dto/request/LoginRequest.java create mode 100644 src/main/java/com/dropit/auth/dto/request/ReissueRequest.java create mode 100644 src/main/java/com/dropit/auth/dto/request/SignupRequest.java create mode 100644 src/main/java/com/dropit/auth/dto/response/TokenResponse.java create mode 100644 src/main/java/com/dropit/auth/exception/AuthErrorCode.java create mode 100644 src/main/java/com/dropit/auth/service/AuthService.java diff --git a/src/main/java/com/dropit/auth/controller/AuthController.java b/src/main/java/com/dropit/auth/controller/AuthController.java new file mode 100644 index 0000000..51c5909 --- /dev/null +++ b/src/main/java/com/dropit/auth/controller/AuthController.java @@ -0,0 +1,49 @@ +package com.dropit.auth.controller; + +import com.dropit.auth.dto.request.LoginRequest; +import com.dropit.auth.dto.request.ReissueRequest; +import com.dropit.auth.dto.request.SignupRequest; +import com.dropit.auth.dto.response.TokenResponse; +import com.dropit.auth.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity signup( + @Valid @RequestBody SignupRequest request + ) { + authService.signup(request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .build(); + } + + @PostMapping("/login") + public ResponseEntity login( + @Valid @RequestBody LoginRequest request + ) { + return ResponseEntity.ok( + authService.login(request) + ); + } + + @PostMapping("/reissue") + public ResponseEntity reissue( + @Valid @RequestBody ReissueRequest request + ) { + return ResponseEntity.ok( + authService.reissue(request) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/LoginRequest.java b/src/main/java/com/dropit/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..46134a5 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/LoginRequest.java @@ -0,0 +1,15 @@ +package com.dropit.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + + @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Email(message = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String email, + + @NotBlank(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String password +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/ReissueRequest.java b/src/main/java/com/dropit/auth/dto/request/ReissueRequest.java new file mode 100644 index 0000000..dda06d8 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/ReissueRequest.java @@ -0,0 +1,10 @@ +package com.dropit.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record ReissueRequest( + + @NotBlank(message = "Refresh Token์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/request/SignupRequest.java b/src/main/java/com/dropit/auth/dto/request/SignupRequest.java new file mode 100644 index 0000000..7e5c49f --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/request/SignupRequest.java @@ -0,0 +1,21 @@ +package com.dropit.auth.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record SignupRequest( + + @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Email(message = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String email, + + @NotBlank(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(min = 8, max = 255, message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String password, + + @NotBlank(message = "์‚ฌ์šฉ์ž ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + @Size(min = 1, max = 100, message = "์‚ฌ์šฉ์ž ์ด๋ฆ„์€ 1์ž ์ด์ƒ 100์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + String username +) { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/dto/response/TokenResponse.java b/src/main/java/com/dropit/auth/dto/response/TokenResponse.java new file mode 100644 index 0000000..3f94715 --- /dev/null +++ b/src/main/java/com/dropit/auth/dto/response/TokenResponse.java @@ -0,0 +1,16 @@ +package com.dropit.auth.dto.response; + +import com.dropit.global.security.jwt.JwtTokenPair; + +public record TokenResponse( + String accessToken, + String refreshToken +) { + + public static TokenResponse from(JwtTokenPair tokenPair) { + return new TokenResponse( + tokenPair.accessToken(), + tokenPair.refreshToken() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/exception/AuthErrorCode.java b/src/main/java/com/dropit/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..e99abcc --- /dev/null +++ b/src/main/java/com/dropit/auth/exception/AuthErrorCode.java @@ -0,0 +1,32 @@ +package com.dropit.auth.exception; + +import com.dropit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + INVALID_CREDENTIALS( + HttpStatus.UNAUTHORIZED, + "์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ), + + INVALID_REFRESH_TOKEN( + HttpStatus.UNAUTHORIZED, + "Refresh Token์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค." + ); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String message() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/com/dropit/auth/service/AuthService.java b/src/main/java/com/dropit/auth/service/AuthService.java new file mode 100644 index 0000000..c4da670 --- /dev/null +++ b/src/main/java/com/dropit/auth/service/AuthService.java @@ -0,0 +1,115 @@ +package com.dropit.auth.service; + +import com.dropit.auth.dto.request.LoginRequest; +import com.dropit.auth.dto.request.ReissueRequest; +import com.dropit.auth.dto.request.SignupRequest; +import com.dropit.auth.dto.response.TokenResponse; +import com.dropit.auth.exception.AuthErrorCode; +import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.jwt.JwtTokenPair; +import com.dropit.global.security.jwt.JwtUtil; +import com.dropit.user.entity.User; +import com.dropit.user.entity.UserRole; +import com.dropit.user.exception.UserErrorCode; +import com.dropit.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + @Transactional + public void signup(SignupRequest request) { + + if (userRepository.existsByEmail(request.email())) { + throw new ServiceException( + UserErrorCode.EMAIL_ALREADY_EXISTS + ); + } + + if (userRepository.existsByUsername(request.username())) { + throw new ServiceException( + UserErrorCode.USERNAME_ALREADY_EXISTS + ); + } + + User user = new User( + request.email(), + passwordEncoder.encode(request.password()), + request.username(), + UserRole.USER + ); + + userRepository.save(user); + } + + @Transactional(readOnly = true) + public TokenResponse login(LoginRequest request) { + + User user = userRepository + .findByEmailAndDeletedAtIsNull(request.email()) + .orElseThrow(() -> + new ServiceException( + AuthErrorCode.INVALID_CREDENTIALS + ) + ); + + if (!passwordEncoder.matches( + request.password(), + user.getPassword() + )) { + throw new ServiceException( + AuthErrorCode.INVALID_CREDENTIALS + ); + } + + JwtTokenPair tokenPair = + jwtUtil.issueTokenPair(user); + + return TokenResponse.from(tokenPair); + } + + @Transactional(readOnly = true) + public TokenResponse reissue(ReissueRequest request) { + + Claims claims = + jwtUtil.getRefreshTokenClaims( + request.refreshToken() + ); + + Long userId = parseUserId( + claims.getSubject() + ); + + User user = userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> + new ServiceException( + AuthErrorCode.INVALID_REFRESH_TOKEN + ) + ); + + JwtTokenPair tokenPair = + jwtUtil.issueTokenPair(user); + + return TokenResponse.from(tokenPair); + } + + private Long parseUserId(String subject) { + try { + return Long.valueOf(subject); + } catch (NumberFormatException | NullPointerException e) { + throw new ServiceException( + AuthErrorCode.INVALID_REFRESH_TOKEN + ); + } + } +} \ No newline at end of file From e2163e7ca4e1488a3441a8cafd7ec848afd20992 Mon Sep 17 00:00:00 2001 From: gustavors88 Date: Sun, 6 Sep 2026 03:26:23 +0900 Subject: [PATCH 56/60] refactor(user): use JWT authenticated user for user APIs --- .../security/principal/CurrentUser.java | 12 +++++++ .../security/principal/CurrentUserId.java | 15 +++++++++ .../user/controller/UserController.java | 23 +++++++++---- .../com/dropit/user/service/UserService.java | 32 +++++++++++++++---- 4 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/dropit/global/security/principal/CurrentUser.java create mode 100644 src/main/java/com/dropit/global/security/principal/CurrentUserId.java diff --git a/src/main/java/com/dropit/global/security/principal/CurrentUser.java b/src/main/java/com/dropit/global/security/principal/CurrentUser.java new file mode 100644 index 0000000..d23bed6 --- /dev/null +++ b/src/main/java/com/dropit/global/security/principal/CurrentUser.java @@ -0,0 +1,12 @@ +package com.dropit.global.security.principal; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal(errorOnInvalidType = true) +public @interface CurrentUser { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/global/security/principal/CurrentUserId.java b/src/main/java/com/dropit/global/security/principal/CurrentUserId.java new file mode 100644 index 0000000..634a09c --- /dev/null +++ b/src/main/java/com/dropit/global/security/principal/CurrentUserId.java @@ -0,0 +1,15 @@ +package com.dropit.global.security.principal; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal( + expression = "id", + errorOnInvalidType = true +) +public @interface CurrentUserId { +} \ No newline at end of file diff --git a/src/main/java/com/dropit/user/controller/UserController.java b/src/main/java/com/dropit/user/controller/UserController.java index c569c1a..cb8c052 100644 --- a/src/main/java/com/dropit/user/controller/UserController.java +++ b/src/main/java/com/dropit/user/controller/UserController.java @@ -1,5 +1,6 @@ package com.dropit.user.controller; +import com.dropit.global.security.principal.CurrentUserId; import com.dropit.user.dto.request.PasswordUpdateRequest; import com.dropit.user.dto.request.UserUpdateRequest; import com.dropit.user.dto.response.UserResponse; @@ -13,12 +14,12 @@ @RestController @RequiredArgsConstructor public class UserController { - // Temporary endpoints, Authentication, Authorization, JWT need to be added + private final UserService userService; @GetMapping("/users/me") public UserResponse getMyInfo( - @RequestHeader("X-User-Id") Long userId + @CurrentUserId Long userId ) { User user = userService.findUser(userId); @@ -27,30 +28,40 @@ public UserResponse getMyInfo( @PatchMapping("/users/me") public UserResponse updateMyProfile( - @RequestHeader("X-User-Id") Long userId, + @CurrentUserId Long userId, @Valid @RequestBody UserUpdateRequest request ) { - User user = userService.updateProfile(userId, request); + User user = + userService.updateProfile(userId, request); return UserResponse.from(user); } @PatchMapping("/users/me/password") public ResponseEntity updateMyPassword( - @RequestHeader("X-User-Id") Long userId, + @CurrentUserId Long userId, @Valid @RequestBody PasswordUpdateRequest request ) { userService.updatePassword(userId, request); + return ResponseEntity.noContent().build(); } @DeleteMapping("/users/me") public ResponseEntity deleteMyAccount( - @RequestHeader("X-User-Id") Long userId + @CurrentUserId Long userId ) { userService.deleteUser(userId); return ResponseEntity.noContent().build(); } + @DeleteMapping("/users/{userId}") + public ResponseEntity deleteUserByAdmin( + @PathVariable Long userId + ) { + userService.deleteUserByAdmin(userId); + + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/dropit/user/service/UserService.java b/src/main/java/com/dropit/user/service/UserService.java index 5f2059c..683b941 100644 --- a/src/main/java/com/dropit/user/service/UserService.java +++ b/src/main/java/com/dropit/user/service/UserService.java @@ -7,6 +7,8 @@ import com.dropit.user.exception.UserErrorCode; import com.dropit.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,12 +19,16 @@ public class UserService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; @Transactional(readOnly = true) public User findUser(Long userId) { - return userRepository.findById(userId) + return userRepository + .findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> - new ServiceException(UserErrorCode.USER_NOT_FOUND) + new ServiceException( + UserErrorCode.USER_NOT_FOUND + ) ); } @@ -67,22 +73,34 @@ public void updatePassword( ) { User user = findUser(userId); - if (!Objects.equals( - user.getPassword(), - request.currentPassword() + if (!passwordEncoder.matches( + request.currentPassword(), + user.getPassword() )) { throw new ServiceException( UserErrorCode.INVALID_CURRENT_PASSWORD ); } - user.updatePassword(request.newPassword()); + user.updatePassword( + passwordEncoder.encode( + request.newPassword() + ) + ); } @Transactional public void deleteUser(Long userId) { + User user = findUser(userId); - userRepository.delete(user); + user.markDeleted(); + } + + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public void deleteUserByAdmin(Long userId) { + User user = findUser(userId); + user.markDeleted(); } } \ No newline at end of file From a4da9095cc2d0704938702359cfa069112e210da Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 7 Sep 2026 09:41:26 +0900 Subject: [PATCH 57/60] =?UTF-8?q?test(product,drop):=20=ED=8C=90=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=EB=B3=84=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropControllerTest.java | 41 +++++++++++++++++ .../dropit/drop/service/DropServiceTest.java | 45 +++++++++++++++++++ .../controller/ProductControllerTest.java | 39 ++++++++++++++++ .../product/service/ProductServiceTest.java | 28 ++++++++++++ 4 files changed, 153 insertions(+) diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java index b92702f..9c4efe5 100644 --- a/src/test/java/com/dropit/drop/controller/DropControllerTest.java +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -122,6 +122,47 @@ void getAllAndOneDrop() throws Exception { .andExpect(jsonPath("$.productId").value(1)); } + @Test + @DisplayName("ํŒ๋งค์ž๋ณ„ ๊ณต๊ฐœ ๋“œ๋ž ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getPublicDropsBySeller() throws Exception { + when(dropService.getPublicDropsBySeller(eq(10L), any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(response()), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/creators/10/drops") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.content[0].visible").value(true)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName("ํŒ๋งค์ž ๊ด€๋ฆฌ์šฉ ๋“œ๋ž ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getDropsForSellerManagement() throws Exception { + when(dropService.getDropsForSellerManagement(eq(10L), any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(response()), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/sellers/10/drops") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + @Test @DisplayName("๋“œ๋ž ์ˆ˜์ • ์š”์ฒญ ์‹œ 200 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void updateDrop() throws Exception { diff --git a/src/test/java/com/dropit/drop/service/DropServiceTest.java b/src/test/java/com/dropit/drop/service/DropServiceTest.java index 821e623..5744354 100644 --- a/src/test/java/com/dropit/drop/service/DropServiceTest.java +++ b/src/test/java/com/dropit/drop/service/DropServiceTest.java @@ -158,6 +158,51 @@ void excludeHiddenDropsFromPublicList() { assertEquals(100L, responses.getContent().get(0).id()); } + @Test + @DisplayName("ํŒ๋งค์ž๋ณ„ ๊ณต๊ฐœ ๋“œ๋ž ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + void getPublicDropsBySeller() { + Long sellerId = 1L; + Drop drop = saveDrop(sellerId, 10L, 100L, LocalDateTime.now().plusDays(1)); + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.findAllByProductSellerIdAndVisibleTrue(sellerId, pageable)) + .thenReturn(new PageImpl<>(List.of(drop), pageable, 1)); + + Page responses = dropService.getPublicDropsBySeller(sellerId, pageable); + + assertEquals(1, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + assertEquals(sellerId, responses.getContent().get(0).sellerId()); + verify(dropRepository).findAllByProductSellerIdAndVisibleTrue(sellerId, pageable); + } + + @Test + @DisplayName("ํŒ๋งค์ž ๊ด€๋ฆฌ์šฉ ๋“œ๋ž ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + void getDropsForSellerManagement() { + Long sellerId = 1L; + Product product = saveProduct(sellerId, 10L); + Drop hiddenDrop = new Drop( + product, + new BigDecimal("59000"), + 10, + 20, + 2, + LocalDateTime.now().plusDays(1), + LocalDateTime.now().plusDays(2) + ); + ReflectionTestUtils.setField(hiddenDrop, "id", 100L); + + Pageable pageable = PageRequest.of(0, 20); + when(dropRepository.findAllByProductSellerId(sellerId, pageable)) + .thenReturn(new PageImpl<>(List.of(hiddenDrop), pageable, 1)); + + Page responses = dropService.getDropsForSellerManagement(sellerId, pageable); + + assertEquals(1, responses.getTotalElements()); + assertEquals(100L, responses.getContent().get(0).id()); + assertEquals(false, responses.getContent().get(0).visible()); + verify(dropRepository).findAllByProductSellerId(sellerId, pageable); + } + @Test @DisplayName("๋น„๊ณต๊ฐœ ๋“œ๋ž ์ƒ์„ธ ์กฐํšŒ๋Š” ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋“œ๋ž์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค") void rejectHiddenDropFromPublicDetail() { diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index abde122..ae3b86d 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -220,6 +220,45 @@ void getProducts() throws Exception { verify(productService).getProducts(any(Pageable.class)); } + @Test + @DisplayName("ํŒ๋งค์ž๋ณ„ ์ƒํ’ˆ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getProductsBySeller() throws Exception { + User seller = new User( + "seller@example.com", + "encoded-password", + "seller", + UserRole.SELLER + ); + ReflectionTestUtils.setField(seller, "id", 1L); + + Product product = new Product( + seller, + "Seller Product", + "Seller product description", + null + ); + ReflectionTestUtils.setField(product, "id", 100L); + + when(productService.getProductsBySeller(eq(1L), any(Pageable.class))) + .thenReturn(new PageImpl<>( + List.of(new ProductResponse(product)), + PageRequest.of(0, 20), + 1 + )); + + mockMvc.perform(get("/sellers/1/products") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(100L)) + .andExpect(jsonPath("$.content[0].sellerId").value(1L)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)) + .andExpect(jsonPath("$.totalElements").value(1)); + + verify(productService).getProductsBySeller(eq(1L), any(Pageable.class)); + } + @Test @DisplayName("์ƒํ’ˆ์„ ์ˆ˜์ •ํ•˜๋ฉด 200 ์ƒํƒœ์™€ ์ˆ˜์ •๋œ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void updateProduct() throws Exception { diff --git a/src/test/java/com/dropit/product/service/ProductServiceTest.java b/src/test/java/com/dropit/product/service/ProductServiceTest.java index ee006f2..cb4e2ad 100644 --- a/src/test/java/com/dropit/product/service/ProductServiceTest.java +++ b/src/test/java/com/dropit/product/service/ProductServiceTest.java @@ -181,6 +181,34 @@ void getProducts() { verify(productRepository).findAll(pageable); } + @Test + @DisplayName("ํŒ๋งค์ž๋ณ„ ์ƒํ’ˆ ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ์กฐํšŒํ•œ๋‹ค") + void getProductsBySeller() { + Long sellerId = 1L; + User seller = createUser(UserRole.SELLER); + ReflectionTestUtils.setField(seller, "id", sellerId); + + Product product = new Product( + seller, + "Seller Product", + "Seller product description", + null + ); + ReflectionTestUtils.setField(product, "id", 100L); + + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.findById(sellerId)).thenReturn(Optional.of(seller)); + when(productRepository.findAllBySellerId(sellerId, pageable)) + .thenReturn(new PageImpl<>(List.of(product), pageable, 1)); + + Page response = productService.getProductsBySeller(sellerId, pageable); + + assertEquals(1, response.getTotalElements()); + assertEquals(100L, response.getContent().get(0).getId()); + assertEquals(sellerId, response.getContent().get(0).getSellerId()); + verify(productRepository).findAllBySellerId(sellerId, pageable); + } + @Test @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๋Š” ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋‹ค") void updateProduct() { From d84fc43b8a91e45eb3a7873d2b91c0e9e8585493 Mon Sep 17 00:00:00 2001 From: Hyiyek Date: Mon, 7 Sep 2026 10:00:46 +0900 Subject: [PATCH 58/60] =?UTF-8?q?refactor(product,drop):=20JWT=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drop/controller/DropController.java | 21 ++++---- .../product/controller/ProductController.java | 8 +-- .../drop/controller/DropControllerTest.java | 35 ++++++++++--- .../controller/ProductControllerTest.java | 50 +++++++++++++------ 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/dropit/drop/controller/DropController.java b/src/main/java/com/dropit/drop/controller/DropController.java index c2f9fd0..4dd4d01 100644 --- a/src/main/java/com/dropit/drop/controller/DropController.java +++ b/src/main/java/com/dropit/drop/controller/DropController.java @@ -6,6 +6,7 @@ import com.dropit.drop.dto.request.DropVisibilityUpdateRequest; import com.dropit.drop.dto.response.DropResponse; import com.dropit.drop.service.DropService; +import com.dropit.global.security.principal.CurrentUserId; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -25,10 +26,10 @@ public class DropController { @PostMapping("/drops") public ResponseEntity create( - @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @CurrentUserId Long sellerId, @Valid @RequestBody DropCreateRequest request ) { - Long dropId = dropService.save(userId, request); + Long dropId = dropService.save(sellerId, request); return ResponseEntity .created(URI.create("/drops/" + dropId)) @@ -69,9 +70,9 @@ public ResponseEntity> getPublicDropsBySeller( return ResponseEntity.ok(response); } - @GetMapping("/sellers/{sellerId}/drops") + @GetMapping("/users/me/drops") public ResponseEntity> getDropsForSellerManagement( - @PathVariable Long sellerId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ID๋กœ ๊ต์ฒด + @CurrentUserId Long sellerId, @PageableDefault( size = 20, sort = "createdAt", @@ -88,28 +89,28 @@ public ResponseEntity> getDropsForSellerManagement( @PatchMapping("/drops/{dropId}") public ResponseEntity update( - @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @CurrentUserId Long sellerId, @PathVariable Long dropId, @Valid @RequestBody DropUpdateRequest request ) { - return ResponseEntity.ok(dropService.update(userId, dropId, request)); + return ResponseEntity.ok(dropService.update(sellerId, dropId, request)); } @PatchMapping("/drops/{dropId}/visibility") public ResponseEntity changeVisibility( - @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @CurrentUserId Long sellerId, @PathVariable Long dropId, @Valid @RequestBody DropVisibilityUpdateRequest request ) { - return ResponseEntity.ok(dropService.changeVisibility(userId, dropId, request)); + return ResponseEntity.ok(dropService.changeVisibility(sellerId, dropId, request)); } @DeleteMapping("/drops/{dropId}") public ResponseEntity delete( - @RequestParam Long userId, // TODO: ์ธ์ฆ ๊ธฐ๋Šฅ ์ ์šฉ ํ›„ SecurityContext์˜ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ •๋ณด์—์„œ ์ถ”์ถœ + @CurrentUserId Long sellerId, @PathVariable Long dropId ) { - dropService.delete(userId, dropId); + dropService.delete(sellerId, dropId); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/dropit/product/controller/ProductController.java b/src/main/java/com/dropit/product/controller/ProductController.java index 6d13c2c..30cad30 100644 --- a/src/main/java/com/dropit/product/controller/ProductController.java +++ b/src/main/java/com/dropit/product/controller/ProductController.java @@ -1,5 +1,6 @@ package com.dropit.product.controller; +import com.dropit.global.security.principal.CurrentUserId; import com.dropit.product.dto.request.ProductCreateRequest; import com.dropit.product.dto.request.ProductUpdateRequest; import com.dropit.product.dto.response.ProductResponse; @@ -17,7 +18,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.net.URI; @@ -29,7 +29,7 @@ public class ProductController { @PostMapping("/products") public ResponseEntity create( - @RequestParam Long sellerId, + @CurrentUserId Long sellerId, @Valid @RequestBody ProductCreateRequest request ) { Long productId = productService.create(sellerId, request); @@ -69,7 +69,7 @@ public ResponseEntity> getProductsBySeller( @PutMapping("/products/{productId}") public ResponseEntity update( - @RequestParam Long sellerId, + @CurrentUserId Long sellerId, @PathVariable Long productId, @Valid @RequestBody ProductUpdateRequest request ) { @@ -80,7 +80,7 @@ public ResponseEntity update( @DeleteMapping("/products/{productId}") public ResponseEntity delete( - @RequestParam Long sellerId, + @CurrentUserId Long sellerId, @PathVariable Long productId ) { productService.delete(sellerId, productId); diff --git a/src/test/java/com/dropit/drop/controller/DropControllerTest.java b/src/test/java/com/dropit/drop/controller/DropControllerTest.java index 9c4efe5..dd2ee4f 100644 --- a/src/test/java/com/dropit/drop/controller/DropControllerTest.java +++ b/src/test/java/com/dropit/drop/controller/DropControllerTest.java @@ -5,6 +5,9 @@ import com.dropit.drop.entity.DropStatus; import com.dropit.drop.service.DropService; import com.dropit.global.exception.GlobalExceptionHandler; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,6 +18,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @@ -46,9 +52,18 @@ void setUp() { mockMvc = MockMvcBuilders .standaloneSetup(new DropController(dropService)) .setControllerAdvice(new GlobalExceptionHandler()) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers( + new PageableHandlerMethodArgumentResolver(), + new AuthenticationPrincipalArgumentResolver() + ) .setValidator(validator) .build(); + authenticate(1L); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } @Test @@ -57,7 +72,6 @@ void createDrop() throws Exception { when(dropService.save(eq(1L), any())).thenReturn(100L); mockMvc.perform(post("/drops") - .queryParam("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -79,7 +93,6 @@ void createDrop() throws Exception { @DisplayName("์œ ํšจํ•˜์ง€ ์•Š์€ ๋“œ๋ž ์ƒ์„ฑ ์š”์ฒญ์€ 400 ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectInvalidCreateRequest() throws Exception { mockMvc.perform(post("/drops") - .queryParam("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -146,6 +159,7 @@ void getPublicDropsBySeller() throws Exception { @Test @DisplayName("ํŒ๋งค์ž ๊ด€๋ฆฌ์šฉ ๋“œ๋ž ๋ชฉ๋ก์„ ํŽ˜์ด์ง•ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void getDropsForSellerManagement() throws Exception { + authenticate(10L); when(dropService.getDropsForSellerManagement(eq(10L), any(Pageable.class))) .thenReturn(new PageImpl<>( List.of(response()), @@ -153,7 +167,7 @@ void getDropsForSellerManagement() throws Exception { 1 )); - mockMvc.perform(get("/sellers/10/drops") + mockMvc.perform(get("/users/me/drops") .param("page", "0") .param("size", "20")) .andExpect(status().isOk()) @@ -169,7 +183,6 @@ void updateDrop() throws Exception { when(dropService.update(eq(1L), eq(100L), any())).thenReturn(response()); mockMvc.perform(patch("/drops/100") - .queryParam("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -191,7 +204,6 @@ void changeVisibility() throws Exception { when(dropService.changeVisibility(eq(1L), eq(100L), any())).thenReturn(response()); mockMvc.perform(patch("/drops/100/visibility") - .queryParam("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -205,7 +217,7 @@ void changeVisibility() throws Exception { @Test @DisplayName("๋“œ๋ž ์‚ญ์ œ ์š”์ฒญ ์‹œ 204 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void deleteDrop() throws Exception { - mockMvc.perform(delete("/drops/100").queryParam("userId", "1")) + mockMvc.perform(delete("/drops/100")) .andExpect(status().isNoContent()); } @@ -213,7 +225,6 @@ void deleteDrop() throws Exception { @DisplayName("๊ณต๊ฐœ ์—ฌ๋ถ€๊ฐ€ ์—†๋Š” ๋ณ€๊ฒฝ ์š”์ฒญ์€ 400 ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectVisibilityRequestWithoutVisible() throws Exception { mockMvc.perform(patch("/drops/100/visibility") - .queryParam("userId", "1") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()) @@ -242,4 +253,12 @@ private DropResponse response() { DropStatus.READY ); } + + private void authenticate(Long userId) { + JwtAuthenticationToken authentication = new JwtAuthenticationToken( + new AuthUser(userId), + List.of(new SimpleGrantedAuthority("ROLE_SELLER")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } } diff --git a/src/test/java/com/dropit/product/controller/ProductControllerTest.java b/src/test/java/com/dropit/product/controller/ProductControllerTest.java index ae3b86d..976f2d8 100644 --- a/src/test/java/com/dropit/product/controller/ProductControllerTest.java +++ b/src/test/java/com/dropit/product/controller/ProductControllerTest.java @@ -2,6 +2,8 @@ import com.dropit.global.exception.GlobalExceptionHandler; import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; import com.dropit.product.dto.request.ProductCreateRequest; import com.dropit.product.dto.request.ProductUpdateRequest; import com.dropit.product.dto.response.ProductResponse; @@ -11,6 +13,7 @@ import com.dropit.user.entity.User; import com.dropit.user.entity.UserRole; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -18,6 +21,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.util.ReflectionTestUtils; @@ -53,8 +59,17 @@ void setUp() { mockMvc = MockMvcBuilders .standaloneSetup(productController) .setControllerAdvice(new GlobalExceptionHandler()) - .setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers( + new PageableHandlerMethodArgumentResolver(), + new AuthenticationPrincipalArgumentResolver() + ) .build(); + authenticate(1L); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } @Test @@ -65,7 +80,6 @@ void create() throws Exception { .thenReturn(100L); mockMvc.perform(post("/products") - .param("sellerId", sellerId.toString()) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -87,9 +101,13 @@ void create() throws Exception { } @Test - @DisplayName("ํŒ๋งค์ž ID๊ฐ€ ์—†์œผ๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - void rejectMissingSellerId() throws Exception { + @DisplayName("์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์•„๋‹ˆ๋ผ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž์˜ ID๋กœ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•œ๋‹ค") + void useAuthenticatedSellerId() throws Exception { + when(productService.create(eq(1L), any(ProductCreateRequest.class))) + .thenReturn(100L); + mockMvc.perform(post("/products") + .param("sellerId", "999") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -97,16 +115,15 @@ void rejectMissingSellerId() throws Exception { "description": "Description" } """)) - .andExpect(status().isBadRequest()); + .andExpect(status().isCreated()); - verifyNoInteractions(productService); + verify(productService).create(eq(1L), any(ProductCreateRequest.class)); } @Test @DisplayName("์ƒํ’ˆ๋ช…์ด ๊ณต๋ฐฑ์ด๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectBlankName() throws Exception { mockMvc.perform(post("/products") - .param("sellerId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -123,7 +140,6 @@ void rejectBlankName() throws Exception { @DisplayName("์š”์ฒญ ๋ณธ๋ฌธ์ด ์—†์œผ๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectMissingBody() throws Exception { mockMvc.perform(post("/products") - .param("sellerId", "1") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); @@ -133,11 +149,11 @@ void rejectMissingBody() throws Exception { @Test @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ํŒ๋งค์ž๋กœ ์ƒํ’ˆ์„ ๋“ฑ๋กํ•˜๋ฉด 404 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectMissingSeller() throws Exception { + authenticate(999L); when(productService.create(eq(999L), any(ProductCreateRequest.class))) .thenThrow(new ServiceException(ProductErrorCode.SELLER_NOT_FOUND)); mockMvc.perform(post("/products") - .param("sellerId", "999") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -157,7 +173,6 @@ void rejectNonSeller() throws Exception { .thenThrow(new ServiceException(ProductErrorCode.SELLER_ROLE_REQUIRED)); mockMvc.perform(post("/products") - .param("sellerId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -284,7 +299,6 @@ void updateProduct() throws Exception { .thenReturn(new ProductResponse(product)); mockMvc.perform(put("/products/{productId}", productId) - .param("sellerId", sellerId.toString()) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -312,7 +326,6 @@ void updateProduct() throws Exception { @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์š”์ฒญ์˜ ์ƒํ’ˆ๋ช…์ด ๊ณต๋ฐฑ์ด๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectUpdatingWithBlankName() throws Exception { mockMvc.perform(put("/products/{productId}", 100L) - .param("sellerId", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -328,11 +341,11 @@ void rejectUpdatingWithBlankName() throws Exception { @Test @DisplayName("์ƒํ’ˆ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์ˆ˜์ • ์‹œ 403 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectUpdatingAnotherSellersProduct() throws Exception { + authenticate(2L); when(productService.update(eq(2L), eq(100L), any(ProductUpdateRequest.class))) .thenThrow(new ServiceException(ProductErrorCode.PRODUCT_OWNER_REQUIRED)); mockMvc.perform(put("/products/{productId}", 100L) - .param("sellerId", "2") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -351,11 +364,18 @@ void deleteProduct() throws Exception { Long sellerId = 1L; Long productId = 100L; - mockMvc.perform(delete("/products/{productId}", productId) - .param("sellerId", sellerId.toString())) + mockMvc.perform(delete("/products/{productId}", productId)) .andExpect(status().isNoContent()) .andExpect(content().string("")); verify(productService).delete(sellerId, productId); } + + private void authenticate(Long userId) { + JwtAuthenticationToken authentication = new JwtAuthenticationToken( + new AuthUser(userId), + List.of(new SimpleGrantedAuthority("ROLE_SELLER")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } } From 2c3b15602f106ae4547ee3ac8b06b0623c013c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EA=B5=AC?= Date: Mon, 7 Sep 2026 10:20:36 +0900 Subject: [PATCH 59/60] =?UTF-8?q?refactor:=20SellerProfile=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SellerProfileController.java | 10 ++--- .../SellerProfileControllerTest.java | 43 ++++++++----------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java index 7595622..e75c088 100644 --- a/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java +++ b/src/main/java/com/dropit/sellerprofile/controller/SellerProfileController.java @@ -14,9 +14,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.dropit.global.security.principal.CurrentUserId; @RestController @RequiredArgsConstructor @@ -27,7 +27,7 @@ public class SellerProfileController { @PostMapping public ResponseEntity create( - @RequestHeader("X-User-Id") Long userId, + @CurrentUserId Long userId, @Valid @RequestBody SellerProfileCreateRequest request ) { SellerProfileResponse response = sellerProfileService.create(userId, request); @@ -37,7 +37,7 @@ public ResponseEntity create( @GetMapping("/me") public ResponseEntity getMine( - @RequestHeader("X-User-Id") Long userId + @CurrentUserId Long userId ) { return ResponseEntity.ok(sellerProfileService.getMine(userId)); } @@ -51,7 +51,7 @@ public ResponseEntity getById( @PatchMapping("/me") public ResponseEntity update( - @RequestHeader("X-User-Id") Long userId, + @CurrentUserId Long userId, @Valid @RequestBody SellerProfileUpdateRequest request ) { return ResponseEntity.ok(sellerProfileService.update(userId, request)); @@ -59,7 +59,7 @@ public ResponseEntity update( @DeleteMapping("/me") public ResponseEntity delete( - @RequestHeader("X-User-Id") Long userId + @CurrentUserId Long userId ) { sellerProfileService.delete(userId); diff --git a/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java index bde1c6d..7856758 100644 --- a/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java +++ b/src/test/java/com/dropit/sellerprofile/controller/SellerProfileControllerTest.java @@ -2,23 +2,29 @@ import com.dropit.global.exception.GlobalExceptionHandler; import com.dropit.global.exception.ServiceException; +import com.dropit.global.security.authentication.JwtAuthenticationToken; +import com.dropit.global.security.principal.AuthUser; import com.dropit.sellerprofile.dto.request.SellerProfileCreateRequest; import com.dropit.sellerprofile.dto.request.SellerProfileUpdateRequest; import com.dropit.sellerprofile.dto.response.SellerProfileResponse; import com.dropit.sellerprofile.exception.SellerProfileErrorCode; import com.dropit.sellerprofile.service.SellerProfileService; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import java.util.List; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -41,7 +47,17 @@ void setUp() { mockMvc = MockMvcBuilders .standaloneSetup(controller) .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) .build(); + + SecurityContextHolder.getContext().setAuthentication( + new JwtAuthenticationToken(new AuthUser(1L), List.of()) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } @Test @@ -54,7 +70,6 @@ void create() throws Exception { .thenReturn(response); mockMvc.perform(post("/seller-profiles") - .header("X-User-Id", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -67,21 +82,6 @@ void create() throws Exception { .andExpect(jsonPath("$.description").value("ํŒ๋งค์ž ์†Œ๊ฐœ")); } - @Test - @DisplayName("์‚ฌ์šฉ์ž ID ํ—ค๋”๊ฐ€ ์—†์œผ๋ฉด 400 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - void rejectMissingUserId() throws Exception { - mockMvc.perform(post("/seller-profiles") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - { - "description": "ํŒ๋งค์ž ์†Œ๊ฐœ" - } - """)) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(sellerProfileService); - } - @Test @DisplayName("์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ํ”„๋กœํ•„ ๋“ฑ๋ก ์‹œ 403 ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void rejectNonSeller() throws Exception { @@ -89,7 +89,6 @@ void rejectNonSeller() throws Exception { .thenThrow(new ServiceException(SellerProfileErrorCode.SELLER_ROLE_REQUIRED)); mockMvc.perform(post("/seller-profiles") - .header("X-User-Id", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -108,7 +107,6 @@ void rejectDuplicateProfile() throws Exception { SellerProfileErrorCode.SELLER_PROFILE_ALREADY_EXISTS)); mockMvc.perform(post("/seller-profiles") - .header("X-User-Id", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -126,8 +124,7 @@ void getMine() throws Exception { .thenReturn(new SellerProfileResponse( 100L, 1L, "ํŒ๋งค์ž ์†Œ๊ฐœ", null, null, null)); - mockMvc.perform(get("/seller-profiles/me") - .header("X-User-Id", "1")) + mockMvc.perform(get("/seller-profiles/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(100L)); } @@ -152,7 +149,6 @@ void update() throws Exception { 100L, 1L, "์ˆ˜์ •๋œ ์†Œ๊ฐœ", null, null, null)); mockMvc.perform(patch("/seller-profiles/me") - .header("X-User-Id", "1") .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -166,8 +162,7 @@ void update() throws Exception { @Test @DisplayName("ํŒ๋งค์ž ํ”„๋กœํ•„์„ ์‚ญ์ œํ•˜๋ฉด 204 ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void deleteProfile() throws Exception { - mockMvc.perform(delete("/seller-profiles/me") - .header("X-User-Id", "1")) + mockMvc.perform(delete("/seller-profiles/me")) .andExpect(status().isNoContent()) .andExpect(content().string("")); From 06add6ce2ad6caa44bf705b5b1dfd9f7075d5887 Mon Sep 17 00:00:00 2001 From: Sooyoung Yoon Date: Mon, 7 Sep 2026 15:28:54 +0900 Subject: [PATCH 60/60] =?UTF-8?q?ci(workflow):=20CI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=83=9D=EC=84=B1,=20actuator,?= =?UTF-8?q?=20parameter=20store=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-cd.yml | 184 ++++++++++++++++++ Dockerfile | 7 + build.gradle | 5 + .../global/security/SecurityConfig.java | 1 + .../resources/application-local.properties | 4 +- .../resources/application-prod.properties | 2 + src/main/resources/application.properties | 2 + .../dropit/DropitServerApplicationTests.java | 6 - 8 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci-cd.yml create mode 100644 Dockerfile create mode 100644 src/main/resources/application-prod.properties diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..0fd27a9 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,184 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +# OIDC ํ† ํฐ ์š”์ฒญ ๊ถŒํ•œ +permissions: + id-token: write # OIDC ํ† ํฐ ์š”์ฒญ์šฉ + contents: read # ์ฝ”๋“œ ์ฒดํฌ์•„์›ƒ์šฉ (์ฒดํฌ์•„์›ƒ - Repo์— ์žˆ๋Š” ์ฝ”๋“œ๋ฅผ Runner์— ์˜ฎ๊น€) + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.vars.outputs.short_sha }} + + steps: + # Step 1: ์ฝ”๋“œ ๊ฐ€์ ธ์˜ค๊ธฐ + - name: Checkout code + uses: actions/checkout@v6 + + # Step 2: ์ปค๋ฐ‹ ํ•ด์‹œ ์ถ”์ถœ + - name: Set short SHA + id: vars + run: echo "short_sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + # Step 3: ์Šคํ”„๋ง ๋นŒ๋“œ๋ฅผ ์œ„ํ•œ Java 21 ์„ค์น˜ + - name: Setup Java 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'corretto' + + # Step 4: Gradle ์บ์‹œ + - name: Cache Gradle packages + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # Step 5: ํ…Œ์ŠคํŠธ ์‹คํ–‰ + - name: Run tests + run: ./gradlew test + + # Step 6: JAR ๋นŒ๋“œ + - name: Build JAR + run: ./gradlew bootJar + + # Step 7: AWS ์ž๊ฒฉ์ฆ๋ช… - OIDC + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + # Step 8: ECR ๋กœ๊ทธ์ธ + - name: Login to ECR + uses: aws-actions/amazon-ecr-login@v2 + + # Step 9: Docker ๋นŒ๋“œ ๋ฐ push + - name: Build and push Docker image + run: | + SHORT_SHA=${{ steps.vars.outputs.short_sha }} + IMAGE_URI=${{ vars.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/${{ vars.ECR_REPOSITORY }} + + # Docker ๋นŒ๋“œ ๋ฐ push + docker build -t $IMAGE_URI:$SHORT_SHA . +# docker build --platform linux/arm64 -t $IMAGE_URI:$SHORT_SHA . + docker push $IMAGE_URI:$SHORT_SHA + + echo "โœ… Pushed: $IMAGE_URI:$SHORT_SHA" + +# deploy: +# needs: build +# if: github.ref == 'refs/heads/main' # main push์—์„œ๋งŒ ๋ฐฐํฌ (PR์—์„œ๋Š” CI๋งŒ ์‹คํ–‰) +# runs-on: ubuntu-latest +# +# steps: +# # Step 1: AWS ์ž๊ฒฉ์ฆ๋ช… - OIDC +# - name: Configure AWS credentials +# uses: aws-actions/configure-aws-credentials@v6 +# with: +# role-to-assume: ${{ vars.AWS_ROLE_ARN }} +# aws-region: ${{ vars.AWS_REGION }} +# +# # Step 2: Launch Template ์ƒˆ ๋ฒ„์ „ ์ƒ์„ฑ (User Data์˜ IMAGE_TAG๋ฅผ ์ƒˆ ์ปค๋ฐ‹ SHA๋กœ ๊ต์ฒด) +# - name: Update Launch Template +# env: +# IMAGE_TAG: ${{ needs.build.outputs.image_tag }} +# AWS_ACCOUNT_ID: ${{ vars.AWS_ACCOUNT_ID }} +# AWS_REGION: ${{ vars.AWS_REGION }} +# ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }} +# LAUNCH_TEMPLATE_ID: ${{ vars.LAUNCH_TEMPLATE_ID }} +# run: | +# ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}" +# +# USER_DATA=$(cat <<'USERDATA' | sed "s|__IMAGE_TAG__|${IMAGE_TAG}|g; s|__AWS_ACCOUNT_ID__|${AWS_ACCOUNT_ID}|g; s|__AWS_REGION__|${AWS_REGION}|g; s|__ECR_REPOSITORY__|${ECR_REPOSITORY}|g" | base64 -w 0 +# #!/bin/bash +# set -e +# AWS_REGION="__AWS_REGION__" +# AWS_ACCOUNT_ID="__AWS_ACCOUNT_ID__" +# ECR_REPOSITORY="__ECR_REPOSITORY__" +# IMAGE_TAG="__IMAGE_TAG__" +# ECR_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}" +# dnf update -y +# dnf install -y docker +# systemctl enable docker +# systemctl start docker +# aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com +# docker pull ${ECR_URI}:${IMAGE_TAG} +# docker run -d -p 8080:8080 --restart unless-stopped --name ci-demo -e SPRING_PROFILES_ACTIVE=prod ${ECR_URI}:${IMAGE_TAG} +# USERDATA +# ) +# +# NEW_VERSION=$(aws ec2 create-launch-template-version \ +# --launch-template-id ${LAUNCH_TEMPLATE_ID} \ +# --source-version '$Latest' \ +# --launch-template-data "{\"UserData\":\"${USER_DATA}\"}" \ +# --query 'LaunchTemplateVersion.VersionNumber' \ +# --output text) +# +# echo "โœ… Launch Template v${NEW_VERSION} ์ƒ์„ฑ ์™„๋ฃŒ" +# +# aws ec2 modify-launch-template \ +# --launch-template-id ${LAUNCH_TEMPLATE_ID} \ +# --default-version ${NEW_VERSION} +# +# echo "โœ… ๊ธฐ๋ณธ ๋ฒ„์ „ โ†’ v${NEW_VERSION} ๋ณ€๊ฒฝ ์™„๋ฃŒ" +# +# # Step 3: Instance Refresh ์‹œ์ž‘ +# - name: Start Instance Refresh +# id: refresh +# env: +# ASG_NAME: ${{ vars.ASG_NAME }} +# run: | +# REFRESH_ID=$(aws autoscaling start-instance-refresh \ +# --auto-scaling-group-name ${ASG_NAME} \ +# --preferences '{ +# "MinHealthyPercentage": 100, +# "InstanceWarmup": 120 +# }' \ +# --query 'InstanceRefreshId' \ +# --output text) +# +# echo "refresh_id=${REFRESH_ID}" >> $GITHUB_OUTPUT +# echo "โœ… Instance Refresh ์‹œ์ž‘: ${REFRESH_ID}" +# +# # Step 4: Instance Refresh ์™„๋ฃŒ ๋Œ€๊ธฐ +# - name: Wait for Instance Refresh +# env: +# ASG_NAME: ${{ vars.ASG_NAME }} +# REFRESH_ID: ${{ steps.refresh.outputs.refresh_id }} +# run: | +# echo "โณ Instance Refresh ์™„๋ฃŒ ๋Œ€๊ธฐ ์ค‘..." +# +# for i in $(seq 1 30); do +# STATUS=$(aws autoscaling describe-instance-refreshes \ +# --auto-scaling-group-name ${ASG_NAME} \ +# --instance-refresh-ids ${REFRESH_ID} \ +# --query 'InstanceRefreshes[0].Status' \ +# --output text) +# +# echo "์‹œ๋„ ${i}/30 - ์ƒํƒœ: ${STATUS}" +# +# if [ "${STATUS}" = "Successful" ]; then +# echo "โœ… Instance Refresh ์„ฑ๊ณต! ๋ฐฐํฌ ์™„๋ฃŒ!" +# exit 0 +# elif [ "${STATUS}" = "Failed" ] || [ "${STATUS}" = "Cancelled" ]; then +# echo "โŒ Instance Refresh ์‹คํŒจ: ${STATUS}" +# exit 1 +# fi +# +# sleep 30 +# done +# +# echo "โŒ Instance Refresh ํƒ€์ž„์•„์›ƒ (15๋ถ„ ์ดˆ๊ณผ)" +# exit 1 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d88470b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY build/libs/*.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index c4fcda7..7343007 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:4.1.0') + implementation 'io.awspring.cloud:spring-cloud-aws-starter-parameter-store' implementation 'io.jsonwebtoken:jjwt-api:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' @@ -43,6 +47,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-security-test' + testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test' testImplementation 'org.testcontainers:testcontainers-mysql' testCompileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/dropit/global/security/SecurityConfig.java b/src/main/java/com/dropit/global/security/SecurityConfig.java index dd152d9..d95a3d0 100644 --- a/src/main/java/com/dropit/global/security/SecurityConfig.java +++ b/src/main/java/com/dropit/global/security/SecurityConfig.java @@ -50,6 +50,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti errorResponseSender.send(response, JwtErrorCode.JWT_ACCESS_DENIED)) ) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll() .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/auth/reissue").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index e758cb8..2fb7625 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -3,4 +3,6 @@ spring.config.import=optional:file:.env.local[.properties] spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true -logging.level.com.dropit=DEBUG \ No newline at end of file +logging.level.com.dropit=DEBUG + +management.endpoint.health.show-details=always \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..72bd2a3 --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,2 @@ +management.endpoint.health.show-details=never +spring.config.import=aws-parameterstore:/dropit-server/prod/ \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 45863b2..5eb7c85 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,3 +12,5 @@ spring.data.redis.port=${REDIS_PORT} jwt.secret=${JWT_SECRET} jwt.expire.access=30m jwt.expire.refresh=14d + +management.endpoints.web.exposure.include=health \ No newline at end of file diff --git a/src/test/java/com/dropit/DropitServerApplicationTests.java b/src/test/java/com/dropit/DropitServerApplicationTests.java index 50f61da..47b0580 100644 --- a/src/test/java/com/dropit/DropitServerApplicationTests.java +++ b/src/test/java/com/dropit/DropitServerApplicationTests.java @@ -1,13 +1,7 @@ package com.dropit; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class DropitServerApplicationTests { - - @Test - void contextLoads() { - } - }